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:
parent
b067326557
commit
668ea44ea2
9 changed files with 335 additions and 3 deletions
|
|
@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
143
backend/app/services/tier_expander.py
Normal file
143
backend/app/services/tier_expander.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue