gmal-scope-builder/backend/app/api/gmal.py
DJP a1bbd330c6 AI-enhanced GMAL descriptions + matching fixes
- New ai_descriptions service: generates rich brief-friendly descriptions
  per GMAL asset via Claude, grouped by category (135/243 generated)
- Descriptions include client synonyms, inclusions/exclusions, use cases,
  channel/format info, complexity differentiators
- GMAL Browser shows AI descriptions with green/amber status indicators
- GMAL Editor: editable AI descriptions, per-asset regenerate, batch generate all
- Matching catalog now includes AI descriptions for better semantic matching
- Fixed ORM session expiry bug: snapshot asset data before batch commits
- Fixed enum issue: removed unused UPLOADING/EXTRACTING statuses
- Added app-level logging (basicConfig) so service logs show in docker
- YOLO now batches 20 selections in parallel
- Matching returns 1 best match by default, extras only within 5% of top

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 10:12:04 -04:00

186 lines
6.9 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)))
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}