package at.procon.ted.controller; import at.procon.dip.runtime.condition.ConditionalOnRuntimeMode; import at.procon.dip.runtime.config.RuntimeMode; import at.procon.ted.model.dto.DocumentDtos.*; import at.procon.ted.model.entity.ContractNature; import at.procon.ted.model.entity.NoticeType; import at.procon.ted.model.entity.ProcedureType; import at.procon.ted.service.SearchService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; import java.time.OffsetDateTime; import java.util.List; import java.util.UUID; /** * REST API controller for searching and retrieving TED procurement documents. * * Provides endpoints for: * - Structured search with filters (country, type, dates, etc.) * - Semantic search using natural language queries * - Document retrieval by ID or publication ID * - Statistics and metadata * * @author Martin.Schweitzer@procon.co.at and claude.ai */ @RestController @RequestMapping("/v1/documents") @RequiredArgsConstructor @Slf4j @ConditionalOnRuntimeMode(RuntimeMode.LEGACY) @Tag(name = "Documents", description = "TED Procurement Document Search API") public class DocumentController { private final SearchService searchService; /** * Search documents with structured and/or semantic filters. */ @GetMapping("/search") @Operation( summary = "Search procurement documents", description = "Search documents using structured filters (country, type, dates) and/or semantic search with natural language queries" ) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Search results returned successfully", content = @Content(schema = @Schema(implementation = SearchResponse.class))), @ApiResponse(responseCode = "400", description = "Invalid search parameters") }) public ResponseEntity searchDocuments( @Parameter(description = "Country code (ISO 3166-1 alpha-3, e.g., POL, DEU, FRA)") @RequestParam(required = false) String countryCode, @Parameter(description = "Multiple country codes") @RequestParam(required = false) List countryCodes, @Parameter(description = "Notice type filter") @RequestParam(required = false) NoticeType noticeType, @Parameter(description = "Contract nature filter") @RequestParam(required = false) ContractNature contractNature, @Parameter(description = "Procedure type filter") @RequestParam(required = false) ProcedureType procedureType, @Parameter(description = "CPV code prefix (e.g., '33' for medical supplies)") @RequestParam(required = false) String cpvPrefix, @Parameter(description = "NUTS region code") @RequestParam(required = false) String nutsCode, @Parameter(description = "Publication date from (inclusive)") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate publicationDateFrom, @Parameter(description = "Publication date to (inclusive)") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate publicationDateTo, @Parameter(description = "Only documents with submission deadline after this date") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime submissionDeadlineAfter, @Parameter(description = "Filter by EU funding status") @RequestParam(required = false) Boolean euFunded, @Parameter(description = "Search in buyer name (case-insensitive)") @RequestParam(required = false) String buyerNameContains, @Parameter(description = "Search in project title (case-insensitive)") @RequestParam(required = false) String projectTitleContains, @Parameter(description = "Natural language semantic search query") @RequestParam(required = false) String q, @Parameter(description = "Similarity threshold for semantic search (0.0-1.0)") @RequestParam(required = false, defaultValue = "0.7") Double similarityThreshold, @Parameter(description = "Page number (0-based)") @RequestParam(required = false, defaultValue = "0") Integer page, @Parameter(description = "Page size (max 100)") @RequestParam(required = false, defaultValue = "20") Integer size, @Parameter(description = "Sort field (publicationDate, submissionDeadline, buyerName, projectTitle)") @RequestParam(required = false, defaultValue = "publicationDate") String sortBy, @Parameter(description = "Sort direction (asc, desc)") @RequestParam(required = false, defaultValue = "desc") String sortDirection ) { SearchRequest request = SearchRequest.builder() .countryCode(countryCode) .countryCodes(countryCodes) .noticeType(noticeType) .contractNature(contractNature) .procedureType(procedureType) .cpvPrefix(cpvPrefix) .nutsCode(nutsCode) .publicationDateFrom(publicationDateFrom) .publicationDateTo(publicationDateTo) .submissionDeadlineAfter(submissionDeadlineAfter) .euFunded(euFunded) .buyerNameContains(buyerNameContains) .projectTitleContains(projectTitleContains) .semanticQuery(q) .similarityThreshold(similarityThreshold) .page(page) .size(size) .sortBy(sortBy) .sortDirection(sortDirection) .build(); log.debug("Search request: {}", request); SearchResponse response = searchService.search(request); return ResponseEntity.ok(response); } /** * Search documents using POST with request body. * Useful for complex queries with many parameters. */ @PostMapping("/search") @Operation( summary = "Search procurement documents (POST)", description = "Search documents using a JSON request body for complex queries" ) public ResponseEntity searchDocumentsPost(@RequestBody SearchRequest request) { log.debug("Search request (POST): {}", request); SearchResponse response = searchService.search(request); return ResponseEntity.ok(response); } /** * Get document by internal UUID. */ @GetMapping("/{id}") @Operation( summary = "Get document by ID", description = "Retrieve full document details by internal UUID" ) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Document found", content = @Content(schema = @Schema(implementation = DocumentDetail.class))), @ApiResponse(responseCode = "404", description = "Document not found") }) public ResponseEntity getDocument( @Parameter(description = "Document UUID") @PathVariable UUID id) { return searchService.getDocumentDetail(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } /** * Get document by TED publication ID. */ @GetMapping("/publication/{publicationId}") @Operation( summary = "Get document by publication ID", description = "Retrieve document by TED publication ID (e.g., '00786665-2025')" ) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Document found"), @ApiResponse(responseCode = "404", description = "Document not found") }) public ResponseEntity getDocumentByPublicationId( @Parameter(description = "TED Publication ID") @PathVariable String publicationId) { return searchService.getDocumentByPublicationId(publicationId) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } /** * Get documents with upcoming submission deadlines. */ @GetMapping("/upcoming-deadlines") @Operation( summary = "Get documents with upcoming deadlines", description = "List documents with submission deadlines in the future, sorted by deadline" ) public ResponseEntity> getUpcomingDeadlines( @Parameter(description = "Maximum number of results") @RequestParam(required = false, defaultValue = "20") Integer limit) { List documents = searchService.getUpcomingDeadlines(Math.min(limit, 100)); return ResponseEntity.ok(documents); } /** * Get collection statistics. */ @GetMapping("/statistics") @Operation( summary = "Get collection statistics", description = "Retrieve statistics about the document collection including counts by country, type, and vectorization status" ) public ResponseEntity getStatistics() { StatisticsResponse stats = searchService.getStatistics(); return ResponseEntity.ok(stats); } /** * Get list of all countries in the collection. */ @GetMapping("/metadata/countries") @Operation( summary = "Get available countries", description = "List all distinct country codes present in the collection" ) public ResponseEntity> getCountries() { List countries = searchService.getDistinctCountries(); return ResponseEntity.ok(countries); } /** * Get available notice types. */ @GetMapping("/metadata/notice-types") @Operation( summary = "Get available notice types", description = "List all notice type enum values" ) public ResponseEntity getNoticeTypes() { return ResponseEntity.ok(NoticeType.values()); } /** * Get available contract natures. */ @GetMapping("/metadata/contract-natures") @Operation( summary = "Get available contract natures", description = "List all contract nature enum values" ) public ResponseEntity getContractNatures() { return ResponseEntity.ok(ContractNature.values()); } /** * Get available procedure types. */ @GetMapping("/metadata/procedure-types") @Operation( summary = "Get available procedure types", description = "List all procedure type enum values" ) public ResponseEntity getProcedureTypes() { return ResponseEntity.ok(ProcedureType.values()); } /** * Semantic search endpoint - convenience method for natural language queries. */ @GetMapping("/semantic-search") @Operation( summary = "Semantic search", description = "Search documents using natural language query with vector similarity" ) public ResponseEntity semanticSearch( @Parameter(description = "Natural language search query", required = true) @RequestParam String query, @Parameter(description = "Minimum similarity score (0.0-1.0)") @RequestParam(required = false, defaultValue = "0.7") Double threshold, @Parameter(description = "Country code filter") @RequestParam(required = false) String countryCode, @Parameter(description = "Notice type filter") @RequestParam(required = false) NoticeType noticeType, @Parameter(description = "Page number") @RequestParam(required = false, defaultValue = "0") Integer page, @Parameter(description = "Page size") @RequestParam(required = false, defaultValue = "20") Integer size ) { SearchRequest request = SearchRequest.builder() .semanticQuery(query) .similarityThreshold(threshold) .countryCode(countryCode) .noticeType(noticeType) .page(page) .size(size) .build(); SearchResponse response = searchService.search(request); return ResponseEntity.ok(response); } }