P1: Frontend profile/tool selector + match feedback loop

- Team Shape tab: profile selector (Conservative/Moderate/Aggressive)
- BTG tool toggles (Pencil, OMG, Creative X, Cortex, Semblance, Share of Model)
- Per-discipline rates shown inline with combined profile+tool percentages
- Efficiency % column in table showing rate per role
- Flat rate fallback still available (10/25/50/75/90%)
- Match feedback endpoint: POST /matches/{id}/feedback (confirm/reject)
- Feedback learning: confirmed matches stored, checked before AI calls
- Known matches applied instantly (no API call, $0 cost)
- Remaining unknowns sent to Claude as before

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-09 14:04:49 -04:00
parent 0aacb2bb4c
commit ecaa5012d9
5 changed files with 265 additions and 28 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -24,6 +24,20 @@ interface TeamShape {
roles: TeamRole[];
}
interface EffProfile {
id: number;
name: string;
is_default: boolean;
rates: Record<string, number>;
}
interface EffTool {
id: number;
tool_name: string;
tool_description: string;
rates: Record<string, number>;
}
const CONF_CLASS: Record<string, string> = {
exact: 'conf-exact',
close: 'conf-close',
@ -48,6 +62,10 @@ export default function ProjectView() {
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
const [previewEfficiency, setPreviewEfficiency] = useState(0);
const [selectedEfficiencyLevels, setSelectedEfficiencyLevels] = useState<Set<number>>(new Set());
const [profiles, setProfiles] = useState<EffProfile[]>([]);
const [tools, setTools] = useState<EffTool[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState<number | null>(null);
const [selectedToolIds, setSelectedToolIds] = useState<Set<number>>(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<HTMLInputElement>) {
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 */}
<div className="efficiency-controls">
<div className="efficiency-preview">
<span className="efficiency-label">Preview AI Efficiency:</span>
<div className="efficiency-row">
<span className="efficiency-label">Efficiency Profile:</span>
<div className="efficiency-buttons">
{[0, 10, 25, 50, 75, 90].map(pct => (
<button
onClick={() => { setSelectedProfileId(null); loadTeamWithEfficiency(0); }}
className={`eff-btn ${!selectedProfileId && previewEfficiency === 0 ? 'eff-btn-active' : ''}`}
>None</button>
{profiles.map(p => (
<button
key={pct}
onClick={() => loadTeamWithEfficiency(pct)}
className={`eff-btn ${previewEfficiency === pct ? 'eff-btn-active' : ''}`}
key={p.id}
onClick={() => setSelectedProfileId(p.id)}
className={`eff-btn ${selectedProfileId === p.id ? 'eff-btn-active' : ''}`}
>
{pct === 0 ? 'None' : `${pct}%`}
{p.name}
</button>
))}
</div>
</div>
<div className="efficiency-export">
<span className="efficiency-label">Include in Excel export:</span>
{selectedProfileId && (
<div className="efficiency-row">
<span className="efficiency-label">BTG Tools:</span>
<div className="efficiency-buttons">
{tools.map(t => (
<button
key={t.id}
onClick={() => toggleTool(t.id)}
className={`eff-btn ${selectedToolIds.has(t.id) ? 'eff-btn-selected' : ''}`}
title={t.tool_description}
>
{t.tool_name}
</button>
))}
</div>
</div>
)}
{selectedProfileId && (
<div className="efficiency-row">
<span className="efficiency-label">Per-discipline rates:</span>
<div className="discipline-rates">
{(() => {
const profile = profiles.find(p => p.id === selectedProfileId);
if (!profile) return null;
// Combine profile + tool rates
const combined: Record<string, number> = { ...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]) => (
<span key={disc} className="disc-rate-tag">
{disc}: <strong>{pct}%</strong>
</span>
));
})()}
</div>
</div>
)}
<div className="efficiency-row">
<span className="efficiency-label">Or flat rate:</span>
<div className="efficiency-buttons">
{[10, 25, 50, 75, 90].map(pct => (
<button
key={pct}
onClick={() => { setSelectedProfileId(null); loadTeamWithEfficiency(pct); }}
className={`eff-btn ${!selectedProfileId && previewEfficiency === pct ? 'eff-btn-active' : ''}`}
>
{pct}%
</button>
))}
</div>
</div>
<div className="efficiency-row">
<span className="efficiency-label">Excel export tabs:</span>
<div className="efficiency-buttons">
{[10, 25, 50, 75, 90].map(pct => (
<button
@ -578,13 +707,13 @@ export default function ProjectView() {
<div className="team-stat-value">{teamShape.total_fte.toFixed(2)}</div>
<div className="team-stat-label">Original FTE</div>
</div>
{previewEfficiency > 0 && (
{(previewEfficiency !== 0 || selectedProfileId) && (
<div className="team-stat team-stat-highlight">
<div className="team-stat-value">{(teamShape as any).adjusted_fte?.toFixed(2) || teamShape.total_fte.toFixed(2)}</div>
<div className="team-stat-label">Adjusted FTE ({previewEfficiency}%)</div>
<div className="team-stat-label">Adjusted FTE</div>
</div>
)}
{previewEfficiency > 0 && (
{(previewEfficiency !== 0 || selectedProfileId) && (
<div className="team-stat team-stat-saved">
<div className="team-stat-value">{(teamShape as any).fte_saved?.toFixed(2) || '0'}</div>
<div className="team-stat-label">FTE Saved</div>
@ -599,8 +728,8 @@ export default function ProjectView() {
<div className="team-stat-label">Programme FTE</div>
</div>
<div className="team-stat">
<div className="team-stat-value">{(previewEfficiency > 0 ? (teamShape as any).adjusted_hours : teamShape.total_hours)?.toLocaleString()}</div>
<div className="team-stat-label">{previewEfficiency > 0 ? 'Adjusted' : 'Total'} Hours</div>
<div className="team-stat-value">{((previewEfficiency !== 0 || selectedProfileId) ? (teamShape as any).adjusted_hours : teamShape.total_hours)?.toLocaleString()}</div>
<div className="team-stat-label">{(previewEfficiency !== 0 || selectedProfileId) ? 'Adjusted' : 'Total'} Hours</div>
</div>
</div>
@ -618,6 +747,7 @@ export default function ProjectView() {
</div>
{/* Table */}
{(() => { const hasEfficiency = previewEfficiency !== 0 || !!selectedProfileId; return (<>
<div className="table-wrap">
<table className="rc-table">
<thead>
@ -627,8 +757,9 @@ export default function ProjectView() {
<th>Type</th>
<th className="text-right">Hours</th>
<th className="text-right">FTE</th>
{previewEfficiency > 0 && <th className="text-right">Adj Hours</th>}
{previewEfficiency > 0 && <th className="text-right">Adj FTE</th>}
{hasEfficiency && <th className="text-right">Eff %</th>}
{hasEfficiency && <th className="text-right">Adj Hours</th>}
{hasEfficiency && <th className="text-right">Adj FTE</th>}
<th className="text-right">Headcount</th>
</tr>
</thead>
@ -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 (
<React.Fragment key={r.role_id}>
{showDiscipline && (
@ -654,10 +785,13 @@ export default function ProjectView() {
</td>
<td className="text-right">{r.total_hours.toFixed(2)}</td>
<td className="text-right">{r.fte.toFixed(2)}</td>
{previewEfficiency > 0 && (
{hasEfficiency && (
<td className="text-right td-eff-pct">{(r.efficiency_pct ?? 0).toFixed(0)}%</td>
)}
{hasEfficiency && (
<td className="text-right">{(r.adjusted_hours ?? r.total_hours).toFixed(2)}</td>
)}
{previewEfficiency > 0 && (
{hasEfficiency && (
<td className={`text-right ${r.fte_saved > 0 ? 'td-fte-highlight' : ''}`}>
{(r.adjusted_fte ?? r.fte).toFixed(2)}
</td>
@ -670,6 +804,7 @@ export default function ProjectView() {
</tbody>
</table>
</div>
</>); })()}
</>
)}
</div>