diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 082b27a..6f54de1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 ^\\\\)" ] } } diff --git a/backend/app/api/matching.py b/backend/app/api/matching.py index 51fab54..6bbe886 100644 --- a/backend/app/api/matching.py +++ b/backend/app/api/matching.py @@ -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, diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index 237ea12..e845a8a 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -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), diff --git a/backend/app/models/project.py b/backend/app/models/project.py index 12d6699..f80ad64 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -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) diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index e42ca05..0942ed8 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -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 diff --git a/backend/app/services/match_refiner.py b/backend/app/services/match_refiner.py new file mode 100644 index 0000000..5a0d3f2 --- /dev/null +++ b/backend/app/services/match_refiner.py @@ -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)), + } diff --git a/backend/app/services/rfp_analysis.py b/backend/app/services/rfp_analysis.py new file mode 100644 index 0000000..12ca18f --- /dev/null +++ b/backend/app/services/rfp_analysis.py @@ -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 diff --git a/frontend/src/pages/ProjectView.css b/frontend/src/pages/ProjectView.css index cd2b678..d3dd652 100644 --- a/frontend/src/pages/ProjectView.css +++ b/frontend/src/pages/ProjectView.css @@ -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; } diff --git a/frontend/src/pages/ProjectView.tsx b/frontend/src/pages/ProjectView.tsx index 1d43864..d7934ed 100644 --- a/frontend/src/pages/ProjectView.tsx +++ b/frontend/src/pages/ProjectView.tsx @@ -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([]); + const [briefAnalysis, setBriefAnalysis] = useState(null); + const [analyzing, setAnalyzing] = useState(false); const [matching, setMatching] = useState(false); const [building, setBuilding] = useState(false); const [expandedGroups, setExpandedGroups] = useState>(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() {
- {(['upload', 'matches', 'ratecard', 'team'] as Tab[]).map(t => ( + {(['upload', 'analysis', 'matches', 'ratecard', 'team'] as Tab[]).map(t => (
)} + {tab === 'analysis' && ( +
+ {!project.source_filename ? ( +
+ Upload a document first to analyze the brief. +
+ ) : !briefAnalysis ? ( +
+

+ Analyze the uploaded brief to extract structured requirements, identify gaps, and generate discovery questions. +

+ +
+ ) : ( +
+
+
Summary
+
{briefAnalysis.summary}
+
+ +
+ {briefAnalysis.objectives?.length > 0 && ( +
+
Objectives
+
    {briefAnalysis.objectives.map((o: string, i: number) =>
  • {o}
  • )}
+
+ )} + {briefAnalysis.channels?.length > 0 && ( +
+
Channels
+
{briefAnalysis.channels.map((c: string, i: number) => {c})}
+
+ )} + {briefAnalysis.deliverable_categories?.length > 0 && ( +
+
Deliverable Categories
+
{briefAnalysis.deliverable_categories.map((d: string, i: number) => {d})}
+
+ )} + {briefAnalysis.audiences?.length > 0 && ( +
+
Audiences
+
    {briefAnalysis.audiences.map((a: string, i: number) =>
  • {a}
  • )}
+
+ )} +
+ + {briefAnalysis.constraints?.length > 0 && ( +
+
Constraints & Requirements
+
    {briefAnalysis.constraints.map((c: string, i: number) =>
  • {c}
  • )}
+
+ )} + +
+ {briefAnalysis.complexity_assessment && ( +
+
Complexity
+ + {briefAnalysis.complexity_assessment.toUpperCase()} + +
+ )} + {briefAnalysis.timeline && ( +
+
Timeline
+
{briefAnalysis.timeline}
+
+ )} + {briefAnalysis.budget_band && ( +
+
Budget
+
{briefAnalysis.budget_band}
+
+ )} +
+ + {briefAnalysis.missing_info?.length > 0 && ( +
+
Discovery Questions ({briefAnalysis.missing_info.length})
+
+ {briefAnalysis.missing_info.map((q: any, i: number) => ( +
+ {q.priority.toUpperCase()} + {q.category} +
{q.question}
+
{q.rationale}
+
+ ))} +
+
+ )} + + {briefAnalysis.notes && ( +
+
Notes
+
{briefAnalysis.notes}
+
+ )} + + +
+ )} +
+ )} + {tab === 'matches' && (
@@ -440,6 +594,34 @@ export default function ProjectView() { )}
+ {matches.length > 0 && !matching && ( +
+
+ {refineLog.length === 0 && ( + + Try: "re-run anything under 70%" / "ignore zero volume" / "set all volumes to 1" + + )} + {refineLog.map((msg, i) => ( +
') ? 'refine-user' : 'refine-system'}>{msg}
+ ))} +
+
+ setRefineInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && !refining && handleRefine()} + placeholder="Type an instruction to refine matches..." + disabled={refining} + /> + +
+
+ )} + {assets.map(a => { const assetMatches = matchesByAsset[a.id] || []; const selectedMatch = assetMatches.find(m => m.is_selected);