From 668ea44ea2b4c634cc6c4c30e43f9ab6bbf41fde Mon Sep 17 00:00:00 2001 From: DJP Date: Thu, 9 Apr 2026 15:02:45 -0400 Subject: [PATCH] Client tier mapping + GMAL complexity variant expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tier mapping on projects: configurable label→complexity mapping - Presets: A/B/C, 1/2/3, Gold/Silver/Bronze - Stored as JSON on project.tier_mapping - ClientAsset.client_tier field for tracking which tier an asset belongs to - GMAL family endpoint: GET /gmal/assets/{id}/family returns all complexity variants - Looks up by asset_name (NOT by GMAL number increment) - Verified: families share asset_name across non-sequential GMAL IDs - Expand to Tiers: POST /projects/{id}/expand-tiers - Splits each matched asset into N tier variants (one per tier) - Finds correct GMAL variant by asset_name + complexity_level query - Creates new ClientAsset + Match per tier with correct GMAL - Removes original un-tiered asset after expansion - Frontend: tier preset buttons + expand button on Match Review tab - Tier tags shown with label → complexity mapping Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 8 +- backend/app/api/gmal.py | 27 +++++ backend/app/api/matching.py | 29 ++++++ backend/app/api/projects.py | 1 + backend/app/models/project.py | 2 + backend/app/schemas/project.py | 2 + backend/app/services/tier_expander.py | 143 ++++++++++++++++++++++++++ frontend/src/pages/ProjectView.css | 35 +++++++ frontend/src/pages/ProjectView.tsx | 91 +++++++++++++++- 9 files changed, 335 insertions(+), 3 deletions(-) create mode 100644 backend/app/services/tier_expander.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6f54de1..d991c2c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,13 @@ "Bash(curl -s \"http://localhost:8002/api/projects/5/team-shape?profile_id=2&tool_ids=3,1\")", "Bash(python3 -c \":*)", "Bash(docker compose:*)", - "Bash(grep -v ^\\\\)" + "Bash(grep -v ^\\\\)", + "Bash(xargs ls:*)", + "Bash(curl -s http://localhost:8002/api/gmal/assets/GMAL101/family)", + "Bash(curl -s http://localhost:8002/api/gmal/assets/GMAL101)", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\(''''gmal_id'''',''''no gmal_id''''\\)\\)\")", + "Bash(curl -sv http://localhost:8002/api/gmal/assets/GMAL101/family)", + "Bash(curl -s http://localhost:8002/openapi.json)" ] } } diff --git a/backend/app/api/gmal.py b/backend/app/api/gmal.py index a7b1d44..b496409 100644 --- a/backend/app/api/gmal.py +++ b/backend/app/api/gmal.py @@ -47,6 +47,33 @@ async def list_assets( return result.scalars().all() +@router.get("/assets/{gmal_id}/family") +async def get_asset_family(gmal_id: str, db: AsyncSession = Depends(get_db)): + """Get all complexity variants (family) for a GMAL asset.""" + 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") + + family_result = await db.execute( + select(GmalAsset).where( + GmalAsset.asset_name == asset.asset_name, + GmalAsset.has_hour_routes == True, + ).order_by(GmalAsset.complexity_level) + ) + family = family_result.scalars().all() + return [ + { + "gmal_id": g.gmal_id, + "asset_name": g.asset_name, + "complexity_level": g.complexity_level, + "complexity_name": g.complexity_name, + "unique_name": g.unique_name, + } + for g in family + ] + + @router.get("/assets/{gmal_id}", response_model=GmalAssetWithHours) async def get_asset(gmal_id: str, db: AsyncSession = Depends(get_db)): result = await db.execute( diff --git a/backend/app/api/matching.py b/backend/app/api/matching.py index a51db22..061d741 100644 --- a/backend/app/api/matching.py +++ b/backend/app/api/matching.py @@ -125,6 +125,35 @@ async def upload_client_document( } +@router.get("/{project_id}/tier-mapping") +async def get_tier_mapping(project_id: int, db: AsyncSession = Depends(get_db)): + """Get the tier mapping for a project.""" + project = await _get_project(project_id, db) + import json + if project.tier_mapping: + return json.loads(project.tier_mapping) + return {"tiers": []} + + +@router.put("/{project_id}/tier-mapping") +async def set_tier_mapping(project_id: int, data: dict, db: AsyncSession = Depends(get_db)): + """Set the tier mapping for a project.""" + import json + project = await _get_project(project_id, db) + project.tier_mapping = json.dumps(data) + await db.commit() + return data + + +@router.post("/{project_id}/expand-tiers") +async def expand_tiers_endpoint(project_id: int, db: AsyncSession = Depends(get_db)): + """Expand matched assets into complexity tier variants.""" + from app.services.tier_expander import expand_to_tiers + project = await _get_project(project_id, db) + result = await expand_to_tiers(db, project) + return result + + @router.get("/{project_id}/brief-analysis") async def get_brief_analysis(project_id: int, db: AsyncSession = Depends(get_db)): """Get the structured brief analysis for a project.""" diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index e845a8a..b6fc5c4 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -99,6 +99,7 @@ def _project_out(project: Project, asset_count: int) -> ProjectOut: source_filename=project.source_filename, parse_stage=project.parse_stage, has_brief_analysis=bool(project.brief_analysis), + has_tier_mapping=bool(project.tier_mapping), 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), diff --git a/backend/app/models/project.py b/backend/app/models/project.py index f80ad64..316d022 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -36,6 +36,7 @@ class Project(Base): source_filename: Mapped[str | None] = mapped_column(String(255)) parse_stage: Mapped[str | None] = mapped_column(String(255)) brief_analysis: Mapped[str | None] = mapped_column(Text) + tier_mapping: Mapped[str | None] = mapped_column(Text) 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) @@ -54,6 +55,7 @@ class ClientAsset(Base): 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) + client_tier: Mapped[str | None] = mapped_column(String(50)) 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) diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 0942ed8..d400d3f 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -26,6 +26,7 @@ class ProjectOut(BaseModel): source_filename: str | None parse_stage: str | None = None has_brief_analysis: bool = False + has_tier_mapping: bool = False ai_input_tokens: int = 0 ai_output_tokens: int = 0 ai_cost_usd: float = 0 @@ -43,6 +44,7 @@ class ClientAssetOut(BaseModel): project_id: int raw_name: str | None raw_description: str | None + client_tier: str | None = None volume: int sort_order: int | None diff --git a/backend/app/services/tier_expander.py b/backend/app/services/tier_expander.py new file mode 100644 index 0000000..f3d0f81 --- /dev/null +++ b/backend/app/services/tier_expander.py @@ -0,0 +1,143 @@ +"""Expand matched assets into complexity tier variants.""" + +import json +import logging + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.gmal import GmalAsset +from app.models.project import Project, ClientAsset, Match, MatchConfidence + +logger = logging.getLogger(__name__) + +# Map complexity names to levels +COMPLEXITY_MAP = { + "simple": 1, + "medium": 2, + "mid": 2, + "complex": 3, +} + + +async def expand_to_tiers(db: AsyncSession, project: Project) -> dict: + """Expand each matched asset into complexity variants based on the project's tier mapping. + + For each selected match: + 1. Find the GMAL family (same asset_name, different complexity_level) + 2. For each tier in the mapping, create a new ClientAsset + Match + 3. Remove the original single-match asset + + Returns summary of what was done. + """ + if not project.tier_mapping: + return {"error": "No tier mapping configured for this project"} + + tier_config = json.loads(project.tier_mapping) + tiers = tier_config.get("tiers", []) + if not tiers: + return {"error": "Tier mapping is empty"} + + # Load client assets with selected 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() + + # Skip assets that already have a tier (already expanded) + unexpanded = [ca for ca in client_assets if not ca.client_tier] + if not unexpanded: + return {"message": "All assets already have tier assignments", "expanded": 0} + + expanded_count = 0 + skipped_count = 0 + + for ca in unexpanded: + # Get the selected match + match_result = await db.execute( + select(Match).where(Match.client_asset_id == ca.id, Match.is_selected == True) + ) + selected_match = match_result.scalar_one_or_none() + if not selected_match: + skipped_count += 1 + continue + + # Get the matched GMAL asset + gmal_result = await db.execute( + select(GmalAsset).where(GmalAsset.id == selected_match.gmal_asset_id) + ) + matched_gmal = gmal_result.scalar_one_or_none() + if not matched_gmal or not matched_gmal.asset_name: + skipped_count += 1 + continue + + # Find the GMAL family by asset_name + family_result = await db.execute( + select(GmalAsset).where( + GmalAsset.asset_name == matched_gmal.asset_name, + GmalAsset.has_hour_routes == True, + ).order_by(GmalAsset.complexity_level) + ) + family = {g.complexity_level: g for g in family_result.scalars().all()} + + if len(family) < 2: + # Only one variant exists, skip expansion + skipped_count += 1 + continue + + # Create a new ClientAsset + Match for each tier + for tier in tiers: + tier_label = tier["label"] + complexity_name = tier["complexity"].lower() + complexity_level = COMPLEXITY_MAP.get(complexity_name) + + if complexity_level is None: + logger.warning(f"Unknown complexity '{complexity_name}' in tier mapping") + continue + + gmal_variant = family.get(complexity_level) + if not gmal_variant: + # This complexity level doesn't exist for this asset + logger.warning(f"No {complexity_name} variant for '{matched_gmal.asset_name}'") + continue + + # Create new client asset with tier label + new_ca = ClientAsset( + project_id=project.id, + raw_name=f"{ca.raw_name} - {tier_label}", + raw_description=ca.raw_description, + client_tier=tier_label, + volume=ca.volume, + sort_order=(ca.sort_order or 0) * 10 + (complexity_level or 0), + ) + db.add(new_ca) + await db.flush() + + # Create match pointing to the correct complexity variant + new_match = Match( + client_asset_id=new_ca.id, + gmal_asset_id=gmal_variant.id, + confidence=MatchConfidence.EXACT, + confidence_score=0.95, + ai_reasoning=f"Tier expansion: {tier_label} → {gmal_variant.complexity_name} variant of {matched_gmal.asset_name} ({gmal_variant.gmal_id})", + caveat_text=f"Auto-mapped from tier '{tier_label}' to {gmal_variant.complexity_name} complexity.", + is_selected=True, + rank=1, + ) + db.add(new_match) + expanded_count += 1 + + # Delete the original un-tiered asset and its matches + orig_matches = await db.execute(select(Match).where(Match.client_asset_id == ca.id)) + for m in orig_matches.scalars().all(): + await db.delete(m) + await db.delete(ca) + + await db.commit() + + return { + "message": f"Expanded {len(unexpanded) - skipped_count} assets into {expanded_count} tier variants. {skipped_count} skipped (no match or single variant).", + "expanded_assets": len(unexpanded) - skipped_count, + "new_variants": expanded_count, + "skipped": skipped_count, + } diff --git a/frontend/src/pages/ProjectView.css b/frontend/src/pages/ProjectView.css index f32a08c..21f5315 100644 --- a/frontend/src/pages/ProjectView.css +++ b/frontend/src/pages/ProjectView.css @@ -636,6 +636,41 @@ span.conf-none { background: var(--color-danger); } font-style: italic; } +/* Tier Mapping */ +.tier-mapping-box { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 14px 18px; + margin-bottom: 12px; +} + +.tier-mapping-header { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.tier-tags { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.tier-tag { + font-size: 12px; + padding: 4px 12px; + border-radius: 6px; + background: rgba(255, 196, 7, 0.08); + border: 1px solid rgba(255, 196, 7, 0.2); + color: var(--color-text-secondary); +} + +.tier-tag strong { + color: var(--color-primary); +} + /* Refine Chat */ .refine-box { background: var(--color-bg-card); diff --git a/frontend/src/pages/ProjectView.tsx b/frontend/src/pages/ProjectView.tsx index 4248ef7..322495b 100644 --- a/frontend/src/pages/ProjectView.tsx +++ b/frontend/src/pages/ProjectView.tsx @@ -63,6 +63,9 @@ export default function ProjectView() { const [briefAnalysis, setBriefAnalysis] = useState(null); const [analyzing, setAnalyzing] = useState(false); const [briefText, setBriefText] = useState(''); + const [tierMapping, setTierMapping] = useState<{tiers: Array<{label: string, complexity: string}>}>({tiers: []}); + const [tierPreset, setTierPreset] = useState(''); + const [expanding, setExpanding] = useState(false); const [matching, setMatching] = useState(false); const [building, setBuilding] = useState(false); const [expandedGroups, setExpandedGroups] = useState>(new Set()); @@ -87,12 +90,18 @@ export default function ProjectView() { setMatches(matchRes.data); } - // Load brief analysis if available + // Load brief analysis and tier mapping if available try { - const analysisRes = await api.get(`/projects/${id}/brief-analysis`); + const [analysisRes, tierRes] = await Promise.all([ + api.get(`/projects/${id}/brief-analysis`), + api.get(`/projects/${id}/tier-mapping`), + ]); if (analysisRes.data.status === 'analyzed') { setBriefAnalysis(analysisRes.data.analysis); } + if (tierRes.data.tiers?.length > 0) { + setTierMapping(tierRes.data); + } } catch {} if (['finalized', 'building'].includes(projRes.data.status)) { @@ -298,6 +307,56 @@ export default function ProjectView() { downloadFile(`/projects/${id}/ratecard/export/pdf`, `${project?.name || 'caveats'}_caveats.pdf`); } + const TIER_PRESETS: Record> = { + 'abc': [ + {label: 'Tier A', complexity: 'complex'}, + {label: 'Tier B', complexity: 'medium'}, + {label: 'Tier C', complexity: 'simple'}, + ], + '123': [ + {label: 'Tier 1', complexity: 'simple'}, + {label: 'Tier 2', complexity: 'medium'}, + {label: 'Tier 3', complexity: 'complex'}, + ], + 'gsb': [ + {label: 'Gold', complexity: 'complex'}, + {label: 'Silver', complexity: 'medium'}, + {label: 'Bronze', complexity: 'simple'}, + ], + }; + + async function saveTierMapping(tiers: Array<{label: string, complexity: string}>) { + const mapping = { tiers }; + setTierMapping(mapping); + try { + await api.put(`/projects/${id}/tier-mapping`, mapping); + } catch {} + } + + function applyTierPreset(preset: string) { + setTierPreset(preset); + const tiers = TIER_PRESETS[preset]; + if (tiers) saveTierMapping(tiers); + } + + async function handleExpandTiers() { + if (tierMapping.tiers.length === 0) { + alert('Set a tier mapping first (e.g. A/B/C → Complex/Medium/Simple)'); + return; + } + if (!confirm(`Expand all matched assets into ${tierMapping.tiers.length} tier variants? This will replace the current asset list.`)) return; + setExpanding(true); + try { + const res = await api.post(`/projects/${id}/expand-tiers`); + alert(res.data.message); + await loadProject(); + } catch (err: any) { + alert(`Expand failed: ${err.response?.data?.detail || err.message}`); + } finally { + setExpanding(false); + } + } + async function handleAnalyzeBrief(mode: 'file' | 'text') { setAnalyzing(true); try { @@ -607,6 +666,34 @@ export default function ProjectView() { )} + {/* Tier Mapping */} + {matches.length > 0 && !matching && ( +
+
+ Client Tier Mapping: +
+ + + +
+ {tierMapping.tiers.length > 0 && ( + + )} +
+ {tierMapping.tiers.length > 0 && ( +
+ {tierMapping.tiers.map((t, i) => ( + + {t.label} → {t.complexity} + + ))} +
+ )} +
+ )} + {matches.length > 0 && !matching && (