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:
parent
8245ae52e2
commit
04eed9fdd6
4 changed files with 210 additions and 12 deletions
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}`}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue