Fix: Brief analysis text input, Excel formulas on all sheets

- Brief Analysis now accepts pasted text OR uploaded file
- Textarea for typing/pasting brief directly (no upload required)
- Re-analyze button returns to input screen
- Team Shape Excel sheets now use formulas:
  - FTE = Hours/1800 (formula)
  - Adjusted Hours = Original * (1-eff%) (formula)
  - Hours Saved = Original - Adjusted (formula)
  - Headcount = IF/CEILING formula
  - Base team shape also uses FTE + headcount formulas
- All sheets are now formula-driven, Finance can edit hours and see recalculation

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

View file

@ -139,30 +139,33 @@ async def get_brief_analysis(project_id: int, db: AsyncSession = Depends(get_db)
@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."""
async def analyze_brief_endpoint(project_id: int, data: dict | None = None, db: AsyncSession = Depends(get_db)):
"""Run AI analysis on uploaded document or pasted text."""
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")
body = data or {}
# Read the file from data dir
import os
from app.config import settings
filepath = os.path.join(settings.data_dir, project.source_filename)
# Option 1: Pasted text
if body.get("text"):
text = body["text"]
# Option 2: Read from uploaded file
elif project.source_filename:
import os
from app.config import settings
filepath = os.path.join(settings.data_dir, project.source_filename)
if not os.path.exists(filepath):
raise HTTPException(status_code=400, detail="Source file not found on disk. Re-upload or paste the brief text.")
with open(filepath, "rb") as f:
content = f.read()
text, _ = extract_text_from_file(content, project.source_filename)
else:
raise HTTPException(status_code=400, detail="No document uploaded and no text provided. Upload a file or paste the brief.")
# 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."}
if len(text.strip()) < 20:
raise HTTPException(status_code=400, detail="Brief text is too short to analyze.")
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}

View file

@ -401,29 +401,36 @@ async def _build_team_shape_sheet(ws, db, project, efficiency_pct: float = 0):
ws.cell(row=row_idx, column=3, value=role_type)
if has_efficiency:
# Col D: Original Hours (value)
ws.cell(row=row_idx, column=4, value=t["total_hours"]).number_format = '#,##0.00'
ws.cell(row=row_idx, column=5, value=t["fte"]).number_format = '0.00'
ws.cell(row=row_idx, column=6, value=t["adjusted_hours"]).number_format = '#,##0.00'
adj_cell = ws.cell(row=row_idx, column=7, value=t["adjusted_fte"])
# Col E: Original FTE = D/1800 (formula)
ws.cell(row=row_idx, column=5).value = f"=D{row_idx}/1800"
ws.cell(row=row_idx, column=5).number_format = '0.00'
# Col F: Adjusted Hours = D * (1 - eff%) (formula)
eff_pct = t.get("efficiency_pct", efficiency_pct) or 0
ws.cell(row=row_idx, column=6).value = f"=D{row_idx}*(1-{eff_pct/100})"
ws.cell(row=row_idx, column=6).number_format = '#,##0.00'
# Col G: Adjusted FTE = F/1800 (formula)
adj_cell = ws.cell(row=row_idx, column=7)
adj_cell.value = f"=F{row_idx}/1800"
adj_cell.number_format = '0.00'
if t["adjusted_fte"] >= 1:
adj_cell.font = FTE_FONT
saved = ws.cell(row=row_idx, column=8, value=t["hours_saved"])
# Col H: Hours Saved = D - F (formula)
saved = ws.cell(row=row_idx, column=8)
saved.value = f"=D{row_idx}-F{row_idx}"
saved.number_format = '#,##0.00'
if t["hours_saved"] > 0:
saved.font = SAVED_FONT
hc = t["adjusted_fte"]
headcount = math.ceil(hc) if hc >= 0.5 else (0.5 if hc > 0 else 0)
ws.cell(row=row_idx, column=9, value=headcount).number_format = '0.0'
saved.font = SAVED_FONT
# Col I: Headcount = CEILING or 0.5 (formula)
ws.cell(row=row_idx, column=9).value = f'=IF(G{row_idx}>=0.5,CEILING(G{row_idx},1),IF(G{row_idx}>0,0.5,0))'
ws.cell(row=row_idx, column=9).number_format = '0.0'
else:
# Col D: Total Hours (value)
ws.cell(row=row_idx, column=4, value=t["total_hours"]).number_format = '#,##0.00'
fte_cell = ws.cell(row=row_idx, column=5, value=t["fte"])
fte_cell.number_format = '0.00'
if t["fte"] >= 1:
fte_cell.font = FTE_FONT
hc = t["fte"]
headcount = math.ceil(hc) if hc >= 0.5 else (0.5 if hc > 0 else 0)
ws.cell(row=row_idx, column=6, value=headcount).number_format = '0.0'
# Col E: FTE = D/1800 (formula)
ws.cell(row=row_idx, column=5).value = f"=D{row_idx}/1800"
ws.cell(row=row_idx, column=5).number_format = '0.00'
# Col F: Headcount (formula)
ws.cell(row=row_idx, column=6).value = f'=IF(E{row_idx}>=0.5,CEILING(E{row_idx},1),IF(E{row_idx}>0,0.5,0))'
ws.cell(row=row_idx, column=6).number_format = '0.0'
if t["is_programme_role"]:
for c in range(1, num_cols + 1):

View file

@ -488,6 +488,20 @@ span.conf-none { background: var(--color-danger); }
}
/* Brief Analysis */
.analysis-input-section {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 24px;
}
.brief-textarea {
min-height: 180px;
resize: vertical;
font-size: 13px;
line-height: 1.6;
}
.analysis-content {}
.analysis-section {

View file

@ -62,6 +62,7 @@ export default function ProjectView() {
const [refineLog, setRefineLog] = useState<string[]>([]);
const [briefAnalysis, setBriefAnalysis] = useState<any>(null);
const [analyzing, setAnalyzing] = useState(false);
const [briefText, setBriefText] = useState('');
const [matching, setMatching] = useState(false);
const [building, setBuilding] = useState(false);
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
@ -297,10 +298,11 @@ export default function ProjectView() {
downloadFile(`/projects/${id}/ratecard/export/pdf`, `${project?.name || 'caveats'}_caveats.pdf`);
}
async function handleAnalyzeBrief() {
async function handleAnalyzeBrief(mode: 'file' | 'text') {
setAnalyzing(true);
try {
const res = await api.post(`/projects/${id}/analyze-brief`);
const payload = mode === 'text' ? { text: briefText } : {};
const res = await api.post(`/projects/${id}/analyze-brief`, payload);
setBriefAnalysis(res.data.analysis);
} catch (err: any) {
alert(`Analysis failed: ${err.response?.data?.detail || err.message}`);
@ -463,18 +465,29 @@ export default function ProjectView() {
{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.
{!briefAnalysis ? (
<div className="analysis-input-section">
<p style={{ marginBottom: 16, color: 'var(--color-text-secondary)', fontSize: 13 }}>
Analyze a client brief to extract structured requirements, identify gaps, and generate discovery questions.
{project.source_filename && ` You can analyze the uploaded file (${project.source_filename}) or paste a brief below.`}
</p>
<button onClick={handleAnalyzeBrief} disabled={analyzing} className="btn btn-primary">
{analyzing ? 'Analyzing...' : 'Analyze Brief'}
</button>
<textarea
className="input brief-textarea"
value={briefText}
onChange={e => setBriefText(e.target.value)}
placeholder="Paste the client brief or RFP text here... Or upload a document on the Upload tab first."
rows={8}
/>
<div style={{ display: 'flex', gap: 10, marginTop: 12 }}>
{project.source_filename && (
<button onClick={() => handleAnalyzeBrief('file')} disabled={analyzing} className="btn btn-primary">
{analyzing ? 'Analyzing...' : `Analyze Uploaded File`}
</button>
)}
<button onClick={() => handleAnalyzeBrief('text')} disabled={analyzing || !briefText.trim()} className="btn btn-primary">
{analyzing ? 'Analyzing...' : 'Analyze Pasted Text'}
</button>
</div>
</div>
) : (
<div className="analysis-content">
@ -563,8 +576,8 @@ export default function ProjectView() {
</div>
)}
<button onClick={handleAnalyzeBrief} disabled={analyzing} className="btn btn-secondary" style={{ marginTop: 16 }}>
{analyzing ? 'Re-analyzing...' : 'Re-analyze Brief'}
<button onClick={() => { setBriefAnalysis(null); }} className="btn btn-secondary" style={{ marginTop: 16 }}>
Re-analyze Brief
</button>
</div>
)}