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

279 lines
9.4 KiB
Python

"""Client document upload and AI matching endpoints."""
import logging
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.gmal import GmalAsset
from app.models.project import Project, ClientAsset, Match, ProjectStatus, MatchConfidence
from app.schemas.project import ClientAssetOut, ClientAssetUpdate, MatchOut, MatchSelectRequest, ManualMatchRequest
from app.services.doc_parser import parse_uploaded_file
from app.services.ai_matching import match_client_assets
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/{project_id}/upload")
async def upload_client_document(
project_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
):
"""Upload a client document and extract assets using AI."""
project = await _get_project(project_id, db)
# Read file
content = await file.read()
project.source_filename = file.filename
project.status = ProjectStatus.PARSING
# Parse document to extract assets
try:
extracted, usage_info = parse_uploaded_file(content, file.filename)
except Exception as e:
logger.error(f"Document parsing failed: {e}")
raise HTTPException(status_code=400, detail=f"Failed to parse document: {str(e)}")
# Save AI costs to project
project.ai_input_tokens = (project.ai_input_tokens or 0) + usage_info.get("input_tokens", 0)
project.ai_output_tokens = (project.ai_output_tokens or 0) + usage_info.get("output_tokens", 0)
project.ai_cost_usd = float(project.ai_cost_usd or 0) + usage_info.get("cost_usd", 0)
project.ai_call_count = (project.ai_call_count or 0) + 1
# Clear existing client assets for this project
existing = await db.execute(
select(ClientAsset).where(ClientAsset.project_id == project_id)
)
for ca in existing.scalars().all():
await db.delete(ca)
# Create client asset records
assets = []
for idx, item in enumerate(extracted):
ca = ClientAsset(
project_id=project_id,
raw_name=item.get("name", "Unknown"),
raw_description=item.get("description", ""),
volume=item.get("volume", 1),
sort_order=idx + 1,
)
db.add(ca)
assets.append(ca)
project.status = ProjectStatus.REVIEW
await db.commit()
return {
"message": f"Extracted {len(assets)} assets from {file.filename}",
"asset_count": len(assets),
"assets": [
{"name": a.raw_name, "description": a.raw_description, "volume": a.volume}
for a in assets
],
}
@router.get("/{project_id}/client-assets", response_model=list[ClientAssetOut])
async def list_client_assets(project_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(ClientAsset)
.where(ClientAsset.project_id == project_id)
.order_by(ClientAsset.sort_order)
)
return result.scalars().all()
@router.put("/{project_id}/client-assets/{asset_id}", response_model=ClientAssetOut)
async def update_client_asset(
project_id: int,
asset_id: int,
data: ClientAssetUpdate,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ClientAsset).where(ClientAsset.id == asset_id, ClientAsset.project_id == project_id)
)
ca = result.scalar_one_or_none()
if not ca:
raise HTTPException(status_code=404, detail="Client asset not found")
if data.raw_name is not None:
ca.raw_name = data.raw_name
if data.raw_description is not None:
ca.raw_description = data.raw_description
if data.volume is not None:
ca.volume = data.volume
await db.commit()
await db.refresh(ca)
return ca
@router.post("/{project_id}/match")
async def run_matching(project_id: int, db: AsyncSession = Depends(get_db)):
"""Trigger AI matching for all client assets in this project."""
project = await _get_project(project_id, db)
# Get client assets
result = await db.execute(
select(ClientAsset).where(ClientAsset.project_id == project_id).order_by(ClientAsset.sort_order)
)
client_assets = result.scalars().all()
if not client_assets:
raise HTTPException(status_code=400, detail="No client assets to match. Upload a document first.")
# Clear existing matches
for ca in client_assets:
matches_result = await db.execute(select(Match).where(Match.client_asset_id == ca.id))
for m in matches_result.scalars().all():
await db.delete(m)
project.status = ProjectStatus.MATCHING
await db.commit()
# Run matching (batched, parallel, commits per batch)
matches = await match_client_assets(db, project_id, client_assets)
# Refresh project and set final status
await db.refresh(project)
project.status = ProjectStatus.REVIEW
await db.commit()
return {
"message": f"Matched {len(client_assets)} client assets",
"total_matches": len(matches),
}
@router.post("/{project_id}/match/cancel")
async def cancel_matching_endpoint(project_id: int, db: AsyncSession = Depends(get_db)):
"""Cancel an in-progress matching run."""
from app.services.ai_matching import cancel_matching
cancel_matching(project_id)
project = await _get_project(project_id, db)
project.status = ProjectStatus.REVIEW
await db.commit()
return {"detail": "Matching cancellation requested"}
@router.get("/{project_id}/matches", response_model=list[MatchOut])
async def list_matches(project_id: int, db: AsyncSession = Depends(get_db)):
"""Get all matches for a project, grouped by client asset."""
# Get client asset IDs for this project
ca_result = await db.execute(
select(ClientAsset.id).where(ClientAsset.project_id == project_id)
)
ca_ids = [r[0] for r in ca_result.all()]
if not ca_ids:
return []
result = await db.execute(
select(Match, GmalAsset)
.join(GmalAsset, Match.gmal_asset_id == GmalAsset.id)
.where(Match.client_asset_id.in_(ca_ids))
.order_by(Match.client_asset_id, Match.rank)
)
matches = []
for match, gmal in result.all():
matches.append(MatchOut(
id=match.id,
client_asset_id=match.client_asset_id,
gmal_asset_id=match.gmal_asset_id,
gmal_id=gmal.gmal_id,
gmal_name=gmal.asset_name,
gmal_unique_name=gmal.unique_name,
confidence=match.confidence.value,
confidence_score=float(match.confidence_score) if match.confidence_score else None,
ai_reasoning=match.ai_reasoning,
caveat_text=match.caveat_text,
is_selected=match.is_selected,
rank=match.rank,
))
return matches
@router.put("/{project_id}/matches/{match_id}/select")
async def select_match(
project_id: int,
match_id: int,
data: MatchSelectRequest,
db: AsyncSession = Depends(get_db),
):
"""Select or deselect a match. Deselects other matches for the same client asset."""
result = await db.execute(select(Match).where(Match.id == match_id))
match = result.scalar_one_or_none()
if not match:
raise HTTPException(status_code=404, detail="Match not found")
if data.is_selected:
# Deselect all other matches for this client asset
siblings = await db.execute(
select(Match).where(Match.client_asset_id == match.client_asset_id)
)
for sibling in siblings.scalars().all():
sibling.is_selected = False
match.is_selected = data.is_selected
await db.commit()
return {"detail": "Match updated"}
@router.post("/{project_id}/matches/{client_asset_id}/manual")
async def manual_match(
project_id: int,
client_asset_id: int,
data: ManualMatchRequest,
db: AsyncSession = Depends(get_db),
):
"""Manually assign a GMAL asset to a client asset."""
# Verify client asset belongs to project
ca_result = await db.execute(
select(ClientAsset).where(ClientAsset.id == client_asset_id, ClientAsset.project_id == project_id)
)
ca = ca_result.scalar_one_or_none()
if not ca:
raise HTTPException(status_code=404, detail="Client asset not found")
# Verify GMAL asset exists
gmal_result = await db.execute(select(GmalAsset).where(GmalAsset.id == data.gmal_asset_id))
gmal = gmal_result.scalar_one_or_none()
if not gmal:
raise HTTPException(status_code=404, detail="GMAL asset not found")
# Deselect existing matches
existing = await db.execute(select(Match).where(Match.client_asset_id == client_asset_id))
for m in existing.scalars().all():
m.is_selected = False
# Create manual match
match = Match(
client_asset_id=client_asset_id,
gmal_asset_id=data.gmal_asset_id,
confidence=MatchConfidence.EXACT,
confidence_score=1.0,
ai_reasoning="Manually assigned by user",
caveat_text="",
is_selected=True,
rank=0,
)
db.add(match)
await db.commit()
return {"detail": f"Manually matched to {gmal.gmal_id}"}
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