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:
parent
bc778ce7af
commit
1d920d62cc
4 changed files with 87 additions and 50 deletions
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue