P1: Role-specific efficiency profiles + BTG tool efficiencies

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) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-09 13:48:30 -04:00
parent b5a21764d8
commit 82046c784c
7 changed files with 337 additions and 17 deletions

View file

@ -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 \":*)"
]
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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