Custom tier editor, brief analysis feeds matching, exportable questions

1. Custom tier editor:
   - Editable label + complexity dropdown per tier row
   - Add/remove tier rows
   - Presets populate editable fields (can be customised after)

2. Brief analysis feeds into matching:
   - Summary, channels, deliverable categories, objectives passed as context
   - Claude gets brief context when matching each asset for better accuracy
   - RFP analysis now improves matching quality, not just informational

3. Discovery questions:
   - "Copy to Clipboard" button for sharing with client
   - Brief Analysis sheet added to Excel export
   - Questions exported with priority, category, rationale

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-09 16:02:11 -04:00
parent 8245ae52e2
commit 04eed9fdd6
4 changed files with 210 additions and 12 deletions

View file

@ -108,18 +108,20 @@ Guidelines:
- Be generous with scoring when the match is semantically correct even if the naming differs."""
def _match_single_asset(client_asset_name, client_asset_desc, volume, catalog_text, num_assets, tier_hint=""):
def _match_single_asset(client_asset_name, client_asset_desc, volume, catalog_text, num_assets, tier_hint="", brief_context=""):
"""Run a single match call to Claude (synchronous, for use in thread pool)."""
tier_instruction = ""
extra = ""
if tier_hint:
tier_instruction = f"\nCLIENT TIER: {tier_hint} — match to the {tier_hint} complexity variant if one exists.\n"
extra += f"\nCLIENT TIER: {tier_hint} — match to the {tier_hint} complexity variant if one exists.\n"
if brief_context:
extra += f"\nBRIEF CONTEXT:\n{brief_context}\n"
user_msg = f"""Match this client asset to the best GMAL equivalent(s):
CLIENT ASSET:
Name: {client_asset_name}
Description: {client_asset_desc or 'No description provided'}
Volume: {volume}{tier_instruction}
Volume: {volume}{extra}
FULL GMAL CATALOG ({num_assets} assets):
{catalog_text}"""
@ -147,11 +149,30 @@ async def match_client_assets(
"""
_clear_cancel(project_id)
# Load project tier mapping if set
# Load project for tier mapping and brief analysis
import json as _json
from app.models.project import Project
proj_result = await db.execute(select(Project).where(Project.id == project_id))
project = proj_result.scalar_one_or_none()
# Extract brief analysis context for matching (if available)
brief_context = ""
if project and project.brief_analysis:
try:
analysis = _json.loads(project.brief_analysis)
parts = []
if analysis.get("summary"):
parts.append(f"Brief summary: {analysis['summary']}")
if analysis.get("channels"):
parts.append(f"Channels: {', '.join(analysis['channels'])}")
if analysis.get("deliverable_categories"):
parts.append(f"Deliverable categories: {', '.join(analysis['deliverable_categories'])}")
if analysis.get("objectives"):
parts.append(f"Objectives: {', '.join(analysis['objectives'][:3])}")
brief_context = "\n".join(parts)
except _json.JSONDecodeError:
pass
tier_config = {}
if project and project.tier_mapping:
try:
@ -265,6 +286,7 @@ async def match_client_assets(
catalog_text,
len(all_gmals),
tier_hint,
brief_context,
)
futures.append((snap, future))

View file

@ -92,7 +92,12 @@ async def export_ratecard_excel(db: AsyncSession, project: Project, efficiency_l
ws2 = wb.create_sheet("Asset Detail")
await _build_asset_detail_sheet(ws2, db, project, client_assets, gmals)
# Sheet 3: Assumptions & Rates
# Sheet 3: Brief Analysis (if available)
if project.brief_analysis:
ws_brief = wb.create_sheet("Brief Analysis")
_build_brief_analysis_sheet(ws_brief, project)
# Sheet 4: Assumptions & Rates
ws_rates = wb.create_sheet("Assumptions & Rates")
_build_assumptions_sheet(ws_rates, roles, lines)
@ -272,6 +277,77 @@ async def _build_asset_detail_sheet(ws, db, project, client_assets, gmals):
ws.column_dimensions[get_column_letter(i)].width = w
BRIEF_FILL = PatternFill(start_color="1565C0", end_color="1565C0", fill_type="solid")
PRIORITY_COLORS = {
"red": Font(bold=True, color="D32F2F"),
"amber": Font(bold=True, color="F57F17"),
"green": Font(bold=True, color="2E7D32"),
}
def _build_brief_analysis_sheet(ws, project):
"""Build the brief analysis sheet from stored JSON."""
import json
try:
analysis = json.loads(project.brief_analysis)
except (json.JSONDecodeError, TypeError):
ws["A1"] = "No brief analysis available"
return
ws.cell(row=1, column=1, value=f"Brief Analysis - {project.name}").font = Font(bold=True, size=14)
row = 3
if analysis.get("summary"):
ws.cell(row=row, column=1, value="SUMMARY").font = Font(bold=True)
ws.cell(row=row, column=1).fill = BRIEF_FILL
ws.cell(row=row, column=1).font = HEADER_FONT
row += 1
ws.cell(row=row, column=1, value=analysis["summary"]).alignment = Alignment(wrap_text=True)
row += 2
for section, label in [("objectives", "OBJECTIVES"), ("channels", "CHANNELS"),
("deliverable_categories", "DELIVERABLE CATEGORIES"),
("audiences", "AUDIENCES"), ("constraints", "CONSTRAINTS")]:
items = analysis.get(section, [])
if items:
ws.cell(row=row, column=1, value=label).font = Font(bold=True)
row += 1
for item in items:
ws.cell(row=row, column=1, value=f"{item}")
row += 1
row += 1
if analysis.get("complexity_assessment"):
ws.cell(row=row, column=1, value=f"Complexity: {analysis['complexity_assessment'].upper()}").font = Font(bold=True)
row += 2
# Discovery questions
questions = analysis.get("missing_info", [])
if questions:
ws.cell(row=row, column=1, value="DISCOVERY QUESTIONS").font = Font(bold=True, size=12)
row += 1
headers = ["Priority", "Category", "Question", "Rationale"]
for col, h in enumerate(headers, 1):
ws.cell(row=row, column=col, value=h).font = HEADER_FONT
ws.cell(row=row, column=col).fill = BRIEF_FILL
row += 1
for q in questions:
priority = q.get("priority", "")
ws.cell(row=row, column=1, value=priority.upper())
if priority in PRIORITY_COLORS:
ws.cell(row=row, column=1).font = PRIORITY_COLORS[priority]
ws.cell(row=row, column=2, value=q.get("category", ""))
ws.cell(row=row, column=3, value=q.get("question", "")).alignment = Alignment(wrap_text=True)
ws.cell(row=row, column=4, value=q.get("rationale", "")).alignment = Alignment(wrap_text=True)
row += 1
ws.column_dimensions["A"].width = 15
ws.column_dimensions["B"].width = 20
ws.column_dimensions["C"].width = 60
ws.column_dimensions["D"].width = 40
ASSUMPTIONS_FILL = PatternFill(start_color="4A148C", end_color="4A148C", fill_type="solid")
INPUT_FILL = PatternFill(start_color="FFF9C4", end_color="FFF9C4", fill_type="solid")

View file

@ -671,6 +671,56 @@ span.conf-none { background: var(--color-danger); }
color: var(--color-primary);
}
.tier-editor {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.tier-editor-row {
display: flex;
align-items: center;
gap: 8px;
}
.tier-input {
width: 140px;
font-size: 12px;
padding: 6px 10px;
}
.tier-select {
width: 120px;
font-size: 12px;
padding: 6px 10px;
}
.tier-arrow {
color: var(--color-primary);
font-weight: 700;
font-size: 14px;
}
.tier-remove {
background: none;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
border-radius: 4px;
width: 24px;
height: 24px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.tier-remove:hover {
color: var(--color-danger);
border-color: var(--color-danger);
}
/* Refine Chat */
.refine-box {
background: var(--color-bg-card);

View file

@ -529,16 +529,54 @@ export default function ProjectView() {
</div>
</div>
{tierMapping.tiers.length > 0 && (
<div className="tier-tags">
<div className="tier-editor">
{tierMapping.tiers.map((t, i) => (
<span key={i} className="tier-tag">
<strong>{t.label}</strong> {t.complexity}
</span>
<div key={i} className="tier-editor-row">
<input
className="input tier-input"
value={t.label}
onChange={e => {
const updated = [...tierMapping.tiers];
updated[i] = { ...updated[i], label: e.target.value };
saveTierMapping(updated);
}}
placeholder="Label (e.g. Tier A)"
/>
<span className="tier-arrow"></span>
<select
className="input tier-select"
value={t.complexity}
onChange={e => {
const updated = [...tierMapping.tiers];
updated[i] = { ...updated[i], complexity: e.target.value };
saveTierMapping(updated);
}}
>
<option value="simple">Simple</option>
<option value="medium">Medium</option>
<option value="complex">Complex</option>
</select>
<button
className="tier-remove"
onClick={() => {
const updated = tierMapping.tiers.filter((_, idx) => idx !== i);
saveTierMapping(updated);
setTierPreset('');
}}
>×</button>
</div>
))}
<button
className="btn btn-secondary btn-sm"
style={{ marginTop: 6 }}
onClick={() => saveTierMapping([...tierMapping.tiers, { label: '', complexity: 'medium' }])}
>
+ Add Tier
</button>
</div>
)}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 8 }}>
Set this before running AI matching. The AI will extract tier labels from the client document and match each to the correct GMAL complexity variant.
Set tiers before running AI matching. The AI will extract tier labels from the client document and match each to the correct GMAL complexity variant.
</div>
</div>
@ -651,7 +689,19 @@ export default function ProjectView() {
{briefAnalysis.missing_info?.length > 0 && (
<div className="analysis-section">
<div className="analysis-label">Discovery Questions ({briefAnalysis.missing_info.length})</div>
<div className="analysis-label" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
Discovery Questions ({briefAnalysis.missing_info.length})
<button
className="btn btn-secondary btn-sm"
onClick={() => {
const text = briefAnalysis.missing_info.map((q: any, i: number) =>
`${i+1}. [${q.priority.toUpperCase()}] ${q.category}\n ${q.question}\n Rationale: ${q.rationale}`
).join('\n\n');
navigator.clipboard.writeText(text);
alert('Discovery questions copied to clipboard');
}}
>Copy to Clipboard</button>
</div>
<div className="discovery-questions">
{briefAnalysis.missing_info.map((q: any, i: number) => (
<div key={i} className={`discovery-q discovery-${q.priority}`}>