- 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>
213 lines
7.8 KiB
Python
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}
|