From 82046c784cb4dffed7bb85958f298d1283dcbf76 Mon Sep 17 00:00:00 2001 From: DJP Date: Thu, 9 Apr 2026 13:48:30 -0400 Subject: [PATCH] P1: Role-specific efficiency profiles + BTG tool efficiencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - New models: MatchFeedback, EfficiencyProfile, EfficiencyRate, ToolEfficiency, ToolEfficiencyRate - 3 preset profiles seeded: Conservative, Moderate, Aggressive with per-discipline rates - 6 BTG tools seeded: Pencil, OMG, Creative X, Cortex, Semblance, Share of Model - Efficiency API: CRUD for profiles and tools at /api/efficiency/ - team_shape.py: accepts profile_rates + tool_rates (per-discipline, additive, capped at 90%) - team-shape endpoint: accepts profile_id and tool_ids query params - Programme roles always exempt regardless of method Example: Moderate profile + Creative X + Pencil → Account Mgmt 10%, Creative 70%, Production 65% Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 8 +- backend/app/api/efficiency.py | 176 +++++++++++++++++++++++++++++ backend/app/api/ratecard.py | 43 ++++++- backend/app/main.py | 3 +- backend/app/models/__init__.py | 3 + backend/app/models/feedback.py | 71 ++++++++++++ backend/app/services/team_shape.py | 50 +++++--- 7 files changed, 337 insertions(+), 17 deletions(-) create mode 100644 backend/app/api/efficiency.py create mode 100644 backend/app/models/feedback.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 440f957..4eac893 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,13 @@ { "permissions": { "allow": [ - "Bash(ssh optical-dev:*)" + "Bash(ssh optical-dev:*)", + "Bash(curl -s http://localhost:8001/api/efficiency/profiles)", + "Bash(curl -s http://localhost:8002/api/efficiency/profiles)", + "Bash(curl -s http://localhost:8002/api/efficiency/tools)", + "Bash(python3 -m json.tool)", + "Bash(curl -s \"http://localhost:8002/api/projects/5/team-shape?profile_id=2&tool_ids=3,1\")", + "Bash(python3 -c \":*)" ] } } diff --git a/backend/app/api/efficiency.py b/backend/app/api/efficiency.py new file mode 100644 index 0000000..3ef2de0 --- /dev/null +++ b/backend/app/api/efficiency.py @@ -0,0 +1,176 @@ +"""Efficiency profiles and tool efficiency endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.feedback import EfficiencyProfile, EfficiencyRate, ToolEfficiency, ToolEfficiencyRate + +router = APIRouter() + +# Preset profiles seeded on first access +PRESET_PROFILES = { + "Conservative": { + "Account Management": 5, "Delivery": 10, "Strategy": 5, + "Creative": 15, "Editorial": 20, "Production": 25, + "Web Design": 20, "Data": 10, "UX": 10, + "Tech and Web Dev": 15, "QA": 20, + "Social & Community Management": 15, "Third Party Fees": 0, + }, + "Moderate": { + "Account Management": 10, "Delivery": 20, "Strategy": 15, + "Creative": 35, "Editorial": 40, "Production": 50, + "Web Design": 40, "Data": 25, "UX": 25, + "Tech and Web Dev": 35, "QA": 45, + "Social & Community Management": 30, "Third Party Fees": 0, + }, + "Aggressive": { + "Account Management": 15, "Delivery": 30, "Strategy": 25, + "Creative": 60, "Editorial": 70, "Production": 80, + "Web Design": 65, "Data": 40, "UX": 45, + "Tech and Web Dev": 60, "QA": 75, + "Social & Community Management": 50, "Third Party Fees": 0, + }, +} + +PRESET_TOOLS = { + "Pencil": { + "description": "AI-powered ad creative generation and concepting", + "rates": {"Creative": 15, "Production": 10, "Delivery": 5}, + }, + "OMG": { + "description": "Media optimization and campaign management platform", + "rates": {"Delivery": 10, "Data": 15, "Strategy": 5}, + }, + "Creative X": { + "description": "Scalable asset generation and adaptation platform", + "rates": {"Creative": 20, "Production": 15, "Editorial": 10}, + }, + "Cortex": { + "description": "AI model orchestration and experimentation", + "rates": {"Data": 20, "Strategy": 10, "Creative": 5, "Production": 5}, + }, + "Semblance": { + "description": "Audience analysis and model insights", + "rates": {"Data": 15, "Strategy": 10}, + }, + "Share of Model": { + "description": "Media modeling and optimization engine", + "rates": {"Data": 15, "Strategy": 10, "Delivery": 5}, + }, +} + + +async def _ensure_presets(db: AsyncSession): + """Seed preset profiles and tools if they don't exist.""" + existing = await db.execute(select(EfficiencyProfile)) + if existing.scalars().first(): + return + + for name, rates in PRESET_PROFILES.items(): + profile = EfficiencyProfile(name=name, is_default=(name == "Moderate")) + db.add(profile) + await db.flush() + for discipline, pct in rates.items(): + db.add(EfficiencyRate(profile_id=profile.id, discipline=discipline, efficiency_pct=pct)) + + for tool_name, tool_data in PRESET_TOOLS.items(): + tool = ToolEfficiency(tool_name=tool_name, tool_description=tool_data["description"]) + db.add(tool) + await db.flush() + for discipline, pct in tool_data["rates"].items(): + db.add(ToolEfficiencyRate(tool_id=tool.id, discipline=discipline, additional_efficiency_pct=pct)) + + await db.commit() + + +@router.get("/profiles") +async def list_profiles(db: AsyncSession = Depends(get_db)): + await _ensure_presets(db) + result = await db.execute(select(EfficiencyProfile).order_by(EfficiencyProfile.id)) + profiles = result.scalars().all() + + out = [] + for p in profiles: + rates_result = await db.execute( + select(EfficiencyRate).where(EfficiencyRate.profile_id == p.id) + ) + rates = {r.discipline: float(r.efficiency_pct) for r in rates_result.scalars().all()} + out.append({ + "id": p.id, + "name": p.name, + "is_default": p.is_default, + "rates": rates, + }) + return out + + +@router.get("/profiles/{profile_id}") +async def get_profile(profile_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(EfficiencyProfile).where(EfficiencyProfile.id == profile_id)) + profile = result.scalar_one_or_none() + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + + rates_result = await db.execute( + select(EfficiencyRate).where(EfficiencyRate.profile_id == profile.id) + ) + rates = {r.discipline: float(r.efficiency_pct) for r in rates_result.scalars().all()} + return {"id": profile.id, "name": profile.name, "is_default": profile.is_default, "rates": rates} + + +@router.put("/profiles/{profile_id}") +async def update_profile(profile_id: int, data: dict, db: AsyncSession = Depends(get_db)): + """Update rates for a profile. Body: {"rates": {"Creative": 40, "Production": 55, ...}}""" + result = await db.execute(select(EfficiencyProfile).where(EfficiencyProfile.id == profile_id)) + profile = result.scalar_one_or_none() + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + + if "rates" in data: + # Delete existing rates and re-create + existing = await db.execute(select(EfficiencyRate).where(EfficiencyRate.profile_id == profile.id)) + for r in existing.scalars().all(): + await db.delete(r) + for discipline, pct in data["rates"].items(): + db.add(EfficiencyRate(profile_id=profile.id, discipline=discipline, efficiency_pct=pct)) + + if "name" in data: + profile.name = data["name"] + + await db.commit() + return await get_profile(profile_id, db) + + +@router.post("/profiles") +async def create_profile(data: dict, db: AsyncSession = Depends(get_db)): + """Create a custom profile. Body: {"name": "My Profile", "rates": {...}}""" + profile = EfficiencyProfile(name=data["name"]) + db.add(profile) + await db.flush() + for discipline, pct in data.get("rates", {}).items(): + db.add(EfficiencyRate(profile_id=profile.id, discipline=discipline, efficiency_pct=pct)) + await db.commit() + return await get_profile(profile.id, db) + + +@router.get("/tools") +async def list_tools(db: AsyncSession = Depends(get_db)): + await _ensure_presets(db) + result = await db.execute(select(ToolEfficiency).order_by(ToolEfficiency.tool_name)) + tools = result.scalars().all() + + out = [] + for t in tools: + rates_result = await db.execute( + select(ToolEfficiencyRate).where(ToolEfficiencyRate.tool_id == t.id) + ) + rates = {r.discipline: float(r.additional_efficiency_pct) for r in rates_result.scalars().all()} + out.append({ + "id": t.id, + "tool_name": t.tool_name, + "tool_description": t.tool_description, + "rates": rates, + }) + return out diff --git a/backend/app/api/ratecard.py b/backend/app/api/ratecard.py index f3b3bd9..2f0c514 100644 --- a/backend/app/api/ratecard.py +++ b/backend/app/api/ratecard.py @@ -138,11 +138,49 @@ async def update_ratecard_line( async def get_team_shape( project_id: int, efficiency_pct: float = 0, + profile_id: int | None = None, + tool_ids: str = "", db: AsyncSession = Depends(get_db), ): - """Get team shape (FTE per role) calculated from the ratecard.""" + """Get team shape (FTE per role) calculated from the ratecard. + + Can use either: + - efficiency_pct: blanket % for all delivery roles (legacy) + - profile_id + tool_ids: per-discipline rates from profile + additive tool rates + """ project = await _get_project(project_id, db) - team = await calculate_team_shape(db, project, efficiency_pct=efficiency_pct) + + profile_rates = None + tool_rates_combined = None + + if profile_id: + from app.models.feedback import EfficiencyProfile, EfficiencyRate, ToolEfficiency, ToolEfficiencyRate + # Load profile rates + rates_result = await db.execute( + select(EfficiencyRate).where(EfficiencyRate.profile_id == profile_id) + ) + profile_rates = {r.discipline: float(r.efficiency_pct) for r in rates_result.scalars().all()} + + # Load tool rates if specified + if tool_ids: + tool_id_list = [int(x) for x in tool_ids.split(",") if x.strip().isdigit()] + if tool_id_list: + tool_rates_combined = {} + for tid in tool_id_list: + tr_result = await db.execute( + select(ToolEfficiencyRate).where(ToolEfficiencyRate.tool_id == tid) + ) + for tr in tr_result.scalars().all(): + tool_rates_combined[tr.discipline] = ( + tool_rates_combined.get(tr.discipline, 0) + float(tr.additional_efficiency_pct) + ) + + team = await calculate_team_shape( + db, project, + efficiency_pct=efficiency_pct, + profile_rates=profile_rates, + tool_rates=tool_rates_combined, + ) total_hours = sum(t["total_hours"] for t in team) total_fte = sum(t["fte"] for t in team) @@ -155,6 +193,7 @@ async def get_team_shape( "project_name": project.name, "hours_per_fte": 1800, "efficiency_pct": efficiency_pct, + "profile_id": profile_id, "total_hours": round(total_hours, 2), "total_fte": round(total_fte, 4), "adjusted_hours": round(adjusted_hours, 2), diff --git a/backend/app/main.py b/backend/app/main.py index 40f26cd..1ed107d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware # Enable app-level logging logging.basicConfig(level=logging.INFO, format="%(levelname)s [%(name)s] %(message)s") -from app.api import gmal, ingest, projects, matching, ratecard +from app.api import gmal, ingest, projects, matching, ratecard, efficiency from app.middleware.auth import get_current_user app = FastAPI(title="Scope Builder", version="1.0.0") @@ -31,6 +31,7 @@ app.include_router(ingest.router, prefix="/api/gmal", tags=["Ingest"], dependenc app.include_router(projects.router, prefix="/api/projects", tags=["Projects"], dependencies=[_auth]) app.include_router(matching.router, prefix="/api/projects", tags=["Matching"], dependencies=[_auth]) app.include_router(ratecard.router, prefix="/api/projects", tags=["Ratecard"], dependencies=[_auth]) +app.include_router(efficiency.router, prefix="/api/efficiency", tags=["Efficiency"], dependencies=[_auth]) @app.get("/api/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 6198d50..6a84d9b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,7 +1,10 @@ from app.models.gmal import GmalAsset, Role, GmalHours, GmalServiceLine, RoleLevelMapping from app.models.project import Project, ClientAsset, Match, RatecardLine +from app.models.feedback import MatchFeedback, EfficiencyProfile, EfficiencyRate, ToolEfficiency, ToolEfficiencyRate __all__ = [ "GmalAsset", "Role", "GmalHours", "GmalServiceLine", "RoleLevelMapping", "Project", "ClientAsset", "Match", "RatecardLine", + "MatchFeedback", "EfficiencyProfile", "EfficiencyRate", + "ToolEfficiency", "ToolEfficiencyRate", ] diff --git a/backend/app/models/feedback.py b/backend/app/models/feedback.py new file mode 100644 index 0000000..343960d --- /dev/null +++ b/backend/app/models/feedback.py @@ -0,0 +1,71 @@ +"""Models for match feedback, efficiency profiles, and tool efficiencies.""" + +from datetime import datetime + +from sqlalchemy import String, Text, Integer, Numeric, Boolean, DateTime, ForeignKey, JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class MatchFeedback(Base): + """Stores confirmed/rejected match mappings for learning.""" + __tablename__ = "match_feedback" + + id: Mapped[int] = mapped_column(primary_key=True) + client_term: Mapped[str] = mapped_column(String(500), index=True) + client_description: Mapped[str | None] = mapped_column(Text) + gmal_asset_id: Mapped[int] = mapped_column(ForeignKey("gmal_assets.id"), nullable=False) + confirmed: Mapped[bool] = mapped_column(Boolean, default=True) + user_comment: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + gmal_asset: Mapped["GmalAsset"] = relationship() + + +class EfficiencyProfile(Base): + """Named efficiency profile (Conservative, Moderate, Aggressive, Custom).""" + __tablename__ = "efficiency_profiles" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True) + is_default: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + rates: Mapped[list["EfficiencyRate"]] = relationship(back_populates="profile", cascade="all, delete-orphan") + + +class EfficiencyRate(Base): + """Per-discipline efficiency rate within a profile.""" + __tablename__ = "efficiency_rates" + + id: Mapped[int] = mapped_column(primary_key=True) + profile_id: Mapped[int] = mapped_column(ForeignKey("efficiency_profiles.id", ondelete="CASCADE"), nullable=False) + discipline: Mapped[str] = mapped_column(String(100), nullable=False) + efficiency_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False) + + profile: Mapped["EfficiencyProfile"] = relationship(back_populates="rates") + + +class ToolEfficiency(Base): + """A BTG tool that provides additional efficiency.""" + __tablename__ = "tool_efficiencies" + + id: Mapped[int] = mapped_column(primary_key=True) + tool_name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True) + tool_description: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + rates: Mapped[list["ToolEfficiencyRate"]] = relationship(back_populates="tool", cascade="all, delete-orphan") + + +class ToolEfficiencyRate(Base): + """Per-discipline efficiency delta for a specific tool.""" + __tablename__ = "tool_efficiency_rates" + + id: Mapped[int] = mapped_column(primary_key=True) + tool_id: Mapped[int] = mapped_column(ForeignKey("tool_efficiencies.id", ondelete="CASCADE"), nullable=False) + discipline: Mapped[str] = mapped_column(String(100), nullable=False) + additional_efficiency_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False) + + tool: Mapped["ToolEfficiency"] = relationship(back_populates="rates") diff --git a/backend/app/services/team_shape.py b/backend/app/services/team_shape.py index d27dd9f..d03d079 100644 --- a/backend/app/services/team_shape.py +++ b/backend/app/services/team_shape.py @@ -12,23 +12,26 @@ from app.models.project import Project, RatecardLine logger = logging.getLogger(__name__) HOURS_PER_FTE = 1800 +MAX_EFFICIENCY = 90 # Cap at 90% - never fully eliminate a role async def calculate_team_shape( db: AsyncSession, project: Project, efficiency_pct: float = 0, + profile_rates: dict[str, float] | None = None, + tool_rates: dict[str, float] | None = None, ) -> list[dict]: """Calculate FTE headcount per role from the ratecard. - FTE = total_hours_per_role / 1800 + Efficiency can be applied in three ways (in priority order): + 1. profile_rates + tool_rates: per-discipline rates from profile + additive tool rates + 2. efficiency_pct: legacy blanket percentage for all delivery roles + 3. None: no efficiency applied - If efficiency_pct > 0, delivery role hours are reduced by that percentage. - Programme roles are NOT reduced (they don't scale with AI efficiency). - - Returns list of dicts sorted by discipline then role order. + Programme roles are NEVER reduced regardless of method. + Total efficiency per discipline is capped at MAX_EFFICIENCY%. """ - # Load ratecard lines lines_result = await db.execute( select(RatecardLine).where(RatecardLine.project_id == project.id) ) @@ -48,9 +51,20 @@ async def calculate_team_shape( roles_result = await db.execute(select(Role).where(Role.id.in_(role_ids))) roles = {r.id: r for r in roles_result.scalars().all()} - # Build team shape - efficiency_multiplier = 1 - (efficiency_pct / 100) if efficiency_pct > 0 else 1 + # Build combined efficiency map per discipline + discipline_efficiency = {} + if profile_rates: + for disc, pct in profile_rates.items(): + discipline_efficiency[disc] = pct + # Add tool rates (additive) + if tool_rates: + for disc, pct in tool_rates.items(): + discipline_efficiency[disc] = discipline_efficiency.get(disc, 0) + pct + # Cap at MAX_EFFICIENCY + for disc in discipline_efficiency: + discipline_efficiency[disc] = min(discipline_efficiency[disc], MAX_EFFICIENCY) + # Build team shape team = [] for role_id, total_hours in role_hours.items(): role = roles.get(role_id) @@ -59,9 +73,19 @@ async def calculate_team_shape( fte = round(total_hours / HOURS_PER_FTE, 4) - # Apply efficiency - programme roles are NOT reduced - if efficiency_pct > 0 and not role.is_programme_role: - adjusted_hours = round(total_hours * efficiency_multiplier, 2) + # Determine efficiency for this role + if role.is_programme_role: + eff = 0 + elif discipline_efficiency: + eff = discipline_efficiency.get(role.discipline, 0) + elif efficiency_pct > 0: + eff = efficiency_pct + else: + eff = 0 + + if eff > 0: + multiplier = 1 - (eff / 100) + adjusted_hours = round(total_hours * multiplier, 2) adjusted_fte = round(adjusted_hours / HOURS_PER_FTE, 4) else: adjusted_hours = round(total_hours, 2) @@ -75,17 +99,17 @@ async def calculate_team_shape( "sort_order": role.sort_order or 0, "total_hours": round(total_hours, 2), "fte": fte, + "efficiency_pct": round(eff, 1), "adjusted_hours": adjusted_hours, "adjusted_fte": adjusted_fte, "hours_saved": round(total_hours - adjusted_hours, 2), "fte_saved": round(fte - adjusted_fte, 4), }) - # Sort by discipline then sort_order team.sort(key=lambda t: (t["discipline"], t["sort_order"])) total_hours = sum(t["total_hours"] for t in team) total_fte = sum(t["fte"] for t in team) - logger.info(f"Team shape: {len(team)} roles, {total_hours:.0f} hours, {total_fte:.2f} FTE (efficiency: {efficiency_pct}%)") + logger.info(f"Team shape: {len(team)} roles, {total_hours:.0f} hours, {total_fte:.2f} FTE") return team