ProposalApiController.java
package com.taxonomy.relations.controller;
import com.taxonomy.dto.RelationProposalDto;
import com.taxonomy.dto.TaxonomyRelationDto;
import com.taxonomy.model.RelationType;
import com.taxonomy.relations.service.RelationProposalService;
import com.taxonomy.relations.service.RelationReviewService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.taxonomy.catalog.model.TaxonomyRelation;
/**
* REST API for the Relation Proposal Pipeline.
*
* <p>Endpoints:
* <ul>
* <li>{@code POST /api/proposals/propose} — trigger proposal generation</li>
* <li>{@code GET /api/proposals} — list all proposals (optionally filter by status)</li>
* <li>{@code GET /api/proposals/pending} — list pending proposals</li>
* <li>{@code GET /api/node/{code}/proposals} — proposals for a specific node</li>
* <li>{@code POST /api/proposals/{id}/accept} — accept a proposal</li>
* <li>{@code POST /api/proposals/{id}/reject} — reject a proposal</li>
* </ul>
*/
@RestController
@RequestMapping("/api")
@Tag(name = "Proposals")
public class ProposalApiController {
private final RelationProposalService proposalService;
private final RelationReviewService reviewService;
public ProposalApiController(RelationProposalService proposalService,
RelationReviewService reviewService) {
this.proposalService = proposalService;
this.reviewService = reviewService;
}
/**
* Trigger the proposal pipeline for a source node and relation type.
*/
@Operation(summary = "Propose relations", description = "Trigger the proposal pipeline for a source node and relation type")
@PostMapping("/proposals/propose")
public ResponseEntity<List<RelationProposalDto>> proposeRelations(
@RequestBody Map<String, String> body) {
String sourceCode = body.get("sourceCode");
String relationTypeStr = body.get("relationType");
String limitStr = body.getOrDefault("limit", "10");
if (sourceCode == null || sourceCode.isBlank() ||
relationTypeStr == null || relationTypeStr.isBlank()) {
return ResponseEntity.badRequest().build();
}
RelationType relationType;
try {
relationType = RelationType.valueOf(relationTypeStr.toUpperCase());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
int limit;
try {
limit = Integer.parseInt(limitStr);
if (limit < 1 || limit > 100) limit = 10;
} catch (NumberFormatException e) {
limit = 10;
}
try {
List<RelationProposalDto> proposals =
proposalService.proposeRelations(sourceCode, relationType, limit);
return ResponseEntity.ok(proposals);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
/**
* List all proposals.
*/
@Operation(summary = "List all proposals", description = "Returns all relation proposals")
@GetMapping("/proposals")
public ResponseEntity<List<RelationProposalDto>> getAllProposals() {
return ResponseEntity.ok(proposalService.getAllProposals());
}
/**
* List pending proposals (review queue).
*/
@Operation(summary = "List pending proposals", description = "Returns all proposals with PENDING status (review queue)")
@GetMapping("/proposals/pending")
public ResponseEntity<List<RelationProposalDto>> getPendingProposals() {
return ResponseEntity.ok(proposalService.getPendingProposals());
}
/**
* List proposals for a specific source node.
*/
@Operation(summary = "List node proposals", description = "Returns all proposals for a specific source node")
@GetMapping("/node/{code}/proposals")
public ResponseEntity<List<RelationProposalDto>> getProposalsForNode(
@PathVariable String code) {
return ResponseEntity.ok(proposalService.getProposalsForNode(code));
}
/**
* Accept a pending proposal — creates the actual TaxonomyRelation.
*/
@Operation(summary = "Accept proposal", description = "Accepts a pending proposal and creates the actual taxonomy relation")
@PostMapping("/proposals/{id}/accept")
public ResponseEntity<TaxonomyRelationDto> acceptProposal(@PathVariable Long id) {
try {
TaxonomyRelationDto relation = reviewService.acceptProposal(id);
return ResponseEntity.ok(relation);
} catch (IllegalArgumentException | IllegalStateException e) {
return ResponseEntity.badRequest().build();
}
}
/**
* Reject a pending proposal.
*/
@Operation(summary = "Reject proposal", description = "Rejects a pending proposal")
@PostMapping("/proposals/{id}/reject")
public ResponseEntity<RelationProposalDto> rejectProposal(@PathVariable Long id) {
try {
RelationProposalDto dto = reviewService.rejectProposal(id);
return ResponseEntity.ok(dto);
} catch (IllegalArgumentException | IllegalStateException e) {
return ResponseEntity.badRequest().build();
}
}
/**
* Create a proposal directly from an analysis hypothesis.
* Request body: {@code { "sourceCode": "CP", "targetCode": "CR",
* "relationType": "REALIZES", "confidence": 0.56, "rationale": "..." }}
*/
@Operation(summary = "Create proposal from hypothesis",
description = "Creates a proposal from an AI-generated relation hypothesis")
@PostMapping("/proposals/from-hypothesis")
public ResponseEntity<RelationProposalDto> createFromHypothesis(@RequestBody Map<String, Object> body) {
String sourceCode = (String) body.get("sourceCode");
String targetCode = (String) body.get("targetCode");
String relationTypeStr = (String) body.get("relationType");
Number confidenceNum = (Number) body.get("confidence");
String rationale = (String) body.get("rationale");
if (sourceCode == null || sourceCode.isBlank()
|| targetCode == null || targetCode.isBlank()
|| relationTypeStr == null || relationTypeStr.isBlank()) {
return ResponseEntity.badRequest().build();
}
RelationType relationType;
try {
relationType = RelationType.valueOf(relationTypeStr.toUpperCase());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
double confidence = confidenceNum != null ? confidenceNum.doubleValue() : 0.5;
try {
RelationProposalDto dto = proposalService.createFromHypothesis(
sourceCode, targetCode, relationType, confidence, rationale);
if (dto == null) {
// Proposal already exists
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("message", "Proposal already exists for this source, target, and relation type");
return ResponseEntity.status(409).body(null);
}
return ResponseEntity.ok(dto);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
/**
* Revert a proposal back to PENDING status (undo accept/reject).
* If the proposal was accepted, the corresponding relation is deleted.
*/
@Operation(summary = "Revert proposal", description = "Reverts a proposal back to PENDING status (undo last action)")
@PostMapping("/proposals/{id}/revert")
public ResponseEntity<RelationProposalDto> revertProposal(@PathVariable Long id) {
try {
RelationProposalDto dto = reviewService.revertProposal(id);
return ResponseEntity.ok(dto);
} catch (IllegalArgumentException | IllegalStateException e) {
return ResponseEntity.badRequest().build();
}
}
/**
* Bulk accept or reject multiple proposals.
* Request body: {@code { "ids": [1, 2, 3], "action": "ACCEPT" | "REJECT" }}
*/
@Operation(summary = "Bulk action on proposals", description = "Accept or reject multiple proposals at once")
@PostMapping("/proposals/bulk")
public ResponseEntity<Map<String, Object>> bulkAction(@RequestBody Map<String, Object> body) {
@SuppressWarnings("unchecked")
List<Number> ids = (List<Number>) body.get("ids");
String action = (String) body.get("action");
if (ids == null || ids.isEmpty() || action == null || action.isBlank()) {
return ResponseEntity.badRequest().build();
}
int success = 0;
int failed = 0;
for (Number idNum : ids) {
Long id = idNum.longValue();
try {
if ("ACCEPT".equalsIgnoreCase(action)) {
reviewService.acceptProposal(id);
} else if ("REJECT".equalsIgnoreCase(action)) {
reviewService.rejectProposal(id);
} else {
return ResponseEntity.badRequest().build();
}
success++;
} catch (IllegalArgumentException | IllegalStateException e) {
failed++;
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("action", action);
result.put("success", success);
result.put("failed", failed);
result.put("total", ids.size());
return ResponseEntity.ok(result);
}
}