commit e18976fdb25f44443dbadf29f29dc0b2df6cd031 Author: DJP Date: Fri Mar 27 17:35:14 2026 -0400 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) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a76c64d --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +POSTGRES_PASSWORD=scope_pass_2024 +ANTHROPIC_API_KEY=your-api-key-here diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15e6278 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.env +__pycache__/ +*.pyc +node_modules/ +dist/ +.vite/ +*.egg-info/ +.pytest_cache/ +data/*.xlsx +*.xlsx +.DS_Store diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..aa69237 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN chmod +x start.sh + +CMD ["bash", "start.sh"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..24b8d7e --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +sqlalchemy.url = postgresql://scope_user:scope_pass_2024@db:5432/scope_builder + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..0c21d66 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,45 @@ +import os +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool +from alembic import context + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Override URL from environment if available +db_url = os.getenv("DATABASE_URL_SYNC") +if db_url: + config.set_main_option("sqlalchemy.url", db_url) + +from app.database import Base +from app.models import * # noqa: F401,F403 + +target_metadata = Base.metadata + + +def run_migrations_offline(): + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..751bb55 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,23 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/001_initial_schema.py b/backend/alembic/versions/001_initial_schema.py new file mode 100644 index 0000000..e8eef98 --- /dev/null +++ b/backend/alembic/versions/001_initial_schema.py @@ -0,0 +1,181 @@ +"""Initial schema + +Revision ID: 001 +Revises: +Create Date: 2026-03-27 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = '001' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + model_type_enum = sa.Enum( + 'current_oplus', 'ai_oplus', 'current_local', 'ai_local', 'asset_factory', + name='modeltype', create_type=False, + ) + project_status_enum = sa.Enum( + 'draft', 'parsing', 'matching', 'review', 'building', 'finalized', + name='projectstatus', create_type=False, + ) + match_confidence_enum = sa.Enum( + 'exact', 'close', 'multiple', 'none', + name='matchconfidence', create_type=False, + ) + + # Create enum types explicitly + op.execute("DO $$ BEGIN CREATE TYPE modeltype AS ENUM ('current_oplus', 'ai_oplus', 'current_local', 'ai_local', 'asset_factory'); EXCEPTION WHEN duplicate_object THEN NULL; END $$") + op.execute("DO $$ BEGIN CREATE TYPE projectstatus AS ENUM ('draft', 'parsing', 'matching', 'review', 'building', 'finalized'); EXCEPTION WHEN duplicate_object THEN NULL; END $$") + op.execute("DO $$ BEGIN CREATE TYPE matchconfidence AS ENUM ('exact', 'close', 'multiple', 'none'); EXCEPTION WHEN duplicate_object THEN NULL; END $$") + + # gmal_assets + op.create_table( + 'gmal_assets', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('gmal_id', sa.String(20), nullable=False, unique=True), + sa.Column('swop_asset_id', sa.String(50)), + sa.Column('region', sa.String(20)), + sa.Column('category', sa.String(100)), + sa.Column('sub_category', sa.String(100), index=True), + sa.Column('sub_category_description', sa.Text()), + sa.Column('asset_name', sa.String(255)), + sa.Column('complexity_level', sa.Integer()), + sa.Column('complexity_name', sa.String(20)), + sa.Column('unique_name', sa.String(255)), + sa.Column('asset_description', sa.Text()), + sa.Column('complexity_description', sa.Text()), + sa.Column('caveats', sa.Text()), + sa.Column('ai_enhanced_description', sa.Text()), + sa.Column('master_adapt', sa.String(20)), + sa.Column('ai_efficiency_pct', sa.Numeric(5, 2)), + sa.Column('has_hour_routes', sa.Boolean(), default=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now()), + ) + op.create_index('ix_gmal_assets_gmal_id', 'gmal_assets', ['gmal_id']) + + # roles + op.create_table( + 'roles', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('discipline', sa.String(100), nullable=False, index=True), + sa.Column('role_title', sa.String(200), nullable=False), + sa.Column('entity', sa.String(100)), + sa.Column('resource_location', sa.String(50)), + sa.Column('unique_name', sa.String(255)), + sa.Column('sort_order', sa.Integer()), + sa.Column('is_programme_role', sa.Boolean(), default=False), + sa.UniqueConstraint('role_title', 'entity', name='uq_role_title_entity'), + ) + + # gmal_hours + op.create_table( + 'gmal_hours', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('gmal_asset_id', sa.Integer(), sa.ForeignKey('gmal_assets.id'), nullable=False), + sa.Column('role_id', sa.Integer(), sa.ForeignKey('roles.id'), nullable=False), + sa.Column('model_type', model_type_enum, nullable=False), + sa.Column('hours', sa.Numeric(10, 2), nullable=False), + sa.UniqueConstraint('gmal_asset_id', 'role_id', 'model_type', name='uq_gmal_role_model'), + ) + op.create_index('idx_gmal_hours_asset', 'gmal_hours', ['gmal_asset_id']) + op.create_index('idx_gmal_hours_model', 'gmal_hours', ['model_type']) + + # gmal_service_lines + op.create_table( + 'gmal_service_lines', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('number', sa.String(20)), + sa.Column('name', sa.String(255)), + sa.Column('type', sa.String(50)), + sa.Column('gmal_id', sa.String(20), index=True), + ) + + # role_level_mappings + op.create_table( + 'role_level_mappings', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('role_name', sa.String(200)), + sa.Column('number', sa.String(20)), + sa.Column('level_name', sa.String(200)), + sa.Column('type', sa.String(50)), + ) + + # projects + op.create_table( + 'projects', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('client_name', sa.String(255)), + sa.Column('description', sa.Text()), + sa.Column('model_type', model_type_enum, default='current_oplus'), + sa.Column('status', project_status_enum, default='draft'), + sa.Column('source_filename', sa.String(255)), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now()), + ) + + # client_assets + op.create_table( + 'client_assets', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('project_id', sa.Integer(), sa.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False), + sa.Column('raw_name', sa.String(500)), + sa.Column('raw_description', sa.Text()), + sa.Column('volume', sa.Integer(), default=1), + sa.Column('sort_order', sa.Integer()), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + ) + + # matches + op.create_table( + 'matches', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('client_asset_id', sa.Integer(), sa.ForeignKey('client_assets.id', ondelete='CASCADE'), nullable=False), + sa.Column('gmal_asset_id', sa.Integer(), sa.ForeignKey('gmal_assets.id'), nullable=False), + sa.Column('confidence', match_confidence_enum, nullable=False), + sa.Column('confidence_score', sa.Numeric(3, 2)), + sa.Column('ai_reasoning', sa.Text()), + sa.Column('caveat_text', sa.Text()), + sa.Column('is_selected', sa.Boolean(), default=False), + sa.Column('rank', sa.Integer(), default=1), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + ) + op.create_index('idx_matches_client_asset', 'matches', ['client_asset_id']) + + # ratecard_lines + op.create_table( + 'ratecard_lines', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('project_id', sa.Integer(), sa.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False), + sa.Column('client_asset_id', sa.Integer(), sa.ForeignKey('client_assets.id'), nullable=False), + sa.Column('gmal_asset_id', sa.Integer(), sa.ForeignKey('gmal_assets.id'), nullable=False), + sa.Column('role_id', sa.Integer(), sa.ForeignKey('roles.id'), nullable=False), + sa.Column('base_hours', sa.Numeric(10, 2)), + sa.Column('volume', sa.Integer(), default=1), + sa.Column('total_hours', sa.Numeric(10, 2)), + sa.Column('manual_override', sa.Numeric(10, 2)), + sa.Column('notes', sa.Text()), + ) + op.create_index('idx_ratecard_project', 'ratecard_lines', ['project_id']) + + +def downgrade() -> None: + op.drop_table('ratecard_lines') + op.drop_table('matches') + op.drop_table('client_assets') + op.drop_table('projects') + op.drop_table('role_level_mappings') + op.drop_table('gmal_service_lines') + op.drop_table('gmal_hours') + op.drop_table('roles') + op.drop_table('gmal_assets') + + sa.Enum(name='matchconfidence').drop(op.get_bind(), checkfirst=True) + sa.Enum(name='projectstatus').drop(op.get_bind(), checkfirst=True) + sa.Enum(name='modeltype').drop(op.get_bind(), checkfirst=True) diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..c7902f2 --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,8 @@ +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db + + +async def get_session(db: AsyncSession = Depends(get_db)): + return db diff --git a/backend/app/api/gmal.py b/backend/app/api/gmal.py new file mode 100644 index 0000000..9f67220 --- /dev/null +++ b/backend/app/api/gmal.py @@ -0,0 +1,160 @@ +"""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()]), + ) diff --git a/backend/app/api/ingest.py b/backend/app/api/ingest.py new file mode 100644 index 0000000..9a59724 --- /dev/null +++ b/backend/app/api/ingest.py @@ -0,0 +1,50 @@ +"""GMAL data ingestion endpoint.""" + +import logging +import os + +from fastapi import APIRouter, HTTPException, UploadFile, File +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from app.config import settings +from app.schemas.gmal import IngestResult +from app.services.excel_parser import parse_gmal_workbook + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.post("/ingest", response_model=IngestResult) +async def ingest_gmal_data(file: UploadFile | None = File(None)): + """Ingest GMAL data from Excel file. + + If no file is uploaded, uses the default file from the data directory. + Uses a synchronous DB session since openpyxl is synchronous. + """ + if file: + # Save uploaded file temporarily + filepath = f"/tmp/{file.filename}" + content = await file.read() + with open(filepath, "wb") as f: + f.write(content) + else: + # Use default data file + filepath = os.path.join(settings.data_dir, "U-Studio GMAL Asset Job Routes Apr25 ForFranky.xlsx") + if not os.path.exists(filepath): + raise HTTPException(status_code=404, detail="Default GMAL file not found in data directory") + + # Use sync engine for openpyxl parsing + sync_engine = create_engine(settings.database_url_sync) + + try: + with Session(sync_engine) as db: + result = parse_gmal_workbook(filepath, db) + return IngestResult(**result) + except Exception as e: + logger.error(f"Ingestion failed: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Ingestion failed: {str(e)}") + finally: + sync_engine.dispose() + if file and os.path.exists(filepath): + os.remove(filepath) diff --git a/backend/app/api/matching.py b/backend/app/api/matching.py new file mode 100644 index 0000000..d6915c1 --- /dev/null +++ b/backend/app/api/matching.py @@ -0,0 +1,279 @@ +"""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 diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py new file mode 100644 index 0000000..d0493cd --- /dev/null +++ b/backend/app/api/projects.py @@ -0,0 +1,107 @@ +"""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, + 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, + ) diff --git a/backend/app/api/ratecard.py b/backend/app/api/ratecard.py new file mode 100644 index 0000000..8a958f3 --- /dev/null +++ b/backend/app/api/ratecard.py @@ -0,0 +1,163 @@ +"""Ratecard build and export endpoints.""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.gmal import GmalAsset, Role +from app.models.project import Project, ClientAsset, RatecardLine, ProjectStatus +from app.schemas.project import RatecardLineOut, RatecardLineUpdate, RatecardSummary +from app.services.ratecard_builder import build_ratecard +from app.services.export_excel import export_ratecard_excel +from app.services.export_pdf import export_caveats_pdf + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.post("/{project_id}/ratecard/build") +async def build_project_ratecard(project_id: int, db: AsyncSession = Depends(get_db)): + """Build ratecard from confirmed matches.""" + project = await _get_project(project_id, db) + project.status = ProjectStatus.BUILDING + + lines = await build_ratecard(db, project) + + project.status = ProjectStatus.FINALIZED + await db.commit() + + return { + "message": f"Ratecard built with {len(lines)} lines", + "total_lines": len(lines), + } + + +@router.get("/{project_id}/ratecard", response_model=RatecardSummary) +async def get_ratecard(project_id: int, db: AsyncSession = Depends(get_db)): + """Get the full ratecard for a project.""" + project = await _get_project(project_id, db) + + result = await db.execute( + select(RatecardLine, ClientAsset, GmalAsset, Role) + .join(ClientAsset, RatecardLine.client_asset_id == ClientAsset.id) + .join(GmalAsset, RatecardLine.gmal_asset_id == GmalAsset.id) + .join(Role, RatecardLine.role_id == Role.id) + .where(RatecardLine.project_id == project_id) + .order_by(Role.sort_order, ClientAsset.sort_order) + ) + + lines = [] + total_hours = 0 + for rl, ca, gmal, role in result.all(): + effective = float(rl.manual_override) if rl.manual_override is not None else float(rl.total_hours or 0) + total_hours += effective + lines.append(RatecardLineOut( + id=rl.id, + client_asset_id=rl.client_asset_id, + client_asset_name=ca.raw_name, + gmal_asset_id=rl.gmal_asset_id, + gmal_id=gmal.gmal_id, + role_id=rl.role_id, + role_title=role.role_title, + discipline=role.discipline, + base_hours=float(rl.base_hours) if rl.base_hours else None, + volume=rl.volume, + total_hours=float(rl.total_hours) if rl.total_hours else None, + manual_override=float(rl.manual_override) if rl.manual_override is not None else None, + notes=rl.notes, + )) + + # Count unique client assets + asset_ids = set(l.client_asset_id for l in lines) + + return RatecardSummary( + project_id=project.id, + project_name=project.name, + model_type=project.model_type.value, + total_assets=len(asset_ids), + total_hours=round(total_hours, 2), + lines=lines, + ) + + +@router.put("/{project_id}/ratecard/lines/{line_id}", response_model=RatecardLineOut) +async def update_ratecard_line( + project_id: int, + line_id: int, + data: RatecardLineUpdate, + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(RatecardLine).where(RatecardLine.id == line_id, RatecardLine.project_id == project_id) + ) + line = result.scalar_one_or_none() + if not line: + raise HTTPException(status_code=404, detail="Ratecard line not found") + + if data.manual_override is not None: + line.manual_override = data.manual_override + if data.notes is not None: + line.notes = data.notes + + await db.commit() + await db.refresh(line) + + # Re-fetch with joins for response + full_result = await db.execute( + select(RatecardLine, ClientAsset, GmalAsset, Role) + .join(ClientAsset, RatecardLine.client_asset_id == ClientAsset.id) + .join(GmalAsset, RatecardLine.gmal_asset_id == GmalAsset.id) + .join(Role, RatecardLine.role_id == Role.id) + .where(RatecardLine.id == line_id) + ) + rl, ca, gmal, role = full_result.one() + + return RatecardLineOut( + id=rl.id, + client_asset_id=rl.client_asset_id, + client_asset_name=ca.raw_name, + gmal_asset_id=rl.gmal_asset_id, + gmal_id=gmal.gmal_id, + role_id=rl.role_id, + role_title=role.role_title, + discipline=role.discipline, + base_hours=float(rl.base_hours) if rl.base_hours else None, + volume=rl.volume, + total_hours=float(rl.total_hours) if rl.total_hours else None, + manual_override=float(rl.manual_override) if rl.manual_override is not None else None, + notes=rl.notes, + ) + + +@router.get("/{project_id}/ratecard/export/excel") +async def export_excel(project_id: int, db: AsyncSession = Depends(get_db)): + project = await _get_project(project_id, db) + data = await export_ratecard_excel(db, project) + return Response( + content=data, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename={project.name}_ratecard.xlsx"}, + ) + + +@router.get("/{project_id}/ratecard/export/pdf") +async def export_pdf(project_id: int, db: AsyncSession = Depends(get_db)): + project = await _get_project(project_id, db) + data = await export_caveats_pdf(db, project) + return Response( + content=data, + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename={project.name}_caveats.pdf"}, + ) + + +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 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..a7dbf96 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,14 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + database_url: str = "postgresql+asyncpg://scope_user:scope_pass_2024@db:5432/scope_builder" + database_url_sync: str = "postgresql://scope_user:scope_pass_2024@db:5432/scope_builder" + anthropic_api_key: str = "" + data_dir: str = "/app/data" + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..51ef009 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,16 @@ +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase + +from app.config import settings + +engine = create_async_engine(settings.database_url, echo=False) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db(): + async with async_session() as session: + yield session diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..7c3998f --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,44 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api import gmal, ingest, projects, matching, ratecard + +app = FastAPI(title="Scope Builder", version="1.0.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000", "http://localhost:3001", "http://localhost:3010"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(gmal.router, prefix="/api/gmal", tags=["GMAL"]) +app.include_router(ingest.router, prefix="/api/gmal", tags=["Ingest"]) +app.include_router(projects.router, prefix="/api/projects", tags=["Projects"]) +app.include_router(matching.router, prefix="/api/projects", tags=["Matching"]) +app.include_router(ratecard.router, prefix="/api/projects", tags=["Ratecard"]) + + +@app.get("/api/health") +async def health(): + return {"status": "ok"} + + +@app.get("/api/ai/usage") +async def ai_usage(): + from app.utils.claude_client import get_usage_stats + return get_usage_stats() + + +@app.post("/api/ai/usage/reset") +async def ai_usage_reset(): + from app.utils.claude_client import reset_usage_stats + reset_usage_stats() + return {"detail": "Usage stats reset"} + + +@app.get("/api/ai/debug") +async def ai_debug(): + from app.utils.claude_client import get_debug_log + return get_debug_log() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..6198d50 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,7 @@ +from app.models.gmal import GmalAsset, Role, GmalHours, GmalServiceLine, RoleLevelMapping +from app.models.project import Project, ClientAsset, Match, RatecardLine + +__all__ = [ + "GmalAsset", "Role", "GmalHours", "GmalServiceLine", "RoleLevelMapping", + "Project", "ClientAsset", "Match", "RatecardLine", +] diff --git a/backend/app/models/gmal.py b/backend/app/models/gmal.py new file mode 100644 index 0000000..eb2f07a --- /dev/null +++ b/backend/app/models/gmal.py @@ -0,0 +1,110 @@ +import enum +from datetime import datetime + +from sqlalchemy import String, Text, Integer, Numeric, Boolean, DateTime, Enum, ForeignKey, UniqueConstraint, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class ModelType(str, enum.Enum): + CURRENT_OPLUS = "current_oplus" + AI_OPLUS = "ai_oplus" + CURRENT_LOCAL = "current_local" + AI_LOCAL = "ai_local" + ASSET_FACTORY = "asset_factory" + + +# Map spreadsheet header text -> enum +MODEL_TYPE_MAP = { + "Current Model - O+ Market": ModelType.CURRENT_OPLUS, + "AI Model - O+ Market": ModelType.AI_OPLUS, + "Current Model - Local Market": ModelType.CURRENT_LOCAL, + "AI Model - Local Market": ModelType.AI_LOCAL, + "Asset Factory Model": ModelType.ASSET_FACTORY, +} + + +class GmalAsset(Base): + __tablename__ = "gmal_assets" + + id: Mapped[int] = mapped_column(primary_key=True) + gmal_id: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True) + swop_asset_id: Mapped[str | None] = mapped_column(String(50)) + region: Mapped[str | None] = mapped_column(String(20)) + category: Mapped[str | None] = mapped_column(String(100)) + sub_category: Mapped[str | None] = mapped_column(String(100), index=True) + sub_category_description: Mapped[str | None] = mapped_column(Text) + asset_name: Mapped[str | None] = mapped_column(String(255)) + complexity_level: Mapped[int | None] = mapped_column(Integer) + complexity_name: Mapped[str | None] = mapped_column(String(20)) + unique_name: Mapped[str | None] = mapped_column(String(255)) + asset_description: Mapped[str | None] = mapped_column(Text) + complexity_description: Mapped[str | None] = mapped_column(Text) + caveats: Mapped[str | None] = mapped_column(Text) + ai_enhanced_description: Mapped[str | None] = mapped_column(Text) + master_adapt: Mapped[str | None] = mapped_column(String(20)) + ai_efficiency_pct: Mapped[float | None] = mapped_column(Numeric(5, 2)) + has_hour_routes: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + hours: Mapped[list["GmalHours"]] = relationship(back_populates="asset", cascade="all, delete-orphan") + + +class Role(Base): + __tablename__ = "roles" + + id: Mapped[int] = mapped_column(primary_key=True) + discipline: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + role_title: Mapped[str] = mapped_column(String(200), nullable=False) + entity: Mapped[str | None] = mapped_column(String(100)) + resource_location: Mapped[str | None] = mapped_column(String(50)) + unique_name: Mapped[str | None] = mapped_column(String(255)) + sort_order: Mapped[int | None] = mapped_column(Integer) + is_programme_role: Mapped[bool] = mapped_column(Boolean, default=False) + + __table_args__ = ( + UniqueConstraint("role_title", "entity", name="uq_role_title_entity"), + ) + + hours: Mapped[list["GmalHours"]] = relationship(back_populates="role") + + +class GmalHours(Base): + __tablename__ = "gmal_hours" + + id: Mapped[int] = mapped_column(primary_key=True) + gmal_asset_id: Mapped[int] = mapped_column(ForeignKey("gmal_assets.id"), nullable=False) + role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), nullable=False) + model_type: Mapped[ModelType] = mapped_column(Enum(ModelType), nullable=False) + hours: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) + + asset: Mapped["GmalAsset"] = relationship(back_populates="hours") + role: Mapped["Role"] = relationship(back_populates="hours") + + __table_args__ = ( + UniqueConstraint("gmal_asset_id", "role_id", "model_type", name="uq_gmal_role_model"), + Index("idx_gmal_hours_asset", "gmal_asset_id"), + Index("idx_gmal_hours_model", "model_type"), + ) + + +class GmalServiceLine(Base): + __tablename__ = "gmal_service_lines" + + id: Mapped[int] = mapped_column(primary_key=True) + number: Mapped[str | None] = mapped_column(String(20)) + name: Mapped[str | None] = mapped_column(String(255)) + type: Mapped[str | None] = mapped_column(String(50)) + gmal_id: Mapped[str | None] = mapped_column(String(50), index=True) + + +class RoleLevelMapping(Base): + __tablename__ = "role_level_mappings" + + id: Mapped[int] = mapped_column(primary_key=True) + role_name: Mapped[str | None] = mapped_column(String(200)) + number: Mapped[str | None] = mapped_column(String(20)) + level_name: Mapped[str | None] = mapped_column(String(200)) + type: Mapped[str | None] = mapped_column(String(50)) diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 0000000..446da58 --- /dev/null +++ b/backend/app/models/project.py @@ -0,0 +1,106 @@ +import enum +from datetime import datetime + +from sqlalchemy import String, Text, Integer, Numeric, Boolean, DateTime, Enum, ForeignKey, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base +from app.models.gmal import ModelType + + +class ProjectStatus(str, enum.Enum): + DRAFT = "draft" + PARSING = "parsing" + MATCHING = "matching" + REVIEW = "review" + BUILDING = "building" + FINALIZED = "finalized" + + +class MatchConfidence(str, enum.Enum): + EXACT = "exact" + CLOSE = "close" + MULTIPLE = "multiple" + NONE = "none" + + +class Project(Base): + __tablename__ = "projects" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + client_name: Mapped[str | None] = mapped_column(String(255)) + description: Mapped[str | None] = mapped_column(Text) + model_type: Mapped[ModelType] = mapped_column(Enum(ModelType), default=ModelType.CURRENT_OPLUS) + status: Mapped[ProjectStatus] = mapped_column(Enum(ProjectStatus), default=ProjectStatus.DRAFT) + source_filename: Mapped[str | None] = mapped_column(String(255)) + ai_input_tokens: Mapped[int] = mapped_column(Integer, default=0) + ai_output_tokens: Mapped[int] = mapped_column(Integer, default=0) + ai_cost_usd: Mapped[float] = mapped_column(Numeric(10, 6), default=0) + ai_call_count: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + client_assets: Mapped[list["ClientAsset"]] = relationship(back_populates="project", cascade="all, delete-orphan") + ratecard_lines: Mapped[list["RatecardLine"]] = relationship(back_populates="project", cascade="all, delete-orphan") + + +class ClientAsset(Base): + __tablename__ = "client_assets" + + id: Mapped[int] = mapped_column(primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), nullable=False) + raw_name: Mapped[str | None] = mapped_column(String(500)) + raw_description: Mapped[str | None] = mapped_column(Text) + volume: Mapped[int] = mapped_column(Integer, default=1) + sort_order: Mapped[int | None] = mapped_column(Integer) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + project: Mapped["Project"] = relationship(back_populates="client_assets") + matches: Mapped[list["Match"]] = relationship(back_populates="client_asset", cascade="all, delete-orphan") + + +class Match(Base): + __tablename__ = "matches" + + id: Mapped[int] = mapped_column(primary_key=True) + client_asset_id: Mapped[int] = mapped_column(ForeignKey("client_assets.id", ondelete="CASCADE"), nullable=False) + gmal_asset_id: Mapped[int] = mapped_column(ForeignKey("gmal_assets.id"), nullable=False) + confidence: Mapped[MatchConfidence] = mapped_column(Enum(MatchConfidence), nullable=False) + confidence_score: Mapped[float | None] = mapped_column(Numeric(3, 2)) + ai_reasoning: Mapped[str | None] = mapped_column(Text) + caveat_text: Mapped[str | None] = mapped_column(Text) + is_selected: Mapped[bool] = mapped_column(Boolean, default=False) + rank: Mapped[int] = mapped_column(Integer, default=1) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + client_asset: Mapped["ClientAsset"] = relationship(back_populates="matches") + gmal_asset: Mapped["GmalAsset"] = relationship() + + __table_args__ = ( + Index("idx_matches_client_asset", "client_asset_id"), + ) + + +class RatecardLine(Base): + __tablename__ = "ratecard_lines" + + id: Mapped[int] = mapped_column(primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), nullable=False) + client_asset_id: Mapped[int] = mapped_column(ForeignKey("client_assets.id"), nullable=False) + gmal_asset_id: Mapped[int] = mapped_column(ForeignKey("gmal_assets.id"), nullable=False) + role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), nullable=False) + base_hours: Mapped[float | None] = mapped_column(Numeric(10, 2)) + volume: Mapped[int] = mapped_column(Integer, default=1) + total_hours: Mapped[float | None] = mapped_column(Numeric(10, 2)) + manual_override: Mapped[float | None] = mapped_column(Numeric(10, 2)) + notes: Mapped[str | None] = mapped_column(Text) + + project: Mapped["Project"] = relationship(back_populates="ratecard_lines") + client_asset: Mapped["ClientAsset"] = relationship() + gmal_asset: Mapped["GmalAsset"] = relationship() + role: Mapped["Role"] = relationship() + + __table_args__ = ( + Index("idx_ratecard_project", "project_id"), + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/gmal.py b/backend/app/schemas/gmal.py new file mode 100644 index 0000000..5ad1a46 --- /dev/null +++ b/backend/app/schemas/gmal.py @@ -0,0 +1,96 @@ +from pydantic import BaseModel +from datetime import datetime + + +class GmalAssetBrief(BaseModel): + id: int + gmal_id: str + asset_name: str | None + sub_category: str | None + complexity_level: int | None + complexity_name: str | None + unique_name: str | None + has_hour_routes: bool + + class Config: + from_attributes = True + + +class GmalAssetDetail(GmalAssetBrief): + swop_asset_id: str | None + region: str | None + category: str | None + sub_category_description: str | None + asset_description: str | None + complexity_description: str | None + caveats: str | None + ai_enhanced_description: str | None + master_adapt: str | None + ai_efficiency_pct: float | None + created_at: datetime + updated_at: datetime + + +class RoleOut(BaseModel): + id: int + discipline: str + role_title: str + entity: str | None + resource_location: str | None + unique_name: str | None + sort_order: int | None + is_programme_role: bool + + class Config: + from_attributes = True + + +class GmalHoursOut(BaseModel): + role_id: int + role_title: str + discipline: str + model_type: str + hours: float + + class Config: + from_attributes = True + + +class GmalAssetWithHours(GmalAssetDetail): + hours_by_role: list[GmalHoursOut] = [] + + +class GmalStatsOut(BaseModel): + total_assets: int + total_roles: int + total_hours_records: int + categories: list[str] + sub_categories: list[str] + + +class GmalAssetUpdate(BaseModel): + asset_name: str | None = None + sub_category: str | None = None + category: str | None = None + complexity_level: int | None = None + complexity_name: str | None = None + unique_name: str | None = None + asset_description: str | None = None + complexity_description: str | None = None + caveats: str | None = None + master_adapt: str | None = None + ai_efficiency_pct: float | None = None + + +class GmalHoursUpdate(BaseModel): + role_id: int + model_type: str + hours: float + + +class IngestResult(BaseModel): + assets_loaded: int + roles_loaded: int + hours_loaded: int + service_lines_loaded: int + role_mappings_loaded: int diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 0000000..33e622e --- /dev/null +++ b/backend/app/schemas/project.py @@ -0,0 +1,113 @@ +from pydantic import BaseModel +from datetime import datetime + + +class ProjectCreate(BaseModel): + name: str + client_name: str | None = None + description: str | None = None + model_type: str = "current_oplus" + + +class ProjectUpdate(BaseModel): + name: str | None = None + client_name: str | None = None + description: str | None = None + model_type: str | None = None + + +class ProjectOut(BaseModel): + id: int + name: str + client_name: str | None + description: str | None + model_type: str + status: str + source_filename: str | None + ai_input_tokens: int = 0 + ai_output_tokens: int = 0 + ai_cost_usd: float = 0 + ai_call_count: int = 0 + created_at: datetime + updated_at: datetime + asset_count: int = 0 + + class Config: + from_attributes = True + + +class ClientAssetOut(BaseModel): + id: int + project_id: int + raw_name: str | None + raw_description: str | None + volume: int + sort_order: int | None + + class Config: + from_attributes = True + + +class ClientAssetUpdate(BaseModel): + raw_name: str | None = None + raw_description: str | None = None + volume: int | None = None + + +class MatchOut(BaseModel): + id: int + client_asset_id: int + gmal_asset_id: int + gmal_id: str | None = None + gmal_name: str | None = None + gmal_unique_name: str | None = None + confidence: str + confidence_score: float | None + ai_reasoning: str | None + caveat_text: str | None + is_selected: bool + rank: int + + class Config: + from_attributes = True + + +class MatchSelectRequest(BaseModel): + is_selected: bool = True + + +class ManualMatchRequest(BaseModel): + gmal_asset_id: int + + +class RatecardLineOut(BaseModel): + id: int + client_asset_id: int + client_asset_name: str | None = None + gmal_asset_id: int + gmal_id: str | None = None + role_id: int + role_title: str | None = None + discipline: str | None = None + base_hours: float | None + volume: int + total_hours: float | None + manual_override: float | None + notes: str | None + + class Config: + from_attributes = True + + +class RatecardLineUpdate(BaseModel): + manual_override: float | None = None + notes: str | None = None + + +class RatecardSummary(BaseModel): + project_id: int + project_name: str + model_type: str + total_assets: int + total_hours: float + lines: list[RatecardLineOut] diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/ai_matching.py b/backend/app/services/ai_matching.py new file mode 100644 index 0000000..488a25b --- /dev/null +++ b/backend/app/services/ai_matching.py @@ -0,0 +1,300 @@ +"""AI-powered matching of client assets to GMAL catalog using Claude.""" + +import asyncio +import logging +import threading +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.gmal import GmalAsset +from app.models.project import ClientAsset, Match, MatchConfidence +from app.utils.claude_client import call_claude, extract_tool_result + +logger = logging.getLogger(__name__) + +# Cancel flag - set project_id to cancel +_cancel_lock = threading.Lock() +_cancelled_projects: set[int] = set() + +BATCH_SIZE = 10 + + +def cancel_matching(project_id: int): + with _cancel_lock: + _cancelled_projects.add(project_id) + + +def _is_cancelled(project_id: int) -> bool: + with _cancel_lock: + return project_id in _cancelled_projects + + +def _clear_cancel(project_id: int): + with _cancel_lock: + _cancelled_projects.discard(project_id) + + +MATCH_TOOLS = [ + { + "name": "submit_matches", + "description": "Submit the best GMAL matches for a client asset.", + "input_schema": { + "type": "object", + "properties": { + "matches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "gmal_id": { + "type": "string", + "description": "The GMAL ID of the matched asset (e.g., 'GMAL101')" + }, + "confidence": { + "type": "string", + "enum": ["exact", "close", "multiple", "none"], + "description": "exact=direct match, close=similar but differences exist, multiple=one of several candidates, none=no reasonable match" + }, + "confidence_score": { + "type": "number", + "description": "Confidence score from 0.0 to 1.0" + }, + "reasoning": { + "type": "string", + "description": "Why this GMAL was matched - what makes it similar" + }, + "caveats": { + "type": "string", + "description": "Important differences between what the client asked for and what the GMAL asset covers. Include scope gaps, complexity mismatches, format differences." + }, + }, + "required": ["gmal_id", "confidence", "confidence_score", "reasoning", "caveats"], + }, + "minItems": 1, + "maxItems": 3, + }, + }, + "required": ["matches"], + }, + } +] + +SYSTEM_PROMPT = """You are a GMAL asset matching specialist for a creative production agency. + +Your job is to match client-described assets/deliverables to the closest equivalent(s) in the GMAL catalog. + +The GMAL catalog is a standardized list of creative production assets, each with: +- A unique GMAL ID (e.g., GMAL101) +- Asset name and description +- Complexity level (Simple=1, Medium=2, Complex=3) +- Detailed complexity description + +Guidelines: +- Match based on the TYPE of deliverable first, then complexity level. +- Consider that clients may use different terminology (e.g., "banner" vs "web banner", "copywriting" vs "editorial"). +- If the client asset maps clearly to one GMAL, set confidence="exact" with score 0.9-1.0. +- If similar but with notable differences, set confidence="close" with score 0.6-0.89. +- If multiple GMALs could match, return up to 3 ranked options with confidence="multiple". +- If nothing matches well, return the closest option with confidence="none" and score below 0.3. +- Always explain caveats: what the GMAL includes/excludes vs what the client described. +- Pay attention to complexity: a "simple banner" should match a Simple complexity GMAL, not Complex.""" + + +def _match_single_asset(client_asset_name, client_asset_desc, volume, candidates_text, num_candidates): + """Run a single match call to Claude (synchronous, for use in thread pool).""" + user_msg = f"""Match this client asset to the best GMAL equivalent(s): + +CLIENT ASSET: +Name: {client_asset_name} +Description: {client_asset_desc or 'No description provided'} +Volume: {volume} + +GMAL CATALOG CANDIDATES ({num_candidates} assets): +{candidates_text}""" + + response = call_claude( + system=SYSTEM_PROMPT, + user_message=user_msg, + tools=MATCH_TOOLS, + tool_choice={"type": "tool", "name": "submit_matches"}, + max_tokens=2048, + ) + usage = getattr(response, '_usage_info', {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0}) + return extract_tool_result(response), usage + + +async def match_client_assets( + db: AsyncSession, + project_id: int, + client_assets: list[ClientAsset], +) -> list[Match]: + """Match all client assets against the GMAL catalog. + + Runs in parallel batches of BATCH_SIZE. Commits after each batch + so the frontend can poll for progress. Supports cancellation. + """ + _clear_cancel(project_id) + + # Load all GMAL assets for candidate selection + result = await db.execute( + select(GmalAsset).where(GmalAsset.has_hour_routes == True) + ) + all_gmals = result.scalars().all() + gmal_by_id = {g.gmal_id: g for g in all_gmals} + + all_matches = [] + total = len(client_assets) + + # Process in batches + for batch_start in range(0, total, BATCH_SIZE): + if _is_cancelled(project_id): + logger.info(f"Matching cancelled for project {project_id} at {batch_start}/{total}") + break + + batch = client_assets[batch_start:batch_start + BATCH_SIZE] + batch_num = batch_start // BATCH_SIZE + 1 + logger.info(f"Matching batch {batch_num} ({batch_start+1}-{min(batch_start+BATCH_SIZE, total)} of {total})") + + # Prepare all calls for this batch + call_args = [] + for ca in batch: + candidates = _prefilter_candidates(ca, all_gmals) + candidates_text = _format_candidates(candidates) + call_args.append((ca, candidates, candidates_text)) + + # Run batch in parallel using thread pool + loop = asyncio.get_event_loop() + with ThreadPoolExecutor(max_workers=BATCH_SIZE) as executor: + futures = [] + for ca, candidates, candidates_text in call_args: + if _is_cancelled(project_id): + break + future = loop.run_in_executor( + executor, + _match_single_asset, + ca.raw_name, + ca.raw_description, + ca.volume, + candidates_text, + len(candidates), + ) + futures.append((ca, future)) + + # Collect results and accumulate costs + batch_input = 0 + batch_output = 0 + batch_cost = 0.0 + + for ca, future in futures: + try: + tool_result, usage = await future + batch_input += usage.get("input_tokens", 0) + batch_output += usage.get("output_tokens", 0) + batch_cost += usage.get("cost_usd", 0) + + if tool_result and "matches" in tool_result: + # Auto-select: if top match is >= 80%, select it + top_score = tool_result["matches"][0].get("confidence_score", 0) if tool_result["matches"] else 0 + auto_select = top_score >= 0.8 + + for rank, m in enumerate(tool_result["matches"], 1): + gmal = gmal_by_id.get(m["gmal_id"]) + if not gmal: + logger.warning(f"Claude returned unknown GMAL ID: {m['gmal_id']}") + continue + + match = Match( + client_asset_id=ca.id, + gmal_asset_id=gmal.id, + confidence=MatchConfidence(m["confidence"]), + confidence_score=m.get("confidence_score"), + ai_reasoning=m.get("reasoning"), + caveat_text=m.get("caveats"), + is_selected=(rank == 1 and auto_select), + rank=rank, + ) + db.add(match) + all_matches.append(match) + else: + logger.warning(f"No match result for: {ca.raw_name}") + except Exception as e: + logger.error(f"Error matching '{ca.raw_name}': {e}") + + # Save batch costs to project + from app.models.project import Project + proj_result = await db.execute(select(Project).where(Project.id == project_id)) + project = proj_result.scalar_one_or_none() + if project: + project.ai_input_tokens = (project.ai_input_tokens or 0) + batch_input + project.ai_output_tokens = (project.ai_output_tokens or 0) + batch_output + project.ai_cost_usd = float(project.ai_cost_usd or 0) + batch_cost + project.ai_call_count = (project.ai_call_count or 0) + len(batch) + + # Commit after each batch so frontend can see progress + await db.commit() + logger.info(f"Batch {batch_num} committed, {len(all_matches)} total matches so far") + + _clear_cancel(project_id) + return all_matches + + +def _prefilter_candidates(client_asset: ClientAsset, all_gmals: list[GmalAsset], max_candidates: int = 25) -> list[GmalAsset]: + """Pre-filter GMAL candidates using keyword overlap to reduce token usage.""" + name = (client_asset.raw_name or "").lower() + desc = (client_asset.raw_description or "").lower() + search_text = f"{name} {desc}" + + stop_words = {"the", "a", "an", "and", "or", "for", "to", "in", "of", "with", "is", "on", "at", "by"} + keywords = set(search_text.split()) - stop_words + + scored = [] + for gmal in all_gmals: + gmal_text = " ".join(filter(None, [ + gmal.asset_name, + gmal.sub_category, + gmal.unique_name, + gmal.complexity_description, + gmal.ai_enhanced_description, + ])).lower() + + score = sum(1 for kw in keywords if kw in gmal_text) + + if gmal.asset_name and any(word in gmal.asset_name.lower() for word in name.split() if len(word) > 3): + score += 5 + + scored.append((score, gmal)) + + scored.sort(key=lambda x: x[0], reverse=True) + candidates = [g for _, g in scored[:max_candidates]] + + if len([s for s, _ in scored[:max_candidates] if s > 0]) < 5: + seen_cats = set() + for _, g in scored: + if g.sub_category not in seen_cats and g not in candidates: + candidates.append(g) + seen_cats.add(g.sub_category) + if len(candidates) >= max_candidates: + break + + return candidates + + +def _format_candidates(candidates: list[GmalAsset]) -> str: + """Format GMAL candidates as text for the Claude prompt.""" + parts = [] + for g in candidates: + desc = g.ai_enhanced_description or g.complexity_description or g.asset_description or "" + if len(desc) > 300: + desc = desc[:300] + "..." + + parts.append( + f"- {g.gmal_id}: {g.unique_name or g.asset_name} " + f"(Complexity: {g.complexity_name or g.complexity_level}, " + f"Category: {g.sub_category})\n" + f" Description: {desc}" + ) + + return "\n\n".join(parts) diff --git a/backend/app/services/doc_parser.py b/backend/app/services/doc_parser.py new file mode 100644 index 0000000..336866a --- /dev/null +++ b/backend/app/services/doc_parser.py @@ -0,0 +1,136 @@ +"""Parse uploaded client documents (Word/Excel) to extract asset lists.""" + +import logging +import io +from pathlib import Path + +import openpyxl +import docx + +from app.utils.claude_client import call_claude, extract_tool_result, extract_text + +logger = logging.getLogger(__name__) + +EXTRACT_TOOLS = [ + { + "name": "extract_assets", + "description": "Extract a structured list of deliverable assets from a client brief or scope document.", + "input_schema": { + "type": "object", + "properties": { + "assets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The asset/deliverable name as described by the client" + }, + "description": { + "type": "string", + "description": "Description of what this asset involves, including any complexity or format details" + }, + "complexity_hint": { + "type": "string", + "enum": ["simple", "medium", "complex", "unknown"], + "description": "Estimated complexity based on the brief" + }, + "volume": { + "type": "integer", + "description": "Number of this asset needed (default 1 if not specified)" + }, + }, + "required": ["name", "description", "complexity_hint", "volume"], + }, + }, + }, + "required": ["assets"], + }, + } +] + +SYSTEM_PROMPT = """You are a creative agency asset specialist who understands production scoping. +Your job is to extract every distinct deliverable/asset from the client brief or scope document provided. + +For each asset, provide: +- name: The asset name as the client describes it (e.g., "Social Media Banner", "TV Commercial Edit", "Brand Book") +- description: What this asset involves based on the document context. Include format, size, channel, and any other relevant details. +- complexity_hint: Your best estimate of complexity (simple/medium/complex) based on the description. Use "unknown" if unclear. +- volume: How many of this asset are needed. Default to 1 if not specified. + +Be thorough - extract every distinct asset type mentioned. If the same asset appears at different complexity levels, list them separately. +Do NOT combine different asset types into one entry.""" + + +def parse_uploaded_file(file_content: bytes, filename: str) -> list[dict]: + """Parse a client document and extract assets using Claude. + + Returns a list of dicts: [{name, description, complexity_hint, volume}, ...] + """ + ext = Path(filename).suffix.lower() + + if ext == ".docx": + text = _extract_docx_text(file_content) + elif ext in (".xlsx", ".xls"): + text = _extract_excel_text(file_content) + elif ext == ".txt": + text = file_content.decode("utf-8", errors="replace") + else: + raise ValueError(f"Unsupported file type: {ext}. Use .docx, .xlsx, or .txt") + + if not text or len(text.strip()) < 20: + raise ValueError("Document appears to be empty or too short to extract assets from.") + + # Truncate very long documents to manage token usage + if len(text) > 50000: + text = text[:50000] + "\n\n[Document truncated...]" + + response = call_claude( + system=SYSTEM_PROMPT, + user_message=f"Extract all deliverable assets from this client document:\n\n{text}", + tools=EXTRACT_TOOLS, + tool_choice={"type": "tool", "name": "extract_assets"}, + max_tokens=16000, + ) + + # Extract usage info for per-project tracking + usage_info = getattr(response, '_usage_info', {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0}) + + result = extract_tool_result(response) + if not result or "assets" not in result: + logger.warning("Claude did not return structured asset data, response: %s", extract_text(response)) + return [], usage_info + + return result["assets"], usage_info + + +def _extract_docx_text(content: bytes) -> str: + """Extract text from a .docx file.""" + doc = docx.Document(io.BytesIO(content)) + paragraphs = [p.text for p in doc.paragraphs if p.text.strip()] + + # Also extract text from tables + for table in doc.tables: + for row in table.rows: + cells = [cell.text.strip() for cell in row.cells if cell.text.strip()] + if cells: + paragraphs.append(" | ".join(cells)) + + return "\n".join(paragraphs) + + +def _extract_excel_text(content: bytes) -> str: + """Extract text from an Excel file, converting all sheets to text.""" + wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True) + parts = [] + + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + parts.append(f"\n=== Sheet: {sheet_name} ===") + for row in ws.iter_rows(values_only=True): + cells = [str(c) for c in row if c is not None] + if cells: + parts.append(" | ".join(cells)) + + return "\n".join(parts) diff --git a/backend/app/services/excel_parser.py b/backend/app/services/excel_parser.py new file mode 100644 index 0000000..991ffbb --- /dev/null +++ b/backend/app/services/excel_parser.py @@ -0,0 +1,336 @@ +"""Parse the GMAL Excel workbook into database records.""" + +import logging +from pathlib import Path + +import openpyxl +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.models.gmal import GmalAsset, Role, GmalHours, GmalServiceLine, RoleLevelMapping, MODEL_TYPE_MAP, ModelType + +logger = logging.getLogger(__name__) + +PROGRAMME_ROLE_KEYWORDS = [ + "programme director", "global programme director", + "regional / national operations director", + "head of creative services", "head of project management", + "creative services director", +] + + +def parse_gmal_workbook(filepath: str, db: Session) -> dict: + """Parse the full GMAL Excel workbook and load all data into the database. + + Returns a dict with counts of loaded records. + """ + wb = openpyxl.load_workbook(filepath, data_only=True) + result = { + "assets_loaded": 0, + "roles_loaded": 0, + "hours_loaded": 0, + "service_lines_loaded": 0, + "role_mappings_loaded": 0, + } + + # Clear existing data (full re-ingest) + db.execute(text("DELETE FROM gmal_hours")) + db.execute(text("DELETE FROM gmal_service_lines")) + db.execute(text("DELETE FROM role_level_mappings")) + db.execute(text("DELETE FROM roles")) + db.execute(text("DELETE FROM ratecard_lines")) + db.execute(text("DELETE FROM matches")) + db.execute(text("DELETE FROM client_assets")) + db.execute(text("DELETE FROM projects")) + db.execute(text("DELETE FROM gmal_assets")) + db.flush() + + # Step 1: Load asset catalog from "Unilever_GMAL Asset List" + asset_map = _load_asset_list(wb, db) + result["assets_loaded"] = len(asset_map) + + # Step 2: Load roles and hours from "GMAL Routes_Jan25" + roles_loaded, hours_loaded, extra_assets = _load_routes(wb, db, asset_map) + result["roles_loaded"] = roles_loaded + result["hours_loaded"] = hours_loaded + result["assets_loaded"] += extra_assets + + # Step 3: Load service lines + result["service_lines_loaded"] = _load_service_lines(wb, db) + + # Step 4: Load role-level mappings + result["role_mappings_loaded"] = _load_role_mappings(wb, db) + + db.commit() + logger.info(f"Ingestion complete: {result}") + return result + + +def _load_asset_list(wb: openpyxl.Workbook, db: Session) -> dict[str, GmalAsset]: + """Load from 'Unilever_GMAL Asset List' sheet. Returns {gmal_id: GmalAsset}.""" + ws = wb["Unilever_GMAL Asset List"] + asset_map = {} + + for row_idx in range(2, ws.max_row + 1): + gmal_id = ws.cell(row=row_idx, column=2).value + if not gmal_id: + continue + gmal_id = str(gmal_id).strip() + + complexity_level = ws.cell(row=row_idx, column=8).value + complexity_name_val = ws.cell(row=row_idx, column=9).value + + asset = GmalAsset( + gmal_id=gmal_id, + swop_asset_id=_str(ws.cell(row=row_idx, column=3).value), + region=_str(ws.cell(row=row_idx, column=1).value), + category=_str(ws.cell(row=row_idx, column=4).value), + sub_category=_str(ws.cell(row=row_idx, column=5).value), + sub_category_description=_str(ws.cell(row=row_idx, column=6).value), + asset_name=_str(ws.cell(row=row_idx, column=7).value), + complexity_level=int(complexity_level) if complexity_level else None, + complexity_name=_str(complexity_name_val), + unique_name=_str(ws.cell(row=row_idx, column=10).value), + asset_description=_str(ws.cell(row=row_idx, column=11).value), + complexity_description=_str(ws.cell(row=row_idx, column=12).value), + caveats=_str(ws.cell(row=row_idx, column=13).value), + has_hour_routes=False, + ) + db.add(asset) + asset_map[gmal_id] = asset + + db.flush() + logger.info(f"Loaded {len(asset_map)} assets from Asset List") + return asset_map + + +def _load_routes(wb: openpyxl.Workbook, db: Session, asset_map: dict[str, GmalAsset]) -> tuple[int, int, int]: + """Load roles and hours from 'GMAL Routes_Jan25' sheet. + + Returns (roles_loaded, hours_loaded, extra_assets_created). + """ + ws = wb["GMAL Routes_Jan25"] + + # Detect model type column boundaries from row 1 + model_sections = [] # [(start_col, end_col, ModelType)] + current_header = None + section_start = None + + for col_idx in range(13, ws.max_column + 1): + header = ws.cell(row=1, column=col_idx).value + if header and header != current_header: + if current_header and section_start: + mt = MODEL_TYPE_MAP.get(current_header) + if mt: + model_sections.append((section_start, col_idx - 1, mt)) + current_header = header + section_start = col_idx + + # Don't forget the last section + if current_header and section_start: + mt = MODEL_TYPE_MAP.get(current_header) + if mt: + model_sections.append((section_start, ws.max_column, mt)) + + logger.info(f"Detected {len(model_sections)} model type sections") + for start, end, mt in model_sections: + logger.info(f" {mt.value}: cols {start}-{end}") + + # Load roles from rows 13-137 (column B=discipline, C=title, D=entity, E=location, F=unique) + # Map row_idx -> Role for hour lookups; use unique_name as dedup key + roles = {} # {(role_title, entity): Role} + row_to_role = {} # {row_idx: Role} for hour lookups + for row_idx in range(13, 138): + discipline = ws.cell(row=row_idx, column=2).value + role_title = ws.cell(row=row_idx, column=3).value + if not role_title: + continue + + entity = _str(ws.cell(row=row_idx, column=4).value) + location = _str(ws.cell(row=row_idx, column=5).value) + unique_name = _str(ws.cell(row=row_idx, column=6).value) + key = (str(role_title).strip(), entity) + + # Skip duplicates - reuse existing role for hour lookups + if key in roles: + row_to_role[row_idx] = roles[key] + continue + + is_prog = any(kw in str(role_title).lower() for kw in PROGRAMME_ROLE_KEYWORDS) + + role = Role( + discipline=_str(discipline) or "Other", + role_title=_str(role_title), + entity=entity, + resource_location=location, + unique_name=unique_name, + sort_order=row_idx - 12, + is_programme_role=is_prog, + ) + db.add(role) + roles[key] = role + row_to_role[row_idx] = role + + db.flush() + logger.info(f"Loaded {len(roles)} roles") + + # For each model type section, build a mapping of column -> gmal_id + # Then load hours for each (role, gmal, model_type) combo + hours_count = 0 + extra_assets = 0 + + # Track which (gmal_id, role_id, model_type) combos we've already inserted + # to handle duplicate columns (Pro vs non-Pro) for same GMAL within a section + seen_hours = set() + + for section_start, section_end, model_type in model_sections: + # Build column -> gmal_id map for this section + # Include Pro variant columns - they contain the actual AI model data + # Extract the base GMAL ID from row 2 (e.g. "GMAL101" from both base and Pro cols) + col_gmal_map = {} + for col_idx in range(section_start, section_end + 1): + gmal_id = ws.cell(row=2, column=col_idx).value + if gmal_id and str(gmal_id).startswith("GMAL"): + base_id = str(gmal_id).strip() + col_gmal_map[col_idx] = base_id + + # Enrich asset_map with metadata from Routes if not in Asset List + for col_idx, gmal_id in col_gmal_map.items(): + if gmal_id not in asset_map: + # Create asset from Routes metadata + asset = GmalAsset( + gmal_id=gmal_id, + sub_category=_str(ws.cell(row=4, column=col_idx).value), + asset_name=_str(ws.cell(row=5, column=col_idx).value), + asset_description=_str(ws.cell(row=6, column=col_idx).value), + complexity_description=_str(ws.cell(row=7, column=col_idx).value), + caveats=_str(ws.cell(row=8, column=col_idx).value), + complexity_level=_int(ws.cell(row=9, column=col_idx).value), + master_adapt=_str(ws.cell(row=10, column=col_idx).value), + ai_efficiency_pct=_float(ws.cell(row=11, column=col_idx).value), + has_hour_routes=True, + ) + db.add(asset) + db.flush() + asset_map[gmal_id] = asset + extra_assets += 1 + else: + # Update Routes-specific fields on existing asset + asset = asset_map[gmal_id] + asset.has_hour_routes = True + if not asset.master_adapt: + asset.master_adapt = _str(ws.cell(row=10, column=col_idx).value) + if not asset.ai_efficiency_pct: + asset.ai_efficiency_pct = _float(ws.cell(row=11, column=col_idx).value) + if not asset.sub_category: + asset.sub_category = _str(ws.cell(row=4, column=col_idx).value) + + # Now load hours for each role x gmal in this section + for row_idx in range(13, 138): + role = row_to_role.get(row_idx) + if not role: + continue + + for col_idx, gmal_id in col_gmal_map.items(): + hours_val = ws.cell(row=row_idx, column=col_idx).value + if hours_val and _float(hours_val) and _float(hours_val) != 0: + asset = asset_map.get(gmal_id) + if not asset: + continue + + # Deduplicate: skip if we already have hours for this combo + key = (asset.id, role.id, model_type) + if key in seen_hours: + continue + seen_hours.add(key) + + gh = GmalHours( + gmal_asset_id=asset.id, + role_id=role.id, + model_type=model_type, + hours=round(_float(hours_val), 2), + ) + db.add(gh) + hours_count += 1 + + # Flush every 5000 to manage memory + if hours_count % 5000 == 0: + db.flush() + + db.flush() + logger.info(f"Loaded {hours_count} hour records across {len(model_sections)} model types") + return len(roles), hours_count, extra_assets + + +def _load_service_lines(wb: openpyxl.Workbook, db: Session) -> int: + """Load from 'GMAL SERVICE LINES' sheet.""" + ws = wb["GMAL SERVICE LINES"] + count = 0 + + for row_idx in range(2, ws.max_row + 1): + number = ws.cell(row=row_idx, column=1).value + name = ws.cell(row=row_idx, column=2).value + if not name: + continue + + sl = GmalServiceLine( + number=_str(number), + name=_str(name), + type=_str(ws.cell(row=row_idx, column=3).value), + gmal_id=_str(ws.cell(row=row_idx, column=4).value), + ) + db.add(sl) + count += 1 + + db.flush() + logger.info(f"Loaded {count} service lines") + return count + + +def _load_role_mappings(wb: openpyxl.Workbook, db: Session) -> int: + """Load from 'Job role to Level mapping' sheet.""" + ws = wb["Job role to Level mapping"] + count = 0 + + for row_idx in range(3, ws.max_row + 1): + role_name = ws.cell(row=row_idx, column=1).value + if not role_name: + continue + + rlm = RoleLevelMapping( + role_name=_str(role_name), + number=_str(ws.cell(row=row_idx, column=3).value), + level_name=_str(ws.cell(row=row_idx, column=4).value), + type=_str(ws.cell(row=row_idx, column=5).value), + ) + db.add(rlm) + count += 1 + + db.flush() + logger.info(f"Loaded {count} role-level mappings") + return count + + +def _str(val) -> str | None: + if val is None: + return None + s = str(val).strip() + return s if s else None + + +def _int(val) -> int | None: + if val is None: + return None + try: + return int(val) + except (ValueError, TypeError): + return None + + +def _float(val) -> float | None: + if val is None: + return None + try: + return float(val) + except (ValueError, TypeError): + return None diff --git a/backend/app/services/export_excel.py b/backend/app/services/export_excel.py new file mode 100644 index 0000000..bf10374 --- /dev/null +++ b/backend/app/services/export_excel.py @@ -0,0 +1,217 @@ +"""Export ratecard data to Excel.""" + +import io +import logging +from collections import defaultdict + +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, PatternFill, Border, Side +from openpyxl.utils import get_column_letter +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.gmal import GmalAsset, Role +from app.models.project import Project, ClientAsset, Match, RatecardLine + +logger = logging.getLogger(__name__) + +HEADER_FILL = PatternFill(start_color="1F4E79", end_color="1F4E79", fill_type="solid") +HEADER_FONT = Font(color="FFFFFF", bold=True, size=11) +DISCIPLINE_FILL = PatternFill(start_color="D6E4F0", end_color="D6E4F0", fill_type="solid") +THIN_BORDER = Border( + left=Side(style="thin"), + right=Side(style="thin"), + top=Side(style="thin"), + bottom=Side(style="thin"), +) + + +async def export_ratecard_excel(db: AsyncSession, project: Project) -> bytes: + """Generate an Excel workbook with the ratecard data. + + Returns the workbook as bytes. + """ + wb = Workbook() + + # Load all data + lines_result = await db.execute( + select(RatecardLine).where(RatecardLine.project_id == project.id) + ) + lines = lines_result.scalars().all() + + if not lines: + ws = wb.active + ws.title = "Ratecard" + ws["A1"] = "No ratecard data available" + return _workbook_to_bytes(wb) + + # Load related entities + role_ids = list(set(l.role_id for l in lines)) + asset_ids = list(set(l.client_asset_id for l in lines)) + gmal_ids = list(set(l.gmal_asset_id for l in lines)) + + roles_result = await db.execute(select(Role).where(Role.id.in_(role_ids))) + roles = {r.id: r for r in roles_result.scalars().all()} + + assets_result = await db.execute(select(ClientAsset).where(ClientAsset.id.in_(asset_ids))) + client_assets = {a.id: a for a in assets_result.scalars().all()} + + gmals_result = await db.execute(select(GmalAsset).where(GmalAsset.id.in_(gmal_ids))) + gmals = {g.id: g for g in gmals_result.scalars().all()} + + # Sheet 1: Ratecard Summary (roles x assets matrix) + ws1 = wb.active + ws1.title = "Ratecard Summary" + _build_ratecard_sheet(ws1, lines, roles, client_assets, gmals) + + # Sheet 2: Asset Detail + ws2 = wb.create_sheet("Asset Detail") + await _build_asset_detail_sheet(ws2, db, project, client_assets, gmals) + + return _workbook_to_bytes(wb) + + +def _build_ratecard_sheet(ws, lines, roles, client_assets, gmals): + """Build the main ratecard matrix: rows=roles, cols=client assets.""" + # Get unique sorted client assets and roles + asset_ids_ordered = sorted(client_assets.keys()) + role_ids_ordered = sorted(roles.keys(), key=lambda rid: (roles[rid].discipline, roles[rid].sort_order or 0)) + + # Build hours lookup: {(role_id, client_asset_id): total_hours} + hours_map = {} + for line in lines: + effective_hours = line.manual_override if line.manual_override is not None else line.total_hours + hours_map[(line.role_id, line.client_asset_id)] = float(effective_hours or 0) + + # Headers + ws.cell(row=1, column=1, value="Discipline").font = HEADER_FONT + ws.cell(row=1, column=1).fill = HEADER_FILL + ws.cell(row=1, column=2, value="Role").font = HEADER_FONT + ws.cell(row=1, column=2).fill = HEADER_FILL + + for col_idx, asset_id in enumerate(asset_ids_ordered, 3): + ca = client_assets[asset_id] + gmal_id = None + for line in lines: + if line.client_asset_id == asset_id: + g = gmals.get(line.gmal_asset_id) + gmal_id = g.gmal_id if g else None + break + + header = f"{ca.raw_name}\n(Vol: {ca.volume})" + if gmal_id: + header += f"\n[{gmal_id}]" + + cell = ws.cell(row=1, column=col_idx, value=header) + cell.font = HEADER_FONT + cell.fill = HEADER_FILL + cell.alignment = Alignment(wrap_text=True, horizontal="center") + + # Total column + total_col = len(asset_ids_ordered) + 3 + ws.cell(row=1, column=total_col, value="Total Hours").font = HEADER_FONT + ws.cell(row=1, column=total_col).fill = HEADER_FILL + + # Data rows + current_discipline = None + row_idx = 2 + + for role_id in role_ids_ordered: + role = roles[role_id] + + # Check if this role has any hours at all + role_total = sum(hours_map.get((role_id, aid), 0) for aid in asset_ids_ordered) + if role_total == 0: + continue + + # Discipline grouping + if role.discipline != current_discipline: + current_discipline = role.discipline + ws.cell(row=row_idx, column=1, value=current_discipline).font = Font(bold=True) + ws.cell(row=row_idx, column=1).fill = DISCIPLINE_FILL + for c in range(1, total_col + 1): + ws.cell(row=row_idx, column=c).fill = DISCIPLINE_FILL + row_idx += 1 + + ws.cell(row=row_idx, column=1, value=role.discipline) + ws.cell(row=row_idx, column=2, value=role.role_title) + + row_total = 0 + for col_idx, asset_id in enumerate(asset_ids_ordered, 3): + hours = hours_map.get((role_id, asset_id), 0) + if hours > 0: + ws.cell(row=row_idx, column=col_idx, value=round(hours, 2)) + row_total += hours + + ws.cell(row=row_idx, column=total_col, value=round(row_total, 2)).font = Font(bold=True) + row_idx += 1 + + # Grand total row + row_idx += 1 + ws.cell(row=row_idx, column=1, value="TOTAL").font = Font(bold=True, size=12) + grand_total = 0 + for col_idx, asset_id in enumerate(asset_ids_ordered, 3): + col_total = sum(hours_map.get((rid, asset_id), 0) for rid in role_ids_ordered) + if col_total > 0: + ws.cell(row=row_idx, column=col_idx, value=round(col_total, 2)).font = Font(bold=True) + grand_total += col_total + ws.cell(row=row_idx, column=total_col, value=round(grand_total, 2)).font = Font(bold=True, size=12) + + # Column widths + ws.column_dimensions["A"].width = 25 + ws.column_dimensions["B"].width = 35 + for col_idx in range(3, total_col + 1): + ws.column_dimensions[get_column_letter(col_idx)].width = 18 + + +async def _build_asset_detail_sheet(ws, db, project, client_assets, gmals): + """Build the asset detail sheet showing matches and caveats.""" + headers = ["Client Asset", "Volume", "Matched GMAL", "GMAL Name", "Confidence", "Score", "Caveats"] + for col_idx, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_idx, value=header) + cell.font = HEADER_FONT + cell.fill = HEADER_FILL + + # Load matches + from app.models.project import Match + matches_result = await db.execute( + select(Match).where( + Match.client_asset_id.in_(list(client_assets.keys())), + Match.is_selected == True, + ) + ) + matches = matches_result.scalars().all() + match_by_asset = {m.client_asset_id: m for m in matches} + + row_idx = 2 + for asset_id in sorted(client_assets.keys()): + ca = client_assets[asset_id] + match = match_by_asset.get(asset_id) + + ws.cell(row=row_idx, column=1, value=ca.raw_name) + ws.cell(row=row_idx, column=2, value=ca.volume) + + if match: + gmal = gmals.get(match.gmal_asset_id) + ws.cell(row=row_idx, column=3, value=gmal.gmal_id if gmal else "") + ws.cell(row=row_idx, column=4, value=gmal.unique_name if gmal else "") + ws.cell(row=row_idx, column=5, value=match.confidence.value) + ws.cell(row=row_idx, column=6, value=float(match.confidence_score) if match.confidence_score else 0) + ws.cell(row=row_idx, column=7, value=match.caveat_text or "") + else: + ws.cell(row=row_idx, column=3, value="No match") + + row_idx += 1 + + # Column widths + widths = [30, 10, 15, 40, 12, 10, 60] + for i, w in enumerate(widths, 1): + ws.column_dimensions[get_column_letter(i)].width = w + + +def _workbook_to_bytes(wb: Workbook) -> bytes: + buf = io.BytesIO() + wb.save(buf) + buf.seek(0) + return buf.read() diff --git a/backend/app/services/export_pdf.py b/backend/app/services/export_pdf.py new file mode 100644 index 0000000..8b15b25 --- /dev/null +++ b/backend/app/services/export_pdf.py @@ -0,0 +1,105 @@ +"""Export caveats report to PDF.""" + +import io +import logging +from datetime import datetime + +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import mm +from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.gmal import GmalAsset +from app.models.project import Project, ClientAsset, Match + +logger = logging.getLogger(__name__) + +CONFIDENCE_COLORS = { + "exact": colors.HexColor("#22c55e"), + "close": colors.HexColor("#f59e0b"), + "multiple": colors.HexColor("#3b82f6"), + "none": colors.HexColor("#ef4444"), +} + + +async def export_caveats_pdf(db: AsyncSession, project: Project) -> bytes: + """Generate a PDF caveats report for a project. + + Returns the PDF as bytes. + """ + buf = io.BytesIO() + doc = SimpleDocTemplate(buf, pagesize=A4, topMargin=20 * mm, bottomMargin=20 * mm) + + styles = getSampleStyleSheet() + title_style = ParagraphStyle("CustomTitle", parent=styles["Title"], fontSize=18, spaceAfter=12) + heading_style = ParagraphStyle("CustomHeading", parent=styles["Heading2"], fontSize=13, spaceAfter=6) + body_style = styles["BodyText"] + caveat_style = ParagraphStyle("Caveat", parent=body_style, textColor=colors.HexColor("#92400e"), leftIndent=10) + + elements = [] + + # Title + elements.append(Paragraph(f"Scope Builder - Caveats Report", title_style)) + elements.append(Paragraph(f"Project: {project.name}", heading_style)) + if project.client_name: + elements.append(Paragraph(f"Client: {project.client_name}", body_style)) + elements.append(Paragraph(f"Generated: {datetime.utcnow().strftime('%d %B %Y')}", body_style)) + elements.append(Spacer(1, 12)) + + # Load client assets and matches + assets_result = await db.execute( + select(ClientAsset).where(ClientAsset.project_id == project.id).order_by(ClientAsset.sort_order) + ) + client_assets = assets_result.scalars().all() + + for ca in client_assets: + matches_result = await db.execute( + select(Match).where(Match.client_asset_id == ca.id).order_by(Match.rank) + ) + matches = matches_result.scalars().all() + + elements.append(Paragraph(f"{ca.raw_name} (Volume: {ca.volume})", heading_style)) + + if ca.raw_description: + elements.append(Paragraph(f"Client description: {ca.raw_description}", body_style)) + elements.append(Spacer(1, 4)) + + if not matches: + elements.append(Paragraph("No matches found.", caveat_style)) + elements.append(Spacer(1, 8)) + continue + + for match in matches: + # Load GMAL asset + gmal_result = await db.execute(select(GmalAsset).where(GmalAsset.id == match.gmal_asset_id)) + gmal = gmal_result.scalar_one_or_none() + if not gmal: + continue + + selected_marker = " [SELECTED]" if match.is_selected else "" + conf_label = match.confidence.value.upper() + score_pct = f"{float(match.confidence_score) * 100:.0f}%" if match.confidence_score else "N/A" + + elements.append(Paragraph( + f"Match #{match.rank}: {gmal.gmal_id} - {gmal.unique_name or gmal.asset_name}{selected_marker}", + body_style, + )) + elements.append(Paragraph(f"Confidence: {conf_label} ({score_pct})", body_style)) + + if match.ai_reasoning: + elements.append(Paragraph(f"Reasoning: {match.ai_reasoning}", body_style)) + + if match.caveat_text: + elements.append(Paragraph(f"Caveats:", body_style)) + elements.append(Paragraph(match.caveat_text, caveat_style)) + + elements.append(Spacer(1, 6)) + + elements.append(Spacer(1, 12)) + + doc.build(elements) + buf.seek(0) + return buf.read() diff --git a/backend/app/services/ratecard_builder.py b/backend/app/services/ratecard_builder.py new file mode 100644 index 0000000..0da0771 --- /dev/null +++ b/backend/app/services/ratecard_builder.py @@ -0,0 +1,76 @@ +"""Build ratecards from confirmed matches.""" + +import logging + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.gmal import GmalHours, ModelType +from app.models.project import Project, ClientAsset, Match, RatecardLine + +logger = logging.getLogger(__name__) + + +async def build_ratecard(db: AsyncSession, project: Project) -> list[RatecardLine]: + """Build a ratecard for a project from its confirmed (selected) matches. + + For each selected match, looks up the hours per role from gmal_hours + for the project's model type, multiplies by volume, and creates ratecard lines. + + Returns the created RatecardLine objects. + """ + # Clear existing ratecard lines for this project + existing = await db.execute( + select(RatecardLine).where(RatecardLine.project_id == project.id) + ) + for line in existing.scalars().all(): + await db.delete(line) + await db.flush() + + # Get all client assets with their selected matches + assets_result = await db.execute( + select(ClientAsset).where(ClientAsset.project_id == project.id) + ) + client_assets = assets_result.scalars().all() + + lines = [] + + for client_asset in client_assets: + # Get the selected match for this asset + match_result = await db.execute( + select(Match).where( + Match.client_asset_id == client_asset.id, + Match.is_selected == True, + ) + ) + selected_match = match_result.scalar_one_or_none() + if not selected_match: + logger.warning(f"No selected match for client asset {client_asset.id}: {client_asset.raw_name}") + continue + + # Get hours for this GMAL asset and model type + hours_result = await db.execute( + select(GmalHours).where( + GmalHours.gmal_asset_id == selected_match.gmal_asset_id, + GmalHours.model_type == project.model_type, + ) + ) + gmal_hours = hours_result.scalars().all() + + for gh in gmal_hours: + total = round(float(gh.hours) * client_asset.volume, 2) + line = RatecardLine( + project_id=project.id, + client_asset_id=client_asset.id, + gmal_asset_id=selected_match.gmal_asset_id, + role_id=gh.role_id, + base_hours=float(gh.hours), + volume=client_asset.volume, + total_hours=total, + ) + db.add(line) + lines.append(line) + + await db.flush() + logger.info(f"Built ratecard with {len(lines)} lines for project {project.id}") + return lines diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/claude_client.py b/backend/app/utils/claude_client.py new file mode 100644 index 0000000..cd5db38 --- /dev/null +++ b/backend/app/utils/claude_client.py @@ -0,0 +1,166 @@ +"""Anthropic Claude API client wrapper with token tracking and debug log.""" + +import json +import logging +import threading +from datetime import datetime + +import anthropic + +from app.config import settings + +logger = logging.getLogger(__name__) + +MODEL = "claude-opus-4-6" + +# Cost per million tokens (USD) +INPUT_COST_PER_M = 3.0 +OUTPUT_COST_PER_M = 15.0 + +# Thread-safe token tracking + debug log +_lock = threading.Lock() +_usage = { + "total_input_tokens": 0, + "total_output_tokens": 0, + "total_cost_usd": 0.0, + "call_count": 0, +} +_debug_log: list[dict] = [] # Last N AI interactions +MAX_DEBUG_LOG = 50 + + +def get_client() -> anthropic.Anthropic: + return anthropic.Anthropic(api_key=settings.anthropic_api_key) + + +def get_usage_stats() -> dict: + with _lock: + return {**_usage} + + +def get_debug_log() -> list[dict]: + with _lock: + return list(_debug_log) + + +def reset_usage_stats(): + with _lock: + _usage["total_input_tokens"] = 0 + _usage["total_output_tokens"] = 0 + _usage["total_cost_usd"] = 0.0 + _usage["call_count"] = 0 + _debug_log.clear() + + +def call_claude( + system: str, + user_message: str, + tools: list[dict] | None = None, + tool_choice: dict | None = None, + max_tokens: int = 4096, +) -> dict: + """Make a Claude API call, optionally with tool_use for structured output.""" + client = get_client() + + kwargs = { + "model": MODEL, + "max_tokens": max_tokens, + "system": system, + "messages": [{"role": "user", "content": user_message}], + } + if tools: + kwargs["tools"] = tools + kwargs["tool_choice"] = tool_choice or {"type": "auto"} + + # Build debug entry + entry = { + "timestamp": datetime.utcnow().isoformat(), + "model": MODEL, + "system_prompt": system[:500] + ("..." if len(system) > 500 else ""), + "user_message_length": len(user_message), + "user_message_preview": user_message[:1000] + ("..." if len(user_message) > 1000 else ""), + "tools": [t["name"] for t in tools] if tools else [], + "tool_choice": tool_choice, + "status": "pending", + } + + try: + response = client.messages.create(**kwargs) + + # Parse response content + response_parts = [] + tool_results = [] + for block in response.content: + if block.type == "text": + response_parts.append({"type": "text", "text": block.text[:1000]}) + elif block.type == "tool_use": + tool_data = block.input + tool_results.append({"tool": block.name, "input": tool_data}) + response_parts.append({ + "type": "tool_use", + "tool": block.name, + "input_preview": json.dumps(tool_data, default=str)[:2000], + }) + + entry["status"] = "success" + entry["stop_reason"] = response.stop_reason + entry["response_parts"] = response_parts + entry["tool_results_count"] = len(tool_results) + + # Track token usage + inp = 0 + out = 0 + cost = 0.0 + if hasattr(response, "usage") and response.usage: + inp = response.usage.input_tokens or 0 + out = response.usage.output_tokens or 0 + cost = (inp / 1_000_000) * INPUT_COST_PER_M + (out / 1_000_000) * OUTPUT_COST_PER_M + + entry["input_tokens"] = inp + entry["output_tokens"] = out + entry["cost_usd"] = round(cost, 6) + + with _lock: + _usage["total_input_tokens"] += inp + _usage["total_output_tokens"] += out + _usage["total_cost_usd"] += cost + _usage["call_count"] += 1 + + # Attach usage to response for callers to save per-project + response._usage_info = {"input_tokens": inp, "output_tokens": out, "cost_usd": cost} + + logger.info( + f"Claude API call: {inp} in / {out} out tokens, " + f"${cost:.4f} this call, ${_usage['total_cost_usd']:.4f} total" + ) + + return response + + except Exception as e: + entry["status"] = "error" + entry["error"] = str(e) + logger.error(f"Claude API error: {e}") + raise + + finally: + with _lock: + _debug_log.append(entry) + if len(_debug_log) > MAX_DEBUG_LOG: + _debug_log.pop(0) + + +def extract_tool_result(response) -> dict | None: + """Extract the first tool_use result from a Claude response.""" + for block in response.content: + if block.type == "tool_use": + return block.input + return None + + +def extract_text(response) -> str: + """Extract text content from a Claude response.""" + parts = [] + for block in response.content: + if block.type == "text": + parts.append(block.text) + return "\n".join(parts) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..0e981b3 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,14 @@ +fastapi[standard]==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy[asyncio]==2.0.36 +alembic==1.14.1 +asyncpg==0.30.0 +psycopg2-binary==2.9.10 +openpyxl==3.1.5 +python-docx==1.1.2 +python-multipart==0.0.19 +anthropic==0.43.0 +reportlab==4.2.5 +pandas==2.2.3 +pydantic==2.10.4 +pydantic-settings==2.7.1 diff --git a/backend/start.sh b/backend/start.sh new file mode 100755 index 0000000..4ee991e --- /dev/null +++ b/backend/start.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +export PYTHONPATH=/app + +echo "Initializing database..." +python -c " +from sqlalchemy import create_engine +from app.database import Base +from app.models import * +import os + +engine = create_engine(os.environ['DATABASE_URL_SYNC']) +Base.metadata.create_all(bind=engine) +engine.dispose() +print('Database tables created successfully') +" + +echo "Starting FastAPI server..." +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..50e022c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: scope_builder + POSTGRES_USER: scope_user + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scope_pass_2024} + ports: + - "5433:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U scope_user -d scope_builder"] + interval: 5s + timeout: 3s + retries: 5 + + backend: + build: ./backend + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgresql+asyncpg://scope_user:${POSTGRES_PASSWORD:-scope_pass_2024}@db:5432/scope_builder + DATABASE_URL_SYNC: postgresql://scope_user:${POSTGRES_PASSWORD:-scope_pass_2024}@db:5432/scope_builder + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} + ports: + - "8001:8000" + volumes: + - ./backend:/app + - ./data:/app/data + + frontend: + build: ./frontend + restart: unless-stopped + depends_on: + - backend + ports: + - "3010:3000" + volumes: + - ./frontend/src:/app/src + - ./frontend/public:/app/public + +volumes: + pgdata: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..13cde27 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install + +COPY . . + +EXPOSE 3000 +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2d113b4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + Scope Builder + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..3c5eea4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "scope-builder-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "axios": "^1.7.9", + "lucide-react": "^0.468.0" + }, + "devDependencies": { + "@types/react": "^18.3.16", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.3" + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..8e09f37 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,307 @@ +.app { + min-height: 100vh; +} + +.nav { + background: #13151c; + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; + z-index: 50; + backdrop-filter: blur(12px); +} + +.nav-inner { + max-width: 1440px; + margin: 0 auto; + padding: 0 28px; + display: flex; + align-items: center; + height: 56px; + gap: 40px; +} + +.logo { + color: #fff !important; + font-weight: 700; + font-size: 16px; + text-decoration: none; + letter-spacing: -0.03em; + display: flex; + align-items: center; + gap: 10px; +} + +.logo-icon { + background: var(--color-primary); + color: #000; + width: 28px; + height: 28px; + border-radius: 7px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 800; +} + +.nav-links { + display: flex; + gap: 2px; +} + +.nav-link { + color: var(--color-text-secondary) !important; + text-decoration: none; + padding: 7px 14px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + transition: all 0.15s; +} + +.nav-link:hover { + color: var(--color-text) !important; + background: rgba(255,255,255,0.05); +} + +.nav-link-active { + color: #fff !important; + background: rgba(255,255,255,0.08); +} + +.nav-spacer { + flex: 1; +} + +.ai-tracker { + display: flex; + align-items: center; + gap: 10px; + background: rgba(255, 196, 7, 0.08); + border: 1px solid rgba(255, 196, 7, 0.2); + border-radius: 8px; + padding: 6px 14px; +} + +.ai-tracker-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-muted); +} + +.ai-tracker-cost { + font-size: 15px; + font-weight: 700; + color: var(--color-primary); + letter-spacing: -0.02em; +} + +.ai-tracker-detail { + font-size: 11px; + color: var(--color-text-muted); + white-space: nowrap; +} + +.main { + max-width: 1440px; + margin: 0 auto; + padding: 28px; + padding-bottom: 60px; +} + +/* Debug Panel */ +.debug-panel { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 100; + background: #0a0b0f; + border-top: 1px solid var(--color-border); +} + +.debug-toggle { + position: absolute; + top: -32px; + right: 28px; + background: #0a0b0f; + color: var(--color-text-muted); + border: 1px solid var(--color-border); + border-bottom: none; + border-radius: 6px 6px 0 0; + padding: 6px 16px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + font-family: var(--font-sans); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.debug-toggle:hover { + color: var(--color-text); +} + +.debug-open { + height: 45vh; + display: flex; + flex-direction: column; +} + +.debug-content { + flex: 1; + overflow: auto; + padding: 12px 20px; + font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace; + font-size: 12px; +} + +.debug-empty { + color: var(--color-text-muted); + padding: 20px; + text-align: center; +} + +.debug-entry { + border: 1px solid var(--color-border); + border-radius: 6px; + margin-bottom: 8px; + overflow: hidden; +} + +.debug-entry.debug-error { + border-color: rgba(239, 68, 68, 0.3); +} + +.debug-entry-header { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + cursor: pointer; + background: rgba(255,255,255,0.02); + transition: background 0.1s; +} + +.debug-entry-header:hover { + background: rgba(255,255,255,0.04); +} + +.debug-status { + padding: 2px 8px; + border-radius: 4px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; +} + +.debug-status-success { + background: var(--color-success-bg); + color: var(--color-success); +} + +.debug-status-error { + background: var(--color-danger-bg); + color: var(--color-danger); +} + +.debug-status-pending { + background: var(--color-warning-bg); + color: var(--color-warning); +} + +.debug-time { + color: var(--color-text-muted); + font-size: 11px; + flex-shrink: 0; +} + +.debug-tools { + color: var(--color-primary); + font-weight: 600; + font-size: 11px; +} + +.debug-tokens { + color: var(--color-text-secondary); + font-size: 11px; + margin-left: auto; +} + +.debug-reason { + color: var(--color-text-muted); + font-size: 11px; +} + +.debug-expand { + color: var(--color-text-muted); + font-size: 10px; + flex-shrink: 0; +} + +.debug-entry-body { + padding: 12px; + border-top: 1px solid var(--color-border); + background: rgba(0,0,0,0.2); +} + +.debug-section { + margin-bottom: 12px; +} + +.debug-section:last-child { + margin-bottom: 0; +} + +.debug-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-muted); + margin-bottom: 4px; +} + +.debug-pre { + background: rgba(255,255,255,0.03); + border: 1px solid var(--color-border-light); + border-radius: 4px; + padding: 8px 10px; + white-space: pre-wrap; + word-break: break-all; + color: var(--color-text-secondary); + font-size: 11px; + line-height: 1.5; + max-height: 300px; + overflow: auto; +} + +.debug-response-part { + margin-bottom: 8px; +} + +.debug-part-type { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + margin-bottom: 4px; + background: rgba(255,255,255,0.06); + color: var(--color-text-secondary); +} + +.debug-tool-name { + color: var(--color-primary); + font-weight: 600; + font-size: 11px; + margin-bottom: 4px; +} + +.debug-error-text { + color: var(--color-danger); + border-color: rgba(239, 68, 68, 0.2); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..875c223 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,225 @@ +import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import api from './api/client'; +import Dashboard from './pages/Dashboard'; +import NewProject from './pages/NewProject'; +import ProjectView from './pages/ProjectView'; +import GmalBrowser from './pages/GmalBrowser'; +import Help from './pages/Help'; +import GmalEditor from './pages/GmalEditor'; +import './App.css'; + +const navItems = [ + { path: '/', label: 'Projects' }, + { path: '/gmal', label: 'GMAL Browser' }, + { path: '/gmal-editor', label: 'GMAL Editor' }, + { path: '/help', label: 'Help' }, +]; + +interface AiUsage { + total_input_tokens: number; + total_output_tokens: number; + total_cost_usd: number; + call_count: number; +} + +interface DebugEntry { + timestamp: string; + model: string; + system_prompt: string; + user_message_length: number; + user_message_preview: string; + tools: string[]; + tool_choice: any; + status: string; + stop_reason?: string; + response_parts?: Array<{ type: string; text?: string; tool?: string; input_preview?: string }>; + tool_results_count?: number; + input_tokens?: number; + output_tokens?: number; + cost_usd?: number; + error?: string; +} + +function AiCostTracker() { + const [usage, setUsage] = useState(null); + + useEffect(() => { + loadUsage(); + const interval = setInterval(loadUsage, 5000); + return () => clearInterval(interval); + }, []); + + async function loadUsage() { + try { + const res = await api.get('/ai/usage'); + setUsage(res.data); + } catch {} + } + + if (!usage) return null; + + return ( +
+
AI Cost
+
${usage.total_cost_usd.toFixed(4)}
+
+ {usage.call_count} calls · {(usage.total_input_tokens / 1000).toFixed(1)}k in · {(usage.total_output_tokens / 1000).toFixed(1)}k out +
+
+ ); +} + +function DebugPanel() { + const [open, setOpen] = useState(false); + const [entries, setEntries] = useState([]); + const [expanded, setExpanded] = useState(null); + + useEffect(() => { + if (open) { + loadDebug(); + const interval = setInterval(loadDebug, 3000); + return () => clearInterval(interval); + } + }, [open]); + + async function loadDebug() { + try { + const res = await api.get('/ai/debug'); + setEntries(res.data); + } catch {} + } + + return ( +
+ + {open && ( +
+ {entries.length === 0 ? ( +
No AI calls yet. Upload a document or run matching.
+ ) : ( + [...entries].reverse().map((e, idx) => { + const realIdx = entries.length - 1 - idx; + const isExpanded = expanded === realIdx; + return ( +
+
setExpanded(isExpanded ? null : realIdx)}> + + {e.status === 'success' ? 'OK' : 'ERR'} + + {new Date(e.timestamp + 'Z').toLocaleTimeString()} + {e.tools.join(', ') || 'no tools'} + {e.input_tokens != null && ( + + {e.input_tokens} in / {e.output_tokens} out · ${e.cost_usd?.toFixed(4)} + + )} + {e.stop_reason || ''} + {isExpanded ? 'â–¼' : 'â–¶'} +
+ + {isExpanded && ( +
+
+
System Prompt
+
{e.system_prompt}
+
+ +
+
User Message ({e.user_message_length.toLocaleString()} chars)
+
{e.user_message_preview}
+
+ + {e.tool_choice && ( +
+
Tool Choice
+
{JSON.stringify(e.tool_choice)}
+
+ )} + + {e.response_parts && e.response_parts.length > 0 && ( +
+
Response ({e.response_parts.length} parts)
+ {e.response_parts.map((part, pi) => ( +
+ {part.type} + {part.type === 'text' &&
{part.text}
} + {part.type === 'tool_use' && ( +
+
Tool: {part.tool}
+
{part.input_preview}
+
+ )} +
+ ))} +
+ )} + + {e.error && ( +
+
Error
+
{e.error}
+
+ )} +
+ )} +
+ ); + }) + )} +
+ )} +
+ ); +} + +function NavBar() { + const location = useLocation(); + + return ( + + ); +} + +export default function App() { + return ( + +
+ +
+ + } /> + } /> + } /> + } /> + } /> + } /> + +
+ +
+
+ ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..6095d35 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,7 @@ +import axios from 'axios'; + +const api = axios.create({ + baseURL: '/api', +}); + +export default api; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..0dc1701 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,90 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); + +:root { + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --color-bg: #0f1117; + --color-bg-card: #1a1d27; + --color-bg-card-hover: #22252f; + --color-bg-input: #1a1d27; + --color-border: #2a2d3a; + --color-border-light: #23262f; + --color-text: #e8eaed; + --color-text-secondary: #8b8fa3; + --color-text-muted: #5f6378; + --color-primary: #FFC407; + --color-primary-hover: #FFD44F; + --color-primary-bg: rgba(255, 196, 7, 0.12); + --color-success: #22c55e; + --color-success-bg: rgba(34, 197, 94, 0.12); + --color-warning: #f59e0b; + --color-warning-bg: rgba(245, 158, 11, 0.12); + --color-danger: #ef4444; + --color-danger-bg: rgba(239, 68, 68, 0.12); + --color-info: #FFC407; + --color-info-bg: rgba(255, 196, 7, 0.12); + --radius: 8px; + --radius-lg: 12px; + --shadow: 0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2); +} + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 14px; +} + +body { + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: var(--color-bg); + color: var(--color-text); + line-height: 1.5; +} + +button { + font-family: var(--font-sans); + cursor: pointer; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed !important; +} + +input, select, textarea { + font-family: var(--font-sans); +} + +a { + color: var(--color-primary); + text-decoration: none; + transition: color 0.15s; +} + +a:hover { + color: var(--color-primary-hover); +} + +::selection { + background: var(--color-primary); + color: #000; +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 3px; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..6177530 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import App from './App'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/frontend/src/pages/Dashboard.css b/frontend/src/pages/Dashboard.css new file mode 100644 index 0000000..77ef747 --- /dev/null +++ b/frontend/src/pages/Dashboard.css @@ -0,0 +1,250 @@ +.loading { + text-align: center; + padding: 80px; + color: var(--color-text-muted); +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 28px; +} + +.page-title { + font-size: 26px; + font-weight: 700; + letter-spacing: -0.03em; + color: #fff; +} + +.page-subtitle { + color: var(--color-text-secondary); + margin-top: 4px; + font-size: 13px; +} + +.header-actions { + display: flex; + gap: 10px; +} + +.btn { + padding: 9px 18px; + border-radius: var(--radius); + font-weight: 600; + font-size: 13px; + border: none; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 6px; + transition: all 0.15s; + line-height: 1.4; +} + +.btn-primary { + background: var(--color-primary); + color: #000; +} + +.btn-primary:hover { + background: var(--color-primary-hover); + color: #000 !important; +} + +.btn-secondary { + background: var(--color-bg-card); + color: var(--color-text); + border: 1px solid var(--color-border); +} + +.btn-secondary:hover { + background: var(--color-bg-card-hover); + border-color: var(--color-text-muted); +} + +.btn-success { + background: var(--color-success); + color: #fff; +} + +.btn-success:hover { + opacity: 0.9; + color: #fff !important; +} + +.btn-danger { + background: var(--color-danger); + color: #fff; +} + +.btn-danger:hover { + opacity: 0.9; + color: #fff !important; +} + +.btn-yolo { + background: linear-gradient(135deg, #f59e0b, #ef4444, #8b5cf6); + color: #fff; + font-weight: 700; + letter-spacing: 0.03em; +} + +.btn-yolo:hover { + opacity: 0.9; + color: #fff !important; +} + +.stats-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; + margin-bottom: 28px; +} + +.stat-card { + background: var(--color-bg-card); + border-radius: var(--radius-lg); + padding: 20px; + border: 1px solid var(--color-border); + text-align: center; +} + +.stat-value { + font-size: 28px; + font-weight: 700; + color: #fff; + letter-spacing: -0.03em; +} + +.stat-label { + font-size: 12px; + color: var(--color-text-muted); + margin-top: 4px; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 500; +} + +.empty-banner { + background: var(--color-warning-bg); + border: 1px solid rgba(245, 158, 11, 0.25); + border-radius: var(--radius); + padding: 14px 20px; + margin-bottom: 24px; + color: var(--color-warning); + font-size: 13px; +} + +.empty-state { + text-align: center; + padding: 80px 20px; + color: var(--color-text-secondary); +} + +.empty-icon { + width: 56px; + height: 56px; + border-radius: 14px; + background: var(--color-primary-bg); + color: var(--color-primary); + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + font-weight: 800; + margin: 0 auto 16px; +} + +.empty-state p { + margin-bottom: 16px; +} + +.project-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 14px; +} + +.project-card { + background: var(--color-bg-card); + border-radius: var(--radius-lg); + padding: 20px; + border: 1px solid var(--color-border); + text-decoration: none !important; + color: inherit !important; + transition: all 0.15s; + cursor: pointer; +} + +.project-card:hover { + border-color: var(--color-text-muted); + background: var(--color-bg-card-hover); +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.card-name { + font-weight: 600; + font-size: 15px; + color: #fff; +} + +.badge { + padding: 3px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.badge-muted { + background: rgba(255,255,255,0.08); + color: var(--color-text-secondary); +} + +.badge-primary { + background: var(--color-primary-bg); + color: var(--color-primary); +} + +.badge-success { + background: var(--color-success-bg); + color: var(--color-success); +} + +.badge-warning { + background: var(--color-warning-bg); + color: var(--color-warning); +} + +.badge-info { + background: var(--color-info-bg); + color: var(--color-info); +} + +.badge-danger { + background: var(--color-danger-bg); + color: var(--color-danger); +} + +.card-client { + color: var(--color-text-secondary); + font-size: 13px; + margin-bottom: 12px; +} + +.card-meta { + display: flex; + justify-content: space-between; + color: var(--color-text-muted); + font-size: 12px; +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..09c8757 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import api from '../api/client'; +import { Project, GmalStats } from '../types'; +import './Dashboard.css'; + +const STATUS_COLORS: Record = { + draft: 'badge-muted', + parsing: 'badge-warning', + matching: 'badge-info', + review: 'badge-primary', + building: 'badge-warning', + finalized: 'badge-success', +}; + +export default function Dashboard() { + const [projects, setProjects] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [ingesting, setIngesting] = useState(false); + + useEffect(() => { loadData(); }, []); + + async function loadData() { + try { + const [projRes, statsRes] = await Promise.all([ + api.get('/projects'), + api.get('/gmal/stats'), + ]); + setProjects(projRes.data); + setStats(statsRes.data); + } catch (err) { + console.error('Failed to load data:', err); + } finally { + setLoading(false); + } + } + + async function handleIngest() { + if (!confirm('This will reload all GMAL data from the master file. Continue?')) return; + setIngesting(true); + try { + const res = await api.post('/gmal/ingest'); + alert(`Ingestion complete: ${res.data.assets_loaded} assets, ${res.data.roles_loaded} roles, ${res.data.hours_loaded} hour records`); + loadData(); + } catch (err: any) { + alert(`Ingestion failed: ${err.response?.data?.detail || err.message}`); + } finally { + setIngesting(false); + } + } + + if (loading) return
Loading...
; + + return ( +
+
+
+

Projects

+

Scope new client ratecards against the GMAL master

+
+
+ + + New Project +
+
+ + {stats && ( +
+
+
{stats.total_assets}
+
GMAL Assets
+
+
+
{stats.total_roles}
+
Roles
+
+
+
{stats.total_hours_records.toLocaleString()}
+
Hour Records
+
+
+
{projects.length}
+
Projects
+
+
+ )} + + {stats && stats.total_assets === 0 && ( +
+ No GMAL data loaded yet. Click "Ingest GMAL Data" to load the master ratecard. +
+ )} + + {projects.length === 0 ? ( +
+
S
+

No projects yet

+ Create your first project +
+ ) : ( +
+ {projects.map(p => ( + +
+ {p.name} + + {p.status} + +
+ {p.client_name &&
{p.client_name}
} +
+ {p.asset_count} assets + {new Date(p.created_at).toLocaleDateString()} +
+ + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/GmalBrowser.css b/frontend/src/pages/GmalBrowser.css new file mode 100644 index 0000000..22780ac --- /dev/null +++ b/frontend/src/pages/GmalBrowser.css @@ -0,0 +1,220 @@ +.gmal-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; + min-height: calc(100vh - 120px); +} + +.gmal-list-panel { + background: var(--color-bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.gmal-detail-panel { + background: var(--color-bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + overflow: auto; + position: sticky; + top: 72px; + max-height: calc(100vh - 88px); + align-self: start; +} + +.panel-title { + font-size: 16px; + font-weight: 700; + padding: 16px 18px 0; + color: #fff; + letter-spacing: -0.02em; +} + +.gmal-filters { + padding: 12px 18px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.filter-search { + flex: 1; + min-width: 140px; +} + +.filter-select { + width: auto; + min-width: 120px; +} + +.gmal-asset-list { + overflow: auto; + flex: 1; +} + +.gmal-row { + padding: 10px 18px; + border-bottom: 1px solid var(--color-border-light); + cursor: pointer; + transition: background 0.1s; +} + +.gmal-row:hover { + background: rgba(255,255,255,0.03); +} + +.gmal-row-active { + background: var(--color-primary-bg) !important; + border-left: 3px solid var(--color-primary); +} + +.gmal-row-id { + font-weight: 700; + font-size: 12px; + color: var(--color-primary); +} + +.gmal-row-name { + font-size: 12px; + margin-top: 2px; + color: var(--color-text); +} + +.gmal-row-meta { + display: flex; + gap: 8px; + margin-top: 4px; + font-size: 11px; + color: var(--color-text-muted); +} + +.complexity-badge { + background: rgba(255,255,255,0.06); + padding: 1px 6px; + border-radius: 4px; +} + +.hours-badge { + background: var(--color-success-bg); + color: var(--color-success); + padding: 1px 6px; + border-radius: 4px; +} + +.gmal-empty { + padding: 24px; + color: var(--color-text-muted); + text-align: center; + font-size: 13px; +} + +.gmal-detail-empty { + padding: 60px; + color: var(--color-text-muted); + text-align: center; + font-size: 13px; +} + +.gmal-detail { + padding: 24px; +} + +.detail-gmal-id { + font-size: 22px; + font-weight: 700; + color: var(--color-primary); + letter-spacing: -0.02em; +} + +.detail-name { + font-size: 14px; + font-weight: 500; + color: var(--color-text-secondary); + margin-top: 2px; + margin-bottom: 20px; +} + +.detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 20px; +} + +.detail-field {} + +.detail-label { + font-size: 11px; + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 4px; +} + +.detail-value { + font-size: 13px; + color: var(--color-text); +} + +.detail-section { + margin-bottom: 20px; +} + +.detail-text { + font-size: 12px; + line-height: 1.6; + color: var(--color-text-secondary); +} + +.detail-caveat { + font-size: 12px; + line-height: 1.6; + color: var(--color-warning); + background: var(--color-warning-bg); + padding: 12px; + border-radius: var(--radius); + white-space: pre-wrap; +} + +.hours-model-group { + margin-bottom: 18px; +} + +.hours-model-title { + font-weight: 600; + font-size: 12px; + margin-bottom: 8px; + color: var(--color-text-secondary); + letter-spacing: 0.03em; +} + +.hours-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.hours-table th { + padding: 6px 10px; + text-align: left; + border-bottom: 1px solid var(--color-border); + font-weight: 600; + font-size: 11px; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.hours-table td { + padding: 5px 10px; + border-bottom: 1px solid var(--color-border-light); + color: var(--color-text-secondary); +} + +.hours-table tr:hover td { + background: rgba(255,255,255,0.02); +} diff --git a/frontend/src/pages/GmalBrowser.tsx b/frontend/src/pages/GmalBrowser.tsx new file mode 100644 index 0000000..19e1e6d --- /dev/null +++ b/frontend/src/pages/GmalBrowser.tsx @@ -0,0 +1,205 @@ +import { useEffect, useState } from 'react'; +import api from '../api/client'; +import { GmalAsset, GmalAssetWithHours, GmalHoursEntry } from '../types'; +import './GmalBrowser.css'; + +export default function GmalBrowser() { + const [assets, setAssets] = useState([]); + const [search, setSearch] = useState(''); + const [subCategory, setSubCategory] = useState(''); + const [complexity, setComplexity] = useState(''); + const [subCategories, setSubCategories] = useState([]); + const [selected, setSelected] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadAssets(); + loadSubCategories(); + }, []); + + async function loadSubCategories() { + try { + const res = await api.get('/gmal/stats'); + setSubCategories(res.data.sub_categories); + } catch {} + } + + async function loadAssets() { + setLoading(true); + try { + const params: any = { limit: 500 }; + if (search) params.search = search; + if (subCategory) params.sub_category = subCategory; + if (complexity) params.complexity_level = complexity; + const res = await api.get('/gmal/assets', { params }); + setAssets(res.data); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + } + + async function selectAsset(gmalId: string) { + try { + const res = await api.get(`/gmal/assets/${gmalId}`); + setSelected(res.data); + } catch (err) { + console.error(err); + } + } + + function handleSearch() { + loadAssets(); + } + + const hoursByModel: Record = {}; + if (selected?.hours_by_role) { + for (const h of selected.hours_by_role) { + if (!hoursByModel[h.model_type]) hoursByModel[h.model_type] = []; + hoursByModel[h.model_type].push(h); + } + } + + return ( +
+
+

GMAL Assets

+ +
+ setSearch(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSearch()} + /> + + + +
+ + {loading ? ( +
Loading...
+ ) : ( +
+ {assets.map(a => ( +
selectAsset(a.gmal_id)} + className={`gmal-row ${selected?.gmal_id === a.gmal_id ? 'gmal-row-active' : ''}`} + > +
{a.gmal_id}
+
{a.unique_name || a.asset_name}
+
+ {a.sub_category} + + {a.complexity_name || `L${a.complexity_level}`} + + {a.has_hour_routes && Has Hours} +
+
+ ))} + {assets.length === 0 && ( +
+ No assets found. Try different criteria or ingest GMAL data first. +
+ )} +
+ )} +
+ +
+ {!selected ? ( +
+ Select an asset to view details +
+ ) : ( +
+

{selected.gmal_id}

+

{selected.unique_name || selected.asset_name}

+ +
+
+
Category
+
{selected.category || '-'}
+
+
+
Sub Category
+
{selected.sub_category || '-'}
+
+
+
Complexity
+
{selected.complexity_name} (Level {selected.complexity_level})
+
+
+
Master/Adapt
+
{selected.master_adapt || '-'}
+
+
+ + {selected.asset_description && ( +
+
Asset Description
+
{selected.asset_description}
+
+ )} + + {selected.complexity_description && ( +
+
Complexity Description
+
{selected.complexity_description}
+
+ )} + + {selected.caveats && ( +
+
Caveats
+
{selected.caveats}
+
+ )} + + {Object.keys(hoursByModel).length > 0 && ( +
+
Hours by Role
+ {Object.entries(hoursByModel).map(([model, hours]) => ( +
+
+ {model.replace(/_/g, ' ').toUpperCase()} +
+ + + + + + + + + + {hours.filter(h => h.hours > 0).map((h, i) => ( + + + + + + ))} + +
DisciplineRoleHours
{h.discipline}{h.role_title}{h.hours.toFixed(2)}
+
+ ))} +
+ )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/GmalEditor.css b/frontend/src/pages/GmalEditor.css new file mode 100644 index 0000000..4bd9bf5 --- /dev/null +++ b/frontend/src/pages/GmalEditor.css @@ -0,0 +1,313 @@ +.editor-layout { + display: grid; + grid-template-columns: 300px 1fr; + gap: 18px; + min-height: calc(100vh - 120px); +} + +.editor-list { + background: var(--color-bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.editor-list-header { + padding: 14px 14px 10px; +} + +.editor-list-header .panel-title { + font-size: 15px; + font-weight: 700; + color: #fff; + margin-bottom: 10px; +} + +.editor-search { + font-size: 12px; + padding: 7px 10px; +} + +.editor-asset-list { + flex: 1; + overflow: auto; +} + +.editor-row { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + cursor: pointer; + border-bottom: 1px solid var(--color-border-light); + font-size: 12px; + transition: background 0.1s; +} + +.editor-row:hover { + background: rgba(255,255,255,0.03); +} + +.editor-row-active { + background: var(--color-primary-bg) !important; + border-left: 3px solid var(--color-primary); +} + +.editor-row-id { + font-weight: 700; + color: var(--color-primary); + flex-shrink: 0; + width: 70px; + font-size: 11px; +} + +.editor-row-name { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-text); +} + +.editor-row-complexity { + color: var(--color-text-muted); + font-size: 10px; + flex-shrink: 0; +} + +.editor-main { + background: var(--color-bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + overflow: auto; + max-height: calc(100vh - 120px); +} + +.editor-empty { + padding: 60px; + text-align: center; + color: var(--color-text-muted); +} + +.editor-toolbar { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 20px; + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; + background: var(--color-bg-card); + z-index: 10; +} + +.editor-title { + font-size: 18px; + font-weight: 700; + color: var(--color-primary); +} + +.editor-dirty { + font-size: 11px; + color: var(--color-warning); + font-weight: 600; + background: var(--color-warning-bg); + padding: 3px 10px; + border-radius: 12px; +} + +.editor-toolbar-spacer { + flex: 1; +} + +.editor-fields { + padding: 18px 20px; + border-bottom: 1px solid var(--color-border); +} + +.editor-field-row { + display: flex; + gap: 14px; + margin-bottom: 12px; +} + +.editor-field { + flex: 1; + margin-bottom: 10px; +} + +.editor-field label { + display: block; + font-size: 11px; + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 4px; +} + +.editor-field .input { + font-size: 12px; + padding: 7px 10px; +} + +.editor-textarea { + min-height: 70px; + resize: vertical; +} + +.editor-textarea-sm { + min-height: 48px; + resize: vertical; +} + +/* Hours section */ +.editor-hours-section { + padding: 0; +} + +.editor-hours-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 20px; + border-bottom: 1px solid var(--color-border); +} + +.editor-hours-header h3 { + font-size: 14px; + font-weight: 600; + color: #fff; +} + +.model-tabs { + display: flex; + gap: 2px; +} + +.model-tab { + padding: 5px 10px; + border-radius: 5px; + border: none; + background: none; + color: var(--color-text-muted); + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + font-family: var(--font-sans); +} + +.model-tab:hover { + color: var(--color-text-secondary); + background: rgba(255,255,255,0.04); +} + +.model-tab-active { + color: #fff; + background: rgba(255,255,255,0.08); +} + +.hours-grid { + overflow: auto; +} + +.hours-edit-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.hours-edit-table th { + padding: 8px 12px; + text-align: left; + font-size: 10px; + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + background: rgba(255,255,255,0.02); + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; +} + +.th-discipline { width: 140px; } +.th-role { } +.th-hours { width: 100px; text-align: right; } + +.hours-edit-table td { + padding: 3px 12px; + border-bottom: 1px solid var(--color-border-light); + color: var(--color-text-secondary); +} + +.td-disc { + font-weight: 600; + color: var(--color-text-muted); + font-size: 11px; + vertical-align: top; + padding-top: 8px; + border-right: 1px solid var(--color-border-light); +} + +.td-role { + font-size: 12px; +} + +.td-hours-input { + text-align: right; + padding: 2px 8px; +} + +.has-hours td { + background: rgba(255, 196, 7, 0.03); +} + +.hours-input { + width: 80px; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid transparent; + background: transparent; + color: var(--color-text-muted); + font-size: 12px; + text-align: right; + font-family: var(--font-sans); + outline: none; + transition: all 0.15s; +} + +.hours-input:focus { + border-color: var(--color-primary); + background: var(--color-bg-input); + color: var(--color-text); + box-shadow: 0 0 0 2px var(--color-primary-bg); +} + +.hours-input.hours-active { + color: #fff; + font-weight: 600; +} + +.hours-input.hours-dirty { + border-color: var(--color-warning); + background: var(--color-warning-bg); + color: var(--color-warning); +} + +.hours-input::placeholder { + color: var(--color-border); +} + +/* Hide number spinners */ +.hours-input::-webkit-outer-spin-button, +.hours-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +.hours-input[type=number] { + -moz-appearance: textfield; +} diff --git a/frontend/src/pages/GmalEditor.tsx b/frontend/src/pages/GmalEditor.tsx new file mode 100644 index 0000000..a8e2a97 --- /dev/null +++ b/frontend/src/pages/GmalEditor.tsx @@ -0,0 +1,313 @@ +import { useEffect, useState, useCallback } from 'react'; +import api from '../api/client'; +import { GmalAsset, GmalAssetWithHours, GmalHoursEntry, Role, MODEL_TYPE_LABELS } from '../types'; +import './GmalEditor.css'; + +interface EditableAsset { + asset_name: string; + sub_category: string; + category: string; + complexity_level: number | null; + complexity_name: string; + unique_name: string; + asset_description: string; + complexity_description: string; + caveats: string; + master_adapt: string; + ai_efficiency_pct: number | null; +} + +interface HourCell { + role_id: number; + model_type: string; + hours: number; + dirty: boolean; +} + +const MODEL_TYPES = ['current_oplus', 'ai_oplus', 'current_local', 'ai_local', 'asset_factory']; + +export default function GmalEditor() { + const [assets, setAssets] = useState([]); + const [roles, setRoles] = useState([]); + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState(null); + const [editFields, setEditFields] = useState(null); + const [hourCells, setHourCells] = useState([]); + const [saving, setSaving] = useState(false); + const [dirty, setDirty] = useState(false); + const [selectedModel, setSelectedModel] = useState('current_oplus'); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.all([loadAssets(), loadRoles()]).then(() => setLoading(false)); + }, []); + + async function loadAssets() { + try { + const res = await api.get('/gmal/assets', { params: { limit: 500 } }); + setAssets(res.data); + } catch {} + } + + async function loadRoles() { + try { + const res = await api.get('/gmal/roles'); + setRoles(res.data); + } catch {} + } + + async function selectAsset(gmalId: string) { + if (dirty && !confirm('You have unsaved changes. Discard?')) return; + try { + const res = await api.get(`/gmal/assets/${gmalId}`); + const asset = res.data as GmalAssetWithHours; + setSelected(asset); + setEditFields({ + asset_name: asset.asset_name || '', + sub_category: asset.sub_category || '', + category: asset.category || '', + complexity_level: asset.complexity_level, + complexity_name: asset.complexity_name || '', + unique_name: asset.unique_name || '', + asset_description: asset.asset_description || '', + complexity_description: asset.complexity_description || '', + caveats: asset.caveats || '', + master_adapt: asset.master_adapt || '', + ai_efficiency_pct: asset.ai_efficiency_pct, + }); + // Build hour cells from existing data + const cells: HourCell[] = []; + for (const h of asset.hours_by_role) { + cells.push({ role_id: h.role_id, model_type: h.model_type, hours: h.hours, dirty: false }); + } + setHourCells(cells); + setDirty(false); + } catch {} + } + + function handleFieldChange(field: keyof EditableAsset, value: string | number | null) { + if (!editFields) return; + setEditFields({ ...editFields, [field]: value }); + setDirty(true); + } + + function getHour(roleId: number, modelType: string): number { + const cell = hourCells.find(c => c.role_id === roleId && c.model_type === modelType); + return cell ? cell.hours : 0; + } + + function setHour(roleId: number, modelType: string, value: number) { + setHourCells(prev => { + const idx = prev.findIndex(c => c.role_id === roleId && c.model_type === modelType); + if (idx >= 0) { + const updated = [...prev]; + updated[idx] = { ...updated[idx], hours: value, dirty: true }; + return updated; + } + return [...prev, { role_id: roleId, model_type: modelType, hours: value, dirty: true }]; + }); + setDirty(true); + } + + async function handleSave() { + if (!selected || !editFields) return; + setSaving(true); + try { + // Save asset fields + await api.put(`/gmal/assets/${selected.gmal_id}`, editFields); + + // Save dirty hours + const dirtyHours = hourCells.filter(c => c.dirty); + if (dirtyHours.length > 0) { + await api.put(`/gmal/assets/${selected.gmal_id}/hours`, dirtyHours.map(c => ({ + role_id: c.role_id, + model_type: c.model_type, + hours: c.hours, + }))); + } + + setDirty(false); + // Reload to get fresh data + await selectAsset(selected.gmal_id); + await loadAssets(); + } catch (err: any) { + alert(`Save failed: ${err.response?.data?.detail || err.message}`); + } finally { + setSaving(false); + } + } + + function handleSearch() { + loadAssets(); + } + + // Group roles by discipline for the hours table + const disciplines = [...new Set(roles.map(r => r.discipline))]; + + const filteredAssets = search + ? assets.filter(a => + (a.gmal_id + ' ' + (a.asset_name || '') + ' ' + (a.unique_name || '')).toLowerCase().includes(search.toLowerCase()) + ) + : assets; + + if (loading) return
Loading...
; + + return ( +
+
+
+

GMAL Assets

+ setSearch(e.target.value)} + /> +
+
+ {filteredAssets.map(a => ( +
selectAsset(a.gmal_id)} + className={`editor-row ${selected?.gmal_id === a.gmal_id ? 'editor-row-active' : ''}`} + > + {a.gmal_id} + {a.asset_name} + {a.complexity_name} +
+ ))} +
+
+ +
+ {!selected || !editFields ? ( +
Select a GMAL asset to edit
+ ) : ( + <> +
+

{selected.gmal_id}

+ {dirty && Unsaved changes} +
+ +
+ +
+
+
+ + handleFieldChange('asset_name', e.target.value)} /> +
+
+ + handleFieldChange('unique_name', e.target.value)} /> +
+
+
+
+ + handleFieldChange('category', e.target.value)} /> +
+
+ + handleFieldChange('sub_category', e.target.value)} /> +
+
+
+
+ + +
+
+ + handleFieldChange('master_adapt', e.target.value)} /> +
+
+ + handleFieldChange('ai_efficiency_pct', e.target.value ? parseFloat(e.target.value) : null)} /> +
+
+
+ +