Client tier mapping + GMAL complexity variant expansion

- 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) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-09 15:02:45 -04:00
parent b067326557
commit 668ea44ea2
9 changed files with 335 additions and 3 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -63,6 +63,9 @@ export default function ProjectView() {
const [briefAnalysis, setBriefAnalysis] = useState<any>(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<Set<number>>(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<string, Array<{label: string, complexity: string}>> = {
'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() {
)}
</div>
{/* Tier Mapping */}
{matches.length > 0 && !matching && (
<div className="tier-mapping-box">
<div className="tier-mapping-header">
<span className="efficiency-label">Client Tier Mapping:</span>
<div className="efficiency-buttons">
<button onClick={() => applyTierPreset('abc')} className={`eff-btn ${tierPreset === 'abc' ? 'eff-btn-active' : ''}`}>A/B/C</button>
<button onClick={() => applyTierPreset('123')} className={`eff-btn ${tierPreset === '123' ? 'eff-btn-active' : ''}`}>1/2/3</button>
<button onClick={() => applyTierPreset('gsb')} className={`eff-btn ${tierPreset === 'gsb' ? 'eff-btn-active' : ''}`}>Gold/Silver/Bronze</button>
</div>
{tierMapping.tiers.length > 0 && (
<button onClick={handleExpandTiers} disabled={expanding} className="btn btn-primary btn-sm" style={{ marginLeft: 12 }}>
{expanding ? 'Expanding...' : `Expand to ${tierMapping.tiers.length} Tiers`}
</button>
)}
</div>
{tierMapping.tiers.length > 0 && (
<div className="tier-tags">
{tierMapping.tiers.map((t, i) => (
<span key={i} className="tier-tag">
<strong>{t.label}</strong> {t.complexity}
</span>
))}
</div>
)}
</div>
)}
{matches.length > 0 && !matching && (
<div className="refine-box">
<div className="refine-log">