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:
parent
1dcf7c084a
commit
bc778ce7af
9 changed files with 775 additions and 4 deletions
|
|
@ -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 ^\\\\)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
198
backend/app/services/match_refiner.py
Normal file
198
backend/app/services/match_refiner.py
Normal 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)),
|
||||
}
|
||||
135
backend/app/services/rfp_analysis.py
Normal file
135
backend/app/services/rfp_analysis.py
Normal 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
|
||||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue