Skip to content

Comprehensive Plan: Flavor Note Mapping Integration

Overview

This plan addresses the complete integration of NonStandardFlavorNote mappings throughout the application. Currently, mappings exist (created via LLM or manually) but are only partially utilized. This plan ensures mappings are applied at every relevant stage.

Current State

Stage Status Issue
Extraction Working LLM extracts flavor notes from crawled pages
Persistence Partial Creates NonStandardFlavorNote but doesn't apply existing mappings
Batch Mapping Working app:map-flavor-notes command maps via LLM
Admin Working Manual mapping, merge, convert functionality
API Output Partial Returns non-standard notes separately, doesn't expand mappings
Filtering Not Working Can't find beans via mapped non-standard notes

Target State

All stages should leverage existing mappings to provide a self-improving system where established mappings automatically enhance data quality.


Section 1: Persistence Stage

Problem

When FlavorNoteResolver::processFlavorNotes() encounters a flavor note that doesn't match a standard FlavorWheelNode, it creates/finds a NonStandardFlavorNote. However, it does not check if that NonStandardFlavorNote already has mappings to standard notes.

Example: "Red Apple" is mapped to "Apple" FlavorWheelNode. A new bean with "Red Apple" gets: - nonStandardFlavorNotes: ["Red Apple"] - flavorNotes: [] (empty - should include "Apple")

Solution

Enhance FlavorNoteResolver to apply existing mappings during persistence.

Files to Modify

  • src/Service/Crawler/Persistance/FlavorNoteResolver.php

Implementation

// In FlavorNoteResolver::handleUnmatchedFlavorNote()

private function handleUnmatchedFlavorNote(CoffeeBean $coffeeBean, string $flavorNoteName): void
{
    $normalizedName = $this->normalizer->normalize($flavorNoteName);

    // Find or create the NonStandardFlavorNote
    $nonStandardNote = $this->nonStandardFlavorNoteRepository->findOneBy([
        'normalizedName' => $normalizedName,
    ]);

    if ($nonStandardNote === null) {
        $nonStandardNote = new NonStandardFlavorNote();
        $nonStandardNote->setName($flavorNoteName);
        $nonStandardNote->setNormalizedName($normalizedName);
        $this->entityManager->persist($nonStandardNote);
    }

    // NEW: Apply existing mappings to the CoffeeBean
    if ($nonStandardNote->isMapped()) {
        foreach ($nonStandardNote->getMappedTo() as $standardNode) {
            if (!$coffeeBean->getFlavorNotes()->contains($standardNode)) {
                $coffeeBean->addFlavorNote($standardNode);
            }
        }
    }

    // Add the non-standard note for tracking/future mapping
    if (!$coffeeBean->getNonStandardFlavorNotes()->contains($nonStandardNote)) {
        $coffeeBean->addNonStandardFlavorNote($nonStandardNote);
    }
}

Todo Checklist - Persistence

  • [ ] Update FlavorNoteResolver::handleUnmatchedFlavorNote() to check for existing mappings
  • [ ] Add mapped standard FlavorWheelNodes to CoffeeBean when NonStandardFlavorNote is already mapped
  • [ ] Write unit tests for the new mapping application logic
  • [ ] Write integration test verifying persistence applies mappings

Section 2: Backfill Command

Problem

Existing CoffeeBeans with NonStandardFlavorNotes that have since been mapped don't have the corresponding standard FlavorWheelNodes applied.

Solution

Create a command to backfill mappings to existing CoffeeBeans.

Files to Create/Modify

  • src/Command/BackfillFlavorNoteMappingsCommand.php (new)

Implementation

#[AsCommand(
    name: 'app:backfill-flavor-mappings',
    description: 'Apply existing NonStandardFlavorNote mappings to CoffeeBeans',
)]
class BackfillFlavorNoteMappingsCommand extends Command
{
    public function __construct(
        private readonly NonStandardFlavorNoteRepository $nonStandardRepo,
        private readonly CoffeeBeanRepository $coffeeBeanRepo,
        private readonly EntityManagerInterface $em,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Preview changes without persisting')
            ->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Batch size for processing', 100);
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $dryRun = $input->getOption('dry-run');
        $batchSize = (int) $input->getOption('batch-size');

        // Find all mapped NonStandardFlavorNotes
        $mappedNotes = $this->nonStandardRepo->findBy(['mapped' => true]);

        $totalUpdated = 0;

        foreach ($mappedNotes as $nonStandardNote) {
            $mappedTo = $nonStandardNote->getMappedTo();
            if ($mappedTo->isEmpty()) {
                continue;
            }

            // Find CoffeeBeans with this non-standard note
            $beans = $this->coffeeBeanRepo->findByNonStandardFlavorNote($nonStandardNote);

            foreach ($beans as $bean) {
                $updated = false;
                foreach ($mappedTo as $standardNode) {
                    if (!$bean->getFlavorNotes()->contains($standardNode)) {
                        $bean->addFlavorNote($standardNode);
                        $updated = true;
                    }
                }

                if ($updated) {
                    $totalUpdated++;
                    if (!$dryRun) {
                        $this->em->persist($bean);
                    }
                }
            }

            if (!$dryRun && $totalUpdated % $batchSize === 0) {
                $this->em->flush();
                $this->em->clear();
            }
        }

        if (!$dryRun) {
            $this->em->flush();
        }

        $io->success(sprintf(
            '%s %d coffee beans with mapped flavor notes.',
            $dryRun ? 'Would update' : 'Updated',
            $totalUpdated
        ));

        return Command::SUCCESS;
    }
}

Todo Checklist - Backfill

  • [ ] Create BackfillFlavorNoteMappingsCommand
  • [ ] Add findByNonStandardFlavorNote() method to CoffeeBeanRepository
  • [ ] Write unit tests for the command
  • [ ] Document the command usage

Section 3: API Output

Problem

The API returns nonStandardFlavorNotes as raw strings without their mapped equivalents. Consumers cannot see that "Red Apple" maps to "Apple" without making additional requests.

Current Behavior

{
  "flavorNotes": [],
  "nonStandardFlavorNotes": ["Red Apple"]
}

Options

Add mapped standard notes to the flavorNotes array automatically.

{
  "flavorNotes": [{"id": "...", "name": "Apple", "fromMapping": true}],
  "nonStandardFlavorNotes": ["Red Apple"]
}

Option B: Enhance nonStandardFlavorNotes structure

Change from string array to objects with mapping info.

{
  "flavorNotes": [],
  "nonStandardFlavorNotes": [
    {"name": "Red Apple", "mappedTo": [{"id": "...", "name": "Apple"}]}
  ]
}

This maintains backward compatibility for flavorNotes consumers while providing complete flavor data.

Files to Modify

  • src/DTO/Api/CoffeeBeanDTO.php
  • src/DTO/Api/FlavorNoteDTO.php
  • src/Service/Api/Mapper/EntityToDtoMapper.php

Implementation

// In FlavorNoteDTO.php - add optional flag
public function __construct(
    public readonly string $id,
    public readonly string $name,
    public readonly ?string $parentId = null,
    public readonly bool $fromMapping = false, // NEW
) {}

// In EntityToDtoMapper::mapCoffeeBeanToDto()
private function mapFlavorNotesWithMappings(CoffeeBean $coffeeBean): array
{
    $flavorNoteDtos = [];
    $addedIds = [];

    // Add direct flavor notes
    foreach ($coffeeBean->getFlavorNotes() as $note) {
        $flavorNoteDtos[] = new FlavorNoteDTO(
            id: $note->getId()->toString(),
            name: $note->getName(),
            parentId: $note->getParent()?->getId()->toString(),
            fromMapping: false,
        );
        $addedIds[] = $note->getId()->toString();
    }

    // Add mapped notes from non-standard flavor notes
    foreach ($coffeeBean->getNonStandardFlavorNotes() as $nonStandardNote) {
        if (!$nonStandardNote->isMapped()) {
            continue;
        }
        foreach ($nonStandardNote->getMappedTo() as $mappedNode) {
            $nodeId = $mappedNode->getId()->toString();
            if (!in_array($nodeId, $addedIds, true)) {
                $flavorNoteDtos[] = new FlavorNoteDTO(
                    id: $nodeId,
                    name: $mappedNode->getName(),
                    parentId: $mappedNode->getParent()?->getId()->toString(),
                    fromMapping: true,
                );
                $addedIds[] = $nodeId;
            }
        }
    }

    return $flavorNoteDtos;
}

Todo Checklist - API

  • [ ] Add fromMapping property to FlavorNoteDTO
  • [ ] Update EntityToDtoMapper::mapCoffeeBeanToDto() to include mapped notes
  • [ ] Update API documentation/OpenAPI spec
  • [ ] Write integration tests for API output with mappings
  • [ ] Verify backward compatibility

Section 4: Filtering

Problem

Filtering by a standard FlavorWheelNode ID does not return CoffeeBeans that have that note only via a mapped NonStandardFlavorNote.

Solution

Create a custom filter type that queries both direct and indirect (mapped) relationships.

Files to Modify

  • src/Service/Api/FilterService.php
  • config/packages/api_filters.php
  • src/Repository/CoffeeBeanRepository.php

Root Cause Analysis

The current FilterService and its uuid_array configuration for flavorNoteIds only creates a JOIN to the direct coffee_bean.flavor_notes relationship. It is completely unaware of the indirect relationship that exists through coffee_bean.non_standard_flavor_notes -> non_standard_flavor_note.mapped_to.

When a user filters by the "Apple" FlavorWheelNode ID, the query only finds beans directly linked to "Apple". It does not find the bean linked to the "Red Apple" NonStandardFlavorNote, even though "Red Apple" is correctly mapped to the "Apple" FlavorWheelNode.

Proposed Solution

Instead of making the generic FilterService overly complex, introduce a new custom filter type (flavor_note_custom). When the FilterService encounters this type, it will not try to build the query itself. Instead, it will call a dedicated, public method on the CoffeeBeanRepository, passing the QueryBuilder and the filter values. This repository method will then build the correct, complex OR query with the necessary extra JOINs.

This approach keeps the FilterService clean and generic while encapsulating specialized query logic where it belongs: in the repository.

Step-by-Step Implementation

Step 1: Create Custom Filter Type in FilterService

File: src/Service/Api/FilterService.php

In the apply method's switch ($type) block, add a new case:

case 'flavor_note_custom':
    // Delegate the complex logic to a dedicated method on the repository
    $repository = $qb->getEntityManager()->getRepository($entityClass);
    if (method_exists($repository, 'applyFlavorNoteFilter')) {
        $repository->applyFlavorNoteFilter($qb, $value);
    }
    break;

Step 2: Update Filter Configuration

File: config/packages/api_filters.php

In the api_filters section for App\Entity\CoffeeBean, find the flavorNoteIds key and change its type:

'flavorNoteIds' => [
    'type' => 'flavor_note_custom', // Changed from 'uuid_array'
    // The other keys (field, alias) are no longer needed here
],

Step 3: Implement Custom Filter Method in CoffeeBeanRepository

File: src/Repository/CoffeeBeanRepository.php

Create a new public method to handle the logic:

public function applyFlavorNoteFilter(QueryBuilder $qb, array $flavorNoteIds): void
{
    if (empty($flavorNoteIds)) {
        return;
    }

    // Add JOINs for both the direct and indirect relationships.
    // The direct join to 'fn' should already exist from the base query.
    $qb->leftJoin('cb.nonStandardFlavorNotes', 'nsfn')
       ->leftJoin('nsfn.mappedTo', 'nsfn_mapped');

    // Create an OR condition to check both places for the ID.
    $orX = $qb->expr()->orX(
        $qb->expr()->in('fn.id', ':flavorNoteIds'),
        $qb->expr()->in('nsfn_mapped.id', ':flavorNoteIds')
    );

    $qb->andWhere($orX)
       ->setParameter('flavorNoteIds', $flavorNoteIds);
}

Testing Strategy

Unit Tests

Update the unit tests for the FilterService to ensure it correctly calls the new repository method when it encounters the flavor_note_custom type.

Integration Tests

This is the most critical test. Create a new integration test for the /api/coffee-beans endpoint that:

  1. Creates a standard FlavorWheelNode (e.g., "Apple")
  2. Creates a CoffeeBean ("Bean A") and directly links it to "Apple"
  3. Creates a NonStandardFlavorNote (e.g., "Red Apple") and maps it to the "Apple" FlavorWheelNode
  4. Creates another CoffeeBean ("Bean B") and links it only to the "Red Apple" NonStandardFlavorNote
  5. Creates a third CoffeeBean ("Bean C") with an unrelated flavor note
  6. Calls the API endpoint, filtering by the "Apple" FlavorWheelNode ID
  7. Asserts that the response contains "Bean A" and "Bean B", but not "Bean C"

Todo Checklist - Filtering

  • [ ] Add flavor_note_custom case to FilterService::apply()
  • [ ] Update api_filters.php configuration for flavorNoteIds
  • [ ] Implement CoffeeBeanRepository::applyFlavorNoteFilter()
  • [ ] Update FilterService unit tests for new custom type
  • [ ] Write integration test verifying filter returns beans with mapped non-standard notes

Section 5: Admin Enhancements

Current State

The admin already supports: - Manual mapping of NonStandardFlavorNote to FlavorWheelNode - Merge functionality for duplicates - Convert to wheel node action

Required Enhancement: Mapped Filter on INDEX

Add a filter on the NonStandardFlavorNoteCrudController INDEX page to filter by mapping status (mapped vs unmapped). This requires a null check on the mappedTo collection.

Files to Modify

  • src/Controller/Admin/NonStandardFlavorNoteCrudController.php

Implementation

// In NonStandardFlavorNoteCrudController::configureFilters()
public function configureFilters(Filters $filters): Filters
{
    return $filters
        ->add(BooleanFilter::new('mapped', 'Is Mapped'))
        // Or use a custom filter for null check on mappedTo collection:
        // ->add(ChoiceFilter::new('mappingStatus')->setChoices([
        //     'Mapped' => 'mapped',
        //     'Unmapped' => 'unmapped',
        // ]))
    ;
}

Alternatively, if filtering by the mapped boolean field is insufficient (e.g., need to check mappedTo collection is not empty), create a custom filter:

// Custom query modification in configureFilters or via FilterConfiguratorInterface
// Filter for unmapped: WHERE mapped = false OR mappedTo IS EMPTY
// Filter for mapped: WHERE mapped = true AND mappedTo IS NOT EMPTY

Potential Enhancements

  1. Bulk apply mappings - Button to trigger backfill for a specific NonStandardFlavorNote
  2. Mapping statistics - Show how many CoffeeBeans would be affected by a mapping
  3. Mapping preview - Before saving a mapping, show which beans will be updated

Todo Checklist - Admin

  • [ ] Add BooleanFilter for mapped field on INDEX page
  • [ ] Verify filter correctly shows mapped vs unmapped NonStandardFlavorNotes
  • [ ] (Optional) Add "Apply to existing beans" action in NonStandardFlavorNoteCrudController
  • [ ] (Optional) Add statistics field showing affected bean count
  • [ ] (Optional) Consider adding mapping preview modal

Section 6: Testing Strategy

Unit Tests

Component Test Cases
FlavorNoteResolver Test mapping application during persistence
BackfillCommand Test dry-run, batch processing, idempotency
EntityToDtoMapper Test fromMapping flag, deduplication
FilterService Test delegation to custom filter method

Integration Tests

Scenario Description
Persistence with mapping Bean created with mapped non-standard note gets standard note
API output with mapping Response includes mapped notes with fromMapping: true
Filter with mapping Filtering by standard note returns beans with mapped non-standard
Backfill command Existing beans updated with newly created mappings

Test Data Setup

// Standard FlavorWheelNode
$apple = new FlavorWheelNode();
$apple->setName('Apple');

// NonStandardFlavorNote mapped to Apple
$redApple = new NonStandardFlavorNote();
$redApple->setName('Red Apple');
$redApple->setMapped(true);
$redApple->addMappedTo($apple);

// CoffeeBean with only non-standard note
$beanA = new CoffeeBean();
$beanA->addNonStandardFlavorNote($redApple);
// After persistence enhancement: $beanA->flavorNotes should contain $apple

// CoffeeBean with direct standard note
$beanB = new CoffeeBean();
$beanB->addFlavorNote($apple);

Implementation Order

Phase 1: Persistence (High Priority)

  1. Update FlavorNoteResolver to apply existing mappings
  2. Create BackfillFlavorNoteMappingsCommand
  3. Run backfill on existing data

Phase 2: API (Medium Priority)

  1. Add fromMapping to FlavorNoteDTO
  2. Update EntityToDtoMapper to include mapped notes
  3. Update API documentation

Phase 3: Filtering (Medium Priority)

  1. Implement custom filter type
  2. Update repository query method

Phase 4: Admin (Low Priority)

  1. Add bulk apply action
  2. Add statistics display

Success Criteria

  • [ ] New CoffeeBeans with mapped NonStandardFlavorNotes automatically receive corresponding FlavorWheelNodes
  • [ ] Existing CoffeeBeans can be backfilled with newly mapped standard notes
  • [ ] API responses include mapped flavor notes with clear indication of source
  • [ ] Filtering by FlavorWheelNode ID returns all relevant beans (direct and mapped)
  • [ ] All changes covered by automated tests
  • [ ] No breaking changes to existing API consumers