| Metadata | Value |
|---|---|
| Status | Archived |
| Version | 1.0.0 |
| Last Updated | 2026-09-10 |
| Author | Sangeetha Grantha Team |
| Document Type | Archive |
[!NOTE] Historical evidence: results, counts, commands, and observations below belong to the original work described here. The editorial update date is not a new test or corpus verification. For present behavior, use current ingestion guide.
This document provides a comprehensive analysis of building a production-grade Krithi import capability for Sangeetha Grantha, incorporating data from multiple web sources with rich metadata associations (Krithi, Raga, Deity, Temple/Kshetra). The analysis builds upon existing evaluations and provides strategic recommendations tailored to the application’s architecture and technology stack.
Architecture Alignment: The application’s Kotlin-based architecture (Ktor + Exposed + PostgreSQL) provides a solid foundation for building a custom import pipeline that aligns with existing patterns.
Data Quality Challenge: The primary challenge is not technical orchestration but data quality, cleansing, and entity resolution across heterogeneous sources with varying structures and completeness.
Phased Approach Essential: Given the complexity of musicological data and the need for expert validation, a phased approach starting with a single high-quality source is strongly recommended.
Koog Position: While Koog.ai offers sophisticated orchestration capabilities, the import pipeline’s requirements are better served by custom Kotlin services leveraging coroutines, with potential Koog integration for specific AI-intensive stages.
Human-in-Loop Critical: Automated import must feed into a robust moderation workflow; musical accuracy and scholarly integrity cannot be fully automated.
Current Stack:
sangita-cli)Import Pipeline Technology Constraints:
workflow_state_enum)ImportedKrithi staging modelCritical Entities for Import:
// Primary import staging ImportedKrithi { id: UUID importSourceId: UUID rawTitle: String rawLyrics: String? rawComposer: String? rawRaga: String? rawTala: String? rawDeity: String? rawTemple: String? rawLanguage: String? parsedPayload: JSONB importStatus: ImportStatusEnum // pending, in_review, mapped, rejected mappedKrithiId: UUID? reviewerUserId: UUID? reviewedAt: Instant? }
// Target canonical entities
Krithi {
id: UUID
title: String
incipit: String
composerId: UUID
primaryRagaId: UUID
talaId: UUID?
deityId: UUID?
templeId: UUID?
primaryLanguage: LanguageCodeEnum
musicalForm: MusicalFormEnum
isRagamalika: Boolean
workflowState: WorkflowStateEnum
}
Key Relationships:
KrithiRaga for ragamalika)| Source | Structure | Metadata Quality | Primary Challenge | Estimated Volume |
|---|---|---|---|---|
| karnatik.com/lyrics | Semi-structured HTML | High (Name, Composer, Raga, Tala, Language) | Transliteration variations | 1,000-3,000 krithis |
| guru-guha.blogspot.com (Dikshitar list) | List-based | Medium (Name, Raga, Composer implicit) | Composer implicit (Dikshitar), parsing list format | 300-700 krithis |
| syamakrishnavaibhavam.blogspot.com | List-based | Low-Medium (Name, Raga implied) | Deity implicit (Krishna), incomplete metadata | 400-1,000 krithis |
| thyagaraja-vaibhavam.blogspot.com | List-based | Medium (Composer implicit: Thyagaraja) | Large volume, varying completeness | 500-2,000 krithis |
| templenet.com | Semi-structured directory | High for temples | Linking temple → krithi requires inference | 3,000+ temples |
Challenge 1: Name Normalization
Example Variations:
- "Endaro Mahanubhavulu" / "Entaro Mahanubhavulu" / "Entāro Mahānubhāvulu"
- "Raghuvamsa Sudha" / "Raghuvamsha Sudha" / "Raghuvamśa Sudhā"
Solution:
- Phonetic normalization using `name_normalized` field
- Unicode normalization (NFC)
- Soundex/Metaphone for Indian languages
- Maintain variants in JSONB `parsedPayload`
Challenge 2: Entity Resolution
The most critical technical challenge is resolving raw text to canonical entities:
// Input from source raw = { composer: “Tyagaraja” // Could be: Tyagaraja, Thyagaraja, Tyāgarāja raga: “Mayamalava Gowla” // Could be: Mayamalavagaula, Mayamalavagowla deity: “Rama” // Could be: Rama, Raghuvira, Raghunatha, Sitapati temple: “Tirupati” // Could be: Tirupati, Tirumala, Sri Venkateswara Temple }
// Must resolve to
canonical = {
composerId: UUID("...") // Must map to existing composer
primaryRagaId: UUID("...") // Must map to existing raga
deityId: UUID("...") // May need to create or map
templeId: UUID("...") // May need temple name lookup
}
Complexity Factors:
Challenge 3: Deity-Temple Association
Not all krithis explicitly mention temples. Association strategies:
Challenge 4: Lyric Section Extraction
Carnatic krithis have structured sections:
Extraction Challenge: Sources may not clearly demarcate sections.
Example Raw Text:
"Endaro mahanubhavulu andariki vandanamulu
Endaro mahanubhavulu
[continues as single block]"
Must Parse To:
Pallavi: "Endaro mahanubhavulu"
Anupallavi: [may be combined with pallavi]
Charanam 1: [separate verse]
Solution: Combination of pattern matching and AI-assisted segmentation.
Architecture:
┌─────────────────────────────────────────────────────┐
│ Admin Import API │
│ (Ktor REST Endpoints) │
└──────────────────────┬──────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────┐
│ ImportOrchestrationService │
│ (Kotlin Coroutines + Flow) │
└─┬────────┬─────────┬─────────┬──────────┬───────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌────┐ ┌────┐ ┌────────┐ ┌──────┐ ┌─────────┐
│Web │ │HTML│ │ Entity │ │ Data │ │De-dup │
│Scr-│ │Pars│ │Resolu- │ │Clean-│ │& Valida-│
│aper│ │ing │ │tion │ │sing │ │tion │
└────┘ └────┘ └────────┘ └──────┘ └─────────┘
│ │ │ │ │
└────────┴─────────┴─────────┴──────────┘
│
┌───────────▼────────────┐
│ ImportedKrithi (DB) │
│ (staging table) │
└───────────┬─────────────┘
│
┌───────────▼─────────────┐
│ Review Workflow UI │
│ (React Admin) │
└───────────┬──────────────┘
│
┌───────────▼──────────────┐
│ Canonicalization │
│ (Create Krithi entity) │
└──────────────────────────┘
Implementation Pattern:
// Main orchestration service
class ImportOrchestrationService(
private val webScrapingService: WebScrapingService,
private val extractionService: MetadataExtractionService,
private val entityResolutionService: EntityResolutionService,
private val cleansingService: DataCleansingService,
private val deduplicationService: DeduplicationService,
private val validationService: ValidationService,
private val importRepository: ImportRepository
) {
suspend fun executeImport(sourceConfig: ImportSourceConfig): Flow
// Stage 1: Discovery & Scraping
val urls = webScrapingService.discoverUrls(sourceConfig)
emit(ImportProgress.DiscoveryComplete(urls.size))
// Stage 2: Parallel scraping with rate limiting
val scraped = urls.asFlow()
.buffer(capacity = 10)
.map { url ->
retry(3) { webScrapingService.scrape(url) }
}
.toList()
emit(ImportProgress.ScrapingComplete(scraped.size))
// Stage 3: Metadata extraction (AI-assisted)
val extracted = scraped.map { html ->
extractionService.extract(html, sourceConfig.extractionRules)
}
// Stage 4: Entity resolution
val resolved = extracted.map { metadata ->
entityResolutionService.resolve(metadata)
}
// Stage 5: Data cleansing
val cleansed = resolved.map { data ->
cleansingService.cleanse(data)
}
// Stage 6: De-duplication
val deduped = deduplicationService.detectDuplicates(cleansed)
// Stage 7: Validation
val validated = deduped.map { data ->
validationService.validate(data)
}
// Stage 8: Save to staging
importRepository.batchInsert(validated)
emit(ImportProgress.Complete(validated.size))
} }
// Supporting services follow similar patterns class EntityResolutionService( private val composerRepo: ComposerRepository, private val ragaRepo: RagaRepository, private val deityRepo: DeityRepository, private val templeRepo: TempleRepository ) { suspend fun resolve(metadata: RawMetadata): ResolvedMetadata { return ResolvedMetadata( composerId = resolveComposer(metadata.composerName), ragaId = resolveRaga(metadata.ragaName), deityId = metadata.deityName?.let { resolveDeity(it) }, templeId = metadata.templeName?.let { resolveTemple(it) } ) }
private suspend fun resolveComposer(name: String): UUID {
// Try exact match first
composerRepo.findByName(name)?.let { return it.id }
// Try normalized match
val normalized = normalize(name)
composerRepo.findByNormalizedName(normalized)?.let { return it.id }
// Try fuzzy match with trigram similarity
val candidates = composerRepo.findSimilar(name, threshold = 0.85)
if (candidates.size == 1) return candidates.first().id
// Multiple matches or no match - flag for review
throw EntityResolutionException.AmbiguousComposer(name, candidates)
}
```text
// Similar patterns for raga, deity, temple } ```
Advantages:
Disadvantages:
Cost Estimate:
Recommendation Fit: ⭐⭐⭐⭐⭐ (5/5)
Architecture:
┌─────────────────────────────────────────────────────┐
│ Admin Import Trigger API │
└──────────────────────┬──────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────┐
│ Koog Agent Orchestrator │
│ (Graph-based workflow, LLM integration) │
└─┬────────┬─────────┬─────────┬──────────┬───────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌────┐ ┌────┐ ┌────────┐ ┌──────┐ ┌─────────┐
│Scr-│ │Extr│ │ Entity │ │Clean-│ │Valida- │
│ape │ │act │ │Resolve │ │sing │ │tion │
│Node│ │Node│ │Node │ │Node │ │Node │
│ │ │(LLM│ │(Tools) │ │ │ │ │
└────┘ └────┘ └────────┘ └──────┘ └─────────┘
│ │ │ │ │
└────────┴─────────┴─────────┴──────────┘
│
┌───────────▼────────────┐
│ Kotlin Services │
│ (Called as Tools) │
└────────────────────────┘
Koog Agent Configuration:
val importAgent = agent { name = “krithi-import-agent” description = “Orchestrates krithi import from web sources”
llm = GeminiProvider(
model = "gemini-2.0-flash-exp",
apiKey = config.geminiApiKey
)
tools = listOf(
scrapeUrlTool,
extractMetadataTool,
resolveComposerTool,
resolveRagaTool,
resolveDeityTool,
validateKrithiTool
)
graph {
val discover = node("discover") { discoverUrls(source) }
val scrape = node("scrape") { scrapeUrl(url) }
val extract = node("extract") {
// LLM-powered extraction
extractWithAI(html)
}
val resolve = node("resolve") {
// Tool calling for entity resolution
resolveEntities(metadata)
}
val validate = node("validate") { validateData(resolved) }
val stage = node("stage") { saveToStaging(validated) }
discover -> scrape -> extract -> resolve -> validate -> stage
}
retryPolicy {
maxRetries = 3
backoffStrategy = ExponentialBackoff(
initialDelay = 1.seconds,
maxDelay = 30.seconds
)
}
tracing {
exporter = OpenTelemetryExporter()
} }
// Tool definitions
val resolveComposerTool = tool(“resolve_composer”) {
description = “Resolve composer name to canonical UUID”
parameter
```text
execute { name ->
entityResolutionService.resolveComposer(name)
} } ```
Advantages:
Disadvantages:
Cost Estimate:
Recommendation Fit: ⭐⭐⭐ (3/5)
Strategic Use of Koog: Rather than using Koog for the entire pipeline, use it selectively for AI-intensive stages:
// Hybrid approach: Custom orchestration + Koog for extraction suspend fun executeImport(source: ImportSource): ImportResult { val urls = webScrapingService.discover(source) val scraped = urls.map { scrape(it) }
// Use Koog only for AI-powered extraction
val extracted = scraped.map { html ->
koogExtractionAgent.extract(html) // LLM-powered
}
// Back to custom Kotlin for deterministic stages
val resolved = extracted.map { entityResolutionService.resolve(it) }
val validated = resolved.map { validationService.validate(it) }
```text
return importRepository.batchInsert(validated) } ```
Architecture:
┌─────────────────────────────────────────────────────┐
│ Airflow Web UI │
│ (Monitoring, Scheduling, Logs) │
└──────────────────────┬──────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────┐
│ Airflow DAG Orchestrator │
│ (Python-based workflow definition) │
└─┬────────┬─────────┬─────────┬──────────┬───────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌────┐ ┌────┐ ┌────────┐ ┌──────┐ ┌─────────┐
│HTTP│ │HTTP│ │ HTTP │ │ HTTP │ │ HTTP │
│Call│ │Call│ │ Call │ │ Call │ │ Call │
└─┬──┘ └─┬──┘ └────┬───┘ └──┬───┘ └────┬────┘
│ │ │ │ │
└────────┴─────────┴─────────┴──────────┘
│
┌───────────▼────────────┐
│ Ktor Import APIs │
│ (Kotlin Services) │
└────────────────────────┘
DAG Definition (Python):
from airflow import DAG from airflow.operators.http import SimpleHttpOperator from datetime import datetime, timedelta
default_args = { ‘owner’: ‘sangita-grantha’, ‘depends_on_past’: False, ‘start_date’: datetime(2026, 1, 1), ‘email_on_failure’: True, ‘retries’: 3, ‘retry_delay’: timedelta(minutes=5), }
with DAG( ‘krithi_import_pipeline’, default_args=default_args, schedule_interval=’@weekly’, # Run weekly catchup=False ) as dag:
# Stage 1: Trigger discovery
discover = SimpleHttpOperator(
task_id='discover_urls',
http_conn_id='sangita_api',
endpoint='/api/admin/import/discover',
method='POST',
data=json.dumps({'source': 'karnatik_com'}),
headers={"Content-Type": "application/json"},
)
# Stage 2: Scrape URLs
scrape = SimpleHttpOperator(
task_id='scrape_content',
http_conn_id='sangita_api',
endpoint='/api/admin/import/scrape',
method='POST',
)
# Stage 3: Extract metadata
extract = SimpleHttpOperator(
task_id='extract_metadata',
http_conn_id='sangita_api',
endpoint='/api/admin/import/extract',
method='POST',
)
# Stage 4: Resolve entities
resolve = SimpleHttpOperator(
task_id='resolve_entities',
http_conn_id='sangita_api',
endpoint='/api/admin/import/resolve',
method='POST',
)
# Stage 5: De-duplicate
dedupe = SimpleHttpOperator(
task_id='deduplicate',
http_conn_id='sangita_api',
endpoint='/api/admin/import/dedupe',
method='POST',
)
# Stage 6: Validate
validate = SimpleHttpOperator(
task_id='validate',
http_conn_id='sangita_api',
endpoint='/api/admin/import/validate',
method='POST',
)
# Define dependencies
```text
discover >> scrape >> extract >> resolve >> dedupe >> validate ```
Advantages:
Disadvantages:
Cost Estimate:
Recommendation Fit: ⭐⭐ (2/5)
Phase 1: Custom Kotlin Orchestration (MVP)
Phase 2: Enhanced with Selective Koog (if extraction complexity warrants)
Phase 3: Scale & Optimize
Airflow Consideration: Only if import frequency increases significantly (daily/hourly) and multiple parallel pipelines needed.
Challenge: Multiple composers with similar names, transliteration variations.
Strategy:
class ComposerResolutionService( private val composerRepo: ComposerRepository ) { suspend fun resolve(rawName: String): ComposerResolutionResult { // Step 1: Exact match composerRepo.findByName(rawName)?.let { return ComposerResolutionResult.Resolved(it.id, confidence = 1.0) }
// Step 2: Normalized match
val normalized = normalizeIndianName(rawName)
composerRepo.findByNormalizedName(normalized)?.let {
return ComposerResolutionResult.Resolved(it.id, confidence = 0.95)
}
// Step 3: Fuzzy match using PostgreSQL trigrams
val candidates = composerRepo.findSimilar(rawName, threshold = 0.75)
return when {
candidates.isEmpty() ->
ComposerResolutionResult.NotFound(rawName)
candidates.size == 1 && candidates.first().similarity > 0.85 ->
ComposerResolutionResult.Resolved(
candidates.first().id,
confidence = candidates.first().similarity
)
else ->
ComposerResolutionResult.Ambiguous(rawName, candidates)
}
}
private fun normalizeIndianName(name: String): String {
return name
.lowercase()
.replace("ā", "a")
.replace("ī", "i")
.replace("ū", "u")
.replace("ṭ", "t")
.replace("ḍ", "d")
.replace("ṇ", "n")
.replace("ś", "s")
.replace("ṣ", "s")
// ... more normalizations
.replace(Regex("\\s+"), " ")
.trim()
} }
// Repository implementation using Exposed
class ComposerRepositoryImpl : ComposerRepository {
suspend fun findSimilar(name: String, threshold: Double): List<ComposerSimilarity> {
return transaction {
// Use PostgreSQL pg_trgm extension
Composers
.select {
Composers.name.similarity(name) greaterEq threshold
}
.orderBy(Composers.name.similarity(name), SortOrder.DESC)
.limit(5)
.map { row ->
ComposerSimilarity(
id = row[Composers.id].value,
name = row[Composers.name],
similarity = calculateSimilarity(name, row[Composers.name])
)
}
}
}
}
Requires PostgreSQL Extension: CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_composers_name_trgm ON composers USING gin (name gin_trgm_ops);
CREATE INDEX idx_composers_normalized_trgm ON composers
USING gin (name_normalized gin_trgm_ops);
Additional Complexity: Ragas have parent/child relationships (melakarta/janya).
class RagaResolutionService( private val ragaRepo: RagaRepository ) { suspend fun resolve(rawName: String): RagaResolutionResult { // Similar to composer, but also consider: // 1. Melakarta number if provided // 2. Parent raga relationships // 3. Arohanam/Avarohanam if available
val normalized = normalizeRagaName(rawName)
// Try exact match first
ragaRepo.findByName(normalized)?.let {
return RagaResolutionResult.Resolved(it.id, confidence = 1.0)
}
// Try alias/variant lookup
ragaRepo.findByVariant(normalized)?.let {
return RagaResolutionResult.Resolved(it.id, confidence = 0.95)
}
// Fuzzy match
val candidates = ragaRepo.findSimilar(normalized, threshold = 0.80)
return when {
candidates.isEmpty() ->
RagaResolutionResult.NotFound(rawName)
candidates.size == 1 && candidates.first().similarity > 0.90 ->
RagaResolutionResult.Resolved(
candidates.first().id,
confidence = candidates.first().similarity
)
else ->
RagaResolutionResult.Ambiguous(rawName, candidates)
}
}
```kotlin
private fun normalizeRagaName(name: String): String {
return name
.lowercase()
.replace("ā", "a")
.replace("ō", "o")
// Common variations
.replace("gaula", "goula")
.replace("gowla", "goula")
.trim()
} } ```
Enhancement: AI-Assisted Ambiguity Resolution
For ambiguous cases, use Gemini to help disambiguate:
suspend fun resolveAmbiguousRaga(
rawName: String,
candidates: List
```text
return aiResponse.bestMatch?.let { matchedId ->
RagaResolutionResult.Resolved(
matchedId,
confidence = aiResponse.confidence,
method = ResolutionMethod.AI_ASSISTED
)
} ?: RagaResolutionResult.RequiresManualReview(rawName, candidates) } ```
Challenge: One deity, many names (epithets, regional variations).
Solution: Synonym Graph
class DeityResolutionService( private val deityRepo: DeityRepository, private val deitySynonymGraph: DeitySynonymGraph ) { suspend fun resolve(rawName: String): DeityResolutionResult { // Step 1: Lookup in synonym graph val canonicalName = deitySynonymGraph.getCanonical(rawName)
// Step 2: Lookup canonical entity
deityRepo.findByName(canonicalName)?.let {
return DeityResolutionResult.Resolved(it.id, confidence = 0.95)
}
// Step 3: Fuzzy match as fallback
val candidates = deityRepo.findSimilar(rawName, threshold = 0.80)
return when {
candidates.isEmpty() ->
DeityResolutionResult.NotFound(rawName)
candidates.size == 1 ->
DeityResolutionResult.Resolved(
candidates.first().id,
confidence = candidates.first().similarity
)
else ->
DeityResolutionResult.Ambiguous(rawName, candidates)
}
} }
// Synonym graph (can be loaded from JSONB config) class DeitySynonymGraph { private val synonyms = mapOf( “vishnu” to setOf(“perumal”, “narayana”, “hari”, “govinda”, “madhava”, “venkateshwara”, “venkateswara”), “rama” to setOf(“raghuvira”, “raghunatha”, “dasarathi”, “kodanda rama”, “sitapati”), “krishna” to setOf(“gopala”, “madhava”, “yadava”, “devaki nandana”, “vaasudeva”, “keshava”), “shiva” to setOf(“shankara”, “ishwara”, “mahadeva”, “hara”, “chandrashekara”, “parameshwara”), “murugan” to setOf(“kartikeya”, “skanda”, “subramanya”, “guha”, “shanmukha”, “kumaraswamy”), “ganesha” to setOf(“ganapati”, “vinayaka”, “pillayar”, “vighneshwara”, “lambodara”) )
fun getCanonical(input: String): String {
val normalized = input.lowercase().trim()
// Direct canonical match
if (synonyms.containsKey(normalized)) return normalized
// Find in synonyms
synonyms.forEach { (canonical, syns) ->
if (syns.contains(normalized)) return canonical
}
```text
return normalized // No match, return as-is
} } ```
**Requires Data Seeding:**
-- Seed with comprehensive synonym data
INSERT INTO deities (id, name, name_normalized, description) VALUES
(gen_random_uuid(), 'Vishnu', 'vishnu', 'Primary deity in Vaishnavism'),
(gen_random_uuid(), 'Rama', 'rama', 'Avatar of Vishnu'),
(gen_random_uuid(), 'Krishna', 'krishna', 'Avatar of Vishnu'),
(gen_random_uuid(), 'Shiva', 'shiva', 'Primary deity in Shaivism'),
(gen_random_uuid(), 'Murugan', 'murugan', 'Son of Shiva, Subramanya'),
(gen_random_uuid(), 'Ganesha', 'ganesha', 'Son of Shiva');
Challenge: Temples have multiple names (historical, regional, deity-based).
Strategy: Use temple_names Table
class TempleResolutionService( private val templeRepo: TempleRepository, private val templeNameRepo: TempleNameRepository ) { suspend fun resolve(rawName: String, deityContext: UUID?): TempleResolutionResult { // Step 1: Exact match in temple_names templeNameRepo.findByName(rawName)?.let { templeName -> return TempleResolutionResult.Resolved( templeName.templeId, confidence = 0.98 ) }
// Step 2: Normalized match
val normalized = normalizeTempleName(rawName)
templeNameRepo.findByNormalizedName(normalized)?.let { templeName ->
return TempleResolutionResult.Resolved(
templeName.templeId,
confidence = 0.95
)
}
// Step 3: Fuzzy match with deity context
val candidates = if (deityContext != null) {
// Narrow search to temples with this deity
templeRepo.findSimilarWithDeity(normalized, deityContext, threshold = 0.75)
} else {
templeRepo.findSimilar(normalized, threshold = 0.75)
}
return when {
candidates.isEmpty() ->
TempleResolutionResult.NotFound(rawName)
candidates.size == 1 && candidates.first().similarity > 0.85 ->
TempleResolutionResult.Resolved(
candidates.first().id,
confidence = candidates.first().similarity
)
else ->
TempleResolutionResult.Ambiguous(rawName, candidates)
}
}
```kotlin
private fun normalizeTempleName(name: String): String {
return name
.lowercase()
.removePrefix("sri ")
.removePrefix("shri ")
.removeSuffix(" temple")
.removeSuffix(" kovil")
.removeSuffix(" devasthanam")
.replace(Regex("\\s+"), " ")
.trim()
} } ```
Temple-Deity Linkage:
Many krithis don’t explicitly mention temples but do mention deities. The system should support:
krithis.temple_iddeity_id, leave temple_id NULLLevel 1: Exact Hash Match
class DeduplicationService(
private val importedKrithiRepo: ImportedKrithiRepository
) {
suspend fun detectDuplicates(batch: List
val exactDuplicates = batch.filter {
it.computeHash() in existingHashes
}
// Remove exact duplicates
val remaining = batch - exactDuplicates.toSet()
// Level 2: Fuzzy match
val fuzzyDuplicates = detectFuzzyDuplicates(remaining)
// Level 3: Semantic similarity
val semanticDuplicates = detectSemanticDuplicates(
remaining - fuzzyDuplicates.toSet()
)
return DeduplicationResult(
exactDuplicates = exactDuplicates,
fuzzyDuplicates = fuzzyDuplicates,
semanticDuplicates = semanticDuplicates,
unique = remaining - fuzzyDuplicates.toSet() - semanticDuplicates.toSet()
)
}
```kotlin
private fun ImportedKrithi.computeHash(): String {
// Hash on normalized title + composer + raga
val normalized = "${this.rawTitle.normalize()}_${this.rawComposer?.normalize()}_${this.rawRaga?.normalize()}"
return normalized.sha256()
} } ```
Level 2: Fuzzy String Matching
private suspend fun detectFuzzyDuplicates(batch: List
for (candidate in batch) {
val similar = importedKrithiRepo.findSimilarByTitle(
title = candidate.rawTitle,
threshold = 0.90 // 90% similarity
)
if (similar.isNotEmpty()) {
// Check if composer + raga also match
val confirmedDupe = similar.any { existing ->
composerMatch(candidate.rawComposer, existing.rawComposer) &&
ragaMatch(candidate.rawRaga, existing.rawRaga)
}
if (confirmedDupe) {
duplicates.add(candidate)
}
}
}
return duplicates }
private fun composerMatch(a: String?, b: String?): Boolean {
if (a == null || b == null) return false
val simil = Levenshtein.ratio(a.normalize(), b.normalize())
return simil > 0.85
}
Level 3: Semantic Similarity (LLM-Assisted)
For cases where title/composer/raga are unclear or transliterated differently:
private suspend fun detectSemanticDuplicates(batch: List
for (candidate in batch) {
// Use first line of lyrics (pallavi) for semantic comparison
val pallavi = candidate.extractPallavi()
if (pallavi != null && pallavi.length > 20) {
// Search existing krithis by similar incipit
val semanticallySimilar = krithiRepo.searchByIncipit(pallavi)
if (semanticallySimilar.isNotEmpty()) {
// Use LLM to confirm if they're the same krithi
val isDuplicate = geminiService.compareLyrics(
lyric1 = pallavi,
lyric2 = semanticallySimilar.first().incipit
)
if (isDuplicate.confidence > 0.9) {
duplicates.add(candidate)
}
}
}
}
```text
return duplicates } ```
When duplicates are detected, determine merge strategy:
sealed class DuplicateAction { data class DiscardNew(val reason: String) : DuplicateAction() data class MergeData(val existing: UUID, val newData: ImportedKrithi) : DuplicateAction() data class FlagForReview(val existingId: UUID, val newId: UUID) : DuplicateAction() }
fun decideDuplicateAction( existing: ImportedKrithi, new: ImportedKrithi ): DuplicateAction { // If existing is already mapped, discard new if (existing.mappedKrithiId != null) { return DuplicateAction.DiscardNew(“Already mapped to canonical krithi”) }
// If new has more complete data, prefer merge
val existingScore = existing.completenessScore()
val newScore = new.completenessScore()
return when {
newScore > existingScore + 0.2 ->
DuplicateAction.MergeData(existing.id, new)
abs(newScore - existingScore) < 0.2 ->
DuplicateAction.FlagForReview(existing.id, new.id)
else ->
DuplicateAction.DiscardNew("Existing record is more complete")
} }
private fun ImportedKrithi.completenessScore(): Double {
var score = 0.0
if (rawTitle.isNotBlank()) score += 0.2
if (rawComposer != null) score += 0.15
if (rawRaga != null) score += 0.15
if (rawTala != null) score += 0.1
if (rawDeity != null) score += 0.1
if (rawTemple != null) score += 0.1
if (rawLyrics != null && rawLyrics.length > 100) score += 0.2
return score
}
class ValidationService(
private val composerRepo: ComposerRepository,
private val ragaRepo: RagaRepository,
private val talaRepo: TalaRepository
) {
suspend fun validate(data: ImportedKrithi): ValidationResult {
val errors = mutableListOf
// Required field validation
if (data.rawTitle.isBlank()) {
errors.add(ValidationError.MissingTitle)
}
// Entity existence validation
if (data.resolvedComposerId != null) {
if (!composerRepo.exists(data.resolvedComposerId)) {
errors.add(ValidationError.InvalidComposer(data.resolvedComposerId))
}
} else {
warnings.add(ValidationWarning.MissingComposer)
}
// Musicological validation
if (data.resolvedRagaId != null) {
val raga = ragaRepo.findById(data.resolvedRagaId)
if (raga == null) {
errors.add(ValidationError.InvalidRaga(data.resolvedRagaId))
} else if (raga.parentRagaId != null) {
// Janya raga - ensure it's known
warnings.add(ValidationWarning.JanyaRaga(raga.name))
}
}
// Deity-temple consistency
if (data.resolvedTempleId != null && data.resolvedDeityId != null) {
val temple = templeRepo.findById(data.resolvedTempleId)
if (temple != null && temple.primaryDeityId != data.resolvedDeityId) {
warnings.add(
ValidationWarning.DeityTempleMismatch(
deityId = data.resolvedDeityId,
templeId = data.resolvedTempleId
)
)
}
}
// Lyrics section structure (if available)
if (data.rawLyrics != null) {
val sectionCheck = validateLyricStructure(data.rawLyrics)
warnings.addAll(sectionCheck.warnings)
}
return ValidationResult(
isValid = errors.isEmpty(),
errors = errors,
warnings = warnings
)
}
private fun validateLyricStructure(lyrics: String): SectionValidation {
val warnings = mutableListOf<ValidationWarning>()
// Check if sections can be identified
if (!lyrics.contains("pallavi", ignoreCase = true) &&
!lyrics.contains("anupallavi", ignoreCase = true)) {
warnings.add(ValidationWarning.UnclearLyricStructure)
}
// Check for minimum content
if (lyrics.split("\n").size < 3) {
warnings.add(ValidationWarning.IncompleteLyrics)
}
return SectionValidation(warnings)
} }
sealed class ValidationError { object MissingTitle : ValidationError() data class InvalidComposer(val id: UUID) : ValidationError() data class InvalidRaga(val id: UUID) : ValidationError() }
sealed class ValidationWarning {
object MissingComposer : ValidationWarning()
data class JanyaRaga(val name: String) : ValidationWarning()
data class DeityTempleMismatch(val deityId: UUID, val templeId: UUID) : ValidationWarning()
object UnclearLyricStructure : ValidationWarning()
object IncompleteLyrics : ValidationWarning()
}
Assign quality scores to guide manual review prioritization:
fun ImportedKrithi.calculateQualityScore(): QualityScore { var score = 0.0 val factors = mutableMapOf<String, Double>()
// Completeness (40%)
factors["completeness"] = this.completenessScore() * 0.4
// Resolution confidence (30%)
val resolutionConf = listOfNotNull(
this.composerResolutionConfidence,
this.ragaResolutionConfidence,
this.deityResolutionConfidence,
this.templeResolutionConfidence
).average()
factors["resolution"] = resolutionConf * 0.3
// Source reliability (20%)
val sourceScore = when (this.importSourceId) {
KARNATIK_COM_SOURCE -> 0.95 // High quality source
GURUGUHA_BLOG_SOURCE -> 0.80
THYAGARAJA_BLOG_SOURCE -> 0.85
else -> 0.70
}
factors["source"] = sourceScore * 0.2
// Validation pass (10%)
factors["validation"] = if (this.validationErrors.isEmpty()) 0.1 else 0.0
score = factors.values.sum()
return QualityScore(
overall = score,
factors = factors,
tier = when {
score >= 0.9 -> QualityTier.EXCELLENT
score >= 0.75 -> QualityTier.GOOD
score >= 0.60 -> QualityTier.FAIR
else -> QualityTier.POOR
}
) }
enum class QualityTier {
EXCELLENT, // Auto-approve for publication
GOOD, // Quick review
FAIR, // Standard review
POOR // Detailed review or discard
}
Objective: Build core infrastructure and single-source POC
Deliverables:
ImportOrchestrationService (Kotlin coroutines)WebScrapingService (JSoup/Ktor client)ImportedKrithi repository & CRUDArchitecture:
// Service layer
interface ImportOrchestrationService {
suspend fun executeImport(config: ImportSourceConfig): Flow
// API layer
fun Route.importRoutes() {
authenticate(“admin”) {
post(“/api/admin/import/execute”) {
val config = call.receive
launch {
importOrchestrationService
.executeImport(config)
.collect { progress ->
// Update batch status in DB
importBatchRepo.updateProgress(batchId, progress)
}
}
call.respond(HttpStatusCode.Accepted, ImportBatchResponse(batchId))
}
```kotlin
get("/api/admin/import/batches/{batchId}") {
val batchId = UUID.fromString(call.parameters["batchId"]!!)
val status = importOrchestrationService.getImportStatus(batchId)
call.respond(status)
}
} } ```
Data Model Additions: – Track import batches CREATE TABLE import_batches ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), import_source_id UUID NOT NULL REFERENCES import_sources(id), initiated_by_user_id UUID NOT NULL REFERENCES users(id), status VARCHAR(50) NOT NULL, – running, completed, failed total_urls INT, processed_urls INT, successful_imports INT, failed_imports INT, started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), completed_at TIMESTAMPTZ, error_message TEXT );
-- Detailed import logs
CREATE TABLE import_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
batch_id UUID NOT NULL REFERENCES import_batches(id),
stage VARCHAR(50) NOT NULL, -- scraping, extraction, validation, etc.
level VARCHAR(20) NOT NULL, -- info, warning, error
message TEXT NOT NULL,
metadata JSONB,
logged_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Testing:
imported_krithis with correct statusObjective: Robust entity resolution and duplicate detection
Deliverables:
ComposerResolutionService with fuzzy matchingRagaResolutionService with melakarta supportDeityResolutionService with synonym graphTempleResolutionService with multi-name supportDeduplicationService with multi-level detectionImplementation:
// Orchestration integration
suspend fun executeImport(config: ImportSourceConfig): Flow
// Stage 4: Entity Resolution
val resolved = extracted.map { metadata ->
val composerId = try {
composerResolutionService.resolve(metadata.composer).getOrThrow()
} catch (e: EntityResolutionException) {
emit(ImportProgress.EntityResolutionWarning(metadata.url, e))
null
}
val ragaId = try {
ragaResolutionService.resolve(metadata.raga).getOrThrow()
} catch (e: EntityResolutionException) {
emit(ImportProgress.EntityResolutionWarning(metadata.url, e))
null
}
// ... deityId, templeId
ResolvedMetadata(
raw = metadata,
composerId = composerId,
ragaId = ragaId,
deityId = deityId,
templeId = templeId,
resolutionWarnings = listOf(/* ... */)
)
}
emit(ImportProgress.EntityResolutionComplete(resolved.size))
// Stage 5: De-duplication
val deduped = deduplicationService.detectDuplicates(resolved)
emit(ImportProgress.DuplicatesDetected(
exact = deduped.exactDuplicates.size,
fuzzy = deduped.fuzzyDuplicates.size
))
```kotlin
// Continue with unique records only
val unique = deduped.unique
// ... } ```
Testing:
Objective: Quality checks and staging for review
Deliverables:
ValidationService with musicological rulesimported_krithisValidation Integration: // Stage 6: Validation val validated = unique.map { resolved -> val validationResult = validationService.validate(resolved) val qualityScore = resolved.calculateQualityScore()
ImportedKrithi(
id = UUID.randomUUID(),
importSourceId = config.sourceId,
batchId = batchId,
rawTitle = resolved.raw.title,
rawLyrics = resolved.raw.lyrics,
rawComposer = resolved.raw.composer,
rawRaga = resolved.raw.raga,
rawDeity = resolved.raw.deity,
rawTemple = resolved.raw.temple,
parsedPayload = resolved.toJsonb(),
resolvedComposerId = resolved.composerId,
resolvedRagaId = resolved.ragaId,
resolvedDeityId = resolved.deityId,
resolvedTempleId = resolved.templeId,
validationErrors = validationResult.errors,
validationWarnings = validationResult.warnings,
qualityScore = qualityScore.overall,
qualityTier = qualityScore.tier,
importStatus = if (validationResult.isValid)
ImportStatusEnum.PENDING
else
ImportStatusEnum.IN_REVIEW
) }
// Stage 7: Batch insert importedKrithiRepo.batchInsert(validated)
emit(ImportProgress.Complete(
total = validated.size,
excellent = validated.count { it.qualityTier == QualityTier.EXCELLENT },
good = validated.count { it.qualityTier == QualityTier.GOOD },
fair = validated.count { it.qualityTier == QualityTier.FAIR },
poor = validated.count { it.qualityTier == QualityTier.POOR }
))
Dashboard UI (React):
Objective: Enable expert review and canonicalization
Deliverables:
ImportedKrithi recordsReview UI Features:
1. Import Review Queue // React component interface ImportReviewQueueProps { qualityFilter?: QualityTier; sourceFilter?: string; statusFilter?: ImportStatus; }
const ImportReviewQueue: React.FC
return (
<div className="review-queue">
<Filters ... />
<Table>
<thead>
<tr>
<th>Title</th>
<th>Composer</th>
<th>Raga</th>
<th>Quality</th>
<th>Warnings</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{imports?.map(imp => (
<ImportRow
key={imp.id}
import={imp}
onReview={() => openReviewModal(imp)}
/>
))}
</tbody>
</Table>
</div>
);
};
2. Detailed Review Modal const ImportReviewModal: React.FC<{ import: ImportedKrithi }> = ({ import }) => { const [composerOverride, setComposerOverride] = useState<UUID | null>(null); const [ragaOverride, setRagaOverride] = useState<UUID | null>(null);
const handleApprove = async () => { await api.canonicalizeImport({ importId: import.id, composerId: composerOverride ?? import.resolvedComposerId, ragaId: ragaOverride ?? import.resolvedRagaId, // … other overrides }); };
return (
<Modal title={Review: ${import.rawTitle}}>
<Section title="Raw Data">
<Section title="Resolved Entities">
<EntityField
label="Composer"
resolvedId={import.resolvedComposerId}
onOverride={setComposerOverride}
/>
<EntityField
label="Raga"
resolvedId={import.resolvedRagaId}
onOverride={setRagaOverride}
/>
{/* ... */}
</Section>
<Section title="Validation">
{import.validationErrors.map(err => (
<Alert severity="error">{err.message}</Alert>
))}
{import.validationWarnings.map(warn => (
<Alert severity="warning">{warn.message}</Alert>
))}
</Section>
```text
<Actions>
<Button onClick={handleApprove}>Approve & Create Krithi</Button>
<Button onClick={handleReject}>Reject</Button>
<Button onClick={handleEdit}>Edit Raw Data</Button>
</Actions>
</Modal> ); }; ```
3. Canonicalization API
post(“/api/admin/import/{id}/canonicalize”) {
val importId = UUID.fromString(call.parameters[“id”]!!)
val overrides = call.receive
val imported = importedKrithiRepo.findById(importId)
?: throw NotFoundException("Import not found")
// Create canonical Krithi
val krithiId = transaction {
// 1. Create Krithi entity
val krithi = Krithi(
id = UUID.randomUUID(),
title = imported.rawTitle,
incipit = imported.extractIncipit(),
composerId = overrides.composerId ?: imported.resolvedComposerId!!,
primaryRagaId = overrides.ragaId ?: imported.resolvedRagaId!!,
talaId = overrides.talaId ?: imported.resolvedTalaId,
deityId = overrides.deityId ?: imported.resolvedDeityId,
templeId = overrides.templeId ?: imported.resolvedTempleId,
primaryLanguage = imported.rawLanguage?.let { LanguageCodeEnum.valueOf(it) } ?: LanguageCodeEnum.SA,
musicalForm = MusicalFormEnum.KRITHI,
isRagamalika = false,
workflowState = WorkflowStateEnum.DRAFT, // Start as draft
createdByUserId = call.principal<UserIdPrincipal>()!!.userId
)
krithiRepo.insert(krithi)
// 2. Create lyric variant
if (imported.rawLyrics != null) {
val lyricVariant = KrithiLyricVariant(
id = UUID.randomUUID(),
krithiId = krithi.id,
language = krithi.primaryLanguage,
script = inferScript(krithi.primaryLanguage),
isPrimary = true,
lyrics = imported.rawLyrics,
createdByUserId = krithi.createdByUserId
)
krithiLyricVariantRepo.insert(lyricVariant)
// 3. Parse and create sections (if possible)
val sections = lyricParser.parseIntoSections(imported.rawLyrics)
sections.forEach { section ->
krithiSectionRepo.insert(section)
}
}
// 4. Update imported krithi with mapping
importedKrithiRepo.update(
imported.copy(
mappedKrithiId = krithi.id,
importStatus = ImportStatusEnum.MAPPED,
reviewerUserId = krithi.createdByUserId,
reviewedAt = Clock.System.now()
)
)
krithi.id
}
```text
call.respond(HttpStatusCode.Created, CanonicalizationResponse(krithiId)) } ```
Testing:
Objective: Extend to all 4 sources, optimize, production-ready
Deliverables:
Source-Specific Scrapers:
// Abstract base
abstract class KrithiSourceScraper {
abstract val sourceId: UUID
abstract suspend fun discoverUrls(): List
// Karnatik.com implementation class KarnatikComScraper( private val httpClient: HttpClient ) : KrithiSourceScraper() { override val sourceId = KARNATIK_COM_SOURCE_ID
override suspend fun discoverUrls(): List<String> {
val indexPage = httpClient.get("https://karnatik.com/lyrics.shtml").bodyAsText()
val doc = Jsoup.parse(indexPage)
return doc.select("a[href*='lyrics']")
.map { it.attr("abs:href") }
.filter { it.contains("/lyrics/") }
.distinct()
}
override suspend fun scrape(url: String): RawHtml {
val html = httpClient.get(url).bodyAsText()
return RawHtml(url = url, content = html, scrapedAt = Clock.System.now())
}
override suspend fun extract(html: RawHtml): RawMetadata {
val doc = Jsoup.parse(html.content)
// Karnatik.com has structured format
val title = doc.selectFirst("h1")?.text() ?: throw ExtractionException("No title")
val composer = doc.selectFirst("div.composer")?.text()
val raga = doc.selectFirst("div.raga")?.text()
val tala = doc.selectFirst("div.tala")?.text()
val lyrics = doc.selectFirst("div.lyrics")?.text()
return RawMetadata(
sourceUrl = html.url,
title = title,
composer = composer,
raga = raga,
tala = tala,
lyrics = lyrics
)
} }
// Guru-Guha blog implementation (Dikshitar list - list-based, not unstructured blog posts) class GuruGuhaBlogScraper( private val httpClient: HttpClient ) : KrithiSourceScraper() { override val sourceId = GURUGUHA_BLOG_SOURCE_ID
override suspend fun discoverUrls(): List<String> {
// This blog has composer-specific list pages
// Known URLs:
return listOf(
"https://guru-guha.blogspot.com/2009/04/dikshitar-kritis-alphabetical-list.html",
// May have other composer lists - discover by crawling blog archive
)
}
override suspend fun scrape(url: String): RawHtml {
val html = httpClient.get(url).bodyAsText()
return RawHtml(url = url, content = html, scrapedAt = Clock.System.now())
}
override suspend fun extract(html: RawHtml): RawMetadata {
val doc = Jsoup.parse(html.content)
val postContent = doc.selectFirst("div.post-body")?.html()
?: throw ExtractionException("No post content")
// Determine composer from URL or page title
val composer = when {
html.url.contains("dikshitar") -> "Muthuswami Dikshitar"
html.url.contains("tyagaraja") -> "Thyagaraja"
else -> extractComposerFromTitle(doc)
}
// Parse list format - typically structured as:
// <p>Krithi Name - Raga Name</p> or
// <li>Krithi Name (Raga Name)</li>
val entries = parseListEntries(doc, postContent)
// This scraper returns multiple krithis, so we need to handle differently
// For now, return as single RawMetadata with all entries in parsedPayload
return RawMetadata(
sourceUrl = html.url,
title = "Batch Import",
composer = composer,
raga = null, // Multiple ragas in list
lyrics = null,
batchEntries = entries // Special field for list-based sources
)
}
private fun parseListEntries(doc: Document, html: String): List<KrithiEntry> {
val entries = mutableListOf<KrithiEntry>()
// Try multiple patterns for list parsing
// Pattern 1: <p>Name - Raga</p>
doc.select("div.post-body p").forEach { p ->
val text = p.text()
val match = Regex("(.+?)\\s*[-–—]\\s*(.+)").find(text)
if (match != null) {
entries.add(KrithiEntry(
title = match.groupValues[1].trim(),
raga = match.groupValues[2].trim()
))
}
}
// Pattern 2: <li>Name (Raga)</li>
if (entries.isEmpty()) {
doc.select("div.post-body li").forEach { li ->
val text = li.text()
val match = Regex("(.+?)\\s*\\((.+?)\\)").find(text)
if (match != null) {
entries.add(KrithiEntry(
title = match.groupValues[1].trim(),
raga = match.groupValues[2].trim()
))
}
}
}
return entries
}
private fun extractComposerFromTitle(doc: Document): String? {
val title = doc.title().lowercase()
return when {
"dikshitar" in title -> "Muthuswami Dikshitar"
"tyagaraja" in title || "thyagaraja" in title -> "Thyagaraja"
"syama sastri" in title -> "Syama Sastri"
else -> null
}
} }
data class KrithiEntry( val title: String, val raga: String? )
// Register all scrapers
val scraperRegistry = mapOf(
KARNATIK_COM_SOURCE_ID to KarnatikComScraper(httpClient),
GURUGUHA_BLOG_SOURCE_ID to GuruGuhaBlogScraper(httpClient), // List-based, no AI needed
SYAMAKRISHNA_BLOG_SOURCE_ID to SyamaKrishnaBlogScraper(httpClient), // Similar list format
THYAGARAJA_BLOG_SOURCE_ID to ThyagarajaBlogScraper(httpClient)
)
Performance Optimization:
class ImportOrchestrationService(
// …
private val scraperRegistry: Map<UUID, KrithiSourceScraper>,
private val cacheService: CacheService
) {
suspend fun executeImport(config: ImportSourceConfig): Flow
emit(ImportProgress.Started)
// Stage 1: Discovery (with caching)
val urls = cacheService.getOrCompute("discover:${config.sourceId}", ttl = 1.days) {
scraper.discoverUrls()
}
emit(ImportProgress.DiscoveryComplete(urls.size))
// Stage 2: Parallel scraping with rate limiting
val scraped = urls.asFlow()
.buffer(capacity = config.parallelism) // Control concurrency
.map { url ->
// Check if already scraped recently
cacheService.get<RawHtml>("scrape:$url")?.let { return@map it }
// Rate limit
delay(config.rateLimitMs)
// Scrape with retry
retry(3) {
val html = scraper.scrape(url)
cacheService.set("scrape:$url", html, ttl = 7.days)
html
}
}
.toList()
emit(ImportProgress.ScrapingComplete(scraped.size))
// Stage 3: Extraction (parallelized)
val extracted = scraped
.asFlow()
.buffer(capacity = config.parallelism)
.map { html ->
retry(3) {
scraper.extract(html)
}
}
.toList()
```text
// ... continue with entity resolution, validation, etc.
} } ```
Monitoring:
// Metrics collection class ImportMetricsCollector { private val meterRegistry: MeterRegistry = SimpleMeterRegistry()
val importsStarted = meterRegistry.counter("imports.started")
val importsCompleted = meterRegistry.counter("imports.completed")
val importsFailed = meterRegistry.counter("imports.failed")
val importDuration = meterRegistry.timer("imports.duration")
val entitiesResolved = meterRegistry.counter("imports.entities.resolved")
val entitiesUnresolved = meterRegistry.counter("imports.entities.unresolved")
val duplicatesDetected = meterRegistry.counter("imports.duplicates.detected")
val validationErrors = meterRegistry.counter("imports.validation.errors") }
// Usage in service
suspend fun executeImport(config: ImportSourceConfig): Flow
try {
// ... pipeline execution
```text
metrics.importsCompleted.increment()
metrics.importDuration.record(Duration.between(startTime, Clock.System.now()))
} catch (e: Exception) {
metrics.importsFailed.increment()
throw e
} } ```
Recommendation: Use Koog selectively for AI-intensive stages, not entire pipeline.
Ideal Koog Use Cases:
Keep Custom Kotlin For:
// Main orchestration remains custom Kotlin
class ImportOrchestrationService(
private val webScrapingService: WebScrapingService,
private val koogExtractionAgent: KrithiExtractionAgent, // Koog agent
private val entityResolutionService: EntityResolutionService,
private val koogAmbiguityResolver: AmbiguityResolverAgent, // Koog agent
// …
) {
suspend fun executeImport(config: ImportSourceConfig): Flow
// Stage 3: Koog (AI extraction for unstructured sources)
val extracted = if (config.requiresAIExtraction) {
scraped.map { html ->
koogExtractionAgent.extract(html)
}
} else {
scraped.map { html ->
customExtraction(html) // Deterministic extraction
}
}
// Stage 4: Custom (entity resolution attempt)
val resolved = extracted.map { metadata ->
entityResolutionService.resolveAll(metadata)
}
// Stage 5: Koog (ambiguity resolution for unclear cases)
val finalResolved = resolved.map { res ->
if (res.hasAmbiguity()) {
koogAmbiguityResolver.resolve(res)
} else {
res
}
}
```text
// Stage 6+: Custom (validation, staging)
// ...
} } ```
Extraction Agent: val krithiExtractionAgent = agent { name = “krithi-metadata-extractor” description = “Extract structured krithi metadata from unstructured blog posts”
llm = GeminiProvider(
model = "gemini-2.0-flash-exp",
apiKey = config.geminiApiKey
)
systemPrompt = """
You are an expert in Carnatic music. Extract structured metadata from blog posts about krithis.
Return JSON with fields:
- title: krithi name (exact)
- composer: composer name (standardized)
- raga: raga name (standardized)
- tala: tala name (if mentioned)
- deity: deity addressed (if clear)
- lyrics: full lyrics with sections separated
If a field is not found, return null. Do not guess.
""".trimIndent()
tools = emptyList() // No tools needed for extraction }
// Usage suspend fun extract(html: RawHtml): RawMetadata { val doc = Jsoup.parse(html.content) val postContent = doc.selectFirst(“div.post-body”)?.text() ?: throw ExtractionException(“No content”)
val response = krithiExtractionAgent.invoke(
message = "Extract krithi metadata from:\n\n$postContent"
)
val extracted = Json.decodeFromString<ExtractedMetadata>(response.content)
```text
return RawMetadata(
sourceUrl = html.url,
title = extracted.title,
composer = extracted.composer,
raga = extracted.raga,
tala = extracted.tala,
deity = extracted.deity,
lyrics = extracted.lyrics
) } ```
Ambiguity Resolution Agent: val ambiguityResolverAgent = agent { name = “entity-ambiguity-resolver” description = “Resolve ambiguous entity matches using musical knowledge”
llm = GeminiProvider(
model = "gemini-1.5-pro", // Use Pro for reasoning
apiKey = config.geminiApiKey
)
systemPrompt = """
You are an expert in Carnatic music. Resolve ambiguous entity matches.
Given:
- Raw entity name from source
- Multiple candidate matches from database
- Context (composer, raga, other metadata)
Determine the most likely match based on musical knowledge.
Return the UUID of the best match and confidence (0-1).
""".trimIndent()
tools = listOf(
getComposerDetailsTool,
getRagaDetailsTool
) }
// Tool definitions
val getComposerDetailsTool = tool(“get_composer_details”) {
description = “Get detailed information about a composer”
parameter
execute { composerId ->
composerRepo.findById(composerId)?.let {
ComposerDetails(
id = it.id,
name = it.name,
period = "${it.birthYear}-${it.deathYear}",
place = it.place,
primaryLanguage = it.primaryLanguage,
notes = it.notes
)
}
} }
// Usage
suspend fun resolveAmbiguousComposer(
rawName: String,
candidates: List
Raw name from source: "$rawName"
Candidate matches:
${candidates.mapIndexed { i, c -> "${i+1}. ${c.name} (${c.id}) - ${c.birthYear}-${c.deathYear}, ${c.place}" }.joinToString("\n")}
Context:
- Raga: ${context.ragaName}
- Lyrics snippet: ${context.lyricsSnippet.take(100)}
- Source: ${context.sourceUrl}
Which composer is most likely? Return JSON: {"composerId": "UUID", "confidence": 0.0-1.0, "reasoning": "..."}
""".trimIndent()
val response = ambiguityResolverAgent.invoke(message = prompt)
val result = Json.decodeFromString<AmbiguityResolution>(response.content)
```text
return if (result.confidence > 0.75) result.composerId else null } ```
Benefits:
Costs:
Decision Criteria:
Estimated LLM Costs:
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| Website structure changes | Medium | High | Version scrapers, automated tests, alerts on scrape failures |
| Entity resolution accuracy < 80% | Medium | High | Human review queue, confidence thresholds, expert validation |
| De-duplication false positives | Medium | Medium | Multi-level detection, manual review for uncertain matches |
| Performance degradation at scale | Low | Medium | Caching, parallel processing, database optimization |
| Lyrics section parsing failures | High | Medium | Accept as limitation, flag for manual parsing |
| Temple association ambiguity | High | Low | Accept many krithis won’t have temples, manual curation |
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| Incomplete metadata (50%+ missing fields) | High | Medium | Prioritize high-quality sources, accept incompleteness |
| Incorrect entity mappings | Medium | High | Confidence scoring, manual review, audit trail |
| Transliteration inconsistencies | High | Medium | Normalization algorithms, accept variations |
| Duplicate canonical krithis | Low | High | Robust de-duplication, review before canonicalization |
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| Manual review bottleneck | High | Medium | Quality tiers (auto-approve excellent), prioritize review queue |
| Source website downtime | Low | Low | Cache scraped data, retry logic, multiple sources |
| Import job failures | Medium | Medium | Retry logic, progress tracking, alerting |
| Team bandwidth for reviews | High | High | Phased rollout, community contributions |
Coverage:
Throughput:
Quality:
Efficiency:
Completeness by Field:
Accuracy:
Search & Discovery:
Editorial Productivity:
Adopt a Phased Custom Kotlin Pipeline with Selective AI Enhancement:
Phase 1 (Weeks 1-8): MVP with Single Source
Phase 2 (Weeks 9-12): Multi-Source Expansion
Phase 3 (Months 4-6): AI Enhancement (Optional)
Do NOT use Koog for entire pipeline orchestration.
Rationale:
Consider Koog for specific AI-intensive stages:
Decision Point: After Phase 1 completion, evaluate if extraction quality from unstructured sources warrants Koog integration.
Temple Data Import:
temples and temple_names tablesKrithi-Temple Linking:
Accept Limitation: Many krithis will not have temple associations. This is acceptable.
import_sources, imported_krithis, import_batches tables existImportOrchestrationService interface/api/admin/import/* endpointsDecision 1: Start with which source?
Decision 2: Use Gemini for extraction now or later?
Decision 3: Auto-approve excellent tier imports?
Decision 4: Community contributions for review?
Phase 1 (MVP - 8 weeks):
Phase 2 (Multi-Source - 4 weeks):
Phase 3 (AI Enhancement - 4 weeks, optional):
Grand Total: $38,050 (or $30,000 without AI enhancement)
Infrastructure:
LLM API (if using Gemini):
Maintenance:
Week 1-3: Core infrastructure, single scraper, basic pipeline
Week 4-5: Entity resolution, de-duplication
Week 6: Validation, staging
Week 7-8: Review workflow UI
[Phase 1 Complete: 100-500 krithis from karnatik.com]
Week 9-10: Add 3 additional scrapers
Week 11: Performance optimization, monitoring
Week 12: Testing, documentation
[Phase 2 Complete: 1,000+ krithis from all sources]
Week 13-16: (Optional) Gemini integration for extraction & ambiguity
[Phase 3 Complete: 2,000+ krithis, reduced manual review]
Milestones:
data class ImportSourceConfig( val sourceId: UUID, val sourceName: String, val baseUrl: String, val scraperType: ScraperType, val extractionStrategy: ExtractionStrategy, val rateLimitMs: Long = 1000, val parallelism: Int = 5, val requiresAIExtraction: Boolean = false, val implicitDeity: String? = null, val implicitComposer: String? = null )
// Example configs val karnatikComConfig = ImportSourceConfig( sourceId = KARNATIK_COM_SOURCE_ID, sourceName = “Karnatik.com”, baseUrl = “https://karnatik.com/lyrics.shtml”, scraperType = ScraperType.STRUCTURED_HTML, extractionStrategy = ExtractionStrategy.CSS_SELECTORS, rateLimitMs = 1000, parallelism = 5 )
val guruGuhaBlogConfig = ImportSourceConfig( sourceId = GURUGUHA_BLOG_SOURCE_ID, sourceName = “Guru-Guha Blog (Dikshitar List)”, baseUrl = “https://guru-guha.blogspot.com/2009/04/dikshitar-kritis-alphabetical-list.html”, scraperType = ScraperType.LIST_BASED, extractionStrategy = ExtractionStrategy.PATTERN_MATCHING, requiresAIExtraction = false, // List format is structured enough for regex implicitComposer = “Muthuswami Dikshitar”, rateLimitMs = 2000, parallelism = 3 )
val thyagarajaBlogConfig = ImportSourceConfig(
sourceId = THYAGARAJA_BLOG_SOURCE_ID,
sourceName = "Thyagaraja Vaibhavam",
baseUrl = "https://thyagaraja-vaibhavam.blogspot.com/",
scraperType = ScraperType.LIST_BASED,
extractionStrategy = ExtractionStrategy.PATTERN_MATCHING,
implicitComposer = "Thyagaraja",
rateLimitMs = 2000,
parallelism = 3
)
– Find composer by name with trigram similarity SELECT id, name, similarity(name, ‘Tyagaraja’) AS sim_score FROM composers WHERE similarity(name, ‘Tyagaraja’) > 0.75 ORDER BY sim_score DESC LIMIT 5;
– Find raga with normalized name match SELECT id, name FROM ragas WHERE name_normalized = normalize(‘Mayamalava Gowla’);
-- Find temple by deity and name similarity
SELECT
t.id,
t.name,
tn.name AS variant_name,
similarity(tn.name, 'Tirupati') AS sim_score
FROM temples t
JOIN temple_names tn ON tn.temple_id = t.id
WHERE t.primary_deity_id = :deityId
AND similarity(tn.name, 'Tirupati') > 0.80
ORDER BY sim_score DESC
LIMIT 5;
Building a robust Krithi import pipeline for Sangeetha Grantha requires careful attention to musicological accuracy, entity resolution complexity, and data quality. The recommended approach is a custom Kotlin-based pipeline leveraging the existing technology stack, with selective AI enhancement for unstructured sources.
Key Success Factors:
Avoid:
With disciplined execution, the import pipeline can achieve 1,000+ krithis within 3 months and 2,000+ within 6 months, significantly accelerating the growth of the Sangeetha Grantha catalog while maintaining musicological integrity.
Document Status: Complete and ready for team review. Next Action: Architecture review meeting, decision on Phase 1 kickoff.