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 toCoffeeBeanRepository - [ ] 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¶
Options¶
Option A: Expand mappings in flavorNotes (Recommended)¶
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"}]}
]
}
Recommended Solution: Option A¶
This maintains backward compatibility for flavorNotes consumers while providing complete flavor data.
Files to Modify¶
src/DTO/Api/CoffeeBeanDTO.phpsrc/DTO/Api/FlavorNoteDTO.phpsrc/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
fromMappingproperty toFlavorNoteDTO - [ ] 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.phpconfig/packages/api_filters.phpsrc/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:
- Creates a standard
FlavorWheelNode(e.g., "Apple") - Creates a
CoffeeBean("Bean A") and directly links it to "Apple" - Creates a
NonStandardFlavorNote(e.g., "Red Apple") and maps it to the "Apple"FlavorWheelNode - Creates another
CoffeeBean("Bean B") and links it only to the "Red Apple"NonStandardFlavorNote - Creates a third
CoffeeBean("Bean C") with an unrelated flavor note - Calls the API endpoint, filtering by the "Apple"
FlavorWheelNodeID - Asserts that the response contains "Bean A" and "Bean B", but not "Bean C"
Todo Checklist - Filtering¶
- [ ] Add
flavor_note_customcase toFilterService::apply() - [ ] Update
api_filters.phpconfiguration forflavorNoteIds - [ ] Implement
CoffeeBeanRepository::applyFlavorNoteFilter() - [ ] Update
FilterServiceunit 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¶
- Bulk apply mappings - Button to trigger backfill for a specific NonStandardFlavorNote
- Mapping statistics - Show how many CoffeeBeans would be affected by a mapping
- Mapping preview - Before saving a mapping, show which beans will be updated
Todo Checklist - Admin¶
- [ ] Add
BooleanFilterformappedfield 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)¶
- Update
FlavorNoteResolverto apply existing mappings - Create
BackfillFlavorNoteMappingsCommand - Run backfill on existing data
Phase 2: API (Medium Priority)¶
- Add
fromMappingtoFlavorNoteDTO - Update
EntityToDtoMapperto include mapped notes - Update API documentation
Phase 3: Filtering (Medium Priority)¶
- Implement custom filter type
- Update repository query method
Phase 4: Admin (Low Priority)¶
- Add bulk apply action
- 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