| Metadata | Value |
|---|---|
| Status | Completed |
| Owner | Backend/Frontend Team |
| Priority | HIGH |
| Created | 2026-01-23 |
| Updated | 2026-01-23 |
| Related Tracks | TRACK-001 (Bulk Import), TRACK-011 (Quality Scoring) |
| Implementation Plan | bulk-import-fixes-implementation-plan.md |
Complete the review workflow APIs and frontend enhancements to enable batch-scale moderation workflows, quality-tier based filtering, and configurable auto-approval rules.
Current implementation has:
Impact:
File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/routes/ImportRoutes.kt
New Endpoint:
/**
* POST /v1/admin/imports/bulk-review
*
* Review multiple imports in a single request.
*
* Request body:
* {
* "importIds": ["uuid1", "uuid2", ...],
* "action": "APPROVE" | "REJECT",
* "overrides": { ... } // Optional entity overrides
* }
*/
post("/bulk-review") {
val request = call.receive<BulkReviewRequest>()
val results = request.importIds.map { importId ->
try {
when (request.action) {
"APPROVE" -> {
importService.reviewImport(
importId = importId,
request = ImportReviewRequest(
action = ImportReviewAction.APPROVE,
overrides = request.overrides
)
)
BulkReviewResult(importId, "APPROVED", null)
}
"REJECT" -> {
importService.reviewImport(
importId = importId,
request = ImportReviewRequest(
action = ImportReviewAction.REJECT,
reason = request.reason
)
)
BulkReviewResult(importId, "REJECTED", null)
}
else -> BulkReviewResult(importId, "ERROR", "Invalid action")
}
} catch (e: Exception) {
BulkReviewResult(importId, "ERROR", e.message)
}
}
call.respond(mapOf(
"total" to results.size,
"succeeded" to results.count { it.status != "ERROR" },
"failed" to results.count { it.status == "ERROR" },
"results" to results
))
}
File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/routes/ImportRoutes.kt
New Endpoint:
/**
* GET /v1/admin/imports/auto-approve-queue
*
* Returns imports that meet auto-approval criteria.
*
* Query parameters:
* - batchId: Filter by batch (optional)
* - qualityTier: Filter by quality tier (EXCELLENT, GOOD, FAIR, POOR) (optional)
* - confidenceMin: Minimum confidence score (0.0-1.0) (optional)
* - limit: Max results (default: 100)
* - offset: Pagination offset (default: 0)
*/
get("/auto-approve-queue") {
val batchId = call.request.queryParameters["batchId"]?.let { Uuid.parse(it) }
val qualityTier = call.request.queryParameters["qualityTier"]
val confidenceMin = call.request.queryParameters["confidenceMin"]?.toDoubleOrNull()
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 100
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val queue = importService.getAutoApproveQueue(
batchId = batchId,
qualityTier = qualityTier,
confidenceMin = confidenceMin,
limit = limit,
offset = offset
)
call.respond(queue)
}
Service Method:
// ImportService.kt
suspend fun getAutoApproveQueue(
batchId: Uuid? = null,
qualityTier: String? = null,
confidenceMin: Double? = null,
limit: Int = 100,
offset: Int = 0
): List<ImportedKrithiDto> {
return dal.imports.listImports(
status = ImportStatus.PENDING,
batchId = batchId,
qualityTier = qualityTier,
minQualityScore = confidenceMin,
limit = limit,
offset = offset
).filter { imported ->
autoApprovalService.shouldAutoApprove(imported)
}
}
File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/AutoApprovalService.kt
Changes:
data class AutoApprovalRules(
val minQualityScore: Double = 0.90, // EXCELLENT tier
val minComposerConfidence: Double = 0.95,
val minRagaConfidence: Double = 0.90,
val minTalaConfidence: Double = 0.85,
val requireComposerMatch: Boolean = true,
val requireRagaMatch: Boolean = true,
val allowAutoCreateEntities: Boolean = false,
val qualityTiers: Set<String> = setOf("EXCELLENT", "GOOD") // Only auto-approve these tiers
)
class AutoApprovalService(
private val dal: SangitaDal,
private val rules: AutoApprovalRules = AutoApprovalRules() // ✅ Configurable
) {
suspend fun shouldAutoApprove(imported: ImportedKrithiDto): Boolean {
// ✅ NEW: Check quality tier
if (imported.qualityTier != null && !rules.qualityTiers.contains(imported.qualityTier)) {
return false
}
// ✅ NEW: Check quality score
if (imported.qualityScore != null && imported.qualityScore < rules.minQualityScore) {
return false
}
// Parse resolution data
val resolutionData = imported.resolutionData?.let {
Json.parseToJsonObject(it)
} ?: return false
val composerConfidence = resolutionData["composer_confidence"]?.jsonPrimitive?.doubleOrNull ?: 0.0
val ragaConfidence = resolutionData["raga_confidence"]?.jsonPrimitive?.doubleOrNull ?: 0.0
val talaConfidence = resolutionData["tala_confidence"]?.jsonPrimitive?.doubleOrNull ?: 0.0
// ✅ NEW: Use configurable thresholds
if (rules.requireComposerMatch && composerConfidence < rules.minComposerConfidence) {
return false
}
if (rules.requireRagaMatch && ragaConfidence < rules.minRagaConfidence) {
return false
}
// Check for duplicates
val duplicates = deduplicationService.findDuplicates(imported)
if (duplicates.any { it.confidence >= 0.95 }) {
return false // High-confidence duplicate
}
return true
}
suspend fun autoApproveIfHighConfidence(imported: ImportedKrithiDto): Boolean {
if (!shouldAutoApprove(imported)) {
return false
}
// Auto-approve
reviewImport(
importedId = imported.id,
request = ImportReviewRequest(
action = ImportReviewAction.APPROVE,
overrides = null
)
)
// ✅ NEW: Audit log
dal.auditLogs.append(
action = "AUTO_APPROVE",
entityTable = "imported_krithis",
entityId = imported.id,
metadata = Json.encodeToString(mapOf(
"qualityScore" to imported.qualityScore,
"qualityTier" to imported.qualityTier,
"rules" to Json.encodeToString(rules)
))
)
return true
}
}
Configuration:
// App.kt or config file
val autoApprovalRules = AutoApprovalRules(
minQualityScore = 0.90,
minComposerConfidence = 0.95,
minRagaConfidence = 0.90,
qualityTiers = setOf("EXCELLENT", "GOOD")
)
File: modules/frontend/sangita-admin-web/src/pages/ReviewQueue.tsx
Changes:
// Add batch filter dropdown
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null);
const [batches, setBatches] = useState<ImportBatch[]>([]);
useEffect(() => {
// Load batches for filter dropdown
api.bulkImport.listBatches({ limit: 100 }).then(setBatches);
}, []);
// Update API call to include batchId filter
const loadImports = async () => {
const params: any = { status: 'PENDING' };
if (selectedBatchId) {
params.batchId = selectedBatchId;
}
const data = await api.imports.listImports(params);
setImports(data);
};
// Add filter UI
<div className="mb-4">
<label className="block text-sm font-medium mb-2">Filter by Batch:</label>
<select
value={selectedBatchId || ''}
onChange={(e) => setSelectedBatchId(e.target.value || null)}
className="border rounded px-3 py-2"
>
<option value="">All Batches</option>
{batches.map(batch => (
<option key={batch.id} value={batch.id}>
{batch.sourceManifest} ({batch.status})
</option>
))}
</select>
</div>
File: modules/frontend/sangita-admin-web/src/pages/ReviewQueue.tsx
Changes:
const [selectedQualityTier, setSelectedQualityTier] = useState<string | null>(null);
const qualityTiers = ['EXCELLENT', 'GOOD', 'FAIR', 'POOR'];
// Add quality tier filter UI
<div className="mb-4">
<label className="block text-sm font-medium mb-2">Filter by Quality Tier:</label>
<select
value={selectedQualityTier || ''}
onChange={(e) => setSelectedQualityTier(e.target.value || null)}
className="border rounded px-3 py-2"
>
<option value="">All Tiers</option>
{qualityTiers.map(tier => (
<option key={tier} value={tier}>{tier}</option>
))}
</select>
</div>
// Update API call
const loadImports = async () => {
const params: any = { status: 'PENDING' };
if (selectedBatchId) params.batchId = selectedBatchId;
if (selectedQualityTier) params.qualityTier = selectedQualityTier;
const data = await api.imports.listImports(params);
setImports(data);
};
File: modules/frontend/sangita-admin-web/src/pages/AutoApproveQueue.tsx (new)
Implementation:
export const AutoApproveQueue: React.FC = () => {
const [imports, setImports] = useState<ImportedKrithi[]>([]);
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null);
const loadQueue = async () => {
const params: any = {};
if (selectedBatchId) params.batchId = selectedBatchId;
const data = await api.imports.getAutoApproveQueue(params);
setImports(data);
};
const handleBulkApprove = async () => {
const importIds = imports.map(i => i.id);
await api.imports.bulkReview({
importIds,
action: 'APPROVE'
});
await loadQueue();
};
return (
<div>
<h1>Auto-Approve Queue</h1>
<button onClick={handleBulkApprove}>
Approve All ({imports.length})
</button>
{/* Import list with quality scores */}
</div>
);
};
POST /v1/admin/imports/bulk-review)GET /v1/admin/imports/auto-approve-queue)shouldAutoApprove() methodgetAutoApproveQueue() with filtering