AI, Bier & Backend: Parsing met Kotlin, Axon en Claude

In een eerdere blog beschreef ik hoe ik met TypeScript en Crawlee aanbiedingen scrape van verschillende supermarkten. In deze blog neem ik je mee in de technische backend van BierExperts: gebouwd in Kotlin, Spring Boot, Axon Framework en Claude Code.

Mijn doel was producttitels zoals:

  • Hertog Jan 0.0 Alcoholvrij bier
  • Affligem Blond abdijbier 6-pack
  • Grolsch - 0.0% Alcoholvrij - Blik - 6 x 330ML
  • Weihenstephaner Hefeweissbier 0.5% 6-pack
  • etc ( 50 + variaties )

...om te zetten naar gestructureerde productdata. Klinkt simpel, maar de werkelijkheid was wel uitdagender.


AI !?

Om meteen met de deur in huis te vallen, in 2025 kan je niet meer om AI heen. Dus ook in dit project heb ik AI veelvuldig ingezet. Omdat we data uit verschillende supermarkten verzamelen, en elk een eigen manier van naamgeving heeft, wilde ik de parsing logica volledig in de backend houden. Geen preprocessing aan de frontend dus, maar alles centraal.

Als eerste heb ik een grote dataset met titels verzameld. Dit deed ik doormiddel van mijn scraping tool in te zetten en als datasets op te slaan. Vervolgend liet ik Claude Code mijn verwachte test resultaten ZELF genereren, dit is beter dan dat ik zelf alles handmatig doorloop. Ik was op zoek naar resultaten als naam, alcohol percentage, type bier, etc. Wat ik hiervan merkte is dat ook claude niet precies de juiste waardes kon destileren wat ik verwachte. Hier zat helaas toch nog wat handwerk in.
Waarna ik een test heb gemaakt die simpelweg de dataset uitleest en de verwachte resutlaat matched, en daarnaast een lege class BeerNameParser, om daadwerkelijk het werk te doen.

Dat ziet er ongeveer zo uit:

@ParameterizedTest(name = "{0}")
@MethodSource("datasetProvider")
fun `should check all is set`(testName: String, testCase: DatasetTestCase) {
    // Parse the input
    val beerResult = parser.parse(testCase.input)

    assertEquals(testCase.expected.name, beerResult.name,
        "Name mismatch in $testName")


}

companion object {
    @JvmStatic
    fun datasetProvider(): Stream<org.junit.jupiter.params.provider.Arguments> {
        val objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build())
        val testCases = mutableListOf<org.junit.jupiter.params.provider.Arguments>()
      
        // Test supermarket datasets
        testCases.addAll(loadDatasets("supermarket", objectMapper, 1, 700))
        
        // Test etc datasets
        //testCases.addAll(loadDatasets("etc", objectMapper, 1, 113))

        return testCases.stream()
    }
    
    private fun loadDatasets(
        supermarket: String, 
        objectMapper: ObjectMapper, 
        startId: Int, 
        endId: Int
    ): List<org.junit.jupiter.params.provider.Arguments> {
        return (startId..endId).mapNotNull { i ->
            val fileName = String.format("%09d.json", i)
            val expectedFileName = String.format("%09d-expected.json", i)
            
            val inputResource = ClassPathResource("datasets/$supermarket/$fileName")
            val expectedResource = ClassPathResource("datasets/$supermarket/$expectedFileName")
            
            if (!inputResource.exists() || !expectedResource.exists()) {
                return@mapNotNull null
            }
            
            try {
       	     objectMapper.readValue<RawSupermarketDatasetInput>(inputResource.inputStream)
               
                
                val input = mapToAddPriceDto(rawInput, supermarket)
                val expected = objectMapper.readValue<ExpectedResult>(expectedResource.inputStream)
                val testCase = DatasetTestCase(input, expected, supermarket)
                val testName = "${supermarket.uppercase()}: $fileName"
                
                org.junit.jupiter.params.provider.Arguments.of(testName, testCase)
            } catch (e: Exception) {
                println("Warning: Could not parse $supermarket/$fileName or $expectedFileName: ${e.message}")
                null
            }
        }
    }

Claude Code aan het werk

Nu gaf ik Claude de opdracht om de parser te implementeren. En hoewel Claude zeker vooruitgang boekte, bleef hij wel regelmatig hangen. Soms verbeterde hij één regel en maakte daarmee een eerder werkend scenario weer stuk. Een soort oneindige cyclus van fixen → breken → fixen. De code raakte vol met regex feesten en werd steeds moeilijker te onderhouden, dit vroeg om handmatig werk


Handmatig werk

AI neemt veel werk weg, maar nog steeds niet alles, en hier komt het werk van een engineer om de hoek kijken. In plaats van blind op AI vertrouwen, besloot ik de parsing logica zelf op te splitsen in kleinere, beheersbare onderdelen:

  • Alcoholpercentage eruit filteren
  • Verpakkingsinformatie herkennen (6-pack, blik, fles)
  • Merknaam en bierstijl onderscheiden

Daarna gaf ik Claude nieuwe, kleinere taken. En ineens ging het veel beter: geen warboel van regex meer, maar gestructureerde code en steeds minder falende tests. Tot uiteindelijk een werkende test suite.


Waarom Axon Framework?

Je zou kunnen denken: “Waarom Axon voor zo’n eenvoudige use-case?”
Het domeinmodel stelt inderdaad niet veel voor – het gaat uiteindelijk gewoon om een product en een prijs

Toch gebruik ik Axon graag voor dit soort systemen. Niet vanwege complexe CQRS of event sourcing, maar omdat het heel handig is om events op te slaan en projections opnieuw te kunnen opbouwen.

Omdat ik niet altijd wist hoe de weergave van de frontend zou zijn is het opnieuw genereren van views vanuit events echt heerlijk. Ik rol gewoon een nieuwe projection uit, en replay the events, en het is klaar voor de frontend


Projection

Dit is een simpel voorbeeld van een projection waar ik de bier lijst update. Moeilijker dan dit is het niet

@AllowReplay
@ProcessingGroup("beer")
@Service
class BeerProjection(
    private val beerRepo: BeerRepository,
    private val beerPriceRepo: BeerPriceRepository,
    private val beerBrandRepository: BeerBrandRepository
) {
    @QueryHandler
    fun handle(query: FetchBeerById): Beer? {
        val optional = beerRepo.findById(query.aggregateIdValue)
        return if (optional.isPresent) optional.get() else null
    }

    @EventHandler
    fun on(event: BeerCreated) {
        val beer = Beer(
            aggregateIdValue = event.id.toString(),
            beerId = event.beerId,
            brandId = event.brandId,
            name = event.name
        )
        beerRepo.save(beer)
    }
}

Parsing van bieraanbiedingen klinkt als een detail, maar in de praktijk komt er toch nog wat bij kijken. AI helpt enorm, maar vraagt soms om begeleiding.

CTA Image

Bierexperts - Bieraanbiedingen

Bierexperts