diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4eac893..082b27a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,8 @@ "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 \":*)" + "Bash(python3 -c \":*)", + "Bash(docker compose:*)" ] } } diff --git a/backend/app/api/matching.py b/backend/app/api/matching.py index 0b26ce8..51fab54 100644 --- a/backend/app/api/matching.py +++ b/backend/app/api/matching.py @@ -242,6 +242,41 @@ async def cancel_matching_endpoint(project_id: int, db: AsyncSession = Depends(g return {"detail": "Matching cancellation requested"} +@router.post("/{project_id}/matches/{match_id}/feedback") +async def submit_match_feedback( + project_id: int, + match_id: int, + data: dict, + db: AsyncSession = Depends(get_db), +): + """Store feedback on a match (confirm or reject) for the learning system.""" + from app.models.feedback import MatchFeedback + + result = await db.execute(select(Match).where(Match.id == match_id)) + match = result.scalar_one_or_none() + if not match: + raise HTTPException(status_code=404, detail="Match not found") + + # Get the client asset name for the feedback record + ca_result = await db.execute(select(ClientAsset).where(ClientAsset.id == match.client_asset_id)) + ca = ca_result.scalar_one_or_none() + + confirmed = data.get("confirmed", True) + comment = data.get("comment", "") + + feedback = MatchFeedback( + client_term=(ca.raw_name or "").strip().lower() if ca else "", + client_description=ca.raw_description if ca else None, + gmal_asset_id=match.gmal_asset_id, + confirmed=confirmed, + user_comment=comment, + ) + db.add(feedback) + await db.commit() + + return {"detail": f"Feedback {'confirmed' if confirmed else 'rejected'} stored"} + + @router.get("/{project_id}/matches", response_model=list[MatchOut]) async def list_matches(project_id: int, db: AsyncSession = Depends(get_db)): """Get all matches for a project, grouped by client asset.""" diff --git a/backend/app/services/ai_matching.py b/backend/app/services/ai_matching.py index f61d6a0..db26975 100644 --- a/backend/app/services/ai_matching.py +++ b/backend/app/services/ai_matching.py @@ -160,8 +160,51 @@ async def match_client_assets( catalog_text = _format_compact_catalog(all_gmals) logger.info(f"Full GMAL catalog: {len(all_gmals)} assets, ~{len(catalog_text)} chars") + # Load confirmed feedback for instant matching (learning loop) + from app.models.feedback import MatchFeedback + feedback_result = await db.execute( + select(MatchFeedback).where(MatchFeedback.confirmed == True) + ) + all_feedback = feedback_result.scalars().all() + # Build lookup: normalized client_term -> gmal_asset_id + feedback_map: dict[str, int] = {} + for fb in all_feedback: + if fb.client_term: + feedback_map[fb.client_term] = fb.gmal_asset_id + logger.info(f"Loaded {len(feedback_map)} confirmed feedback mappings") + + # Check feedback for instant matches (no AI needed) + gmal_by_db_id = {g.id: g for g in all_gmals} all_matches = [] - total = len(asset_snapshots) + remaining_snapshots = [] + + for snap in asset_snapshots: + normalized = (snap["raw_name"] or "").strip().lower() + if normalized in feedback_map: + gmal_db_id = feedback_map[normalized] + gmal = gmal_by_db_id.get(gmal_db_id) + if gmal: + match = Match( + client_asset_id=snap["id"], + gmal_asset_id=gmal_db_id, + confidence=MatchConfidence.EXACT, + confidence_score=0.95, + ai_reasoning=f"Matched from confirmed feedback (previously verified match to {gmal.gmal_id})", + caveat_text="Auto-matched from learning system - verify if context differs from previous use.", + is_selected=True, + rank=1, + ) + db.add(match) + all_matches.append(match) + logger.info(f"Feedback match: '{snap['raw_name']}' -> {gmal.gmal_id}") + continue + remaining_snapshots.append(snap) + + if all_matches: + await db.commit() + logger.info(f"Instant feedback matches: {len(all_matches)}, remaining for AI: {len(remaining_snapshots)}") + + total = len(remaining_snapshots) # Process in batches for batch_start in range(0, total, BATCH_SIZE): @@ -169,7 +212,7 @@ async def match_client_assets( logger.info(f"Matching cancelled for project {project_id} at {batch_start}/{total}") break - batch = asset_snapshots[batch_start:batch_start + BATCH_SIZE] + batch = remaining_snapshots[batch_start:batch_start + BATCH_SIZE] batch_num = batch_start // BATCH_SIZE + 1 logger.info(f"Matching batch {batch_num} ({batch_start+1}-{min(batch_start+BATCH_SIZE, total)} of {total})") diff --git a/frontend/src/pages/ProjectView.css b/frontend/src/pages/ProjectView.css index 1c47e77..cd2b678 100644 --- a/frontend/src/pages/ProjectView.css +++ b/frontend/src/pages/ProjectView.css @@ -498,16 +498,15 @@ span.conf-none { background: var(--color-danger); } padding: 16px 20px; margin-bottom: 16px; display: flex; - gap: 32px; - align-items: center; - flex-wrap: wrap; + flex-direction: column; + gap: 12px; } -.efficiency-preview, -.efficiency-export { +.efficiency-row { display: flex; align-items: center; gap: 12px; + width: 100%; } .efficiency-label { @@ -552,6 +551,30 @@ span.conf-none { background: var(--color-danger); } color: var(--color-success); } +.discipline-rates { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.disc-rate-tag { + font-size: 11px; + color: var(--color-text-secondary); + background: rgba(255,255,255,0.05); + padding: 2px 8px; + border-radius: 4px; +} + +.disc-rate-tag strong { + color: var(--color-primary); +} + +.td-eff-pct { + color: var(--color-primary) !important; + font-weight: 600; + font-size: 11px; +} + /* Team Shape */ .team-stat-highlight { border-color: var(--color-primary) !important; diff --git a/frontend/src/pages/ProjectView.tsx b/frontend/src/pages/ProjectView.tsx index 325015c..1d43864 100644 --- a/frontend/src/pages/ProjectView.tsx +++ b/frontend/src/pages/ProjectView.tsx @@ -24,6 +24,20 @@ interface TeamShape { roles: TeamRole[]; } +interface EffProfile { + id: number; + name: string; + is_default: boolean; + rates: Record; +} + +interface EffTool { + id: number; + tool_name: string; + tool_description: string; + rates: Record; +} + const CONF_CLASS: Record = { exact: 'conf-exact', close: 'conf-close', @@ -48,6 +62,10 @@ export default function ProjectView() { const [expandedGroups, setExpandedGroups] = useState>(new Set()); const [previewEfficiency, setPreviewEfficiency] = useState(0); const [selectedEfficiencyLevels, setSelectedEfficiencyLevels] = useState>(new Set()); + const [profiles, setProfiles] = useState([]); + const [tools, setTools] = useState([]); + const [selectedProfileId, setSelectedProfileId] = useState(null); + const [selectedToolIds, setSelectedToolIds] = useState>(new Set()); const loadProject = useCallback(async () => { try { @@ -85,6 +103,22 @@ export default function ProjectView() { useEffect(() => { loadProject(); }, [loadProject]); + useEffect(() => { + async function loadEfficiency() { + try { + const [pRes, tRes] = await Promise.all([ + api.get('/efficiency/profiles'), + api.get('/efficiency/tools'), + ]); + setProfiles(pRes.data); + setTools(tRes.data); + const defaultProfile = pRes.data.find((p: EffProfile) => p.is_default); + if (defaultProfile) setSelectedProfileId(defaultProfile.id); + } catch {} + } + loadEfficiency(); + }, []); + async function handleUpload(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; @@ -186,6 +220,33 @@ export default function ProjectView() { } catch {} } + async function loadTeamWithProfile() { + if (!selectedProfileId) return; + try { + const toolIds = Array.from(selectedToolIds).join(','); + const url = `/projects/${id}/team-shape?profile_id=${selectedProfileId}${toolIds ? `&tool_ids=${toolIds}` : ''}`; + const res = await api.get(url); + setTeamShape(res.data); + setPreviewEfficiency(-1); // -1 = profile mode + } catch {} + } + + function toggleTool(toolId: number) { + setSelectedToolIds(prev => { + const next = new Set(prev); + if (next.has(toolId)) next.delete(toolId); + else next.add(toolId); + return next; + }); + } + + // Reload team shape when profile or tools change + useEffect(() => { + if (selectedProfileId && teamShape) { + loadTeamWithProfile(); + } + }, [selectedProfileId, selectedToolIds]); + function toggleEfficiencyLevel(level: number) { setSelectedEfficiencyLevels(prev => { const next = new Set(prev); @@ -542,22 +603,90 @@ export default function ProjectView() { <> {/* Efficiency Controls */}
-
- Preview AI Efficiency: +
+ Efficiency Profile:
- {[0, 10, 25, 50, 75, 90].map(pct => ( + + {profiles.map(p => ( ))}
-
- Include in Excel export: + + {selectedProfileId && ( +
+ BTG Tools: +
+ {tools.map(t => ( + + ))} +
+
+ )} + + {selectedProfileId && ( +
+ Per-discipline rates: +
+ {(() => { + const profile = profiles.find(p => p.id === selectedProfileId); + if (!profile) return null; + // Combine profile + tool rates + const combined: Record = { ...profile.rates }; + for (const tid of selectedToolIds) { + const tool = tools.find(t => t.id === tid); + if (tool) { + for (const [disc, pct] of Object.entries(tool.rates)) { + combined[disc] = Math.min(90, (combined[disc] || 0) + pct); + } + } + } + return Object.entries(combined) + .filter(([, pct]) => pct > 0) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([disc, pct]) => ( + + {disc}: {pct}% + + )); + })()} +
+
+ )} + +
+ Or flat rate: +
+ {[10, 25, 50, 75, 90].map(pct => ( + + ))} +
+
+ +
+ Excel export tabs:
{[10, 25, 50, 75, 90].map(pct => (
Original FTE
- {previewEfficiency > 0 && ( + {(previewEfficiency !== 0 || selectedProfileId) && (
{(teamShape as any).adjusted_fte?.toFixed(2) || teamShape.total_fte.toFixed(2)}
-
Adjusted FTE ({previewEfficiency}%)
+
Adjusted FTE
)} - {previewEfficiency > 0 && ( + {(previewEfficiency !== 0 || selectedProfileId) && (
{(teamShape as any).fte_saved?.toFixed(2) || '0'}
FTE Saved
@@ -599,8 +728,8 @@ export default function ProjectView() {
Programme FTE
-
{(previewEfficiency > 0 ? (teamShape as any).adjusted_hours : teamShape.total_hours)?.toLocaleString()}
-
{previewEfficiency > 0 ? 'Adjusted' : 'Total'} Hours
+
{((previewEfficiency !== 0 || selectedProfileId) ? (teamShape as any).adjusted_hours : teamShape.total_hours)?.toLocaleString()}
+
{(previewEfficiency !== 0 || selectedProfileId) ? 'Adjusted' : 'Total'} Hours
@@ -618,6 +747,7 @@ export default function ProjectView() {
{/* Table */} + {(() => { const hasEfficiency = previewEfficiency !== 0 || !!selectedProfileId; return (<>
@@ -627,8 +757,9 @@ export default function ProjectView() { - {previewEfficiency > 0 && } - {previewEfficiency > 0 && } + {hasEfficiency && } + {hasEfficiency && } + {hasEfficiency && } @@ -636,9 +767,9 @@ export default function ProjectView() { {teamShape.roles.map((r: any, idx: number) => { const prevDisc = idx > 0 ? teamShape.roles[idx - 1].discipline : null; const showDiscipline = r.discipline !== prevDisc; - const displayFte = previewEfficiency > 0 ? (r.adjusted_fte ?? r.fte) : r.fte; + const displayFte = hasEfficiency ? (r.adjusted_fte ?? r.fte) : r.fte; const headcount = displayFte >= 0.5 ? Math.ceil(displayFte) : displayFte > 0 ? 0.5 : 0; - const cols = previewEfficiency > 0 ? 8 : 6; + const cols = hasEfficiency ? 9 : 6; return ( {showDiscipline && ( @@ -654,10 +785,13 @@ export default function ProjectView() { - {previewEfficiency > 0 && ( + {hasEfficiency && ( + + )} + {hasEfficiency && ( )} - {previewEfficiency > 0 && ( + {hasEfficiency && ( @@ -670,6 +804,7 @@ export default function ProjectView() {
Type Hours FTEAdj HoursAdj FTEEff %Adj HoursAdj FTEHeadcount
{r.total_hours.toFixed(2)} {r.fte.toFixed(2)}{(r.efficiency_pct ?? 0).toFixed(0)}%{(r.adjusted_hours ?? r.total_hours).toFixed(2)} 0 ? 'td-fte-highlight' : ''}`}> {(r.adjusted_fte ?? r.fte).toFixed(2)}
+ ); })()} )}