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

110 lines
3.8 KiB
Python

"""Project CRUD endpoints."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.project import Project, ClientAsset, ProjectStatus
from app.models.gmal import ModelType
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectOut
router = APIRouter()
@router.post("", response_model=ProjectOut)
async def create_project(data: ProjectCreate, db: AsyncSession = Depends(get_db)):
project = Project(
name=data.name,
client_name=data.client_name,
description=data.description,
model_type=ModelType(data.model_type),
)
db.add(project)
await db.commit()
await db.refresh(project)
return _project_out(project, 0)
@router.get("", response_model=list[ProjectOut])
async def list_projects(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Project).order_by(Project.created_at.desc()))
projects = result.scalars().all()
out = []
for p in projects:
count_result = await db.execute(
select(func.count(ClientAsset.id)).where(ClientAsset.project_id == p.id)
)
count = count_result.scalar() or 0
out.append(_project_out(p, count))
return out
@router.get("/{project_id}", response_model=ProjectOut)
async def get_project(project_id: int, db: AsyncSession = Depends(get_db)):
project = await _get_project(project_id, db)
count_result = await db.execute(
select(func.count(ClientAsset.id)).where(ClientAsset.project_id == project.id)
)
return _project_out(project, count_result.scalar() or 0)
@router.put("/{project_id}", response_model=ProjectOut)
async def update_project(project_id: int, data: ProjectUpdate, db: AsyncSession = Depends(get_db)):
project = await _get_project(project_id, db)
if data.name is not None:
project.name = data.name
if data.client_name is not None:
project.client_name = data.client_name
if data.description is not None:
project.description = data.description
if data.model_type is not None:
project.model_type = ModelType(data.model_type)
await db.commit()
await db.refresh(project)
count_result = await db.execute(
select(func.count(ClientAsset.id)).where(ClientAsset.project_id == project.id)
)
return _project_out(project, count_result.scalar() or 0)
@router.delete("/{project_id}")
async def delete_project(project_id: int, db: AsyncSession = Depends(get_db)):
project = await _get_project(project_id, db)
await db.delete(project)
await db.commit()
return {"detail": "Project deleted"}
async def _get_project(project_id: int, db: AsyncSession) -> Project:
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
def _project_out(project: Project, asset_count: int) -> ProjectOut:
return ProjectOut(
id=project.id,
name=project.name,
client_name=project.client_name,
description=project.description,
model_type=project.model_type.value,
status=project.status.value,
source_filename=project.source_filename,
parse_stage=project.parse_stage,
has_brief_analysis=bool(project.brief_analysis),
has_tier_mapping=bool(project.tier_mapping),
ai_input_tokens=project.ai_input_tokens or 0,
ai_output_tokens=project.ai_output_tokens or 0,
ai_cost_usd=float(project.ai_cost_usd or 0),
ai_call_count=project.ai_call_count or 0,
created_at=project.created_at,
updated_at=project.updated_at,
asset_count=asset_count,
)