diff --git a/README.md b/README.md index cbf1c16..ef75da2 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/app/__init__.py b/app/__init__.py index 263e909..8d2490f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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. diff --git a/app/chatgpt_client.py b/app/chatgpt_client.py index 4ef6246..97b38c9 100644 --- a/app/chatgpt_client.py +++ b/app/chatgpt_client.py @@ -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: @@ -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.') diff --git a/app/routes.py b/app/routes.py index 9b83f81..85b7237 100644 --- a/app/routes.py +++ b/app/routes.py @@ -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 @@ -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": @@ -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": { @@ -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('/') @@ -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()) @@ -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) @@ -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}", + } diff --git a/static/css/styles.css b/static/css/styles.css index b46a73f..8399638 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -18,6 +18,77 @@ main { flex: 1 0 auto; } +.landing-hero { + background: linear-gradient(135deg, rgba(13, 110, 253, 0.08), rgba(32, 201, 151, 0.08)); + border-radius: 1.5rem; + padding: 3rem 3rem 2.5rem; +} + +.kpi-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1rem; +} + +.kpi-card { + background: #ffffff; + border-radius: 1rem; + padding: 1rem 1.25rem; + box-shadow: 0 0.5rem 1.5rem rgba(13, 110, 253, 0.08); +} + +.insight-showcase { + display: grid; + gap: 1.5rem; +} + +.showcase-card { + background: #ffffff; + border-radius: 1.25rem; + padding: 2rem; +} + +.showcase-steps { + margin: 0; + padding-left: 1.25rem; + display: grid; + gap: 0.75rem; +} + +.showcase-steps li span { + display: block; + color: #495057; +} + +.showcase-footer { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + margin-top: 1.5rem; +} + +.feature-list li { + position: relative; + padding-left: 1.5rem; + margin-bottom: 0.75rem; + color: #495057; +} + +.feature-list li::before { + content: '•'; + position: absolute; + left: 0; + color: var(--brand-primary); + font-weight: 700; +} + +.value-card { + background: #ffffff; + border-radius: 1.25rem; + padding: 1.75rem; + border: 1px solid rgba(13, 110, 253, 0.12); +} + .navbar-brand { letter-spacing: 0.04em; } @@ -39,10 +110,182 @@ main { border-radius: 1rem; } +.insight-progress .progress-chip { + display: flex; + gap: 1rem; + background: #f8f9fa; + border-radius: 1rem; + padding: 1rem 1.25rem; + border: 1px solid rgba(13, 110, 253, 0.12); + height: 100%; +} + +.progress-chip.active { + background: linear-gradient(135deg, rgba(13, 110, 253, 0.18), rgba(32, 201, 151, 0.18)); + border-color: transparent; + color: #052c65; +} + +.chip-index { + font-weight: 700; + font-size: 1.25rem; + background: #ffffff; + border-radius: 50%; + width: 2.75rem; + height: 2.75rem; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 0.5rem 1.5rem rgba(13, 110, 253, 0.15); +} + +.chip-title { + margin-bottom: 0.25rem; + font-weight: 600; +} + +.chip-description { + margin: 0; + color: #6c757d; + font-size: 0.9rem; +} + +.upload-panel { + border: 1px dashed rgba(13, 110, 253, 0.4); + border-radius: 1rem; + padding: 1.75rem; + background: rgba(13, 110, 253, 0.04); +} + +.upload-icon { + width: 3rem; + height: 3rem; + background: linear-gradient(135deg, var(--brand-primary), var(--brand-accent)); + color: #ffffff; + font-size: 1.5rem; + box-shadow: 0 0.5rem 1.5rem rgba(13, 110, 253, 0.35); +} + +.upload-glyph { + font-weight: 700; + transform: translateY(-1px); +} + +.file-feedback { + min-width: 200px; + white-space: pre-line; +} + textarea.form-control { resize: vertical; } +.insight-callout { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + background: #ffffff; + border-radius: 1.25rem; + padding: 1.5rem 1.75rem; + border: 1px solid rgba(13, 110, 253, 0.12); + margin-top: 3rem; +} + +.insight-list { + list-style: none; + padding-left: 0; + display: grid; + gap: 0.5rem; +} + +.insight-list li { + position: relative; + padding-left: 1.5rem; + color: #495057; +} + +.insight-list li::before { + content: ''; + position: absolute; + width: 0.6rem; + height: 0.6rem; + border-radius: 50%; + background: var(--brand-primary); + left: 0; + top: 0.35rem; +} + +.insight-stat { + background: #ffffff; + border-radius: 1.25rem; + padding: 1.75rem; + border: 1px solid rgba(13, 110, 253, 0.12); + box-shadow: 0 0.5rem 1.2rem rgba(13, 110, 253, 0.08); +} + +.badge-tag { + letter-spacing: 0.08em; + font-size: 0.7rem; +} + +.badge-tag.badge-success { + background: rgba(32, 201, 151, 0.15); + color: #0f5132; +} + +.badge-tag.badge-warning { + background: rgba(255, 193, 7, 0.2); + color: #664d03; +} + +.badge-tag.badge-secondary { + background: rgba(73, 80, 87, 0.2); + color: #343a40; +} + +.document-grid { + display: grid; + gap: 1.5rem; +} + +.document-chip { + background: #f8f9fa; + border-radius: 1.25rem; + padding: 1.5rem; + border: 1px solid rgba(13, 110, 253, 0.12); +} + +.document-preview { + background: #ffffff; + border-radius: 0.75rem; + border: 1px solid rgba(13, 110, 253, 0.12); + padding: 1rem; + max-height: 12rem; + overflow: auto; + white-space: pre-wrap; + font-family: 'IBM Plex Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + font-size: 0.85rem; + color: #495057; +} + +.risk-panel { + background: #f8f9fa; + border-radius: 1rem; + padding: 1.25rem; + border: 1px solid rgba(13, 110, 253, 0.12); +} + +.risk-score { + font-weight: 700; + font-size: 1.25rem; + color: var(--brand-primary); +} + +.risk-progress .progress-bar { + background: linear-gradient(135deg, var(--brand-primary), var(--brand-accent)); +} + .swot-tile { border-radius: 1rem; padding: 1.25rem; @@ -82,4 +325,13 @@ footer { .navbar-brand { font-size: 1.1rem; } + + .landing-hero { + padding: 2rem 1.5rem; + } + + .insight-callout { + flex-direction: column; + align-items: stretch; + } } diff --git a/templates/index.html b/templates/index.html index 46a6775..6e2b5e8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,48 +1,77 @@ {% extends 'base.html' %} {% block title %}Insight Navigator · Home{% endblock %} {% block content %} -
-
-

Business insight without the noise

-

- Insight Navigator transforms your raw business metrics into forward-looking forecasts, - risk mitigation strategies, and investor-ready narratives powered by your ChatGPT - connection. -

-
- Launch Predictor - Ask an Advisor +
+
+
+

Executive-grade intelligence on demand

+

+ Insight Navigator transforms raw metrics and uploaded evidence into forecasts, risk heatmaps, SWOT insights, and a guided action plan — all powered securely through your ChatGPT connection. +

+ +
+
+

90s

+

to upload data and receive a strategic playbook

+
+
+

4 pillars

+

Forecast · Risk · Recommendations · SWOT

+
+
+

100%

+

private — API keys loaded from your environment

+
+
-
-
-
-
-

Why executives rely on Insight Navigator

-
    -
  • - 01 -
    -

    Predictive intelligence

    -

    Scenario-aware growth projections tailored to your inputs.

    -
    -
  • -
  • - 02 -
    -

    Risk under control

    -

    Automated heat-mapping with mitigation plans you can act on.

    -
    -
  • -
  • - 03 -
    -

    Board-ready visuals

    -

    Interactive comparisons and SWOT summaries packaged for stakeholders.

    -
    -
  • -
+
+
+
+

Workflow snapshot

+
    +
  1. Upload your KPIs and documentation.
  2. +
  3. Insight Navigator benchmarks targets against baselines.
  4. +
  5. AI-powered advisor surfaces risk mitigations and priorities.
  6. +
+ +
+
+

Built for decision makers

+
    +
  • Smart upload parsing for CSV, JSON, and free-form notes.
  • +
  • Radar benchmarking with instant highlight cards.
  • +
  • Advisor chat workspace for follow-up guidance.
  • +
+
+ +
+
+
+

Predict with confidence

+

Blend baseline metrics with scenario targets to build credible revenue, expense, and market share trajectories.

+
+
+
+
+

Control your exposure

+

Automated risk narratives highlight mitigation levers tailored to your declared tolerance.

+
+
+
+
+

Present like a pro

+

SWOT matrices, radar charts, and document intelligence summaries are ready for board decks.

+
+
+
{% endblock %} diff --git a/templates/predictor.html b/templates/predictor.html index 96e40bc..fc0425d 100644 --- a/templates/predictor.html +++ b/templates/predictor.html @@ -2,17 +2,51 @@ {% block title %}Insight Navigator · Predictor{% endblock %} {% block content %}
-
+
-
+
-

Business Scenario Input

-

Populate the fields below to generate forecasts and risk profiles.

+

Scenario architect

+

Feed in performance data, attach support files, and Insight Navigator will deliver projections, risks, and action plans.

+
+
+ Predictor module + AI copiloted +
+
+ +
+
+
+
1
+
+

Profile the business

+

Core identifiers and financial baselines to tailor the analysis.

+
+
+
+
+
+
2
+
+

Attach intelligence

+

Upload CSV, JSON, or notes to ground the model in your data.

+
+
+
+
+
+
3
+
+

Review the playbook

+

Receive forecasts, SWOT, and risk navigation guidance.

+
+
- Predictor module
-
+ +
@@ -22,6 +56,7 @@

Business Scenario Input

name="business_name" value="{{ form_values.get('business_name', '') }}" required + placeholder="Acme Labs" />
@@ -32,12 +67,12 @@

Business Scenario Input

name="industry" value="{{ form_values.get('industry', '') }}" required + placeholder="Healthcare analytics" />
-

Key Metrics

-
+
Key Metrics />
- +
+
+
+
+
+
+ +
+
+ +

Attach up to three CSV, JSON, or TXT files (max 1 MB each). We will surface highlights and incorporate them in the forecast.

+
+ +
No file selected.
+
+ +
+
+
+
+
+

SWOT Inputs

@@ -169,8 +232,12 @@

SWOT Inputs

-
- +
+
+

Quality assurance

+

We validate numeric fields, secure your API key from the environment, and sanitize uploaded text before sending it to ChatGPT.

+
+
@@ -178,3 +245,27 @@

SWOT Inputs

{% endblock %} + +{% block scripts %} + {{ super() }} + +{% endblock %} diff --git a/templates/results.html b/templates/results.html index ad26eba..f5ae76e 100644 --- a/templates/results.html +++ b/templates/results.html @@ -4,6 +4,35 @@ {% endblock %} {% block content %} + {% if highlights %} +
+ {% for card in highlights %} +
+
+
+
+

{{ loop.index }} · Focus Area

+

{{ card.title }}

+
+ {{ card.direction }} +
+
+
Current
+
{{ card.current }}
+
Target
+
{{ card.target }}
+
Delta
+
{{ card.delta }}
+
Shift
+
{{ card.percent }}
+
+

{{ card.descriptor }}

+
+
+ {% endfor %} +
+ {% endif %} +
@@ -21,11 +50,33 @@

Business Forecast

Risk Management

-

{{ analysis.risk_management }}

+ {% set risk_lines = analysis.risk_management.split('\n') %} + {% if risk_lines|length > 1 %} +
    + {% for line in risk_lines %} + {% if line.strip() %} +
  • {{ line }}
  • + {% endif %} + {% endfor %} +
+ {% else %} +

{{ analysis.risk_management }}

+ {% endif %}

Key Recommendations

-

{{ analysis.recommendations }}

+ {% set recommendation_lines = analysis.recommendations.split('\n') %} + {% if recommendation_lines|length > 1 %} +
    + {% for item in recommendation_lines %} + {% if item.strip() %} +
  • {{ item }}
  • + {% endif %} + {% endfor %} +
+ {% else %} +

{{ analysis.recommendations }}

+ {% endif %}
@@ -61,6 +112,39 @@

Threats

+ + {% if scenario.additional_notes %} +
+
+

Scenario Notes

+

{{ scenario.additional_notes }}

+
+
+ {% endif %} + + {% if scenario.uploaded_documents %} +
+
+

Document intelligence

+

Key highlights extracted from your upload. Full excerpts are trimmed for security before being shared with ChatGPT.

+
+ {% for doc in scenario.uploaded_documents %} +
+
+
+

{{ doc.filename }}

+

{{ doc.summary }}

+
+ {{ doc.size_kb }} KB +
+
{{ doc.format|upper }} · {{ doc.line_count }} lines processed
+
{{ doc.excerpt }}
+
+ {% endfor %} +
+
+
+ {% endif %}
@@ -77,7 +161,7 @@

Financial Trajectory

Scenario Summary

-
+
Risk tolerance
{{ scenario.risk_tolerance }}
Current revenue
@@ -93,6 +177,19 @@

Scenario Summary

Target market share
{{ '{:.2f}%'.format(scenario.market_share_target) }}
+
+
+
+

Risk posture

+

{{ risk_profile.label }}

+
+
{{ risk_profile.score }}/5
+
+
+
+
+

{{ risk_profile.description }}

+