gmal-scope-builder/backend/app/api/gmal.py
DJP e18976fdb2 Initial commit - GMAL Scope Builder
Dockerized web app (FastAPI + React + PostgreSQL) for scoping client ratecards
against the GMAL master asset database. Features:
- GMAL data ingestion from Excel (390 assets, 120 roles, 5 model types)
- AI-powered document parsing and asset extraction (Claude Opus 4.6)
- AI matching engine with parallel batching, confidence scoring, caveats
- Ratecard builder with hours x volume calculation
- Excel and PDF export
- GMAL browser and inline editor
- AI cost tracking per project (persisted to DB)
- Debug panel for AI call inspection
- Dark theme UI with gold (#FFC407) accent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:35:14 -04:00

160 lines
5.7 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}", 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)))
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()]),
)