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:
parent
0aacb2bb4c
commit
ecaa5012d9
5 changed files with 265 additions and 28 deletions
|
|
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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})")
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue