Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ your ChatGPT connection.
- **Risk management insights** with mitigation strategies aligned to your stated
tolerance level.
- **Dynamic SWOT summaries** and professional-grade data visualizations powered by Chart.js.
- **Document intelligence ingestion** for CSV, JSON, or text files that are parsed and incorporated into the forecast.
- **Advisor chat workspace** for continuing strategic discussions and next steps.
- **Responsive, multi-page Flask interface** with polished styling.

Expand Down Expand Up @@ -51,8 +52,8 @@ your ChatGPT connection.

## Usage tips

- Populate the predictor form with the most recent financial metrics to receive
tailored forecasts, mitigation plans, and SWOT analysis.
- Populate the predictor form with the most recent financial metrics and optionally upload
up to three supporting CSV, JSON, or TXT documents to weave qualitative context into forecasts.
- Use the advisor chat page to iterate on board decks, investor updates, or go-to-market plans.
- The application never stores your API key; it reads the value from the
environment at runtime.
Expand Down
9 changes: 8 additions & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ def create_app() -> Flask:
if env_path.exists():
load_dotenv(env_path)

app = Flask(__name__)
package_root = Path(__file__).resolve().parent
project_root = package_root.parent

app = Flask(
__name__,
template_folder=str(project_root / 'templates'),
static_folder=str(project_root / 'static'),
)
app.config['SECRET_KEY'] = os.getenv('FLASK_SECRET_KEY', 'change-me')

# Configure logging for better observability in production deployments.
Expand Down
57 changes: 47 additions & 10 deletions app/chatgpt_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,29 @@ def generate_business_insights(self, payload: Dict[str, Any]) -> Dict[str, Any]:
user_prompt = (
"Create a forecast, risk review, and SWOT based on the following scenario."
"\nRespond with concise bullet points or short paragraphs."
"\nAlways return STRICT JSON matching the schema when providing your answer."
f"\n\nScenario:\n{json.dumps(payload, indent=2)}"
)

response = self._client.responses.create(
model='gpt-4.1-mini',
input=[
request_arguments: Dict[str, Any] = {
'model': 'gpt-4.1-mini',
'input': [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.3,
max_output_tokens=800,
response_format={"type": "json_schema", "json_schema": self._schema()},
)
'temperature': 0.3,
'max_output_tokens': 900,
}

try:
response = self._client.responses.create(
**request_arguments,
response_format={"type": "json_schema", "json_schema": self._schema()},
)
except TypeError:
# Older versions of the SDK do not yet support ``response_format``.
response = self._client.responses.create(**request_arguments)

return self._parse_response(response)

def continue_conversation(self, conversation: str, prompt: str) -> str:
Expand Down Expand Up @@ -104,8 +114,35 @@ def _schema() -> Dict[str, Any]:

@staticmethod
def _parse_response(response: Any) -> Dict[str, Any]:
raw_payload = _extract_response_text(response)
try:
return json.loads(response.output[0].content[0].text)
except (KeyError, AttributeError, IndexError, json.JSONDecodeError) as exc:
raise RuntimeError('Unable to parse response from ChatGPT.') from exc
return json.loads(raw_payload)
except json.JSONDecodeError:
sanitized = raw_payload.strip()
if sanitized.startswith('```'):
segments = sanitized.split('```')
for segment in segments:
segment = segment.strip()
if segment and not segment.startswith('{') and '{' not in segment:
continue
if segment.startswith('{'):
try:
return json.loads(segment)
except json.JSONDecodeError:
continue
raise RuntimeError('Unable to parse response from ChatGPT.')


def _extract_response_text(response: Any) -> str:
"""Return the textual payload from an OpenAI Responses result."""

for attribute in ('output_text', 'text'): # type: ignore[attr-defined]
value = getattr(response, attribute, None)
if isinstance(value, str) and value.strip():
return value

try:
return response.output[0].content[0].text # type: ignore[index]
except (AttributeError, IndexError, KeyError):
raise RuntimeError('Unable to read response payload from ChatGPT.')

199 changes: 197 additions & 2 deletions app/routes.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
from __future__ import annotations

import json
from dataclasses import dataclass
import csv
import io
import itertools
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List

from werkzeug.datastructures import FileStorage

from flask import Blueprint, flash, render_template, request

from .chatgpt_client import ChatGPTClient
Expand All @@ -27,6 +33,7 @@ class ScenarioData:
opportunities: List[str]
threats: List[str]
additional_notes: str
uploaded_documents: List[Dict[str, str]] = field(default_factory=list)

@classmethod
def from_form(cls, form: Dict[str, str]) -> "ScenarioData":
Expand All @@ -48,7 +55,7 @@ def from_form(cls, form: Dict[str, str]) -> "ScenarioData":
)

def to_payload(self) -> Dict[str, object]:
return {
payload: Dict[str, object] = {
"business_name": self.business_name,
"industry": self.industry,
"financials": {
Expand All @@ -74,6 +81,9 @@ def to_payload(self) -> Dict[str, object]:
},
"notes": self.additional_notes,
}
if self.uploaded_documents:
payload["uploaded_documents"] = self.uploaded_documents
return payload


@bp.route('/')
Expand All @@ -95,6 +105,21 @@ def predictor():
flash('Business name and industry are required.', 'danger')
return render_template('predictor.html', **context)

uploads: List[Dict[str, str]] = []
uploaded_files = [file for file in request.files.getlist('data_file') if file and file.filename]
if len(uploaded_files) > 3:
flash('Please upload up to three supporting files at a time.', 'warning')
return render_template('predictor.html', **context)

for uploaded_file in uploaded_files:
try:
uploads.append(_process_uploaded_file(uploaded_file))
except ValueError as exc:
flash(str(exc), 'danger')
return render_template('predictor.html', **context)

scenario.uploaded_documents = uploads

try:
client = ChatGPTClient()
analysis = client.generate_business_insights(scenario.to_payload())
Expand All @@ -103,11 +128,15 @@ def predictor():
return render_template('predictor.html', **context)

metrics = _build_metric_summary(scenario)
highlights = _derive_growth_highlights(scenario)
risk_profile = _describe_risk_profile(scenario.risk_tolerance)
return render_template(
'results.html',
scenario=scenario,
metrics=json.dumps(metrics),
analysis=analysis,
highlights=highlights,
risk_profile=risk_profile,
)

return render_template('predictor.html', **context)
Expand Down Expand Up @@ -149,3 +178,169 @@ def _build_metric_summary(scenario: ScenarioData) -> Dict[str, List[float]]:
scenario.market_share_target,
],
}


MAX_UPLOAD_BYTES = 1_000_000 # 1 MB cap keeps prompts responsive.
PREVIEW_CHAR_LIMIT = 1_200


def _process_uploaded_file(file: FileStorage) -> Dict[str, str]:
filename = Path(file.filename or '').name
if not filename:
raise ValueError('Select a data file to upload.')

raw_bytes = file.read()
if not raw_bytes:
raise ValueError('The uploaded file is empty.')

if len(raw_bytes) > MAX_UPLOAD_BYTES:
raise ValueError('Uploaded file exceeds the 1 MB limit.')

try:
text = raw_bytes.decode('utf-8')
except UnicodeDecodeError:
text = raw_bytes.decode('utf-8', errors='ignore')

extension = Path(filename).suffix.lower()
preview = text.strip()
summary = ''

if extension == '.csv':
preview, summary = _summarize_csv(text)
elif extension == '.json':
preview, summary = _summarize_json(text)
else:
preview, summary = _summarize_text(text)

return {
"filename": filename,
"format": extension.lstrip('.') or 'text',
"size_kb": f"{len(raw_bytes) / 1024:.1f}",
"summary": summary,
"excerpt": preview[:PREVIEW_CHAR_LIMIT].strip(),
"line_count": str(len([line for line in text.splitlines() if line.strip()])),
}


def _summarize_csv(text: str) -> (str, str):
buffer = io.StringIO(text)
reader = csv.reader(buffer)
rows = list(itertools.islice(reader, 0, 11))
if not rows:
return '', 'CSV file with no rows detected.'

header = rows[0]
body_rows = rows[1:]
row_count = len(body_rows)
preview_rows = [header] + body_rows[:5]
preview = '\n'.join(', '.join(cell.strip() for cell in row) for row in preview_rows)
summary = (
f"CSV dataset with {row_count} data rows. Columns: "
+ ', '.join(header[:6])
+ ('...' if len(header) > 6 else '')
)
return preview, summary


def _summarize_json(text: str) -> (str, str):
try:
parsed = json.loads(text)
preview = json.dumps(parsed, indent=2)
if isinstance(parsed, dict):
keys = list(parsed.keys())
summary = f"JSON document with {len(keys)} top-level keys: {', '.join(keys[:6])}" + (
'...' if len(keys) > 6 else ''
)
elif isinstance(parsed, list):
summary = f"JSON array with {len(parsed)} entries."
else:
summary = f"JSON value of type {type(parsed).__name__}."
except json.JSONDecodeError:
preview, summary = _summarize_text(text)
else:
preview = preview
return preview, summary


def _summarize_text(text: str) -> (str, str):
stripped = text.strip()
words = [word for word in stripped.replace('\n', ' ').split(' ') if word]
summary = f"Narrative brief with approximately {len(words)} words." if words else 'Text document.'
preview = stripped or text
return preview, summary


def _derive_growth_highlights(scenario: ScenarioData) -> List[Dict[str, str]]:
highlights: List[Dict[str, str]] = []
highlights.append(_build_growth_card('Revenue trajectory', scenario.revenue_current, scenario.revenue_target, 'USD'))
highlights.append(_build_growth_card('Expense efficiency', scenario.expenses_current, scenario.expenses_target, 'USD', invert=True))
highlights.append(
_build_growth_card('Market share momentum', scenario.market_share_current, scenario.market_share_target, 'percentage')
)
return [item for item in highlights if item]


def _build_growth_card(title: str, current: float, target: float, unit: str, invert: bool = False) -> Dict[str, str]:
if current == 0 and target == 0:
return {
"title": title,
"current": '0',
"target": '0',
"direction": 'steady',
"delta": '0',
"percent": '0',
"descriptor": 'No change from current baseline.',
"badge": 'secondary',
}

delta = target - current
direction = 'increase' if delta >= 0 else 'decrease'
baseline = current if current != 0 else None
percent = (abs(delta) / baseline * 100) if baseline else None
formatted_delta = _format_value(abs(delta), unit)
positive_change = delta >= 0
if invert:
positive_change = not positive_change
descriptor = (
f"Targeting a {formatted_delta} {direction} relative to the current baseline."
if percent is None
else f"Targeting a {percent:.1f}% {direction} relative to the current baseline."
)
badge = 'success' if positive_change else 'warning'

return {
"title": title,
"current": _format_value(current, unit),
"target": _format_value(target, unit),
"direction": direction,
"delta": formatted_delta,
"percent": f"{percent:.1f}%" if percent is not None else '—',
"descriptor": descriptor,
"badge": badge,
}


def _format_value(value: float, unit: str) -> str:
if unit == 'USD':
return f"${value:,.2f}"
if unit == 'percentage':
return f"{value:.2f}%"
return f"{value:,.2f}"


def _describe_risk_profile(score: int) -> Dict[str, str]:
categories = {
1: ('Very conservative', 'Low tolerance for volatility. Prioritize preservation and incremental gains.'),
2: ('Conservative', 'Measured appetite for risk. Focus on steady growth with guardrails.'),
3: ('Balanced', 'Comfortable with balanced risk-reward trade-offs to accelerate growth.'),
4: ('Growth-oriented', 'Open to calculated risks that can unlock competitive advantages.'),
5: ('Aggressive', 'Seeks outsized returns and is prepared for significant variability.'),
}
normalized = max(1, min(5, score))
label, description = categories.get(normalized, categories[3])
return {
"score": str(normalized),
"label": label,
"description": description,
"progress": f"{normalized / 5 * 100:.0f}",
}
Loading