gmal-scope-builder/backend/app/services/tier_expander.py
DJP 668ea44ea2 Client tier mapping + GMAL complexity variant expansion
- Tier mapping on projects: configurable label→complexity mapping
  - Presets: A/B/C, 1/2/3, Gold/Silver/Bronze
  - Stored as JSON on project.tier_mapping
- ClientAsset.client_tier field for tracking which tier an asset belongs to
- GMAL family endpoint: GET /gmal/assets/{id}/family returns all complexity variants
  - Looks up by asset_name (NOT by GMAL number increment)
  - Verified: families share asset_name across non-sequential GMAL IDs
- Expand to Tiers: POST /projects/{id}/expand-tiers
  - Splits each matched asset into N tier variants (one per tier)
  - Finds correct GMAL variant by asset_name + complexity_level query
  - Creates new ClientAsset + Match per tier with correct GMAL
  - Removes original un-tiered asset after expansion
- Frontend: tier preset buttons + expand button on Match Review tab
- Tier tags shown with label → complexity mapping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:02:45 -04:00

143 lines
5.2 KiB
Python

"""Expand matched assets into complexity tier variants."""
import json
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.gmal import GmalAsset
from app.models.project import Project, ClientAsset, Match, MatchConfidence
logger = logging.getLogger(__name__)
# Map complexity names to levels
COMPLEXITY_MAP = {
"simple": 1,
"medium": 2,
"mid": 2,
"complex": 3,
}
async def expand_to_tiers(db: AsyncSession, project: Project) -> dict:
"""Expand each matched asset into complexity variants based on the project's tier mapping.
For each selected match:
1. Find the GMAL family (same asset_name, different complexity_level)
2. For each tier in the mapping, create a new ClientAsset + Match
3. Remove the original single-match asset
Returns summary of what was done.
"""
if not project.tier_mapping:
return {"error": "No tier mapping configured for this project"}
tier_config = json.loads(project.tier_mapping)
tiers = tier_config.get("tiers", [])
if not tiers:
return {"error": "Tier mapping is empty"}
# Load client assets with selected matches
assets_result = await db.execute(
select(ClientAsset).where(ClientAsset.project_id == project.id).order_by(ClientAsset.sort_order)
)
client_assets = assets_result.scalars().all()
# Skip assets that already have a tier (already expanded)
unexpanded = [ca for ca in client_assets if not ca.client_tier]
if not unexpanded:
return {"message": "All assets already have tier assignments", "expanded": 0}
expanded_count = 0
skipped_count = 0
for ca in unexpanded:
# Get the selected match
match_result = await db.execute(
select(Match).where(Match.client_asset_id == ca.id, Match.is_selected == True)
)
selected_match = match_result.scalar_one_or_none()
if not selected_match:
skipped_count += 1
continue
# Get the matched GMAL asset
gmal_result = await db.execute(
select(GmalAsset).where(GmalAsset.id == selected_match.gmal_asset_id)
)
matched_gmal = gmal_result.scalar_one_or_none()
if not matched_gmal or not matched_gmal.asset_name:
skipped_count += 1
continue
# Find the GMAL family by asset_name
family_result = await db.execute(
select(GmalAsset).where(
GmalAsset.asset_name == matched_gmal.asset_name,
GmalAsset.has_hour_routes == True,
).order_by(GmalAsset.complexity_level)
)
family = {g.complexity_level: g for g in family_result.scalars().all()}
if len(family) < 2:
# Only one variant exists, skip expansion
skipped_count += 1
continue
# Create a new ClientAsset + Match for each tier
for tier in tiers:
tier_label = tier["label"]
complexity_name = tier["complexity"].lower()
complexity_level = COMPLEXITY_MAP.get(complexity_name)
if complexity_level is None:
logger.warning(f"Unknown complexity '{complexity_name}' in tier mapping")
continue
gmal_variant = family.get(complexity_level)
if not gmal_variant:
# This complexity level doesn't exist for this asset
logger.warning(f"No {complexity_name} variant for '{matched_gmal.asset_name}'")
continue
# Create new client asset with tier label
new_ca = ClientAsset(
project_id=project.id,
raw_name=f"{ca.raw_name} - {tier_label}",
raw_description=ca.raw_description,
client_tier=tier_label,
volume=ca.volume,
sort_order=(ca.sort_order or 0) * 10 + (complexity_level or 0),
)
db.add(new_ca)
await db.flush()
# Create match pointing to the correct complexity variant
new_match = Match(
client_asset_id=new_ca.id,
gmal_asset_id=gmal_variant.id,
confidence=MatchConfidence.EXACT,
confidence_score=0.95,
ai_reasoning=f"Tier expansion: {tier_label}{gmal_variant.complexity_name} variant of {matched_gmal.asset_name} ({gmal_variant.gmal_id})",
caveat_text=f"Auto-mapped from tier '{tier_label}' to {gmal_variant.complexity_name} complexity.",
is_selected=True,
rank=1,
)
db.add(new_match)
expanded_count += 1
# Delete the original un-tiered asset and its matches
orig_matches = await db.execute(select(Match).where(Match.client_asset_id == ca.id))
for m in orig_matches.scalars().all():
await db.delete(m)
await db.delete(ca)
await db.commit()
return {
"message": f"Expanded {len(unexpanded) - skipped_count} assets into {expanded_count} tier variants. {skipped_count} skipped (no match or single variant).",
"expanded_assets": len(unexpanded) - skipped_count,
"new_variants": expanded_count,
"skipped": skipped_count,
}