P2: Iterative prompting + RFP brief analysis engine

Iterative Prompting:
- Chat box on Match Review tab for natural language refinement
- "re-run under 70%" / "ignore zero volume" / "set all volumes to 1"
- Claude interprets instruction into structured actions
- Actions: rematch_below_threshold, rematch_specific, delete_assets, set_volume
- Re-matches affected assets automatically after refinement
- Chat log shows instruction history

RFP/Brief Analysis:
- New "Brief Analysis" tab between Upload and Match Review
- Extracts: summary, objectives, KPIs, channels, audiences, deliverable categories,
  constraints, timeline, budget, complexity assessment
- Generates prioritized discovery questions (Red/Amber/Green)
- Questions include category, rationale, and priority level
- Stored as JSON in project.brief_analysis field
- Uploaded files now saved to data dir for re-analysis
- Re-analyze button to refresh analysis

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-09 14:15:31 -04:00
parent 1dcf7c084a
commit bc778ce7af
9 changed files with 775 additions and 4 deletions

View file

@ -8,7 +8,8 @@
"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(docker compose:*)"
"Bash(docker compose:*)",
"Bash(grep -v ^\\\\)"
]
}
}

View file

@ -88,8 +88,13 @@ async def upload_client_document(
"""Upload a client document and extract assets using AI."""
project = await _get_project(project_id, db)
# Stage 1: Read file
# Stage 1: Read file and save to data dir
import os
from app.config import settings
content = await file.read()
save_path = os.path.join(settings.data_dir, file.filename)
with open(save_path, "wb") as f:
f.write(content)
project.source_filename = file.filename
project.status = ProjectStatus.PARSING
project.parse_stage = f"Uploading {file.filename}..."
@ -120,6 +125,48 @@ async def upload_client_document(
}
@router.get("/{project_id}/brief-analysis")
async def get_brief_analysis(project_id: int, db: AsyncSession = Depends(get_db)):
"""Get the structured brief analysis for a project."""
project = await _get_project(project_id, db)
if not project.brief_analysis:
return {"status": "not_analyzed"}
import json
try:
return {"status": "analyzed", "analysis": json.loads(project.brief_analysis)}
except json.JSONDecodeError:
return {"status": "error", "analysis": None}
@router.post("/{project_id}/analyze-brief")
async def analyze_brief_endpoint(project_id: int, db: AsyncSession = Depends(get_db)):
"""Run AI analysis on the uploaded document."""
from app.services.rfp_analysis import analyze_brief
from app.services.doc_parser import extract_text_from_file
project = await _get_project(project_id, db)
if not project.source_filename:
raise HTTPException(status_code=400, detail="No document uploaded yet")
# Read the file from data dir
import os
from app.config import settings
filepath = os.path.join(settings.data_dir, project.source_filename)
# Try to read from the stored file - if not available, re-extract from any recent upload
# For now, we store the extracted text in parse_stage temporarily during upload
# We need the raw text - let's store it
if not os.path.exists(filepath):
return {"status": "error", "detail": "Source file not found on disk. Re-upload the document."}
with open(filepath, "rb") as f:
content = f.read()
text, metadata = extract_text_from_file(content, project.source_filename)
analysis = await analyze_brief(db, project, text)
return {"status": "analyzed", "analysis": analysis}
@router.get("/{project_id}/client-assets", response_model=list[ClientAssetOut])
async def list_client_assets(project_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(
@ -242,6 +289,34 @@ async def cancel_matching_endpoint(project_id: int, db: AsyncSession = Depends(g
return {"detail": "Matching cancellation requested"}
@router.post("/{project_id}/refine")
async def refine_matches_endpoint(
project_id: int,
data: dict,
db: AsyncSession = Depends(get_db),
):
"""Interpret a natural language instruction to refine matches."""
from app.services.match_refiner import refine_matches
instruction = data.get("instruction", "")
if not instruction:
raise HTTPException(status_code=400, detail="No instruction provided")
result = await refine_matches(db, project_id, instruction)
# If there are assets to re-match, trigger matching for just those
if result.get("rematch_count", 0) > 0:
rematch_ids = result["rematch_asset_ids"]
ca_result = await db.execute(
select(ClientAsset).where(ClientAsset.id.in_(rematch_ids)).order_by(ClientAsset.sort_order)
)
client_assets = ca_result.scalars().all()
if client_assets:
matches = await match_client_assets(db, project_id, client_assets)
result["new_matches"] = len(matches)
return result
@router.post("/{project_id}/matches/{match_id}/feedback")
async def submit_match_feedback(
project_id: int,

View file

@ -98,6 +98,7 @@ def _project_out(project: Project, asset_count: int) -> ProjectOut:
status=project.status.value,
source_filename=project.source_filename,
parse_stage=project.parse_stage,
has_brief_analysis=bool(project.brief_analysis),
ai_input_tokens=project.ai_input_tokens or 0,
ai_output_tokens=project.ai_output_tokens or 0,
ai_cost_usd=float(project.ai_cost_usd or 0),

View file

@ -35,6 +35,7 @@ class Project(Base):
status: Mapped[ProjectStatus] = mapped_column(Enum(ProjectStatus), default=ProjectStatus.DRAFT)
source_filename: Mapped[str | None] = mapped_column(String(255))
parse_stage: Mapped[str | None] = mapped_column(String(255))
brief_analysis: Mapped[str | None] = mapped_column(Text)
ai_input_tokens: Mapped[int] = mapped_column(Integer, default=0)
ai_output_tokens: Mapped[int] = mapped_column(Integer, default=0)
ai_cost_usd: Mapped[float] = mapped_column(Numeric(10, 6), default=0)

View file

@ -25,6 +25,7 @@ class ProjectOut(BaseModel):
status: str
source_filename: str | None
parse_stage: str | None = None
has_brief_analysis: bool = False
ai_input_tokens: int = 0
ai_output_tokens: int = 0
ai_cost_usd: float = 0

View file

@ -0,0 +1,198 @@
"""Interpret natural language instructions to refine matches."""
import logging
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.gmal import GmalAsset
from app.models.project import Project, ClientAsset, Match, MatchConfidence
from app.utils.claude_client import call_claude, extract_tool_result
logger = logging.getLogger(__name__)
REFINE_TOOL = {
"name": "execute_refinement",
"description": "Execute the user's refinement instruction on the matching results.",
"input_schema": {
"type": "object",
"properties": {
"actions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["rematch_below_threshold", "rematch_specific", "delete_assets", "set_volume", "split_asset", "message"],
"description": "The action to take"
},
"threshold": {
"type": "number",
"description": "For rematch_below_threshold: the confidence threshold (0-1)"
},
"asset_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "Client asset IDs to act on"
},
"hint": {
"type": "string",
"description": "For rematch_specific: hint to guide the re-match (e.g. 'this is a video not static')"
},
"volume": {
"type": "integer",
"description": "For set_volume: new volume value"
},
"message": {
"type": "string",
"description": "For message: response text to show the user"
},
},
"required": ["action"],
},
},
},
"required": ["actions"],
},
}
SYSTEM_PROMPT = """You are a helper that interprets user instructions about creative asset matching results.
The user has a list of client assets matched to GMAL production assets. They want to refine the results.
You will receive the current state of matches and the user's instruction. Translate the instruction into structured actions.
Available actions:
- rematch_below_threshold: Re-run AI matching for all assets below a confidence threshold. Set "threshold" (0-1).
- rematch_specific: Re-match specific assets with a hint. Set "asset_ids" and "hint".
- delete_assets: Remove client assets (e.g. "ignore zero volume items"). Set "asset_ids".
- set_volume: Change volume for assets. Set "asset_ids" and "volume".
- message: If the instruction is unclear or you need to explain something. Set "message".
When the user says things like:
- "re-run anything under 70%" rematch_below_threshold with threshold 0.7
- "ignore all the zero volume ones" delete_assets for those with volume 0
- "this should be a video" rematch_specific for the referenced asset with hint
- "set all volumes to 1" set_volume for all assets
Always include a "message" action at the end summarizing what you're doing."""
async def refine_matches(
db: AsyncSession,
project_id: int,
instruction: str,
) -> dict:
"""Interpret a user instruction and apply refinements to the project's matches."""
# Load current state
assets_result = await db.execute(
select(ClientAsset).where(ClientAsset.project_id == project_id).order_by(ClientAsset.sort_order)
)
assets = assets_result.scalars().all()
matches_result = await db.execute(
select(Match, GmalAsset)
.join(GmalAsset, Match.gmal_asset_id == GmalAsset.id)
.where(Match.client_asset_id.in_([a.id for a in assets]))
.order_by(Match.client_asset_id, Match.rank)
)
matches_data = matches_result.all()
# Build context for Claude
state_lines = []
for a in assets:
asset_matches = [(m, g) for m, g in matches_data if m.client_asset_id == a.id]
selected = next((m for m, g in asset_matches if m.is_selected), None)
if selected:
gmal = next((g for m, g in asset_matches if m.id == selected.id), None)
score = float(selected.confidence_score or 0)
state_lines.append(
f" Asset ID {a.id}: \"{a.raw_name}\" (vol:{a.volume}) → {gmal.gmal_id if gmal else '?'} "
f"({selected.confidence.value} {score:.0%}) {'SELECTED' if selected.is_selected else ''}"
)
else:
state_lines.append(f" Asset ID {a.id}: \"{a.raw_name}\" (vol:{a.volume}) → NO MATCH SELECTED")
state_text = "\n".join(state_lines)
user_msg = f"""Current matching state ({len(assets)} assets):
{state_text}
User instruction: {instruction}"""
response = call_claude(
system=SYSTEM_PROMPT,
user_message=user_msg,
tools=[REFINE_TOOL],
tool_choice={"type": "tool", "name": "execute_refinement"},
max_tokens=2048,
)
result = extract_tool_result(response)
if not result or "actions" not in result:
return {"message": "Could not interpret the instruction.", "actions_taken": 0}
actions_taken = 0
messages = []
rematch_ids = []
for action in result["actions"]:
act = action["action"]
if act == "rematch_below_threshold":
threshold = action.get("threshold", 0.7)
# Find assets with selected match below threshold
for a in assets:
asset_matches = [(m, g) for m, g in matches_data if m.client_asset_id == a.id]
selected = next((m for m, g in asset_matches if m.is_selected), None)
if selected and float(selected.confidence_score or 0) < threshold:
rematch_ids.append(a.id)
elif not selected:
rematch_ids.append(a.id)
actions_taken += 1
elif act == "rematch_specific":
ids = action.get("asset_ids", [])
rematch_ids.extend(ids)
actions_taken += 1
elif act == "delete_assets":
ids = action.get("asset_ids", [])
if ids:
for aid in ids:
del_result = await db.execute(select(ClientAsset).where(ClientAsset.id == aid))
ca = del_result.scalar_one_or_none()
if ca:
await db.delete(ca)
actions_taken += 1
elif act == "set_volume":
ids = action.get("asset_ids", [])
vol = action.get("volume", 1)
for aid in ids:
vol_result = await db.execute(select(ClientAsset).where(ClientAsset.id == aid))
ca = vol_result.scalar_one_or_none()
if ca:
ca.volume = vol
actions_taken += 1
elif act == "message":
messages.append(action.get("message", ""))
# Clear existing matches for assets to be re-matched
if rematch_ids:
for aid in set(rematch_ids):
existing = await db.execute(select(Match).where(Match.client_asset_id == aid))
for m in existing.scalars().all():
await db.delete(m)
await db.commit()
return {
"message": " ".join(messages) if messages else f"Applied {actions_taken} actions.",
"actions_taken": actions_taken,
"rematch_asset_ids": list(set(rematch_ids)),
"rematch_count": len(set(rematch_ids)),
}

View file

@ -0,0 +1,135 @@
"""RFP/Brief analysis engine - extract structured requirements from client documents."""
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.project import Project
from app.utils.claude_client import call_claude, extract_tool_result
logger = logging.getLogger(__name__)
ANALYSIS_TOOL = {
"name": "save_analysis",
"description": "Save the structured RFP/brief analysis.",
"input_schema": {
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "2-3 sentence executive summary of the brief"
},
"objectives": {
"type": "array",
"items": {"type": "string"},
"description": "Key business objectives and goals"
},
"kpis": {
"type": "array",
"items": {"type": "string"},
"description": "KPIs and success metrics mentioned or implied"
},
"channels": {
"type": "array",
"items": {"type": "string"},
"description": "Marketing/media channels in scope (social, web, print, video, OOH, etc.)"
},
"audiences": {
"type": "array",
"items": {"type": "string"},
"description": "Target audiences or customer segments"
},
"deliverable_categories": {
"type": "array",
"items": {"type": "string"},
"description": "High-level categories of deliverables (e.g. 'Toolbox assets', 'Paid media', 'eCommerce')"
},
"constraints": {
"type": "array",
"items": {"type": "string"},
"description": "Constraints, requirements, or restrictions (timeline, budget, legal, brand, tech)"
},
"timeline": {
"type": "string",
"description": "Timeline or deadline information if mentioned"
},
"budget_band": {
"type": "string",
"description": "Budget range or band if mentioned"
},
"missing_info": {
"type": "array",
"items": {
"type": "object",
"properties": {
"priority": {"type": "string", "enum": ["red", "amber", "green"]},
"category": {"type": "string"},
"question": {"type": "string"},
"rationale": {"type": "string"},
},
"required": ["priority", "category", "question", "rationale"],
},
"description": "Discovery questions for missing information, prioritized Red/Amber/Green"
},
"complexity_assessment": {
"type": "string",
"enum": ["low", "medium", "high"],
"description": "Overall scope complexity"
},
"notes": {
"type": "string",
"description": "Any other observations or flags for the scoping team"
},
},
"required": ["summary", "objectives", "channels", "deliverable_categories", "missing_info", "complexity_assessment"],
},
}
SYSTEM_PROMPT = """You are an expert creative agency strategist analyzing a client RFP or brief document.
Your job is to extract structured requirements that will help the production team scope the work accurately.
Be thorough but practical. Focus on what the scoping team needs to know:
- What are they actually asking for?
- What channels and formats are involved?
- What's the volume and complexity?
- What information is MISSING that we'd need to scope accurately?
For missing_info, prioritize questions:
- RED (must-have): Without this, we cannot scope accurately (e.g. primary KPIs, data access, volumes)
- AMBER (important): Needed for accurate sizing (e.g. budget, timeline, audience segments)
- GREEN (nice-to-have): Would improve the scope but not a blocker (e.g. brand guidelines, vendor preferences)
Be specific in your questions - reference the actual document content."""
async def analyze_brief(db: AsyncSession, project: Project, document_text: str) -> dict:
"""Analyze a client brief/RFP and extract structured requirements.
Returns the analysis dict and saves it to the project.
"""
response = call_claude(
system=SYSTEM_PROMPT,
user_message=f"Analyze this client brief/RFP and extract structured requirements:\n\n{document_text}",
tools=[ANALYSIS_TOOL],
tool_choice={"type": "tool", "name": "save_analysis"},
max_tokens=8192,
)
usage = getattr(response, '_usage_info', {})
project.ai_input_tokens = (project.ai_input_tokens or 0) + usage.get("input_tokens", 0)
project.ai_output_tokens = (project.ai_output_tokens or 0) + usage.get("output_tokens", 0)
project.ai_cost_usd = float(project.ai_cost_usd or 0) + usage.get("cost_usd", 0)
project.ai_call_count = (project.ai_call_count or 0) + 1
result = extract_tool_result(response)
if not result:
return {"summary": "Analysis failed - no structured data returned", "missing_info": []}
# Store analysis as JSON in project description (or a new field)
import json
project.brief_analysis = json.dumps(result)
await db.commit()
return result

View file

@ -487,6 +487,183 @@ span.conf-none { background: var(--color-danger); }
font-weight: 600;
}
/* Brief Analysis */
.analysis-content {}
.analysis-section {
margin-bottom: 18px;
}
.analysis-label {
font-size: 11px;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
}
.analysis-text {
font-size: 13px;
color: var(--color-text);
line-height: 1.6;
}
.analysis-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-bottom: 18px;
}
.analysis-row {
display: flex;
gap: 24px;
margin-bottom: 18px;
}
.analysis-list {
list-style: none;
padding: 0;
}
.analysis-list li {
font-size: 13px;
color: var(--color-text-secondary);
padding: 3px 0;
padding-left: 14px;
position: relative;
}
.analysis-list li::before {
content: '';
position: absolute;
left: 0;
top: 10px;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--color-primary);
}
.analysis-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.analysis-tag {
font-size: 12px;
padding: 3px 10px;
border-radius: 6px;
background: rgba(255,255,255,0.06);
color: var(--color-text-secondary);
}
.analysis-complexity {
font-size: 13px;
font-weight: 700;
padding: 4px 14px;
border-radius: 6px;
display: inline-block;
}
.analysis-complexity-low { background: var(--color-success-bg); color: var(--color-success); }
.analysis-complexity-medium { background: var(--color-warning-bg); color: var(--color-warning); }
.analysis-complexity-high { background: var(--color-danger-bg); color: var(--color-danger); }
.discovery-questions {
display: flex;
flex-direction: column;
gap: 10px;
}
.discovery-q {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 12px 14px;
border-left: 4px solid;
}
.discovery-red { border-left-color: var(--color-danger); }
.discovery-amber { border-left-color: var(--color-warning); }
.discovery-green { border-left-color: var(--color-success); }
.q-priority {
font-size: 10px;
font-weight: 700;
padding: 1px 6px;
border-radius: 4px;
margin-right: 8px;
}
.q-red { background: var(--color-danger-bg); color: var(--color-danger); }
.q-amber { background: var(--color-warning-bg); color: var(--color-warning); }
.q-green { background: var(--color-success-bg); color: var(--color-success); }
.q-category {
font-size: 11px;
color: var(--color-text-muted);
font-weight: 600;
}
.q-text {
font-size: 13px;
color: var(--color-text);
margin-top: 6px;
line-height: 1.5;
}
.q-rationale {
font-size: 12px;
color: var(--color-text-muted);
margin-top: 4px;
font-style: italic;
}
/* Refine Chat */
.refine-box {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 14px;
margin-bottom: 16px;
}
.refine-log {
max-height: 120px;
overflow: auto;
margin-bottom: 10px;
font-size: 12px;
}
.refine-hint {
color: var(--color-text-muted);
font-style: italic;
font-size: 12px;
}
.refine-user {
color: var(--color-primary);
font-weight: 600;
padding: 2px 0;
}
.refine-system {
color: var(--color-text-secondary);
padding: 2px 0;
}
.refine-input-row {
display: flex;
gap: 8px;
}
.refine-input {
flex: 1;
}
.text-right { text-align: right; }
.text-center { text-align: center; }

View file

@ -4,7 +4,7 @@ import api from '../api/client';
import { Project, ClientAsset, Match, RatecardSummary, MODEL_TYPE_LABELS, CONFIDENCE_COLORS } from '../types';
import './ProjectView.css';
type Tab = 'upload' | 'matches' | 'ratecard' | 'team';
type Tab = 'upload' | 'analysis' | 'matches' | 'ratecard' | 'team';
interface TeamRole {
role_id: number;
@ -57,6 +57,11 @@ export default function ProjectView() {
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [uploadStage, setUploadStage] = useState('');
const [refineInput, setRefineInput] = useState('');
const [refining, setRefining] = useState(false);
const [refineLog, setRefineLog] = useState<string[]>([]);
const [briefAnalysis, setBriefAnalysis] = useState<any>(null);
const [analyzing, setAnalyzing] = useState(false);
const [matching, setMatching] = useState(false);
const [building, setBuilding] = useState(false);
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
@ -81,6 +86,14 @@ export default function ProjectView() {
setMatches(matchRes.data);
}
// Load brief analysis if available
try {
const analysisRes = await api.get(`/projects/${id}/brief-analysis`);
if (analysisRes.data.status === 'analyzed') {
setBriefAnalysis(analysisRes.data.analysis);
}
} catch {}
if (['finalized', 'building'].includes(projRes.data.status)) {
try {
const [rcRes, tsRes] = await Promise.all([
@ -284,6 +297,36 @@ export default function ProjectView() {
downloadFile(`/projects/${id}/ratecard/export/pdf`, `${project?.name || 'caveats'}_caveats.pdf`);
}
async function handleAnalyzeBrief() {
setAnalyzing(true);
try {
const res = await api.post(`/projects/${id}/analyze-brief`);
setBriefAnalysis(res.data.analysis);
} catch (err: any) {
alert(`Analysis failed: ${err.response?.data?.detail || err.message}`);
} finally {
setAnalyzing(false);
}
}
async function handleRefine() {
if (!refineInput.trim()) return;
setRefining(true);
setRefineLog(prev => [...prev, `> ${refineInput}`]);
try {
const res = await api.post(`/projects/${id}/refine`, { instruction: refineInput });
const msg = res.data.message || 'Done.';
const extra = res.data.rematch_count ? ` Re-matched ${res.data.rematch_count} assets.` : '';
setRefineLog(prev => [...prev, msg + extra]);
setRefineInput('');
await loadProject();
} catch (err: any) {
setRefineLog(prev => [...prev, `Error: ${err.response?.data?.detail || err.message}`]);
} finally {
setRefining(false);
}
}
async function handleDelete() {
if (!confirm(`Delete project "${project?.name}"? This cannot be undone.`)) return;
try {
@ -358,13 +401,14 @@ export default function ProjectView() {
</div>
<div className="tabs">
{(['upload', 'matches', 'ratecard', 'team'] as Tab[]).map(t => (
{(['upload', 'analysis', 'matches', 'ratecard', 'team'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`tab ${tab === t ? 'tab-active' : ''}`}
>
{t === 'upload' ? `Upload & Assets (${assets.length})` :
t === 'analysis' ? `Brief Analysis${briefAnalysis ? ' ✓' : ''}` :
t === 'matches' ? `Match Review (${matches.length})` :
t === 'team' ? `Team Shape${teamShape ? ` (${teamShape.total_fte.toFixed(1)} FTE)` : ''}` :
'Ratecard'}
@ -417,6 +461,116 @@ export default function ProjectView() {
</div>
)}
{tab === 'analysis' && (
<div className="tab-content">
{!project.source_filename ? (
<div className="empty-state" style={{ padding: 40 }}>
Upload a document first to analyze the brief.
</div>
) : !briefAnalysis ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<p style={{ marginBottom: 16, color: 'var(--color-text-secondary)' }}>
Analyze the uploaded brief to extract structured requirements, identify gaps, and generate discovery questions.
</p>
<button onClick={handleAnalyzeBrief} disabled={analyzing} className="btn btn-primary">
{analyzing ? 'Analyzing...' : 'Analyze Brief'}
</button>
</div>
) : (
<div className="analysis-content">
<div className="analysis-section">
<div className="analysis-label">Summary</div>
<div className="analysis-text">{briefAnalysis.summary}</div>
</div>
<div className="analysis-grid">
{briefAnalysis.objectives?.length > 0 && (
<div className="analysis-section">
<div className="analysis-label">Objectives</div>
<ul className="analysis-list">{briefAnalysis.objectives.map((o: string, i: number) => <li key={i}>{o}</li>)}</ul>
</div>
)}
{briefAnalysis.channels?.length > 0 && (
<div className="analysis-section">
<div className="analysis-label">Channels</div>
<div className="analysis-tags">{briefAnalysis.channels.map((c: string, i: number) => <span key={i} className="analysis-tag">{c}</span>)}</div>
</div>
)}
{briefAnalysis.deliverable_categories?.length > 0 && (
<div className="analysis-section">
<div className="analysis-label">Deliverable Categories</div>
<div className="analysis-tags">{briefAnalysis.deliverable_categories.map((d: string, i: number) => <span key={i} className="analysis-tag">{d}</span>)}</div>
</div>
)}
{briefAnalysis.audiences?.length > 0 && (
<div className="analysis-section">
<div className="analysis-label">Audiences</div>
<ul className="analysis-list">{briefAnalysis.audiences.map((a: string, i: number) => <li key={i}>{a}</li>)}</ul>
</div>
)}
</div>
{briefAnalysis.constraints?.length > 0 && (
<div className="analysis-section">
<div className="analysis-label">Constraints & Requirements</div>
<ul className="analysis-list">{briefAnalysis.constraints.map((c: string, i: number) => <li key={i}>{c}</li>)}</ul>
</div>
)}
<div className="analysis-row">
{briefAnalysis.complexity_assessment && (
<div className="analysis-section">
<div className="analysis-label">Complexity</div>
<span className={`analysis-complexity analysis-complexity-${briefAnalysis.complexity_assessment}`}>
{briefAnalysis.complexity_assessment.toUpperCase()}
</span>
</div>
)}
{briefAnalysis.timeline && (
<div className="analysis-section">
<div className="analysis-label">Timeline</div>
<div className="analysis-text">{briefAnalysis.timeline}</div>
</div>
)}
{briefAnalysis.budget_band && (
<div className="analysis-section">
<div className="analysis-label">Budget</div>
<div className="analysis-text">{briefAnalysis.budget_band}</div>
</div>
)}
</div>
{briefAnalysis.missing_info?.length > 0 && (
<div className="analysis-section">
<div className="analysis-label">Discovery Questions ({briefAnalysis.missing_info.length})</div>
<div className="discovery-questions">
{briefAnalysis.missing_info.map((q: any, i: number) => (
<div key={i} className={`discovery-q discovery-${q.priority}`}>
<span className={`q-priority q-${q.priority}`}>{q.priority.toUpperCase()}</span>
<span className="q-category">{q.category}</span>
<div className="q-text">{q.question}</div>
<div className="q-rationale">{q.rationale}</div>
</div>
))}
</div>
</div>
)}
{briefAnalysis.notes && (
<div className="analysis-section">
<div className="analysis-label">Notes</div>
<div className="analysis-text">{briefAnalysis.notes}</div>
</div>
)}
<button onClick={handleAnalyzeBrief} disabled={analyzing} className="btn btn-secondary" style={{ marginTop: 16 }}>
{analyzing ? 'Re-analyzing...' : 'Re-analyze Brief'}
</button>
</div>
)}
</div>
)}
{tab === 'matches' && (
<div className="tab-content">
<div className="match-actions">
@ -440,6 +594,34 @@ export default function ProjectView() {
)}
</div>
{matches.length > 0 && !matching && (
<div className="refine-box">
<div className="refine-log">
{refineLog.length === 0 && (
<span className="refine-hint">
Try: "re-run anything under 70%" / "ignore zero volume" / "set all volumes to 1"
</span>
)}
{refineLog.map((msg, i) => (
<div key={i} className={msg.startsWith('>') ? 'refine-user' : 'refine-system'}>{msg}</div>
))}
</div>
<div className="refine-input-row">
<input
className="input refine-input"
value={refineInput}
onChange={e => setRefineInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !refining && handleRefine()}
placeholder="Type an instruction to refine matches..."
disabled={refining}
/>
<button onClick={handleRefine} disabled={refining || !refineInput.trim()} className="btn btn-primary">
{refining ? 'Refining...' : 'Send'}
</button>
</div>
</div>
)}
{assets.map(a => {
const assetMatches = matchesByAsset[a.id] || [];
const selectedMatch = assetMatches.find(m => m.is_selected);