gmal-scope-builder/backend/app/api/gmal.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

213 lines
7.8 KiB
Python

"""GMAL asset browse/search endpoints."""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy import select, func, distinct
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.gmal import GmalAsset, Role, GmalHours
from app.models.gmal import ModelType
from app.schemas.gmal import GmalAssetBrief, GmalAssetDetail, GmalAssetWithHours, GmalHoursOut, RoleOut, GmalStatsOut, GmalAssetUpdate, GmalHoursUpdate
router = APIRouter()
@router.get("/assets", response_model=list[GmalAssetBrief])
async def list_assets(
db: AsyncSession = Depends(get_db),
search: str | None = None,
sub_category: str | None = None,
category: str | None = None,
complexity_level: int | None = None,
has_hours: bool | None = None,
skip: int = 0,
limit: int = 100,
):
query = select(GmalAsset)
if search:
pattern = f"%{search}%"
query = query.where(
GmalAsset.gmal_id.ilike(pattern)
| GmalAsset.asset_name.ilike(pattern)
| GmalAsset.unique_name.ilike(pattern)
| GmalAsset.asset_description.ilike(pattern)
)
if sub_category:
query = query.where(GmalAsset.sub_category == sub_category)
if category:
query = query.where(GmalAsset.category == category)
if complexity_level:
query = query.where(GmalAsset.complexity_level == complexity_level)
if has_hours is not None:
query = query.where(GmalAsset.has_hour_routes == has_hours)
query = query.order_by(GmalAsset.gmal_id).offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@router.get("/assets/{gmal_id}/family")
async def get_asset_family(gmal_id: str, db: AsyncSession = Depends(get_db)):
"""Get all complexity variants (family) for a GMAL asset."""
result = await db.execute(select(GmalAsset).where(GmalAsset.gmal_id == gmal_id))
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail=f"GMAL asset {gmal_id} not found")
family_result = await db.execute(
select(GmalAsset).where(
GmalAsset.asset_name == asset.asset_name,
GmalAsset.has_hour_routes == True,
).order_by(GmalAsset.complexity_level)
)
family = family_result.scalars().all()
return [
{
"gmal_id": g.gmal_id,
"asset_name": g.asset_name,
"complexity_level": g.complexity_level,
"complexity_name": g.complexity_name,
"unique_name": g.unique_name,
}
for g in family
]
@router.get("/assets/{gmal_id}", response_model=GmalAssetWithHours)
async def get_asset(gmal_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(GmalAsset).where(GmalAsset.gmal_id == gmal_id)
)
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail=f"GMAL asset {gmal_id} not found")
# Load hours with role info
hours_result = await db.execute(
select(GmalHours, Role)
.join(Role, GmalHours.role_id == Role.id)
.where(GmalHours.gmal_asset_id == asset.id)
.order_by(Role.sort_order)
)
hours_by_role = []
for gh, role in hours_result.all():
hours_by_role.append(GmalHoursOut(
role_id=role.id,
role_title=role.role_title,
discipline=role.discipline,
model_type=gh.model_type.value,
hours=float(gh.hours),
))
return GmalAssetWithHours(
**{k: v for k, v in asset.__dict__.items() if not k.startswith("_")},
hours_by_role=hours_by_role,
)
@router.put("/assets/{gmal_id}", response_model=GmalAssetDetail)
async def update_asset(gmal_id: str, data: GmalAssetUpdate, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(GmalAsset).where(GmalAsset.gmal_id == gmal_id))
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail=f"GMAL asset {gmal_id} not found")
for field, value in data.model_dump(exclude_unset=True).items():
setattr(asset, field, value)
await db.commit()
await db.refresh(asset)
return asset
@router.put("/assets/{gmal_id}/hours")
async def update_asset_hours(gmal_id: str, hours: list[GmalHoursUpdate], db: AsyncSession = Depends(get_db)):
"""Bulk update hours for a GMAL asset. Replaces existing hours for the given role+model combos."""
result = await db.execute(select(GmalAsset).where(GmalAsset.gmal_id == gmal_id))
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail=f"GMAL asset {gmal_id} not found")
updated = 0
for h in hours:
mt = ModelType(h.model_type)
existing = await db.execute(
select(GmalHours).where(
GmalHours.gmal_asset_id == asset.id,
GmalHours.role_id == h.role_id,
GmalHours.model_type == mt,
)
)
gh = existing.scalar_one_or_none()
if h.hours == 0 and gh:
await db.delete(gh)
updated += 1
elif h.hours != 0 and gh:
gh.hours = h.hours
updated += 1
elif h.hours != 0 and not gh:
db.add(GmalHours(
gmal_asset_id=asset.id,
role_id=h.role_id,
model_type=mt,
hours=h.hours,
))
updated += 1
await db.commit()
return {"detail": f"Updated {updated} hour records"}
@router.get("/roles", response_model=list[RoleOut])
async def list_roles(db: AsyncSession = Depends(get_db), discipline: str | None = None):
query = select(Role).order_by(Role.sort_order)
if discipline:
query = query.where(Role.discipline == discipline)
result = await db.execute(query)
return result.scalars().all()
@router.get("/stats", response_model=GmalStatsOut)
async def get_stats(db: AsyncSession = Depends(get_db)):
assets_count = await db.execute(select(func.count(GmalAsset.id)))
roles_count = await db.execute(select(func.count(Role.id)))
hours_count = await db.execute(select(func.count(GmalHours.id)))
categories = await db.execute(select(distinct(GmalAsset.category)).where(GmalAsset.category.isnot(None)))
sub_cats = await db.execute(select(distinct(GmalAsset.sub_category)).where(GmalAsset.sub_category.isnot(None)))
ai_desc_count = await db.execute(
select(func.count(GmalAsset.id)).where(GmalAsset.ai_enhanced_description.isnot(None))
)
return GmalStatsOut(
total_assets=assets_count.scalar() or 0,
total_roles=roles_count.scalar() or 0,
total_hours_records=hours_count.scalar() or 0,
categories=sorted([r[0] for r in categories.all()]),
sub_categories=sorted([r[0] for r in sub_cats.all()]),
ai_descriptions_count=ai_desc_count.scalar() or 0,
)
@router.post("/generate-descriptions")
async def generate_all_descriptions(db: AsyncSession = Depends(get_db)):
"""Generate AI-enhanced descriptions for all GMAL assets."""
from app.services.ai_descriptions import generate_descriptions_batch
result = await generate_descriptions_batch(db)
return result
@router.post("/assets/{gmal_id}/generate-description")
async def generate_single_description(gmal_id: str, db: AsyncSession = Depends(get_db)):
"""Generate/regenerate AI-enhanced description for a single GMAL asset."""
from app.services.ai_descriptions import generate_description_single
result = await db.execute(select(GmalAsset).where(GmalAsset.gmal_id == gmal_id))
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail=f"GMAL asset {gmal_id} not found")
desc = await generate_description_single(db, asset)
return {"gmal_id": gmal_id, "ai_enhanced_description": desc}