You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

318 lines
13 KiB
Java

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<SearchResponse> 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<String> 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<SearchResponse> 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<DocumentDetail> 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<DocumentDetail> 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<List<DocumentSummary>> getUpcomingDeadlines(
@Parameter(description = "Maximum number of results")
@RequestParam(required = false, defaultValue = "20") Integer limit) {
List<DocumentSummary> 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<StatisticsResponse> 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<List<String>> getCountries() {
List<String> 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<NoticeType[]> 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<ContractNature[]> 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<ProcedureType[]> 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<SearchResponse> 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);
}
}