Initial commit - GMAL Scope Builder

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-03-27 17:35:14 -04:00
commit e18976fdb2
59 changed files with 6627 additions and 0 deletions

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
POSTGRES_PASSWORD=scope_pass_2024
ANTHROPIC_API_KEY=your-api-key-here

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
.env
__pycache__/
*.pyc
node_modules/
dist/
.vite/
*.egg-info/
.pytest_cache/
data/*.xlsx
*.xlsx
.DS_Store

16
backend/Dockerfile Normal file
View file

@ -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"]

36
backend/alembic.ini Normal file
View file

@ -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

45
backend/alembic/env.py Normal file
View file

@ -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()

View file

@ -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"}

View file

@ -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)

0
backend/app/__init__.py Normal file
View file

View file

8
backend/app/api/deps.py Normal file
View file

@ -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

160
backend/app/api/gmal.py Normal file
View file

@ -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()]),
)

50
backend/app/api/ingest.py Normal file
View file

@ -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)

279
backend/app/api/matching.py Normal file
View file

@ -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

107
backend/app/api/projects.py Normal file
View file

@ -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,
)

163
backend/app/api/ratecard.py Normal file
View file

@ -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

14
backend/app/config.py Normal file
View file

@ -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()

16
backend/app/database.py Normal file
View file

@ -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

44
backend/app/main.py Normal file
View file

@ -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()

View file

@ -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",
]

110
backend/app/models/gmal.py Normal file
View file

@ -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))

View file

@ -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"),
)

View file

View file

@ -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

View file

@ -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]

View file

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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"<i>Client description: {ca.raw_description}</i>", 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"<b>Match #{match.rank}: {gmal.gmal_id} - {gmal.unique_name or gmal.asset_name}</b>{selected_marker}",
body_style,
))
elements.append(Paragraph(f"Confidence: {conf_label} ({score_pct})", body_style))
if match.ai_reasoning:
elements.append(Paragraph(f"<b>Reasoning:</b> {match.ai_reasoning}", body_style))
if match.caveat_text:
elements.append(Paragraph(f"<b>Caveats:</b>", 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()

View file

@ -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

View file

View file

@ -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)

14
backend/requirements.txt Normal file
View file

@ -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

20
backend/start.sh Executable file
View file

@ -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

47
docker-compose.yml Normal file
View file

@ -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:

11
frontend/Dockerfile Normal file
View file

@ -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"]

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scope Builder</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

25
frontend/package.json Normal file
View file

@ -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"
}
}

307
frontend/src/App.css Normal file
View file

@ -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);
}

225
frontend/src/App.tsx Normal file
View file

@ -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<AiUsage | null>(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 (
<div className="ai-tracker">
<div className="ai-tracker-label">AI Cost</div>
<div className="ai-tracker-cost">${usage.total_cost_usd.toFixed(4)}</div>
<div className="ai-tracker-detail">
{usage.call_count} calls &middot; {(usage.total_input_tokens / 1000).toFixed(1)}k in &middot; {(usage.total_output_tokens / 1000).toFixed(1)}k out
</div>
</div>
);
}
function DebugPanel() {
const [open, setOpen] = useState(false);
const [entries, setEntries] = useState<DebugEntry[]>([]);
const [expanded, setExpanded] = useState<number | null>(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 (
<div className={`debug-panel ${open ? 'debug-open' : ''}`}>
<button className="debug-toggle" onClick={() => setOpen(!open)}>
{open ? 'Hide' : 'Show'} AI Debug ({entries.length})
</button>
{open && (
<div className="debug-content">
{entries.length === 0 ? (
<div className="debug-empty">No AI calls yet. Upload a document or run matching.</div>
) : (
[...entries].reverse().map((e, idx) => {
const realIdx = entries.length - 1 - idx;
const isExpanded = expanded === realIdx;
return (
<div key={realIdx} className={`debug-entry debug-${e.status}`}>
<div className="debug-entry-header" onClick={() => setExpanded(isExpanded ? null : realIdx)}>
<span className={`debug-status debug-status-${e.status}`}>
{e.status === 'success' ? 'OK' : 'ERR'}
</span>
<span className="debug-time">{new Date(e.timestamp + 'Z').toLocaleTimeString()}</span>
<span className="debug-tools">{e.tools.join(', ') || 'no tools'}</span>
{e.input_tokens != null && (
<span className="debug-tokens">
{e.input_tokens} in / {e.output_tokens} out &middot; ${e.cost_usd?.toFixed(4)}
</span>
)}
<span className="debug-reason">{e.stop_reason || ''}</span>
<span className="debug-expand">{isExpanded ? '▼' : '▶'}</span>
</div>
{isExpanded && (
<div className="debug-entry-body">
<div className="debug-section">
<div className="debug-label">System Prompt</div>
<pre className="debug-pre">{e.system_prompt}</pre>
</div>
<div className="debug-section">
<div className="debug-label">User Message ({e.user_message_length.toLocaleString()} chars)</div>
<pre className="debug-pre">{e.user_message_preview}</pre>
</div>
{e.tool_choice && (
<div className="debug-section">
<div className="debug-label">Tool Choice</div>
<pre className="debug-pre">{JSON.stringify(e.tool_choice)}</pre>
</div>
)}
{e.response_parts && e.response_parts.length > 0 && (
<div className="debug-section">
<div className="debug-label">Response ({e.response_parts.length} parts)</div>
{e.response_parts.map((part, pi) => (
<div key={pi} className="debug-response-part">
<span className="debug-part-type">{part.type}</span>
{part.type === 'text' && <pre className="debug-pre">{part.text}</pre>}
{part.type === 'tool_use' && (
<div>
<div className="debug-tool-name">Tool: {part.tool}</div>
<pre className="debug-pre">{part.input_preview}</pre>
</div>
)}
</div>
))}
</div>
)}
{e.error && (
<div className="debug-section">
<div className="debug-label">Error</div>
<pre className="debug-pre debug-error-text">{e.error}</pre>
</div>
)}
</div>
)}
</div>
);
})
)}
</div>
)}
</div>
);
}
function NavBar() {
const location = useLocation();
return (
<nav className="nav">
<div className="nav-inner">
<Link to="/" className="logo">
<span className="logo-icon">S</span>
Scope Builder
</Link>
<div className="nav-links">
{navItems.map(item => (
<Link
key={item.path}
to={item.path}
className={`nav-link ${location.pathname === item.path ? 'nav-link-active' : ''}`}
>
{item.label}
</Link>
))}
</div>
<div className="nav-spacer" />
<AiCostTracker />
</div>
</nav>
);
}
export default function App() {
return (
<BrowserRouter>
<div className="app">
<NavBar />
<main className="main">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/new" element={<NewProject />} />
<Route path="/projects/:id/*" element={<ProjectView />} />
<Route path="/gmal" element={<GmalBrowser />} />
<Route path="/gmal-editor" element={<GmalEditor />} />
<Route path="/help" element={<Help />} />
</Routes>
</main>
<DebugPanel />
</div>
</BrowserRouter>
);
}

View file

@ -0,0 +1,7 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
});
export default api;

90
frontend/src/index.css Normal file
View file

@ -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;
}

10
frontend/src/main.tsx Normal file
View file

@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -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;
}

View file

@ -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<string, string> = {
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<Project[]>([]);
const [stats, setStats] = useState<GmalStats | null>(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 <div className="loading">Loading...</div>;
return (
<div>
<div className="page-header">
<div>
<h1 className="page-title">Projects</h1>
<p className="page-subtitle">Scope new client ratecards against the GMAL master</p>
</div>
<div className="header-actions">
<button onClick={handleIngest} disabled={ingesting} className="btn btn-secondary">
{ingesting ? 'Ingesting...' : 'Ingest GMAL Data'}
</button>
<Link to="/new" className="btn btn-primary">+ New Project</Link>
</div>
</div>
{stats && (
<div className="stats-row">
<div className="stat-card">
<div className="stat-value">{stats.total_assets}</div>
<div className="stat-label">GMAL Assets</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.total_roles}</div>
<div className="stat-label">Roles</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.total_hours_records.toLocaleString()}</div>
<div className="stat-label">Hour Records</div>
</div>
<div className="stat-card">
<div className="stat-value">{projects.length}</div>
<div className="stat-label">Projects</div>
</div>
</div>
)}
{stats && stats.total_assets === 0 && (
<div className="empty-banner">
No GMAL data loaded yet. Click "Ingest GMAL Data" to load the master ratecard.
</div>
)}
{projects.length === 0 ? (
<div className="empty-state">
<div className="empty-icon">S</div>
<p>No projects yet</p>
<Link to="/new" className="btn btn-primary">Create your first project</Link>
</div>
) : (
<div className="project-grid">
{projects.map(p => (
<Link key={p.id} to={`/projects/${p.id}`} className="project-card">
<div className="card-header">
<span className="card-name">{p.name}</span>
<span className={`badge ${STATUS_COLORS[p.status] || 'badge-muted'}`}>
{p.status}
</span>
</div>
{p.client_name && <div className="card-client">{p.client_name}</div>}
<div className="card-meta">
<span>{p.asset_count} assets</span>
<span>{new Date(p.created_at).toLocaleDateString()}</span>
</div>
</Link>
))}
</div>
)}
</div>
);
}

View file

@ -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);
}

View file

@ -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<GmalAsset[]>([]);
const [search, setSearch] = useState('');
const [subCategory, setSubCategory] = useState('');
const [complexity, setComplexity] = useState('');
const [subCategories, setSubCategories] = useState<string[]>([]);
const [selected, setSelected] = useState<GmalAssetWithHours | null>(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<string, GmalHoursEntry[]> = {};
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 (
<div className="gmal-layout">
<div className="gmal-list-panel">
<h2 className="panel-title">GMAL Assets</h2>
<div className="gmal-filters">
<input
className="input filter-search"
placeholder="Search assets..."
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
/>
<select className="input filter-select" value={subCategory} onChange={e => setSubCategory(e.target.value)}>
<option value="">All Categories</option>
{subCategories.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<select className="input filter-select" value={complexity} onChange={e => setComplexity(e.target.value)}>
<option value="">All Complexity</option>
<option value="1">Simple</option>
<option value="2">Medium</option>
<option value="3">Complex</option>
</select>
<button onClick={handleSearch} className="btn btn-primary">Search</button>
</div>
{loading ? (
<div className="loading">Loading...</div>
) : (
<div className="gmal-asset-list">
{assets.map(a => (
<div
key={a.gmal_id}
onClick={() => selectAsset(a.gmal_id)}
className={`gmal-row ${selected?.gmal_id === a.gmal_id ? 'gmal-row-active' : ''}`}
>
<div className="gmal-row-id">{a.gmal_id}</div>
<div className="gmal-row-name">{a.unique_name || a.asset_name}</div>
<div className="gmal-row-meta">
<span>{a.sub_category}</span>
<span className="complexity-badge">
{a.complexity_name || `L${a.complexity_level}`}
</span>
{a.has_hour_routes && <span className="hours-badge">Has Hours</span>}
</div>
</div>
))}
{assets.length === 0 && (
<div className="gmal-empty">
No assets found. Try different criteria or ingest GMAL data first.
</div>
)}
</div>
)}
</div>
<div className="gmal-detail-panel">
{!selected ? (
<div className="gmal-detail-empty">
Select an asset to view details
</div>
) : (
<div className="gmal-detail">
<h2 className="detail-gmal-id">{selected.gmal_id}</h2>
<h3 className="detail-name">{selected.unique_name || selected.asset_name}</h3>
<div className="detail-grid">
<div className="detail-field">
<div className="detail-label">Category</div>
<div className="detail-value">{selected.category || '-'}</div>
</div>
<div className="detail-field">
<div className="detail-label">Sub Category</div>
<div className="detail-value">{selected.sub_category || '-'}</div>
</div>
<div className="detail-field">
<div className="detail-label">Complexity</div>
<div className="detail-value">{selected.complexity_name} (Level {selected.complexity_level})</div>
</div>
<div className="detail-field">
<div className="detail-label">Master/Adapt</div>
<div className="detail-value">{selected.master_adapt || '-'}</div>
</div>
</div>
{selected.asset_description && (
<div className="detail-section">
<div className="detail-label">Asset Description</div>
<div className="detail-text">{selected.asset_description}</div>
</div>
)}
{selected.complexity_description && (
<div className="detail-section">
<div className="detail-label">Complexity Description</div>
<div className="detail-text">{selected.complexity_description}</div>
</div>
)}
{selected.caveats && (
<div className="detail-section">
<div className="detail-label">Caveats</div>
<div className="detail-caveat">{selected.caveats}</div>
</div>
)}
{Object.keys(hoursByModel).length > 0 && (
<div className="detail-section">
<div className="detail-label">Hours by Role</div>
{Object.entries(hoursByModel).map(([model, hours]) => (
<div key={model} className="hours-model-group">
<div className="hours-model-title">
{model.replace(/_/g, ' ').toUpperCase()}
</div>
<table className="hours-table">
<thead>
<tr>
<th>Discipline</th>
<th>Role</th>
<th className="text-right">Hours</th>
</tr>
</thead>
<tbody>
{hours.filter(h => h.hours > 0).map((h, i) => (
<tr key={i}>
<td className="td-discipline">{h.discipline}</td>
<td>{h.role_title}</td>
<td className="text-right td-total">{h.hours.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}

View file

@ -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;
}

View file

@ -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<GmalAsset[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<GmalAssetWithHours | null>(null);
const [editFields, setEditFields] = useState<EditableAsset | null>(null);
const [hourCells, setHourCells] = useState<HourCell[]>([]);
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 <div className="loading">Loading...</div>;
return (
<div className="editor-layout">
<div className="editor-list">
<div className="editor-list-header">
<h2 className="panel-title">GMAL Assets</h2>
<input
className="input editor-search"
placeholder="Filter assets..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="editor-asset-list">
{filteredAssets.map(a => (
<div
key={a.gmal_id}
onClick={() => selectAsset(a.gmal_id)}
className={`editor-row ${selected?.gmal_id === a.gmal_id ? 'editor-row-active' : ''}`}
>
<span className="editor-row-id">{a.gmal_id}</span>
<span className="editor-row-name">{a.asset_name}</span>
<span className="editor-row-complexity">{a.complexity_name}</span>
</div>
))}
</div>
</div>
<div className="editor-main">
{!selected || !editFields ? (
<div className="editor-empty">Select a GMAL asset to edit</div>
) : (
<>
<div className="editor-toolbar">
<h2 className="editor-title">{selected.gmal_id}</h2>
{dirty && <span className="editor-dirty">Unsaved changes</span>}
<div className="editor-toolbar-spacer" />
<button onClick={handleSave} disabled={saving || !dirty} className="btn btn-primary">
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
<div className="editor-fields">
<div className="editor-field-row">
<div className="editor-field">
<label>Asset Name</label>
<input className="input" value={editFields.asset_name} onChange={e => handleFieldChange('asset_name', e.target.value)} />
</div>
<div className="editor-field">
<label>Unique Name</label>
<input className="input" value={editFields.unique_name} onChange={e => handleFieldChange('unique_name', e.target.value)} />
</div>
</div>
<div className="editor-field-row">
<div className="editor-field">
<label>Category</label>
<input className="input" value={editFields.category} onChange={e => handleFieldChange('category', e.target.value)} />
</div>
<div className="editor-field">
<label>Sub Category</label>
<input className="input" value={editFields.sub_category} onChange={e => handleFieldChange('sub_category', e.target.value)} />
</div>
</div>
<div className="editor-field-row">
<div className="editor-field" style={{ flex: '0 0 120px' }}>
<label>Complexity</label>
<select className="input" value={editFields.complexity_level ?? ''} onChange={e => handleFieldChange('complexity_level', e.target.value ? parseInt(e.target.value) : null)}>
<option value="">-</option>
<option value="1">1 - Simple</option>
<option value="2">2 - Medium</option>
<option value="3">3 - Complex</option>
</select>
</div>
<div className="editor-field" style={{ flex: '0 0 120px' }}>
<label>Master/Adapt</label>
<input className="input" value={editFields.master_adapt} onChange={e => handleFieldChange('master_adapt', e.target.value)} />
</div>
<div className="editor-field" style={{ flex: '0 0 140px' }}>
<label>AI Efficiency %</label>
<input className="input" type="number" step="0.01" value={editFields.ai_efficiency_pct ?? ''} onChange={e => handleFieldChange('ai_efficiency_pct', e.target.value ? parseFloat(e.target.value) : null)} />
</div>
</div>
<div className="editor-field">
<label>Asset Description</label>
<textarea className="input editor-textarea" value={editFields.asset_description} onChange={e => handleFieldChange('asset_description', e.target.value)} />
</div>
<div className="editor-field">
<label>Complexity Description</label>
<textarea className="input editor-textarea-sm" value={editFields.complexity_description} onChange={e => handleFieldChange('complexity_description', e.target.value)} />
</div>
<div className="editor-field">
<label>Caveats</label>
<textarea className="input editor-textarea-sm" value={editFields.caveats} onChange={e => handleFieldChange('caveats', e.target.value)} />
</div>
</div>
<div className="editor-hours-section">
<div className="editor-hours-header">
<h3>Hours by Role</h3>
<div className="model-tabs">
{MODEL_TYPES.map(mt => (
<button
key={mt}
onClick={() => setSelectedModel(mt)}
className={`model-tab ${selectedModel === mt ? 'model-tab-active' : ''}`}
>
{MODEL_TYPE_LABELS[mt]?.replace('Model - ', '').replace(' Market', '') || mt}
</button>
))}
</div>
</div>
<div className="hours-grid">
<table className="hours-edit-table">
<thead>
<tr>
<th className="th-discipline">Discipline</th>
<th className="th-role">Role</th>
<th className="th-hours">Hours</th>
</tr>
</thead>
<tbody>
{disciplines.map(disc => {
const discRoles = roles.filter(r => r.discipline === disc);
const hasAnyHours = discRoles.some(r => getHour(r.id, selectedModel) > 0);
return discRoles.map((role, ri) => {
const hrs = getHour(role.id, selectedModel);
const cell = hourCells.find(c => c.role_id === role.id && c.model_type === selectedModel);
const isDirty = cell?.dirty;
return (
<tr key={role.id} className={hrs > 0 ? 'has-hours' : ''}>
{ri === 0 && (
<td className="td-disc" rowSpan={discRoles.length}>{disc}</td>
)}
<td className="td-role">{role.role_title}</td>
<td className="td-hours-input">
<input
type="number"
step="0.25"
min="0"
className={`hours-input ${isDirty ? 'hours-dirty' : ''} ${hrs > 0 ? 'hours-active' : ''}`}
value={hrs || ''}
placeholder="0"
onChange={e => setHour(role.id, selectedModel, parseFloat(e.target.value) || 0)}
/>
</td>
</tr>
);
});
})}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
</div>
);
}

177
frontend/src/pages/Help.css Normal file
View file

@ -0,0 +1,177 @@
.help-page {
max-width: 800px;
}
.help-page .page-title {
font-size: 26px;
font-weight: 700;
margin-bottom: 28px;
letter-spacing: -0.03em;
color: #fff;
}
.help-section {
margin-bottom: 36px;
}
.help-heading {
font-size: 18px;
font-weight: 700;
color: #fff;
margin-bottom: 12px;
letter-spacing: -0.02em;
}
.help-intro {
color: var(--color-text-secondary);
font-size: 13px;
margin-bottom: 16px;
line-height: 1.6;
}
.help-steps {
display: flex;
flex-direction: column;
gap: 16px;
}
.help-step {
display: flex;
gap: 16px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 18px 20px;
}
.step-num {
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--color-primary);
color: #000;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 800;
flex-shrink: 0;
}
.help-step h3 {
font-size: 14px;
font-weight: 600;
color: #fff;
margin-bottom: 6px;
}
.help-step p {
font-size: 13px;
color: var(--color-text-secondary);
line-height: 1.6;
margin-bottom: 4px;
}
.help-step ul {
list-style: none;
padding: 0;
margin: 8px 0;
}
.help-step li {
font-size: 13px;
color: var(--color-text-secondary);
padding: 4px 0;
display: flex;
align-items: center;
gap: 8px;
}
.help-note {
font-style: italic;
color: var(--color-text-muted) !important;
font-size: 12px !important;
}
.conf-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #fff;
flex-shrink: 0;
}
.conf-tag-exact { background: var(--color-success); }
.conf-tag-close { background: var(--color-warning); color: #000; }
.conf-tag-multiple { background: var(--color-primary); color: #000; }
.conf-tag-none { background: var(--color-danger); }
.model-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.model-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 18px 20px;
}
.model-card h3 {
font-size: 14px;
font-weight: 600;
color: #fff;
margin-bottom: 8px;
}
.model-card p {
font-size: 13px;
color: var(--color-text-secondary);
line-height: 1.6;
}
.model-tag {
margin-top: 10px;
font-size: 12px;
color: var(--color-primary);
font-weight: 500;
}
.caveat-list {
list-style: none;
padding: 0;
margin: 12px 0;
}
.caveat-list li {
font-size: 13px;
color: var(--color-text-secondary);
line-height: 1.6;
padding: 6px 0;
padding-left: 16px;
position: relative;
}
.caveat-list li::before {
content: '';
position: absolute;
left: 0;
top: 13px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-primary);
}
.help-section > p {
font-size: 13px;
color: var(--color-text-secondary);
line-height: 1.6;
margin-bottom: 8px;
}

125
frontend/src/pages/Help.tsx Normal file
View file

@ -0,0 +1,125 @@
import './Help.css';
export default function Help() {
return (
<div className="help-page">
<h1 className="page-title">Help & Guide</h1>
<section className="help-section">
<h2 className="help-heading">How Scope Builder Works</h2>
<div className="help-steps">
<div className="help-step">
<div className="step-num">1</div>
<div>
<h3>Create a Project</h3>
<p>Start by creating a new project with a name, client, and selecting a <strong>model type</strong> (see below). The model type determines which set of hourly rates are used for the ratecard.</p>
</div>
</div>
<div className="help-step">
<div className="step-num">2</div>
<div>
<h3>Upload a Client Brief</h3>
<p>Upload the client's scope document (.docx or .xlsx). The AI will read through it and extract every distinct deliverable/asset, along with descriptions, complexity hints, and volumes.</p>
<p className="help-note">Client documents can be in any format or naming convention - the AI handles the interpretation.</p>
</div>
</div>
<div className="help-step">
<div className="step-num">3</div>
<div>
<h3>AI Matching</h3>
<p>Click "Run AI Matching" to match each client asset to the closest GMAL equivalent(s). For each match you'll see:</p>
<ul>
<li><span className="conf-tag conf-tag-exact">EXACT</span> Direct match to a GMAL asset</li>
<li><span className="conf-tag conf-tag-close">CLOSE</span> Similar but with notable differences</li>
<li><span className="conf-tag conf-tag-multiple">MULTIPLE</span> Several candidates - you choose</li>
<li><span className="conf-tag conf-tag-none">NONE</span> No reasonable match found</li>
</ul>
<p>Each match includes reasoning and caveats explaining what the GMAL does and doesn't cover vs what the client asked for.</p>
</div>
</div>
<div className="help-step">
<div className="step-num">4</div>
<div>
<h3>Review & Select</h3>
<p>Review each match, read the caveats, and select the best GMAL for each client asset. You can also manually assign a GMAL if the AI got it wrong.</p>
</div>
</div>
<div className="help-step">
<div className="step-num">5</div>
<div>
<h3>Build Ratecard</h3>
<p>Once all assets have a selected match, click "Build Ratecard". This looks up the hours per role for each matched GMAL and multiplies by the asset volume to produce the full ratecard.</p>
</div>
</div>
<div className="help-step">
<div className="step-num">6</div>
<div>
<h3>Export</h3>
<p><strong>Excel</strong> - Full ratecard with roles x assets matrix and asset detail sheet.</p>
<p><strong>PDF</strong> - Caveats report explaining each match, confidence level, and differences for stakeholder review.</p>
</div>
</div>
</div>
</section>
<section className="help-section">
<h2 className="help-heading">Model Types</h2>
<p className="help-intro">Each model type represents a different delivery structure with different hourly allocations per role. Choose the one that matches the client's engagement model.</p>
<div className="model-cards">
<div className="model-card">
<h3>Current Model - O+ Market</h3>
<p>The standard delivery model for O+ (Omnichannel Plus) markets. Uses the current established team structure and hour allocations. This is the <strong>default</strong> and most commonly used model.</p>
<div className="model-tag">Best for: Standard client scopes in established O+ markets</div>
</div>
<div className="model-card">
<h3>AI Model - O+ Market</h3>
<p>An AI-augmented version of the O+ model with adjusted hour allocations that factor in AI-assisted production. Typically results in fewer hours per asset for certain roles where AI tools increase efficiency.</p>
<div className="model-tag">Best for: Forward-looking scopes that account for AI productivity gains</div>
</div>
<div className="model-card">
<h3>Current Model - Local Market</h3>
<p>The standard delivery model for local/single-market engagements. Hour allocations may differ from O+ to reflect the simpler coordination needs of local-only delivery.</p>
<div className="model-tag">Best for: Single-market or local-only client engagements</div>
</div>
<div className="model-card">
<h3>AI Model - Local Market</h3>
<p>AI-augmented version of the local market model. Combines the local delivery structure with AI efficiency adjustments.</p>
<div className="model-tag">Best for: Local engagements with AI productivity assumptions</div>
</div>
<div className="model-card">
<h3>Asset Factory Model</h3>
<p>A high-volume, production-line approach optimised for asset adaptation and localisation at scale. Typically uses different role mixes and lower hours per asset.</p>
<div className="model-tag">Best for: High-volume adaptation and localisation work</div>
</div>
</div>
</section>
<section className="help-section">
<h2 className="help-heading">Understanding Caveats</h2>
<p>When the AI matches a client asset to a GMAL, it generates caveats to flag important differences. Common caveat types:</p>
<ul className="caveat-list">
<li><strong>Scope gaps</strong> - The GMAL doesn't fully cover what the client described (e.g. client wants animation but the matched GMAL is static only)</li>
<li><strong>Complexity mismatch</strong> - The closest GMAL is a different complexity level than what the client implied</li>
<li><strong>Format differences</strong> - Different output formats, sizes, or channels than specified</li>
<li><strong>Additional work</strong> - The client scope includes elements that would require separate GMAL assets</li>
<li><strong>No match</strong> - Nothing in the GMAL catalog closely resembles the client's requirement - this needs manual scoping</li>
</ul>
</section>
<section className="help-section">
<h2 className="help-heading">AI Cost Tracking</h2>
<p>The gold cost indicator in the top-right of the navigation bar shows real-time spending on Claude API calls for the current session. Pricing:</p>
<ul className="caveat-list">
<li><strong>Input tokens</strong> - $3.00 per million tokens (the document text and GMAL catalog sent to Claude)</li>
<li><strong>Output tokens</strong> - $15.00 per million tokens (Claude's match results and reasoning)</li>
</ul>
<p>Cost resets when the backend restarts. Typical costs: ~$0.01-0.05 per document parse, ~$0.02-0.10 per asset matched.</p>
</section>
</div>
);
}

View file

@ -0,0 +1,83 @@
.new-project {
max-width: 560px;
}
.new-project .page-title {
font-size: 26px;
font-weight: 700;
margin-bottom: 24px;
letter-spacing: -0.03em;
color: #fff;
}
.form-card {
background: var(--color-bg-card);
border-radius: var(--radius-lg);
padding: 28px;
border: 1px solid var(--color-border);
}
.field {
margin-bottom: 20px;
}
.label {
display: block;
font-weight: 500;
font-size: 13px;
margin-bottom: 6px;
color: var(--color-text-secondary);
}
.required {
color: var(--color-primary);
}
.input {
width: 100%;
padding: 10px 14px;
border-radius: var(--radius);
border: 1px solid var(--color-border);
font-size: 13px;
font-family: var(--font-sans);
background: var(--color-bg-input);
color: var(--color-text);
outline: none;
transition: border-color 0.15s;
}
.input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-bg);
}
.input::placeholder {
color: var(--color-text-muted);
}
.textarea {
min-height: 80px;
resize: vertical;
}
select.input {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%238b8fa3' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
select.input option {
background: var(--color-bg-card);
color: var(--color-text);
}
.form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid var(--color-border);
}

View file

@ -0,0 +1,87 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../api/client';
import { MODEL_TYPE_LABELS } from '../types';
import './NewProject.css';
export default function NewProject() {
const navigate = useNavigate();
const [name, setName] = useState('');
const [clientName, setClientName] = useState('');
const [description, setDescription] = useState('');
const [modelType, setModelType] = useState('current_oplus');
const [creating, setCreating] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
setCreating(true);
try {
const res = await api.post('/projects', {
name: name.trim(),
client_name: clientName.trim() || null,
description: description.trim() || null,
model_type: modelType,
});
navigate(`/projects/${res.data.id}`);
} catch (err: any) {
alert(`Failed to create project: ${err.response?.data?.detail || err.message}`);
setCreating(false);
}
}
return (
<div className="new-project">
<h1 className="page-title">New Project</h1>
<form onSubmit={handleSubmit} className="form-card">
<div className="field">
<label className="label">Project Name <span className="required">*</span></label>
<input
className="input"
value={name}
onChange={e => setName(e.target.value)}
placeholder="e.g. Acme Corp Q2 2025 Scope"
required
/>
</div>
<div className="field">
<label className="label">Client Name</label>
<input
className="input"
value={clientName}
onChange={e => setClientName(e.target.value)}
placeholder="e.g. Acme Corp"
/>
</div>
<div className="field">
<label className="label">Description</label>
<textarea
className="input textarea"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Brief description of this scope..."
/>
</div>
<div className="field">
<label className="label">Model Type</label>
<select className="input" value={modelType} onChange={e => setModelType(e.target.value)}>
{Object.entries(MODEL_TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div className="form-actions">
<button type="button" onClick={() => navigate('/')} className="btn btn-secondary">Cancel</button>
<button type="submit" disabled={creating || !name.trim()} className="btn btn-primary">
{creating ? 'Creating...' : 'Create Project'}
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,468 @@
.project-view {}
.pv-header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.back-link {
font-size: 12px;
color: var(--color-primary) !important;
font-weight: 500;
}
.pv-title {
font-size: 26px;
font-weight: 700;
margin: 4px 0 8px;
letter-spacing: -0.03em;
color: #fff;
}
.pv-meta {
display: flex;
gap: 12px;
align-items: center;
font-size: 13px;
}
.pv-client {
color: var(--color-text-secondary);
}
.pv-model {
color: var(--color-text-muted);
font-size: 12px;
}
.pv-cost {
color: var(--color-primary);
font-size: 12px;
font-weight: 600;
}
.tabs {
display: flex;
gap: 2px;
margin-bottom: 24px;
border-bottom: 1px solid var(--color-border);
}
.tab {
padding: 10px 18px;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-weight: 500;
font-size: 13px;
color: var(--color-text-muted);
transition: all 0.15s;
}
.tab:hover {
color: var(--color-text-secondary);
}
.tab-active {
color: #fff;
border-bottom-color: var(--color-primary);
}
.tab-content {}
/* Upload */
.upload-zone {
border: 2px dashed var(--color-border);
border-radius: var(--radius-lg);
padding: 48px 32px;
text-align: center;
background: var(--color-bg-card);
transition: border-color 0.15s;
}
.upload-zone:hover {
border-color: var(--color-text-muted);
}
.upload-icon {
color: var(--color-text-muted);
margin-bottom: 16px;
}
.upload-title {
font-weight: 600;
font-size: 15px;
color: #fff;
margin-bottom: 6px;
}
.upload-desc {
color: var(--color-text-secondary);
font-size: 13px;
margin-bottom: 20px;
}
.upload-btn {
cursor: pointer;
}
.upload-file {
margin-top: 14px;
color: var(--color-text-muted);
font-size: 12px;
}
.assets-section {
margin-top: 28px;
}
.section-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 14px;
color: #fff;
}
.asset-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.asset-item {
background: var(--color-bg-card);
padding: 14px 18px;
border-radius: var(--radius);
border: 1px solid var(--color-border);
}
.asset-name {
font-weight: 500;
color: #fff;
font-size: 13px;
}
.asset-desc {
color: var(--color-text-secondary);
font-size: 12px;
margin-top: 4px;
line-height: 1.5;
}
.asset-vol {
color: var(--color-text-muted);
font-size: 11px;
margin-top: 6px;
}
/* Matches */
.match-actions {
display: flex;
gap: 10px;
margin-bottom: 24px;
}
.match-group {
background: var(--color-bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
margin-bottom: 14px;
overflow: hidden;
}
.match-group-header {
padding: 12px 18px;
background: rgba(255,255,255,0.02);
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.match-asset-info {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
flex: 1;
}
.match-asset-name-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.match-asset-name {
font-weight: 600;
font-size: 13px;
color: #fff;
}
.match-selected-summary {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--color-text-secondary);
}
.conf-badge-sm {
padding: 1px 6px;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
color: #fff;
}
span.conf-badge-sm.conf-exact { background: var(--color-success); }
span.conf-badge-sm.conf-close { background: var(--color-warning); color: #000; }
span.conf-badge-sm.conf-multiple { background: var(--color-primary); color: #000; }
span.conf-badge-sm.conf-none { background: var(--color-danger); }
.match-expand-hint {
font-size: 10px;
color: var(--color-text-muted);
font-style: italic;
}
.match-group-collapsed-body {
display: none;
}
.match-group-expanded .match-group-collapsed-body {
display: block;
}
.match-group-collapsed .match-group-header {
border-bottom: none;
}
.match-asset-desc {
font-size: 12px;
color: var(--color-text-secondary);
line-height: 1.4;
}
.match-asset-vol {
color: var(--color-text-muted);
font-size: 12px;
flex-shrink: 0;
}
.match-empty {
padding: 20px 18px;
color: var(--color-text-muted);
font-size: 13px;
}
.match-card {
display: flex;
border-bottom: 1px solid var(--color-border-light);
}
.match-card:last-child {
border-bottom: none;
}
.match-selected {
background: rgba(34, 197, 94, 0.04);
}
.match-card-accent {
width: 4px;
flex-shrink: 0;
}
.conf-exact { background: var(--color-success); }
.conf-close { background: var(--color-warning); }
.conf-multiple { background: var(--color-info); }
.conf-none { background: var(--color-danger); }
.match-card-body {
flex: 1;
padding: 12px 18px;
}
.match-card-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.match-gmal-info {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.match-gmal-id {
font-weight: 700;
font-size: 13px;
color: var(--color-primary);
flex-shrink: 0;
}
.match-gmal-name {
color: var(--color-text-secondary);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.match-card-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.conf-badge {
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #fff;
}
span.conf-exact { background: var(--color-success); }
span.conf-close { background: var(--color-warning); color: #000; }
span.conf-multiple { background: var(--color-info); color: #000; }
span.conf-none { background: var(--color-danger); }
.btn-select {
background: transparent;
color: var(--color-primary);
padding: 4px 12px;
border-radius: 6px;
border: 1px solid var(--color-primary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.btn-select:hover {
background: var(--color-primary-bg);
}
.selected-label {
color: var(--color-success);
font-weight: 600;
font-size: 12px;
}
.match-reasoning {
font-size: 12px;
color: var(--color-text-secondary);
margin-top: 8px;
line-height: 1.5;
}
.match-caveat {
font-size: 12px;
color: var(--color-warning);
margin-top: 6px;
background: var(--color-warning-bg);
padding: 8px 12px;
border-radius: 6px;
line-height: 1.5;
}
/* Ratecard */
.rc-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.rc-stats {
display: flex;
align-items: center;
gap: 16px;
}
.rc-total {
font-weight: 700;
font-size: 16px;
color: #fff;
}
.rc-assets {
color: var(--color-text-secondary);
font-size: 13px;
}
.rc-exports {
display: flex;
gap: 8px;
}
.table-wrap {
background: var(--color-bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
overflow: auto;
}
.rc-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.rc-table th {
padding: 10px 14px;
text-align: left;
background: rgba(255,255,255,0.03);
border-bottom: 1px solid var(--color-border);
font-weight: 600;
font-size: 11px;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rc-table td {
padding: 8px 14px;
border-bottom: 1px solid var(--color-border-light);
color: var(--color-text-secondary);
}
.rc-table tr:hover td {
background: rgba(255,255,255,0.02);
}
.td-discipline {
color: var(--color-text-muted) !important;
font-size: 11px;
}
.td-gmal {
color: var(--color-primary) !important;
font-weight: 600;
font-size: 11px;
}
.td-total {
color: #fff !important;
font-weight: 600;
}
.text-right { text-align: right; }
.text-center { text-align: center; }

View file

@ -0,0 +1,409 @@
import { useEffect, useState, useCallback } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import api from '../api/client';
import { Project, ClientAsset, Match, RatecardSummary, MODEL_TYPE_LABELS, CONFIDENCE_COLORS } from '../types';
import './ProjectView.css';
type Tab = 'upload' | 'matches' | 'ratecard';
const CONF_CLASS: Record<string, string> = {
exact: 'conf-exact',
close: 'conf-close',
multiple: 'conf-multiple',
none: 'conf-none',
};
export default function ProjectView() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [project, setProject] = useState<Project | null>(null);
const [tab, setTab] = useState<Tab>('upload');
const [assets, setAssets] = useState<ClientAsset[]>([]);
const [matches, setMatches] = useState<Match[]>([]);
const [ratecard, setRatecard] = useState<RatecardSummary | null>(null);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [matching, setMatching] = useState(false);
const [building, setBuilding] = useState(false);
const loadProject = useCallback(async () => {
try {
const [projRes, assetsRes] = await Promise.all([
api.get(`/projects/${id}`),
api.get(`/projects/${id}/client-assets`),
]);
setProject(projRes.data);
setAssets(assetsRes.data);
if (assetsRes.data.length > 0) {
const matchRes = await api.get(`/projects/${id}/matches`);
setMatches(matchRes.data);
}
if (['finalized', 'building'].includes(projRes.data.status)) {
try {
const rcRes = await api.get(`/projects/${id}/ratecard`);
setRatecard(rcRes.data);
} catch {}
}
if (projRes.data.status === 'finalized') setTab('ratecard');
else if (assetsRes.data.length > 0) setTab('matches');
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { loadProject(); }, [loadProject]);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const form = new FormData();
form.append('file', file);
await api.post(`/projects/${id}/upload`, form);
await loadProject();
setTab('matches');
} catch (err: any) {
alert(`Upload failed: ${err.response?.data?.detail || err.message}`);
} finally {
setUploading(false);
}
}
async function handleMatch() {
setMatching(true);
// Start polling for matches while the request runs
const pollInterval = setInterval(async () => {
try {
const matchRes = await api.get(`/projects/${id}/matches`);
setMatches(matchRes.data);
} catch {}
}, 3000);
try {
await api.post(`/projects/${id}/match`);
await loadProject();
} catch (err: any) {
if (!err.message?.includes('cancel')) {
alert(`Matching failed: ${err.response?.data?.detail || err.message}`);
}
await loadProject();
} finally {
clearInterval(pollInterval);
setMatching(false);
}
}
async function handleCancelMatch() {
try {
await api.post(`/projects/${id}/match/cancel`);
} catch {}
}
async function handleYolo() {
// For each asset without a selected match, select the rank 1 match
const unselected = assets.filter(a => {
const am = matchesByAsset[a.id];
return am && am.length > 0 && !am.some(m => m.is_selected);
});
for (const a of unselected) {
const topMatch = matchesByAsset[a.id]?.find(m => m.rank === 1) || matchesByAsset[a.id]?.[0];
if (topMatch) {
try {
await api.put(`/projects/${id}/matches/${topMatch.id}/select`, { is_selected: true });
} catch {}
}
}
await loadProject();
}
async function handleDelete() {
if (!confirm(`Delete project "${project?.name}"? This cannot be undone.`)) return;
try {
await api.delete(`/projects/${id}`);
navigate('/');
} catch (err: any) {
alert(`Failed to delete: ${err.response?.data?.detail || err.message}`);
}
}
async function handleSelectMatch(matchId: number) {
try {
await api.put(`/projects/${id}/matches/${matchId}/select`, { is_selected: true });
await loadProject();
} catch (err: any) {
alert(`Failed: ${err.response?.data?.detail || err.message}`);
}
}
async function handleBuildRatecard() {
setBuilding(true);
try {
await api.post(`/projects/${id}/ratecard/build`);
await loadProject();
setTab('ratecard');
} catch (err: any) {
alert(`Build failed: ${err.response?.data?.detail || err.message}`);
} finally {
setBuilding(false);
}
}
if (loading) return <div className="loading">Loading...</div>;
if (!project) return <div className="loading">Project not found</div>;
const matchesByAsset: Record<number, Match[]> = {};
for (const m of matches) {
if (!matchesByAsset[m.client_asset_id]) matchesByAsset[m.client_asset_id] = [];
matchesByAsset[m.client_asset_id].push(m);
}
const allAssetsHaveSelectedMatch = assets.length > 0 && assets.every(
a => matchesByAsset[a.id]?.some(m => m.is_selected)
);
return (
<div className="project-view">
<div className="pv-header">
<div>
<Link to="/" className="back-link">Back to Projects</Link>
<h1 className="pv-title">{project.name}</h1>
<div className="pv-meta">
{project.client_name && <span className="pv-client">{project.client_name}</span>}
<span className={`badge ${project.status === 'finalized' ? 'badge-success' : 'badge-muted'}`}>
{project.status}
</span>
<span className="pv-model">{MODEL_TYPE_LABELS[project.model_type]}</span>
{(project as any).ai_cost_usd > 0 && (
<span className="pv-cost">
AI: ${(project as any).ai_cost_usd.toFixed(4)} ({(project as any).ai_call_count} calls)
</span>
)}
</div>
</div>
<button onClick={handleDelete} className="btn btn-danger btn-sm">Delete Project</button>
</div>
<div className="tabs">
{(['upload', 'matches', 'ratecard'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`tab ${tab === t ? 'tab-active' : ''}`}
>
{t === 'upload' ? `Upload & Assets (${assets.length})` :
t === 'matches' ? `Match Review (${matches.length})` :
'Ratecard'}
</button>
))}
</div>
{tab === 'upload' && (
<div className="tab-content">
<div className="upload-zone">
<div className="upload-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<p className="upload-title">Upload Client Document</p>
<p className="upload-desc">Word (.docx) or Excel (.xlsx) file with the client's asset brief</p>
<label className="btn btn-primary upload-btn">
{uploading ? 'Uploading & Parsing...' : 'Choose File'}
<input type="file" accept=".docx,.xlsx,.txt" onChange={handleUpload} hidden disabled={uploading} />
</label>
{project.source_filename && (
<p className="upload-file">Current: {project.source_filename}</p>
)}
</div>
{assets.length > 0 && (
<div className="assets-section">
<h3 className="section-title">Extracted Assets ({assets.length})</h3>
<div className="asset-list">
{assets.map(a => (
<div key={a.id} className="asset-item">
<div className="asset-name">{a.raw_name}</div>
{a.raw_description && <div className="asset-desc">{a.raw_description}</div>}
<div className="asset-vol">Volume: {a.volume}</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{tab === 'matches' && (
<div className="tab-content">
<div className="match-actions">
<button onClick={handleMatch} disabled={matching || assets.length === 0} className="btn btn-primary">
{matching ? `Matching... (${matches.length} found)` : 'Run AI Matching'}
</button>
{matching && (
<button onClick={handleCancelMatch} className="btn btn-danger">
Stop Matching
</button>
)}
{!allAssetsHaveSelectedMatch && !matching && matches.length > 0 && (
<button onClick={handleYolo} className="btn btn-yolo">
YOLO - Select All Top Matches
</button>
)}
{allAssetsHaveSelectedMatch && !matching && (
<button onClick={handleBuildRatecard} disabled={building} className="btn btn-success">
{building ? 'Building...' : 'Build Ratecard'}
</button>
)}
</div>
{assets.map(a => {
const assetMatches = matchesByAsset[a.id] || [];
const selectedMatch = assetMatches.find(m => m.is_selected);
const isCollapsed = !!selectedMatch && (selectedMatch.confidence_score ?? 0) >= 0.8;
return (
<div key={a.id} className={`match-group ${isCollapsed ? 'match-group-collapsed' : ''}`}>
<div
className="match-group-header"
onClick={() => {
// Toggle expand/collapse by toggling a CSS class
const el = document.getElementById(`match-group-${a.id}`);
el?.classList.toggle('match-group-expanded');
}}
style={{ cursor: isCollapsed ? 'pointer' : 'default' }}
>
<div className="match-asset-info">
<div className="match-asset-name-row">
<span className="match-asset-name">{a.raw_name}</span>
{selectedMatch && (
<span className="match-selected-summary">
<span className={`conf-badge-sm ${CONF_CLASS[selectedMatch.confidence]}`}>
{Math.round((selectedMatch.confidence_score || 0) * 100)}%
</span>
{selectedMatch.gmal_id} - {selectedMatch.gmal_unique_name || selectedMatch.gmal_name}
</span>
)}
{isCollapsed && <span className="match-expand-hint">click to expand</span>}
</div>
{a.raw_description && (
<span className="match-asset-desc">{a.raw_description}</span>
)}
</div>
<span className="match-asset-vol">Vol: {a.volume}</span>
</div>
<div id={`match-group-${a.id}`} className={`match-group-body ${isCollapsed ? 'match-group-collapsed-body' : ''}`}>
{assetMatches.length === 0 ? (
<div className="match-empty">
No matches yet. Click "Run AI Matching" to find GMAL equivalents.
</div>
) : (
assetMatches.map(m => (
<div key={m.id} className={`match-card ${m.is_selected ? 'match-selected' : ''}`}>
<div className={`match-card-accent ${CONF_CLASS[m.confidence]}`} />
<div className="match-card-body">
<div className="match-card-top">
<div className="match-gmal-info">
<span className="match-gmal-id">{m.gmal_id}</span>
<span className="match-gmal-name">{m.gmal_unique_name || m.gmal_name}</span>
</div>
<div className="match-card-actions">
<span className={`conf-badge ${CONF_CLASS[m.confidence]}`}>
{m.confidence} {m.confidence_score ? `${Math.round(m.confidence_score * 100)}%` : ''}
</span>
{!m.is_selected ? (
<button onClick={(e) => { e.stopPropagation(); handleSelectMatch(m.id); }} className="btn-select">
Select
</button>
) : (
<span className="selected-label">Selected</span>
)}
</div>
</div>
{m.ai_reasoning && (
<div className="match-reasoning">
<strong>Reasoning:</strong> {m.ai_reasoning}
</div>
)}
{m.caveat_text && (
<div className="match-caveat">
<strong>Caveats:</strong> {m.caveat_text}
</div>
)}
</div>
</div>
))
)}
</div>
</div>
);
})}
</div>
)}
{tab === 'ratecard' && (
<div className="tab-content">
{!ratecard || ratecard.lines.length === 0 ? (
<div className="empty-state" style={{ padding: 40 }}>
No ratecard built yet. Complete matching and click "Build Ratecard".
</div>
) : (
<>
<div className="rc-header">
<div className="rc-stats">
<span className="rc-total">Total Hours: {ratecard.total_hours.toLocaleString()}</span>
<span className="rc-assets">{ratecard.total_assets} assets</span>
</div>
<div className="rc-exports">
<a href={`/api/projects/${id}/ratecard/export/excel`} className="btn btn-secondary">
Export Excel
</a>
<a href={`/api/projects/${id}/ratecard/export/pdf`} className="btn btn-secondary">
Export PDF Caveats
</a>
</div>
</div>
<div className="table-wrap">
<table className="rc-table">
<thead>
<tr>
<th>Discipline</th>
<th>Role</th>
<th>Asset</th>
<th>GMAL</th>
<th className="text-right">Base Hrs</th>
<th className="text-center">Vol</th>
<th className="text-right">Total Hrs</th>
</tr>
</thead>
<tbody>
{ratecard.lines.map(l => (
<tr key={l.id}>
<td className="td-discipline">{l.discipline}</td>
<td>{l.role_title}</td>
<td>{l.client_asset_name}</td>
<td className="td-gmal">{l.gmal_id}</td>
<td className="text-right">{l.base_hours?.toFixed(2)}</td>
<td className="text-center">{l.volume}</td>
<td className="text-right td-total">
{(l.manual_override ?? l.total_hours)?.toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
)}
</div>
);
}

125
frontend/src/types/index.ts Normal file
View file

@ -0,0 +1,125 @@
export interface GmalAsset {
id: number;
gmal_id: string;
asset_name: string | null;
sub_category: string | null;
complexity_level: number | null;
complexity_name: string | null;
unique_name: string | null;
has_hour_routes: boolean;
category?: string | null;
asset_description?: string | null;
complexity_description?: string | null;
caveats?: string | null;
ai_enhanced_description?: string | null;
master_adapt?: string | null;
ai_efficiency_pct?: number | null;
}
export interface GmalHoursEntry {
role_id: number;
role_title: string;
discipline: string;
model_type: string;
hours: number;
}
export interface GmalAssetWithHours extends GmalAsset {
hours_by_role: GmalHoursEntry[];
}
export interface Role {
id: number;
discipline: string;
role_title: string;
entity: string | null;
resource_location: string | null;
unique_name: string | null;
sort_order: number | null;
is_programme_role: boolean;
}
export interface Project {
id: number;
name: string;
client_name: string | null;
description: string | null;
model_type: string;
status: string;
source_filename: string | null;
created_at: string;
updated_at: string;
asset_count: number;
}
export interface ClientAsset {
id: number;
project_id: number;
raw_name: string | null;
raw_description: string | null;
volume: number;
sort_order: number | null;
}
export interface Match {
id: number;
client_asset_id: number;
gmal_asset_id: number;
gmal_id: string | null;
gmal_name: string | null;
gmal_unique_name: string | null;
confidence: 'exact' | 'close' | 'multiple' | 'none';
confidence_score: number | null;
ai_reasoning: string | null;
caveat_text: string | null;
is_selected: boolean;
rank: number;
}
export interface RatecardLine {
id: number;
client_asset_id: number;
client_asset_name: string | null;
gmal_asset_id: number;
gmal_id: string | null;
role_id: number;
role_title: string | null;
discipline: string | null;
base_hours: number | null;
volume: number;
total_hours: number | null;
manual_override: number | null;
notes: string | null;
}
export interface RatecardSummary {
project_id: number;
project_name: string;
model_type: string;
total_assets: number;
total_hours: number;
lines: RatecardLine[];
}
export interface GmalStats {
total_assets: number;
total_roles: number;
total_hours_records: number;
categories: string[];
sub_categories: string[];
}
export const MODEL_TYPE_LABELS: Record<string, string> = {
current_oplus: 'Current Model - O+ Market',
ai_oplus: 'AI Model - O+ Market',
current_local: 'Current Model - Local Market',
ai_local: 'AI Model - Local Market',
asset_factory: 'Asset Factory Model',
};
export const CONFIDENCE_COLORS: Record<string, string> = {
exact: '#22c55e',
close: '#f59e0b',
multiple: '#FFC407',
none: '#ef4444',
};

20
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

16
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
},
},
},
})