Per-stage AI cost badges + Stage 10 efficiency UI + Stage 8 fix

Three things in one commit because they share the same plumbing.

1) PER-STAGE COST DISPLAY
Migration 0006 adds cost_usd / input_tokens / output_tokens columns to
stage_artifacts. Every Claude-driven agent now stamps its own run cost
on the artifact it produces:
  - intake_agent (Stage 1)
  - diagnosis_agent (Stage 2)
  - asset_normalizer (Stage 6)
  - ai_matching (Stage 7) — accumulates across the per-asset loop and
    persists a NEW summary artifact (artifact_type='matching_run')
    with the run totals + assets_matched + matches_created counts
  - delivery_model_agent (Stage 9)
  - capability_gap_agent (Stage 12)
  - support_docs_agent (Stage 13)
A new <AgentRunCost> component renders a compact pill (label, cost,
in/out tokens) sourced from the most-recent stage artifact. Embedded
in the header of every Claude-driven stage panel: 1, 2, 6, 7, 9, 12, 13.
Per-deal cumulative cost still on the Stage 8 stats card.

2) STAGE 10 FRONTEND
Wires the Stage 10 efficiency-profile endpoint shipped in 2eb0422 to
a real UI (was placeholder before). Scenario picker (conservative /
moderate / aggressive), blanket slider, per-discipline overrides
(disabled when blanket > 0), tools-applied chips, free-text notes,
live impact preview that hits team-shape with the active settings,
and a Save button that persists to the artifact. Hydrates on mount
from the most recent saved profile.

3) STAGE 8 EMPTY STATE
"No ratecard yet" is now smart: when there ARE selected matches at
Stage 7 it surfaces the count + a primary "Build ratecard from N
matches" action, instead of telling you to do something you've
already done.

Smoke-tested: opp #2 ratecard rebuilt (35 lines); Stage 9 delivery
agent re-run shows $0.0437 / 4994 in / 1915 out on the badge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-27 14:38:50 -04:00
parent 96b8e2dc3d
commit b41e3999c6
21 changed files with 537 additions and 17 deletions

View file

@ -0,0 +1,40 @@
"""artifact cost columns
Revision ID: 0006_artifact_cost
Revises: 0005_assets_match_rc
Create Date: 2026-04-27
Adds per-artifact AI cost tracking so each Claude-driven stage surfaces
its run cost in the UI alongside the result. Existing rows get 0.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0006_artifact_cost"
down_revision: Union[str, None] = "0005_assets_match_rc"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"stage_artifacts",
sa.Column("cost_usd", sa.Numeric(10, 6), nullable=False, server_default="0"),
)
op.add_column(
"stage_artifacts",
sa.Column("input_tokens", sa.Integer(), nullable=False, server_default="0"),
)
op.add_column(
"stage_artifacts",
sa.Column("output_tokens", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("stage_artifacts", "output_tokens")
op.drop_column("stage_artifacts", "input_tokens")
op.drop_column("stage_artifacts", "cost_usd")

View file

@ -3,7 +3,7 @@
import enum
from datetime import datetime
from sqlalchemy import String, Text, Integer, DateTime, Enum, ForeignKey, UniqueConstraint, Index, JSON
from sqlalchemy import String, Text, Integer, Numeric, DateTime, Enum, ForeignKey, UniqueConstraint, Index, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
@ -74,6 +74,12 @@ class StageArtifact(Base):
artifact_type: Mapped[str] = mapped_column(String(100), nullable=False)
content_json: Mapped[dict | None] = mapped_column(JSON)
file_path: Mapped[str | None] = mapped_column(String(500))
# Per-artifact AI cost — populated by the agent that produced this artifact.
# Stages without a Claude call (qualification scorecard, ratecard build,
# pitch deck stub) leave these at 0.
cost_usd: Mapped[float] = mapped_column(Numeric(10, 6), default=0, nullable=False)
input_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
output_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
opportunity: Mapped["Opportunity"] = relationship(back_populates="artifacts")

View file

@ -25,6 +25,9 @@ class StageArtifactOut(BaseModel):
artifact_type: str
content_json: dict | None
file_path: str | None
cost_usd: float = 0
input_tokens: int = 0
output_tokens: int = 0
created_at: datetime
class Config:

View file

@ -167,6 +167,11 @@ async def match_opportunity_assets(
brief_context = await _load_brief_context(db, opportunity)
all_matches: list[Match] = []
# Accumulate cost across all per-asset Claude calls for the artifact stamp.
run_cost_usd = 0.0
run_input_tokens = 0
run_output_tokens = 0
for snap in snapshots:
tier_hint = ""
if snap["client_tier"]:
@ -199,6 +204,9 @@ async def match_opportunity_assets(
opportunity.ai_output_tokens = (opportunity.ai_output_tokens or 0) + (usage.get("output_tokens", 0) or 0)
opportunity.ai_cost_usd = float(opportunity.ai_cost_usd or 0) + float(usage.get("cost_usd", 0) or 0)
opportunity.ai_call_count = (opportunity.ai_call_count or 0) + 1
run_cost_usd += float(usage.get("cost_usd", 0) or 0)
run_input_tokens += int(usage.get("input_tokens", 0) or 0)
run_output_tokens += int(usage.get("output_tokens", 0) or 0)
result = extract_tool_result(response) or {}
raw_matches = result.get("matches") or []
@ -241,5 +249,23 @@ async def match_opportunity_assets(
await db.commit()
await db.refresh(opportunity)
logger.info(f"Match: {len(all_matches)} matches across {len(snapshots)} client assets")
# Stamp a stage_artifact for the matching run so the Stage 7 panel shows
# the run cost the same way every other agent stage does.
summary_artifact = StageArtifact(
opportunity_id=opportunity.id,
stage_number=7,
artifact_type="matching_run",
content_json={
"assets_matched": len(snapshots),
"matches_created": len(all_matches),
"auto_selected": sum(1 for m in all_matches if m.is_selected),
},
cost_usd=run_cost_usd,
input_tokens=run_input_tokens,
output_tokens=run_output_tokens,
)
db.add(summary_artifact)
await db.commit()
logger.info(f"Match: {len(all_matches)} matches across {len(snapshots)} client assets, ${run_cost_usd:.4f}")
return all_matches

View file

@ -133,12 +133,15 @@ async def run_normalize(db: AsyncSession, opportunity: Opportunity) -> dict:
opportunity.ai_cost_usd = float(opportunity.ai_cost_usd or 0) + float(usage.get("cost_usd", 0) or 0)
opportunity.ai_call_count = (opportunity.ai_call_count or 0) + 1
# Save artifact
# Save artifact (with per-run cost stamp).
artifact = StageArtifact(
opportunity_id=opportunity.id,
stage_number=6,
artifact_type="normalized_assets",
content_json=result,
cost_usd=float(usage.get("cost_usd", 0) or 0),
input_tokens=int(usage.get("input_tokens", 0) or 0),
output_tokens=int(usage.get("output_tokens", 0) or 0),
)
db.add(artifact)

View file

@ -113,6 +113,9 @@ async def run_capability_gaps(db: AsyncSession, opportunity: Opportunity) -> dic
stage_number=12,
artifact_type="capability_gaps",
content_json=result,
cost_usd=float(usage.get("cost_usd", 0) or 0),
input_tokens=int(usage.get("input_tokens", 0) or 0),
output_tokens=int(usage.get("output_tokens", 0) or 0),
)
db.add(artifact)
await db.flush()

View file

@ -124,6 +124,9 @@ async def run_delivery_model(db: AsyncSession, opportunity: Opportunity) -> dict
stage_number=9,
artifact_type="delivery_model",
content_json=result,
cost_usd=float(usage.get("cost_usd", 0) or 0),
input_tokens=int(usage.get("input_tokens", 0) or 0),
output_tokens=int(usage.get("output_tokens", 0) or 0),
)
db.add(artifact)
await db.flush()

View file

@ -182,12 +182,15 @@ async def run_diagnosis(db: AsyncSession, opportunity: Opportunity) -> dict:
opportunity.ai_cost_usd = float(opportunity.ai_cost_usd or 0) + float(usage.get("cost_usd", 0) or 0)
opportunity.ai_call_count = (opportunity.ai_call_count or 0) + 1
# Persist as artifact
# Persist as artifact (with per-run cost stamp).
artifact = StageArtifact(
opportunity_id=opportunity.id,
stage_number=2,
artifact_type="brief_diagnosis",
content_json=diagnosis,
cost_usd=float(usage.get("cost_usd", 0) or 0),
input_tokens=int(usage.get("input_tokens", 0) or 0),
output_tokens=int(usage.get("output_tokens", 0) or 0),
)
db.add(artifact)

View file

@ -134,12 +134,15 @@ async def run_intake(db: AsyncSession, opportunity: Opportunity) -> dict:
opportunity.ai_cost_usd = float(opportunity.ai_cost_usd or 0) + float(usage.get("cost_usd", 0) or 0)
opportunity.ai_call_count = (opportunity.ai_call_count or 0) + 1
# Persist as a stage artifact
# Persist as a stage artifact (with per-run cost stamp).
artifact = StageArtifact(
opportunity_id=opportunity.id,
stage_number=1,
artifact_type="intake_metadata",
content_json=metadata,
cost_usd=float(usage.get("cost_usd", 0) or 0),
input_tokens=int(usage.get("input_tokens", 0) or 0),
output_tokens=int(usage.get("output_tokens", 0) or 0),
)
db.add(artifact)

View file

@ -153,6 +153,9 @@ async def run_support_docs(db: AsyncSession, opportunity: Opportunity) -> dict:
stage_number=13,
artifact_type="support_docs",
content_json=result,
cost_usd=float(usage.get("cost_usd", 0) or 0),
input_tokens=int(usage.get("input_tokens", 0) or 0),
output_tokens=int(usage.get("output_tokens", 0) or 0),
)
db.add(artifact)
await db.flush()

View file

@ -0,0 +1,55 @@
import { StageArtifact } from '../types';
/**
* Small cost badge that surfaces the AI cost incurred by an agent run,
* pulled from the latest artifact for that stage. Renders compactly in a
* stage panel header so users see the per-run cost alongside the result.
*/
export default function AgentRunCost({
artifact,
label = 'This run',
}: {
artifact: StageArtifact | undefined | null;
label?: string;
}) {
if (!artifact) return null;
const cost = artifact.cost_usd ?? 0;
const inTok = artifact.input_tokens ?? 0;
const outTok = artifact.output_tokens ?? 0;
if (cost === 0 && inTok === 0 && outTok === 0) return null;
return (
<span style={pillStyle} title={`Run at ${new Date(artifact.created_at).toLocaleString()}`}>
<span style={labelStyle}>{label}</span>
<span style={costStyle}>${cost.toFixed(4)}</span>
<span style={tokensStyle}>{inTok.toLocaleString()} in · {outTok.toLocaleString()} out</span>
</span>
);
}
const pillStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '4px 10px',
borderRadius: 12,
background: 'rgba(255,196,7,0.06)',
border: '1px solid rgba(255,196,7,0.20)',
fontSize: 11,
whiteSpace: 'nowrap',
};
const labelStyle: React.CSSProperties = {
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
fontWeight: 600,
};
const costStyle: React.CSSProperties = {
fontFamily: 'ui-monospace, SFMono-Regular, monospace',
color: 'var(--color-accent)',
fontWeight: 700,
};
const tokensStyle: React.CSSProperties = {
color: 'var(--color-text-muted)',
fontFamily: 'ui-monospace, SFMono-Regular, monospace',
};

View file

@ -0,0 +1,321 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import api from '../api/client';
import { useTeamShape } from '../api/teamShape';
interface Props {
opportunityId: number;
canEdit: boolean;
}
interface EfficiencyProfileContent {
scenario?: 'conservative' | 'moderate' | 'aggressive';
blanket_pct?: number;
discipline_overrides?: Record<string, number>;
tools_applied?: string[];
notes?: string;
}
interface EfficiencyProfileWrapper {
artifact_id: number;
saved_at: string;
content: EfficiencyProfileContent;
}
const SCENARIO_HINTS: Record<string, string> = {
conservative: '515% per discipline · safe ramp',
moderate: '2040% per discipline · steady state',
aggressive: '50%+ on adapt-heavy disciplines · proven tools live',
};
export default function Stage10Efficiency({ opportunityId, canEdit }: Props) {
const qc = useQueryClient();
const { data: existing, isLoading: existingLoading } = useQuery({
queryKey: ['efficiency-profile', opportunityId],
queryFn: async (): Promise<EfficiencyProfileWrapper | null> => {
const res = await api.get(`/opportunities/${opportunityId}/efficiency-profile`);
return res.data;
},
enabled: opportunityId > 0,
});
const [scenario, setScenario] = useState<'conservative' | 'moderate' | 'aggressive'>('moderate');
const [blanket, setBlanket] = useState(0);
const [overrides, setOverrides] = useState<Record<string, number>>({});
const [tools, setTools] = useState<string[]>([]);
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
const [savedAt, setSavedAt] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
// Hydrate on first load of existing profile
useEffect(() => {
if (existing?.content) {
const c = existing.content;
setScenario(c.scenario ?? 'moderate');
setBlanket(c.blanket_pct ?? 0);
setOverrides(c.discipline_overrides ?? {});
setTools(c.tools_applied ?? []);
setNotes(c.notes ?? '');
}
}, [existing?.artifact_id]);
// Compute live preview using the team-shape endpoint
const useOverrides = Object.keys(overrides).length > 0;
const { data: teamShape } = useTeamShape(opportunityId, useOverrides ? 0 : blanket, useOverrides ? overrides : undefined);
const disciplines = useMemo(() => {
const set = new Set<string>();
(teamShape?.roles ?? []).forEach((r) => set.add(r.discipline));
return Array.from(set).sort();
}, [teamShape?.roles]);
const TOOL_OPTIONS = ['Pencil', 'Creative-X', 'Semblance', 'OMG', 'Google Vids', 'Synthesia', 'Adobe Firefly'];
async function save() {
setSaving(true);
setError(null);
try {
await api.post(`/opportunities/${opportunityId}/efficiency-profile`, {
scenario,
blanket_pct: blanket,
discipline_overrides: overrides,
tools_applied: tools,
notes: notes || null,
});
qc.invalidateQueries({ queryKey: ['efficiency-profile', opportunityId] });
setSavedAt(Date.now());
setTimeout(() => setSavedAt(null), 2500);
} catch (err: any) {
setError(err?.response?.data?.detail ?? err?.message ?? 'Save failed');
} finally {
setSaving(false);
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<section style={cardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16 }}>
<div>
<h3 style={titleStyle}>Efficiency profile</h3>
<p style={hintStyle}>
Defines how much we expect AI tooling and process gains to reduce role hours. Stage 11 reads this profile
when calculating FTE. Programme roles never see efficiency cuts; per-discipline overrides cap at 90%.
</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
<button onClick={save} disabled={!canEdit || saving} style={primaryBtnStyle}>
{saving ? 'Saving…' : (existing ? 'Update profile' : 'Save profile')}
</button>
{savedAt && <span style={{ color: '#86efac', fontSize: 11 }}> Saved</span>}
{existing && (
<span style={{ color: 'var(--color-text-muted)', fontSize: 11 }}>
Last saved {new Date(existing.saved_at).toLocaleString()}
</span>
)}
</div>
</div>
{error && <div style={errorStyle}>{error}</div>}
</section>
<section style={cardStyle}>
<h4 style={subTitleStyle}>Scenario</h4>
<p style={hintStyle}>Pick a band; tweak the per-discipline sliders below for finer control.</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8, marginTop: 8 }}>
{(['conservative', 'moderate', 'aggressive'] as const).map((s) => (
<button
key={s}
onClick={() => setScenario(s)}
disabled={!canEdit}
style={{
...scenarioBtnStyle,
...(scenario === s ? scenarioBtnActiveStyle : {}),
}}
>
<div style={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: 11 }}>{s}</div>
<div style={{ marginTop: 4, color: 'var(--color-text-muted)', fontSize: 11 }}>{SCENARIO_HINTS[s]}</div>
</button>
))}
</div>
</section>
<section style={cardStyle}>
<h4 style={subTitleStyle}>Blanket efficiency</h4>
<p style={hintStyle}>
Single number applied across all delivery roles. Disabled when any per-discipline override is set.
</p>
<label style={{ display: 'block', marginTop: 12 }}>
<span style={{ ...metaLabelStyle, marginBottom: 6 }}>{blanket}%</span>
<input
type="range" min={0} max={90} step={5}
value={blanket}
onChange={(e) => setBlanket(parseInt(e.target.value, 10))}
disabled={!canEdit || useOverrides}
style={sliderStyle}
/>
</label>
</section>
<section style={cardStyle}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
<h4 style={subTitleStyle}>Per-discipline overrides</h4>
{useOverrides && (
<button onClick={() => setOverrides({})} style={ghostBtnStyle}>Clear all</button>
)}
</div>
<p style={hintStyle}>Per-discipline takes precedence over the blanket. Capped at 90% (one role can't be 100% AI).</p>
{disciplines.length === 0 && (
<p style={emptyStyle}>No disciplines yet build the ratecard at Stage 8 first.</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12, marginTop: 12 }}>
{disciplines.map((disc) => {
const v = overrides[disc] ?? 0;
return (
<div key={disc} style={overrideCardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 4 }}>
<span style={{ fontSize: 12, fontWeight: 600 }}>{disc}</span>
<span style={{ fontFamily: 'ui-monospace, monospace', color: v > 0 ? 'var(--color-accent)' : 'var(--color-text-muted)' }}>
{v}%
</span>
</div>
<input
type="range" min={0} max={90} step={5}
value={v}
disabled={!canEdit}
onChange={(e) => {
const pct = parseInt(e.target.value, 10);
if (pct === 0) {
const next = { ...overrides };
delete next[disc];
setOverrides(next);
} else {
setOverrides({ ...overrides, [disc]: pct });
}
}}
style={sliderStyle}
/>
</div>
);
})}
</div>
</section>
<section style={cardStyle}>
<h4 style={subTitleStyle}>Tools applied</h4>
<p style={hintStyle}>For audit only Stage 11 doesn't auto-derive efficiency from tools yet, but recording them makes the assumption legible.</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{TOOL_OPTIONS.map((t) => {
const active = tools.includes(t);
return (
<button
key={t}
onClick={() => {
if (!canEdit) return;
setTools(active ? tools.filter((x) => x !== t) : [...tools, t]);
}}
disabled={!canEdit}
style={{ ...chipBtnStyle, ...(active ? chipBtnActiveStyle : {}) }}
>
{t}
</button>
);
})}
</div>
</section>
<section style={cardStyle}>
<h4 style={subTitleStyle}>Notes</h4>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Anchor the assumptions — e.g. 'Year-1 ramp; aggressive on social statics; manual on motion'"
disabled={!canEdit}
rows={3}
style={textareaStyle}
/>
</section>
{teamShape && teamShape.roles.length > 0 && (
<section style={cardStyle}>
<h4 style={subTitleStyle}>Live impact preview</h4>
<p style={hintStyle}>What Stage 11 will show with this profile applied (does NOT save until you click "Save profile").</p>
<div style={statsRowStyle}>
<Stat label="Total hrs" value={teamShape.total_hours.toLocaleString()} />
<Stat label="Total FTE" value={teamShape.total_fte.toFixed(2)} />
<Stat label="Adjusted hrs" value={teamShape.adjusted_hours.toLocaleString()} fg="#86efac" />
<Stat label="Adjusted FTE" value={teamShape.adjusted_fte.toFixed(2)} fg="#86efac" />
<Stat label="Hours saved" value={teamShape.hours_saved.toLocaleString()} fg={teamShape.hours_saved > 0 ? '#86efac' : undefined} />
</div>
</section>
)}
{existingLoading && <div style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>Loading saved profile</div>}
</div>
);
}
function Stat({ label, value, fg }: { label: string; value: number | string; fg?: string }) {
return (
<div>
<div style={{ fontFamily: 'ui-monospace, monospace', fontWeight: 700, fontSize: 22, color: fg || 'var(--color-text)' }}>{value}</div>
<div style={metaLabelStyle}>{label}</div>
</div>
);
}
const cardStyle: React.CSSProperties = {
background: 'var(--color-bg-card)', border: '1px solid var(--color-border)',
borderRadius: 10, padding: 18,
};
const titleStyle: React.CSSProperties = { margin: 0, fontSize: 15, fontWeight: 600 };
const subTitleStyle: React.CSSProperties = { margin: 0, fontSize: 13, fontWeight: 600 };
const hintStyle: React.CSSProperties = { marginTop: 4, marginBottom: 0, color: 'var(--color-text-muted)', fontSize: 12 };
const emptyStyle: React.CSSProperties = { color: 'var(--color-text-muted)', fontSize: 13, marginTop: 8 };
const statsRowStyle: React.CSSProperties = { display: 'flex', gap: 28, padding: '8px 4px', flexWrap: 'wrap' };
const metaLabelStyle: React.CSSProperties = {
display: 'block', fontSize: 11, color: 'var(--color-text-muted)',
textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600,
};
const sliderStyle: React.CSSProperties = { width: '100%', accentColor: 'var(--color-accent)', marginTop: 4 };
const primaryBtnStyle: React.CSSProperties = {
background: '#FFC407', color: '#0e0f13', border: 'none',
padding: '10px 18px', borderRadius: 8, fontWeight: 600, fontSize: 13, cursor: 'pointer',
};
const ghostBtnStyle: React.CSSProperties = {
background: 'transparent', color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border)', padding: '6px 10px', borderRadius: 6,
fontWeight: 500, fontSize: 11, cursor: 'pointer',
};
const errorStyle: React.CSSProperties = {
marginTop: 12, background: 'rgba(239,68,68,0.10)',
border: '1px solid rgba(239,68,68,0.3)', color: '#fca5a5',
padding: '10px 12px', borderRadius: 8, fontSize: 13,
};
const scenarioBtnStyle: React.CSSProperties = {
background: 'var(--color-bg-input)', color: 'var(--color-text)',
border: '1px solid var(--color-border-light)', padding: 14, borderRadius: 8,
textAlign: 'left', cursor: 'pointer', transition: 'border-color 120ms ease',
};
const scenarioBtnActiveStyle: React.CSSProperties = {
border: '1px solid var(--color-accent)', background: 'rgba(255,196,7,0.06)',
};
const overrideCardStyle: React.CSSProperties = {
padding: 12, background: 'var(--color-bg-input)',
border: '1px solid var(--color-border-light)', borderRadius: 8,
};
const chipBtnStyle: React.CSSProperties = {
background: 'var(--color-bg-input)', color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border-light)', padding: '6px 12px', borderRadius: 14,
fontSize: 12, cursor: 'pointer',
};
const chipBtnActiveStyle: React.CSSProperties = {
background: 'rgba(255,196,7,0.12)', color: 'var(--color-accent)',
border: '1px solid var(--color-accent)',
};
const textareaStyle: React.CSSProperties = {
marginTop: 6, width: '100%', padding: 10,
background: 'var(--color-bg-input)',
border: '1px solid var(--color-border)', borderRadius: 6,
color: 'var(--color-text)', fontSize: 13, fontFamily: 'inherit', resize: 'vertical',
};

View file

@ -3,6 +3,7 @@ import { useStageArtifacts } from '../api/opportunities';
import { opportunitiesKeys } from '../api/opportunities';
import api from '../api/client';
import { useQueryClient } from '@tanstack/react-query';
import AgentRunCost from './AgentRunCost';
interface Props {
opportunityId: number;
@ -60,7 +61,10 @@ export default function Stage12CapabilityGaps({ opportunityId, canEdit }: Props)
<section style={cardStyle}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
<div>
<h3 style={titleStyle}>Capability gaps</h3>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
<h3 style={titleStyle}>Capability gaps</h3>
<AgentRunCost artifact={latest} />
</div>
<p style={hintStyle}>
Identifies work outside OLIVER core capability and recommends where to source it
(internal SME / Brandtech partner / external vendor).

View file

@ -2,6 +2,7 @@ import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useStageArtifacts, opportunitiesKeys } from '../api/opportunities';
import api from '../api/client';
import AgentRunCost from './AgentRunCost';
interface Props {
opportunityId: number;
@ -52,7 +53,10 @@ export default function Stage13SupportDocs({ opportunityId, canEdit }: Props) {
<section style={cardStyle}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
<div>
<h3 style={titleStyle}>Caveats / SLAs / KPIs / governance</h3>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
<h3 style={titleStyle}>Caveats / SLAs / KPIs / governance</h3>
<AgentRunCost artifact={latest} />
</div>
<p style={hintStyle}>
Generates the support docs that go into the proposal so sales promises match delivery reality.
Reads the diagnosis (Stage 2), delivery model (Stage 9), and capability gaps (Stage 12).

View file

@ -7,6 +7,7 @@ import {
useStageArtifacts,
} from '../api/opportunities';
import { IntakeMetadata } from '../types';
import AgentRunCost from './AgentRunCost';
interface Props {
opportunityId: number;
@ -110,9 +111,10 @@ export default function Stage1Intake({ opportunityId, canEdit }: Props) {
</section>
<section>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 6 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 6, flexWrap: 'wrap' }}>
<h3 style={sectionTitleStyle}>Intake Agent</h3>
{metadata && <span style={pillStyle}>Result on file</span>}
<AgentRunCost artifact={latestIntake} />
</div>
<p style={hintStyle}>
Run Claude over every uploaded document to extract client, region, brands, service types and key dates.

View file

@ -6,6 +6,7 @@ import {
useClarifications,
} from '../api/opportunities';
import { BriefDiagnosis, ClarificationQuestion } from '../types';
import AgentRunCost from './AgentRunCost';
interface Props {
opportunityId: number;
@ -48,9 +49,10 @@ export default function Stage2Diagnose({ opportunityId, canEdit }: Props) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<section>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 6 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 6, flexWrap: 'wrap' }}>
<h3 style={sectionTitleStyle}>Diagnosis Agent</h3>
{diagnosis && <span style={pillStyle}>Result on file</span>}
<AgentRunCost artifact={latest} />
</div>
<p style={hintStyle}>
Reads every file uploaded at Stage 1 and surfaces a structured diagnosis: deliverables, channels, markets,

View file

@ -7,6 +7,8 @@ import {
useUpdateAsset,
} from '../api/assets';
import { ClientAsset } from '../types';
import AgentRunCost from './AgentRunCost';
import { useStageArtifacts } from '../api/opportunities';
interface Props {
opportunityId: number;
@ -15,6 +17,8 @@ interface Props {
export default function Stage6Normalize({ opportunityId, canEdit }: Props) {
const { data: assets, isLoading } = useClientAssets(opportunityId);
const { data: artifacts } = useStageArtifacts(opportunityId, 6);
const latestArtifact = artifacts?.find((a) => a.artifact_type === 'normalized_assets');
const normalize = useRunNormalize(opportunityId);
const create = useCreateAsset(opportunityId);
const [error, setError] = useState<string | null>(null);
@ -37,7 +41,10 @@ export default function Stage6Normalize({ opportunityId, canEdit }: Props) {
<section style={cardStyle}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
<div>
<h3 style={titleStyle}>Asset Normalizer</h3>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
<h3 style={titleStyle}>Asset Normalizer</h3>
<AgentRunCost artifact={latestArtifact} />
</div>
<p style={hintStyle}>
Reads the uploaded files (and the Stage 2 diagnosis if present) and produces a clean, priceable
deliverables list. Re-running wipes existing assets and downstream matches/ratecard.

View file

@ -1,6 +1,8 @@
import { useMemo, useState } from 'react';
import { useClientAssets, useKickOffMatching, useMatches, useSelectMatch } from '../api/assets';
import { useStageArtifacts } from '../api/opportunities';
import { ClientAsset, Match } from '../types';
import AgentRunCost from './AgentRunCost';
interface Props {
opportunityId: number;
@ -18,6 +20,8 @@ export default function Stage7Match({ opportunityId, canEdit }: Props) {
const { data: assets } = useClientAssets(opportunityId);
const [polling, setPolling] = useState(false);
const { data: matches, isLoading } = useMatches(opportunityId, polling ? 4 : undefined);
const { data: artifacts } = useStageArtifacts(opportunityId, 7);
const latestRun = artifacts?.find((a) => a.artifact_type === 'matching_run');
const kick = useKickOffMatching(opportunityId);
const select = useSelectMatch(opportunityId);
const [error, setError] = useState<string | null>(null);
@ -59,7 +63,10 @@ export default function Stage7Match({ opportunityId, canEdit }: Props) {
<section style={cardStyle}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
<div>
<h3 style={titleStyle}>Match assets to GMAL</h3>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
<h3 style={titleStyle}>Match assets to GMAL</h3>
<AgentRunCost artifact={latestRun} label="Last run" />
</div>
<p style={hintStyle}>
Sends each ClientAsset to Claude with the full GMAL catalog and stores up to 3 candidates per asset
(top match auto-selected when score 0.8). Re-run any time after editing assets.

View file

@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { useBuildRatecard, useRatecard } from '../api/assets';
import { useMatches } from '../api/assets';
import { useOpportunity } from '../api/opportunities';
import { MODEL_TYPE_LABELS, RatecardLine } from '../types';
@ -11,9 +12,12 @@ interface Props {
export default function Stage8Ratecard({ opportunityId, canEdit }: Props) {
const { data: ratecard, isLoading } = useRatecard(opportunityId);
const { data: opp } = useOpportunity(opportunityId);
const { data: matches } = useMatches(opportunityId);
const build = useBuildRatecard(opportunityId);
const [error, setError] = useState<string | null>(null);
const selectedMatchCount = (matches ?? []).filter((m) => m.is_selected).length;
const groupedByAsset = useMemo(() => {
const map: Record<number, { name: string; volume: number; lines: RatecardLine[]; total: number }> = {};
(ratecard?.lines ?? []).forEach((l) => {
@ -73,10 +77,22 @@ export default function Stage8Ratecard({ opportunityId, canEdit }: Props) {
{!isLoading && (!ratecard || ratecard.lines.length === 0) && (
<div style={cardStyle}>
<p style={emptyStyle}>
No ratecard yet. Make sure each ClientAsset has a <strong>selected match</strong> at Stage 7,
then click "Build ratecard" above.
</p>
{selectedMatchCount > 0 ? (
<>
<p style={{ ...emptyStyle, color: 'var(--color-text-secondary)' }}>
<strong>{selectedMatchCount} selected match{selectedMatchCount === 1 ? '' : 'es'}</strong> from Stage 7
ready to build. The ratecard wipes when you re-run normalize/match click <strong>Build ratecard</strong> above to compute lines from the current selections.
</p>
<button onClick={run} disabled={!canEdit || build.isPending} style={{ ...primaryBtnStyle, marginTop: 12 }}>
{build.isPending ? 'Building…' : `Build ratecard from ${selectedMatchCount} matches`}
</button>
</>
) : (
<p style={emptyStyle}>
No ratecard yet, and no selected matches at Stage 7. Run matching at Stage 7 first, confirm a primary
match per asset, then come back and click "Build ratecard".
</p>
)}
</div>
)}

View file

@ -3,6 +3,7 @@ import { useStageArtifacts } from '../api/opportunities';
import api from '../api/client';
import { useQueryClient } from '@tanstack/react-query';
import { opportunitiesKeys } from '../api/opportunities';
import AgentRunCost from './AgentRunCost';
interface Props {
opportunityId: number;
@ -61,7 +62,10 @@ export default function Stage9DeliveryModel({ opportunityId, canEdit }: Props) {
<section style={cardStyle}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
<div>
<h3 style={titleStyle}>Delivery model</h3>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
<h3 style={titleStyle}>Delivery model</h3>
<AgentRunCost artifact={latest} />
</div>
<p style={hintStyle}>
Reads the Stage 2 diagnosis and recommends traditional / AI-supported / hybrid, with a
per-workflow-stage breakdown and tool caveats. Reqs Stage 2 to have run first.

View file

@ -11,6 +11,7 @@ import Stage6Normalize from '../components/Stage6Normalize';
import Stage7Match from '../components/Stage7Match';
import Stage8Ratecard from '../components/Stage8Ratecard';
import Stage9DeliveryModel from '../components/Stage9DeliveryModel';
import Stage10Efficiency from '../components/Stage10Efficiency';
import Stage11TeamShape from '../components/Stage11TeamShape';
import Stage12CapabilityGaps from '../components/Stage12CapabilityGaps';
import Stage13SupportDocs from '../components/Stage13SupportDocs';
@ -128,6 +129,10 @@ export default function OpportunityView() {
<Stage9DeliveryModel opportunityId={opportunityId} canEdit={stageState?.status !== 'completed'} />
)}
{activeStage === 10 && (
<Stage10Efficiency opportunityId={opportunityId} canEdit={stageState?.status !== 'completed'} />
)}
{activeStage === 11 && (
<Stage11TeamShape opportunityId={opportunityId} canEdit={stageState?.status !== 'completed'} />
)}
@ -140,7 +145,7 @@ export default function OpportunityView() {
<Stage13SupportDocs opportunityId={opportunityId} canEdit={stageState?.status !== 'completed'} />
)}
{activeStage > 8 && ![9, 11, 12, 13].includes(activeStage) && !GATED_STAGES.has(activeStage) && (
{activeStage > 8 && ![9, 10, 11, 12, 13].includes(activeStage) && !GATED_STAGES.has(activeStage) && (
<div style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>
This stage isn't built yet — the state machine runs but there's no agent, UI, or artifact persistence
specific to it. Advancing here is harmless on a test opportunity.