diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ace6c1b --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +FLASK_APP=app +FLASK_ENV=development +OLLAMA_HOST=http://localhost:11434 +LLM_MODEL=llama3.2:latest +LLM_CONTEXT=4096 +LLM_MAX_TOKENS=768 +LLM_TEMPERATURE=0.2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c1f29d --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.venv/ +__pycache__/ +*.pyc +instance/ +.env +uploads/* +!uploads/.gitkeep +*.log +.DS_Store +node_modules/ +client/node_modules/ +server/node_modules/ diff --git a/README.md b/README.md index 3a08a72..b588e37 100644 --- a/README.md +++ b/README.md @@ -1 +1,128 @@ -# beta \ No newline at end of file +# Aurora CRM Intelligence Suite + +Aurora CRM Intelligence Suite is a local-first command center for predictive strategy, risk analytics, AI advisory chat, and deep document comparison. Every insight runs on your workstation through the [Ollama](https://ollama.com) runtime, so nothing ever leaves your RTX 4090 (or compatible) GPU. + +> **Security reminder** +> No cloud API keys are required. Keep your model checkpoints private and stored locally. + +## Project structure + +``` +. +├── app/ # Flask application package +│ ├── services/ # Local inference + file helper utilities +│ ├── static/ # Custom CSS and interaction scripts +│ └── templates/ # Multi-page Jinja UI +├── uploads/ # Local document catalog (created automatically) +├── app.py # Flask entry point +├── requirements.txt # Python dependencies (Flask + requests) +├── install.ps1 # Windows installer shim (PowerShell) +├── run.ps1 # Windows auto runner +├── install.bat # Convenience launcher that calls install.ps1 +├── run.bat # Convenience launcher that calls run.ps1 +└── README.md # You are here +``` + +## Prerequisites + +- Python **3.10+** (tested with CPython 3.12) +- PowerShell 5.1+ (bundled with Windows 11) for the automation scripts +- NVIDIA GPU with recent drivers (4090 recommended) or enough CPU cores if you prefer CPU inference +- [Ollama for Windows](https://ollama.com/download/windows) installed and running (`ollama serve` starts automatically once installed) +- A local **GGUF** checkpoint available to Ollama (use `ollama pull llama3.2` or `ollama pull qwen2.5-coder` etc.) + +## Quick start (Windows 11) + +1. Install [Ollama](https://ollama.com/download/windows) if you have not already, then open **PowerShell** and pull a chat-capable model (for example Meta's Llama 3.2): + ```powershell + ollama pull llama3.2 + ``` + Ollama automatically stores the GGUF checkpoint in its managed directory and serves it to local clients. +2. Open **PowerShell** and `cd` into the project directory. +3. If this is your first time running local scripts, allow the session to run them: + ```powershell + Set-ExecutionPolicy -Scope Process RemoteSigned + ``` +4. Run the installer: + ```powershell + ./install.ps1 + ``` + The script will: + - Detect Python 3 + - Create (or reuse) a `.venv` virtual environment + - Install dependencies from `requirements.txt` +5. Create your `.env` file and set the Ollama options: + ```powershell + Copy-Item .env.example .env + notepad .env + ``` + Update `LLM_MODEL` with the model name you pulled (for example `llama3.2:latest`). Leave `OLLAMA_HOST` at the default unless you run Ollama on a different machine. +6. Launch the stack: + ```powershell + ./run.ps1 + ``` + The runner activates the virtual environment, exports variables from `.env`, verifies the model path, and starts Flask in debug mode at . + +You can double-click `install.bat` or `run.bat` from Explorer—they simply relay to the PowerShell scripts with execution policy bypassed. + +## Quick start (macOS/Linux) + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # edit with your Ollama host and model +export OLLAMA_HOST="http://localhost:11434" +export LLM_MODEL="llama3.2:latest" +flask --app app --debug run +``` + +Visit once the server reports it is running. + +## Feature tour + +- **Overview dashboard** – animated chrome with catalog health, workflow readiness, and immersive storytelling. +- **Data steward** – upload TXT, MD, CSV, or JSON files and request local AI summaries with quality scoring, tags, and opportunity prompts. +- **Predictive strategy** – craft revenue scenarios, score growth outlooks, and review initiative guidance with progress dials and impact distributions. +- **Risk command** – quantify exposure, inspect mitigation cards, and monitor early warnings with visual coverage bars. +- **Advisor lounge** – chat with the embedded strategist, auto-load document summaries, and keep every conversation on device. +- **Comparison lab** – run deep comparisons between two documents to expose alignment, divergence, and actionable recommendations. + +Each workflow uses structured prompts defined in `app/services/insights.py`. Responses are parsed as JSON and rendered dynamically with a custom glassmorphism UI—no external CDNs required. + +## Configuration notes + +- `OLLAMA_HOST` – Base URL where Ollama listens (default `http://localhost:11434`). +- `LLM_MODEL` – **required**. Model name as listed by `ollama list` (for example `llama3.2:latest`). +- `LLM_CONTEXT` – Context window supplied to Ollama (default `4096`). Increase if your model supports a larger context. +- `LLM_MAX_TOKENS` – Maximum tokens to generate per response (default `768`). +- `LLM_TEMPERATURE` – Sampling temperature (default `0.2`). +- Uploaded documents remain on disk inside the local `uploads/` folder. Delete files there to clear the catalog. + +## Automation scripts + +The Windows automation lives under `scripts/windows/` and is intentionally PowerShell-only—no external tooling required. + +- `install.ps1` – ensures Python is present, provisions `.venv`, and installs dependencies. +- `run.ps1` – verifies the environment, loads `.env`, reminds you if Ollama variables are unset, and starts Flask using the virtual environment's interpreter. + +Both scripts print friendly diagnostics (missing Python, missing model, etc.) so issues are easy to resolve. + +## Validation checklist + +- Flask routes use built-in templating; no frontend framework is required. +- All inference stays local by calling the Ollama HTTP API on `OLLAMA_HOST`; there are **no** outbound internet calls from the Flask app. +- File uploads are limited to text-based formats and capped at 10 MB per file to keep processing light. +- UI assets rely entirely on bundled CSS/JS with layered gradients, animated nav, and custom progress visuals. + +## Troubleshooting + +| Symptom | Resolution | +| --- | --- | +| "Local inference is not configured" error | Launch Ollama (it starts automatically after installation), run `ollama pull `, and set `LLM_MODEL` in `.env`. | +| App loads but responses are empty | Confirm the pulled Ollama model supports chat instruction tuning and that `LLM_CONTEXT` is within the model's limit. | +| Slow responses on Windows | Verify Ollama is using your GPU (run `ollama run llama3.2 "test"` in a console and watch GPU usage). | +| PowerShell blocks the scripts | Run PowerShell as Administrator once and execute `Set-ExecutionPolicy -Scope Process RemoteSigned`. | +| Installer reports Python not found | Install Python 3.10+ from and tick "Add to PATH" during setup. | + +Enjoy orchestrating predictive CRM intelligence locally! diff --git a/app.py b/app.py new file mode 100644 index 0000000..488dae9 --- /dev/null +++ b/app.py @@ -0,0 +1,6 @@ +from app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run(debug=True) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..7ea159b --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,18 @@ +from flask import Flask +from pathlib import Path + +from dotenv import load_dotenv + + +def create_app() -> Flask: + load_dotenv() + app = Flask(__name__) + app.config["UPLOAD_FOLDER"] = Path(app.root_path).parent / "uploads" + app.config["UPLOAD_FOLDER"].mkdir(parents=True, exist_ok=True) + app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024 # 10 MB per file + app.config["SECRET_KEY"] = app.config.get("SECRET_KEY") or "dev-secret-key" + + from . import routes # noqa: WPS433 + + routes.register(app) + return app diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..9b78343 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import json +from collections import Counter +from typing import Any, Dict, List, Tuple + +from flask import ( + Blueprint, + Response, + current_app, + flash, + jsonify, + redirect, + render_template, + request, + url_for, +) +from werkzeug.utils import secure_filename + +from .services import files as file_service +from .services.insights import InsightService, InsightServiceError + +blueprint = Blueprint("core", __name__) + +insight_service = InsightService() + + +@blueprint.app_errorhandler(InsightServiceError) +def handle_insight_error(error: InsightServiceError) -> tuple[Response, int]: + return ( + jsonify({ + "error": str(error), + "hint": "Start Ollama, pull your model, and ensure OLLAMA_HOST/LLM_MODEL are configured.", + }), + 400, + ) + + +@blueprint.route("/") +def overview() -> str: + return _render_workspace("overview.html") + + +@blueprint.route("/data-management") +def data_management() -> str: + return _render_workspace("data_management.html") + + +@blueprint.route("/predictive-strategy") +def predictive_strategy() -> str: + return _render_workspace("prediction.html") + + +@blueprint.route("/risk-command") +def risk_command() -> str: + return _render_workspace("risk.html") + + +@blueprint.route("/ai-advisor") +def ai_advisor() -> str: + return _render_workspace("advisor.html") + + +@blueprint.route("/comparison-lab") +def comparison_lab() -> str: + return _render_workspace("comparison.html") + + +@blueprint.route("/data/upload", methods=["POST"]) +def upload_file() -> Response: + if "document" not in request.files: + flash("Select a document before uploading.", "danger") + return redirect(url_for("core.data_management")) + + document = request.files["document"] + if not document.filename: + flash("The selected document has no filename.", "danger") + return redirect(url_for("core.data_management")) + + filename = secure_filename(document.filename) + try: + saved_path = file_service.save_upload( + storage=document, + filename=filename, + destination=current_app.config["UPLOAD_FOLDER"], + ) + except ValueError as exc: + flash(str(exc), "danger") + return redirect(url_for("core.data_management")) + + flash(f"Uploaded {filename} to {saved_path.name}.", "success") + return redirect(url_for("core.data_management")) + + +@blueprint.route("/api/catalog") +def api_catalog() -> Response: + catalog = file_service.catalog(current_app.config["UPLOAD_FOLDER"]) + return jsonify({"documents": catalog}) + + +@blueprint.route("/api/data/summarize", methods=["POST"]) +def api_summarize() -> Response: + payload: Dict[str, Any] = request.get_json(force=True) + filename = payload.get("filename") + if not filename: + return jsonify({"error": "filename is required"}), 400 + + try: + document = file_service.load_document(current_app.config["UPLOAD_FOLDER"], filename) + except (FileNotFoundError, ValueError) as exc: + return jsonify({"error": str(exc)}), 400 + + summary = insight_service.summarize_document(document) + return jsonify(summary) + + +@blueprint.route("/api/predict", methods=["POST"]) +def api_predict() -> Response: + data = _clean_payload(request.get_json(force=True)) + result = insight_service.generate_prediction(data) + return jsonify(result) + + +@blueprint.route("/api/risk", methods=["POST"]) +def api_risk() -> Response: + data = _clean_payload(request.get_json(force=True)) + result = insight_service.assess_risk(data) + return jsonify(result) + + +@blueprint.route("/api/advice", methods=["POST"]) +def api_advice() -> Response: + data = _clean_payload(request.get_json(force=True)) + result = insight_service.provide_advice(data) + return jsonify(result) + + +@blueprint.route("/api/chat", methods=["POST"]) +def api_chat() -> Response: + data = _clean_payload(request.get_json(force=True)) + conversation: List[Dict[str, str]] = data.get("conversation", []) + if not isinstance(conversation, list): + return jsonify({"error": "conversation must be a list"}), 400 + + result = insight_service.chat(conversation, context=data.get("context")) + return jsonify(result) + + +@blueprint.route("/api/compare", methods=["POST"]) +def api_compare() -> Response: + data = _clean_payload(request.get_json(force=True)) + left_name = data.get("left") + right_name = data.get("right") + if not left_name or not right_name: + return jsonify({"error": "left and right filenames are required"}), 400 + + try: + left_doc = file_service.load_document(current_app.config["UPLOAD_FOLDER"], left_name) + right_doc = file_service.load_document(current_app.config["UPLOAD_FOLDER"], right_name) + except (FileNotFoundError, ValueError) as exc: + return jsonify({"error": str(exc)}), 400 + result = insight_service.compare_documents(left_doc, right_doc) + return jsonify(result) + + +def _clean_payload(payload: Dict[str, Any] | None) -> Dict[str, Any]: + if payload is None: + return {} + return json.loads(json.dumps(payload)) + + +def register(app) -> None: # pragma: no cover - flask wiring + app.register_blueprint(blueprint) + + +def _render_workspace(template: str, **context: Any) -> str: + catalog, catalog_summary, recent_documents = _build_catalog_context() + return render_template( + template, + catalog=catalog, + catalog_summary=catalog_summary, + recent_documents=recent_documents, + api_ready=insight_service.available, + **context, + ) + + +def _build_catalog_context() -> Tuple[List[Dict[str, Any]], Dict[str, Any], List[Dict[str, Any]]]: + catalog = file_service.catalog(current_app.config["UPLOAD_FOLDER"]) + summary = _summarize_catalog(catalog) + spotlight = catalog[:3] + return catalog, summary, spotlight + + +def _summarize_catalog(catalog: List[Dict[str, Any]]) -> Dict[str, Any]: + if not catalog: + return { + "count": 0, + "total_size": "0 B", + "average_size": "0 B", + "last_updated": "—", + "coverage": [], + } + + total_bytes = sum(item.get("size_bytes", 0) for item in catalog) + average_bytes = total_bytes / len(catalog) + latest = max(catalog, key=lambda item: item.get("updated_epoch", 0)) + coverage_counter = Counter(item.get("type", "OTHER") for item in catalog) + coverage = [ + { + "label": label, + "value": count, + "percent": round((count / len(catalog)) * 100), + } + for label, count in coverage_counter.most_common() + ] + + return { + "count": len(catalog), + "total_size": _format_bytes(total_bytes), + "average_size": _format_bytes(int(average_bytes)), + "last_updated": latest.get("updated"), + "coverage": coverage, + } + + +def _format_bytes(value: int) -> str: + if value < 1024: + return f"{value} B" + if value < 1024 ** 2: + return f"{value / 1024:.1f} KB" + if value < 1024 ** 3: + return f"{value / (1024 ** 2):.1f} MB" + return f"{value / (1024 ** 3):.1f} GB" diff --git a/app/services/files.py b/app/services/files.py new file mode 100644 index 0000000..09ad586 --- /dev/null +++ b/app/services/files.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import csv +import io +import json +from datetime import datetime +from pathlib import Path +from typing import BinaryIO, Dict, List + +ALLOWED_EXTENSIONS = {"txt", "md", "csv", "json"} + + +class Document(Dict[str, str]): + """Typed dictionary representing an uploaded document.""" + + +def catalog(directory: Path) -> List[Dict[str, str]]: + directory.mkdir(parents=True, exist_ok=True) + entries: List[Dict[str, str]] = [] + for file_path in sorted( + directory.glob("*"), key=lambda path: path.stat().st_mtime, reverse=True + ): + if not file_path.is_file(): + continue + size_bytes = file_path.stat().st_size + extension = file_path.suffix.lstrip(".").upper() or "N/A" + updated_dt = datetime.fromtimestamp(file_path.stat().st_mtime) + entries.append( + { + "name": file_path.name, + "size": _format_size(size_bytes), + "size_bytes": size_bytes, + "type": extension, + "updated": updated_dt.strftime("%Y-%m-%d %H:%M"), + "updated_iso": updated_dt.isoformat(), + "updated_epoch": file_path.stat().st_mtime, + } + ) + return entries + + +def save_upload(storage: BinaryIO, filename: str, destination: Path) -> Path: + extension = filename.rsplit(".", 1)[-1].lower() + if extension not in ALLOWED_EXTENSIONS: + raise ValueError( + f"Unsupported file type: .{extension}. Allowed types are {', '.join(sorted(ALLOWED_EXTENSIONS))}." + ) + destination.mkdir(parents=True, exist_ok=True) + file_path = destination / filename + if hasattr(storage, "seek"): + storage.seek(0) + data = storage.read() + else: + storage.stream.seek(0) + data = storage.stream.read() + with open(file_path, "wb") as buffer: + buffer.write(data) + return file_path + + +def load_document(directory: Path, filename: str) -> Document: + file_path = (directory / filename).resolve() + if not file_path.exists(): + raise FileNotFoundError(f"Document {filename} was not found in uploads directory.") + text = _extract_text(file_path) + return Document( + name=filename, + text=text, + size=str(file_path.stat().st_size), + type=file_path.suffix.lstrip(".") or "unknown", + ) + + +def _extract_text(file_path: Path) -> str: + extension = file_path.suffix.lower() + if extension in {".txt", ".md"}: + return file_path.read_text(encoding="utf-8", errors="ignore") + if extension == ".json": + data = json.loads(file_path.read_text(encoding="utf-8", errors="ignore")) + return json.dumps(data, indent=2) + if extension == ".csv": + content = io.StringIO(file_path.read_text(encoding="utf-8", errors="ignore")) + reader = csv.reader(content) + rows = [", ".join(row) for row in reader] + return "\n".join(rows) + raise ValueError(f"Unable to extract text from {file_path.name}.") + + +def _format_size(size_bytes: int) -> str: + if size_bytes < 1024: + return f"{size_bytes} B" + if size_bytes < 1024 ** 2: + return f"{size_bytes / 1024:.1f} KB" + if size_bytes < 1024 ** 3: + return f"{size_bytes / (1024 ** 2):.1f} MB" + return f"{size_bytes / (1024 ** 3):.1f} GB" diff --git a/app/services/insights.py b/app/services/insights.py new file mode 100644 index 0000000..e016877 --- /dev/null +++ b/app/services/insights.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import json +import os +import re +from typing import Any, Dict, Iterable, List, Optional + +import requests + +DEFAULT_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434").rstrip("/") +DEFAULT_MODEL = os.getenv("LLM_MODEL", "").strip() +DEFAULT_CONTEXT = int(os.getenv("LLM_CONTEXT", "4096")) +DEFAULT_MAX_TOKENS = int(os.getenv("LLM_MAX_TOKENS", "768")) +DEFAULT_TEMPERATURE = float(os.getenv("LLM_TEMPERATURE", "0.2")) + + +class InsightServiceError(RuntimeError): + """Raised when the AI assistant cannot complete a request.""" + + +class InsightService: + def __init__( + self, + *, + host: str = DEFAULT_HOST, + model: str = DEFAULT_MODEL, + context: int = DEFAULT_CONTEXT, + max_tokens: int = DEFAULT_MAX_TOKENS, + temperature: float = DEFAULT_TEMPERATURE, + ) -> None: + self.host = host or "http://localhost:11434" + self.model = model + self.context = context + self.max_tokens = max_tokens + self.temperature = temperature + self._session = requests.Session() + self.available = self._detect_availability() + + # ------------------------------------------------------------------ + # Public helpers + # ------------------------------------------------------------------ + def generate_prediction(self, payload: Dict[str, Any]) -> Dict[str, Any]: + self._ensure_ready() + scenario = json.dumps(payload, ensure_ascii=False, indent=2) + messages = [ + self._system( + "You are an elite business strategist. Return a JSON object with fields: " + "summary, growth_outlook (0-100), confidence (0-100), key_initiatives (array of strings), " + "insight_cards (array of {title, detail}), and chart {labels: string[], series: number[]} " + "representing projected impact areas. Use concise business language." + ), + self._user(f"Scenario data:```json\n{scenario}\n```"), + ] + return self._respond(messages) + + def assess_risk(self, payload: Dict[str, Any]) -> Dict[str, Any]: + self._ensure_ready() + scenario = json.dumps(payload, ensure_ascii=False, indent=2) + messages = [ + self._system( + "You are a chief risk officer. Return JSON with fields: risk_summary, overall_score (0-100), " + "risk_matrix (array of {name, likelihood, impact, mitigation, detail}), early_warnings (string array), " + "and heatmap {labels: string[], series: number[]} for visualization." + ), + self._user(f"Business context:```json\n{scenario}\n```"), + ] + return self._respond(messages) + + def provide_advice(self, payload: Dict[str, Any]) -> Dict[str, Any]: + self._ensure_ready() + context = json.dumps(payload, ensure_ascii=False, indent=2) + messages = [ + self._system( + "You are an executive advisor. Reply as JSON with fields: recommendation, execution_steps (array), " + "follow_up_questions (array), and sentiment (supportive | neutral | cautionary)." + ), + self._user(f"Context for advice:```json\n{context}\n```"), + ] + return self._respond(messages) + + def chat(self, conversation: Iterable[Dict[str, str]], context: Optional[str] = None) -> Dict[str, Any]: + self._ensure_ready() + messages = [self._system("You are a CRM co-pilot offering crisp, actionable insights.")] + if context: + messages.append(self._user(f"Reference context: {context}")) + for turn in conversation: + role = turn.get("role") or "user" + content = turn.get("content") or "" + messages.append({"role": role, "content": content}) + response = self._raw_request(messages) + answer = response.get("choices", [{}])[0].get("message", {}).get("content", "") + return {"reply": answer} + + def compare_documents(self, left: Dict[str, str], right: Dict[str, str]) -> Dict[str, Any]: + self._ensure_ready() + left_excerpt = left.get("text", "")[:6000] + right_excerpt = right.get("text", "")[:6000] + messages = [ + self._system( + "You are a principal consultant performing deep document comparison. " + "Return JSON with sections: synopsis (string), alignment (array of {theme, assessment}), " + "divergence (array of {issue, severity, detail}), recommendations (array), " + "and comparison_score (0-100)." + ), + self._user( + f"Document A ({left.get('name')}):\n```\n{left_excerpt}\n```\n\n" + f"Document B ({right.get('name')}):\n```\n{right_excerpt}\n```" + ), + ] + return self._respond(messages) + + def summarize_document(self, document: Dict[str, str]) -> Dict[str, Any]: + self._ensure_ready() + snippet = document.get("text", "")[:6000] + messages = [ + self._system( + "Summarize the document for CRM stakeholders. Respond as JSON with fields: summary, tags (array), " + "data_quality (0-100), and opportunities (array)." + ), + self._user( + f"Document {document.get('name')} excerpt:\n```\n{snippet}\n```" + ), + ] + return self._respond(messages) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + def _respond(self, messages: List[Dict[str, str]]) -> Dict[str, Any]: + response = self._raw_request(messages) + content = response.get("choices", [{}])[0].get("message", {}).get("content", "") + return self._parse_json(content) + + def _raw_request(self, messages: List[Dict[str, str]]) -> Dict[str, Any]: + payload = { + "model": self.model, + "messages": messages, + "stream": False, + "options": { + "temperature": self.temperature, + "num_ctx": self.context, + "num_predict": self.max_tokens, + }, + } + url = f"{self.host}/api/chat" + try: + response = self._session.post(url, json=payload, timeout=120) + response.raise_for_status() + except requests.RequestException as exc: # pragma: no cover - network + raise InsightServiceError( + "Unable to contact the local Ollama service. Ensure Ollama is running and the model is pulled." + ) from exc + + try: + data = response.json() + except ValueError as exc: # pragma: no cover - invalid JSON from runtime + raise InsightServiceError("The local model returned an invalid response. Review your Ollama logs.") from exc + + if isinstance(data, dict) and data.get("error"): + raise InsightServiceError(f"Ollama error: {data['error']}") + + message = (data.get("message") or {}).get("content", "") + return {"choices": [{"message": {"content": message}}]} + + def _ensure_ready(self) -> None: + if not self.available: + raise InsightServiceError( + "Local inference is not configured. Install and start Ollama, set OLLAMA_HOST, and specify LLM_MODEL in your .env." + ) + + def _detect_availability(self) -> bool: + if not self.model: + return False + try: + response = self._session.get(f"{self.host}/api/tags", timeout=5) + response.raise_for_status() + payload = response.json() + except requests.RequestException: + return False + except ValueError: + return False + + models = payload.get("models", []) if isinstance(payload, dict) else [] + if not models: + return True + return any( + self.model == entry.get("name") or self.model == entry.get("model") + for entry in models + if isinstance(entry, dict) + ) + + @staticmethod + def _system(content: str) -> Dict[str, str]: + return {"role": "system", "content": content} + + @staticmethod + def _user(content: str) -> Dict[str, str]: + return {"role": "user", "content": content} + + @staticmethod + def _parse_json(payload: str) -> Dict[str, Any]: + try: + return json.loads(payload) + except json.JSONDecodeError: + match = re.search(r"\{[\s\S]*\}", payload) + if match: + try: + return json.loads(match.group(0)) + except json.JSONDecodeError as exc: + raise InsightServiceError( + "The AI response was not valid JSON. Adjust your prompt or retry." + ) from exc + raise InsightServiceError("The AI response was not valid JSON. Adjust your prompt or retry.") diff --git a/app/static/css/styles.css b/app/static/css/styles.css new file mode 100644 index 0000000..2f91354 --- /dev/null +++ b/app/static/css/styles.css @@ -0,0 +1,1181 @@ +:root { + --font-sans: "Inter", "Segoe UI", "Manrope", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + --bg-primary: #050513; + --bg-elevated: rgba(18, 21, 45, 0.72); + --bg-elevated-strong: rgba(30, 33, 64, 0.84); + --bg-panel: rgba(18, 22, 48, 0.82); + --bg-panel-soft: rgba(25, 29, 62, 0.7); + --bg-glow: linear-gradient(135deg, rgba(94, 147, 255, 0.22), rgba(120, 72, 255, 0.18)); + --border-subtle: rgba(255, 255, 255, 0.08); + --border-strong: rgba(94, 147, 255, 0.45); + --text-primary: #f8f9ff; + --text-secondary: rgba(248, 249, 255, 0.72); + --text-muted: rgba(248, 249, 255, 0.48); + --accent: #6f8bff; + --accent-strong: #4dd0ff; + --accent-hot: #ff7ab8; + --danger: #ff6b6b; + --warning: #ffc861; + --success: #58f29a; + --shadow-soft: 0 25px 60px -35px rgba(0, 0, 0, 0.65); + --radius-xl: 34px; + --radius-lg: 26px; + --radius-md: 18px; + --radius-sm: 12px; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +body { + min-height: 100vh; + margin: 0; + font-family: var(--font-sans); + background: + radial-gradient(circle at 12% 18%, rgba(109, 173, 255, 0.22), transparent 55%), + radial-gradient(circle at 85% 12%, rgba(255, 112, 173, 0.18), transparent 65%), + radial-gradient(circle at 65% 85%, rgba(77, 208, 255, 0.14), transparent 60%), + var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + transition: background 0.3s ease; +} + +body.focus-mode { + background: radial-gradient(circle at 50% 0%, rgba(36, 53, 120, 0.3), transparent 70%), #020209; +} + +.background-veil { + position: fixed; + inset: 0; + z-index: -2; + pointer-events: none; +} + +.background-veil::before, +.background-veil::after { + content: ""; + position: absolute; + border-radius: 50%; + filter: blur(140px); + opacity: 0.65; +} + +.background-veil::before { + width: 460px; + height: 460px; + top: -140px; + left: -110px; + background: radial-gradient(circle at 30% 30%, rgba(125, 151, 255, 0.45), transparent 70%); +} + +.background-veil::after { + width: 520px; + height: 520px; + bottom: -220px; + right: -160px; + background: radial-gradient(circle at 50% 50%, rgba(255, 120, 184, 0.32), transparent 65%); +} + +.container { + width: min(1280px, 92vw); + margin: 0 auto; +} + +.shell-header { + position: sticky; + top: 0; + z-index: 20; + background: rgba(6, 7, 18, 0.7); + backdrop-filter: blur(24px); + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.shell-header__inner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.1rem 0; +} + +.brand-mark { + display: flex; + align-items: center; + gap: 0.9rem; + text-decoration: none; + color: var(--text-primary); + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.brand-icon { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 14px; + background: linear-gradient(135deg, rgba(112, 142, 255, 0.75), rgba(77, 208, 255, 0.75)); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.4); + font-weight: 800; +} + +.shell-header__actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.nav-toggle { + display: none; + width: 44px; + height: 44px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(18, 23, 46, 0.65); + color: var(--text-primary); + font-size: 1.2rem; +} + +.shell-nav { + display: flex; + gap: 0.4rem; + padding-bottom: 1.1rem; +} + +.shell-nav__link { + padding: 0.55rem 1.25rem; + border-radius: 999px; + text-decoration: none; + font-size: 0.9rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); + background: transparent; + border: 1px solid transparent; + transition: background 0.3s ease, border-color 0.3s ease, color 0.3s ease; +} + +.shell-nav__link:hover { + color: var(--text-primary); + border-color: rgba(255, 255, 255, 0.12); + background: rgba(111, 139, 255, 0.12); +} + +.shell-nav__link.is-active { + color: var(--text-primary); + border-color: rgba(255, 255, 255, 0.32); + background: linear-gradient(135deg, rgba(111, 139, 255, 0.26), rgba(77, 208, 255, 0.26)); +} + +.status-strip { + padding: 1.75rem 0 1rem; +} + +.status-strip__inner { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.status-card { + position: relative; + padding: 1.35rem 1.5rem; + border-radius: var(--radius-lg); + background: var(--bg-elevated); + border: 1px solid rgba(255, 255, 255, 0.06); + box-shadow: var(--shadow-soft); +} + +.status-card__label { + display: block; + font-size: 0.75rem; + letter-spacing: 0.32em; + text-transform: uppercase; + color: var(--text-muted); +} + +.status-card__value { + margin-top: 0.75rem; + font-size: 1.1rem; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 0.65rem; +} + +.status-card__value .dot { + width: 11px; + height: 11px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.5); + box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.08); +} + +.status-card__value.online .dot { + background: var(--success); +} + +.status-card__value.offline .dot { + background: var(--warning); +} + +.status-card__hint { + margin-top: 0.45rem; + font-size: 0.78rem; + color: var(--text-muted); +} + +.command-strip { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.command-btn { + border: 1px solid rgba(255, 255, 255, 0.1); + color: var(--text-primary); + background: rgba(13, 16, 34, 0.72); + padding: 0.6rem 1.5rem; + border-radius: 999px; + font-size: 0.78rem; + letter-spacing: 0.18em; + text-transform: uppercase; + transition: transform 0.3s ease, background 0.3s ease; +} + +.command-btn:hover { + background: rgba(111, 139, 255, 0.2); + transform: translateY(-2px); +} + +.disabled { + opacity: 0.5; + pointer-events: none; +} + +.page-shell { + padding: 1rem 0 4rem; +} + +.flash-stack { + display: grid; + gap: 0.8rem; + margin-bottom: 2rem; +} + +.flash-message { + padding: 1rem 1.25rem; + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + font-size: 0.95rem; +} + +.flash-message.success { + border-color: rgba(120, 255, 190, 0.4); + background: rgba(120, 255, 190, 0.12); +} + +.flash-message.danger { + border-color: rgba(255, 120, 160, 0.4); + background: rgba(255, 120, 160, 0.12); +} + +code, +kbd { + background: rgba(255, 255, 255, 0.08); + border-radius: 6px; + padding: 0.15rem 0.4rem; + font-family: var(--font-sans); + font-size: 0.75rem; +} + +.panel { + position: relative; + border-radius: var(--radius-xl); + background: var(--bg-panel); + padding: clamp(1.75rem, 1.6vw + 1.4rem, 3rem); + border: 1px solid rgba(255, 255, 255, 0.07); + box-shadow: var(--shadow-soft); + backdrop-filter: blur(28px); +} + +.panel::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + border: 1px solid rgba(255, 255, 255, 0.05); + pointer-events: none; + mix-blend-mode: screen; +} + +.panel--soft { + background: var(--bg-panel-soft); +} + +.hero-grid { + display: grid; + gap: clamp(2rem, 2vw + 1rem, 3.5rem); + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +.hero-eyebrow { + display: inline-flex; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 1.2rem; + border-radius: 999px; + font-size: 0.75rem; + letter-spacing: 0.32em; + text-transform: uppercase; + color: var(--text-primary); + background: linear-gradient(135deg, rgba(111, 139, 255, 0.24), rgba(77, 208, 255, 0.2)); +} + +.hero-eyebrow .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success); +} + +.display-xl { + font-size: clamp(2.8rem, 2.4vw + 2.2rem, 4.2rem); + line-height: 1.08; + font-weight: 700; + margin: 1.1rem 0 1.5rem; + background: linear-gradient(135deg, #fff, rgba(180, 207, 255, 0.9)); + -webkit-background-clip: text; + color: transparent; +} + +.display-md { + font-size: clamp(1.8rem, 1.4vw + 1.3rem, 2.6rem); + line-height: 1.2; + font-weight: 600; + margin: 0.9rem 0 1.1rem; +} + +.text-muted { + color: var(--text-secondary); +} + +.text-dimmed { + color: var(--text-muted); +} + +.kpi-grid { + display: grid; + gap: 1.2rem; +} + +.kpi-card { + border-radius: var(--radius-lg); + background: rgba(12, 16, 36, 0.75); + border: 1px solid rgba(255, 255, 255, 0.07); + padding: 1.4rem 1.6rem; + display: grid; + gap: 0.6rem; +} + +.kpi-card.primary { + background: linear-gradient(135deg, rgba(104, 135, 255, 0.35), rgba(77, 205, 255, 0.2)); + border-color: rgba(120, 180, 255, 0.5); +} + +.kpi-label { + text-transform: uppercase; + letter-spacing: 0.26em; + font-size: 0.72rem; + color: var(--text-muted); +} + +.kpi-value { + font-size: 1.9rem; + font-weight: 700; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 0.85rem; + margin-top: 2rem; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + border-radius: 16px; + border: none; + padding: 0.85rem 1.65rem; + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--text-primary); + background: rgba(16, 18, 36, 0.8); + border: 1px solid rgba(255, 255, 255, 0.06); + cursor: pointer; + transition: transform 0.25s ease, box-shadow 0.25s ease, background 0.25s ease; +} + +.btn:hover { + transform: translateY(-2px); + box-shadow: 0 18px 32px -24px rgba(111, 139, 255, 0.8); +} + +.btn--primary { + background: linear-gradient(135deg, rgba(111, 139, 255, 0.85), rgba(77, 208, 255, 0.85)); + border-color: rgba(255, 255, 255, 0.24); +} + +.btn--secondary { + background: rgba(12, 18, 44, 0.8); +} + +.btn--outline { + background: transparent; + border-color: rgba(255, 255, 255, 0.25); +} + +.btn--ghost { + background: rgba(255, 255, 255, 0.08); + border-color: transparent; +} + +.btn--danger { + background: linear-gradient(135deg, rgba(255, 122, 152, 0.85), rgba(255, 107, 107, 0.85)); +} + +.btn--full { + width: 100%; +} + +.btn--small { + padding: 0.55rem 1.1rem; + font-size: 0.8rem; +} + +.scroll-link { + display: inline-flex; + align-items: center; + gap: 0.55rem; + padding: 0.5rem 1rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + color: var(--text-primary); + text-decoration: none; +} + +.marquee { + overflow: hidden; + position: relative; + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.07); + background: rgba(10, 12, 28, 0.72); + margin-top: 1.8rem; +} + +.marquee-track { + display: inline-flex; + gap: 2.5rem; + padding: 0.9rem 1.4rem; + animation: marquee 24s linear infinite; +} + +.marquee-item { + font-size: 0.9rem; + color: var(--text-secondary); +} + +@keyframes marquee { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } +} + +.spotlight-grid { + display: grid; + gap: 1rem; + margin-top: 1.5rem; +} + +.spotlight-card { + padding: 1rem 1.25rem; + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(14, 16, 34, 0.72); + display: grid; + gap: 0.35rem; +} + +.spotlight-card header { + display: flex; + justify-content: space-between; + font-size: 0.78rem; + color: var(--text-muted); + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 0.35rem 0.85rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.06); +} + +.badge--muted { + background: rgba(255, 255, 255, 0.12); +} + +.coverage-bars { + display: grid; + gap: 0.8rem; + margin-top: 1.3rem; +} + +.coverage-bar { + display: grid; + gap: 0.4rem; +} + +.coverage-bar .bar { + position: relative; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; + height: 8px; +} + +.coverage-bar .bar span { + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(111, 139, 255, 0.65), rgba(77, 208, 255, 0.65)); +} + +.feature-grid { + display: grid; + gap: 1.75rem; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + margin-top: 3rem; +} + +.feature-card { + border-radius: var(--radius-lg); + background: rgba(12, 16, 34, 0.72); + border: 1px solid rgba(255, 255, 255, 0.08); + padding: 1.6rem; + display: grid; + gap: 0.9rem; + transition: transform 0.3s ease, background 0.3s ease; +} + +.feature-card:hover { + transform: translateY(-6px); + background: rgba(22, 28, 56, 0.85); +} + +.feature-icon { + font-size: 1.75rem; +} + +.section-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.8rem; +} + +.section-heading .eyebrow { + font-size: 0.75rem; + letter-spacing: 0.32em; + text-transform: uppercase; + color: var(--text-muted); +} + +.eyebrow { + font-size: 0.74rem; + letter-spacing: 0.32em; + text-transform: uppercase; + color: var(--text-muted); + margin: 0; +} + +.timeline { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 1.1rem; +} + +.timeline-item { + position: relative; + padding-left: 1.75rem; +} + +.timeline-item::before { + content: ""; + position: absolute; + left: 0.4rem; + top: 0.35rem; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 12px rgba(111, 139, 255, 0.5); +} + +.timeline-item::after { + content: ""; + position: absolute; + left: 0.85rem; + top: 1.2rem; + bottom: -1.2rem; + width: 1px; + background: rgba(255, 255, 255, 0.18); +} + +.timeline-item:last-child::after { + display: none; +} + +.story-grid { + display: grid; + gap: 1.25rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.story-card { + border-radius: var(--radius-lg); + padding: 1.35rem; + background: rgba(12, 16, 38, 0.75); + border: 1px solid rgba(255, 255, 255, 0.08); + display: grid; + gap: 0.6rem; +} + +.story-card.highlight { + background: linear-gradient(135deg, rgba(111, 139, 255, 0.28), rgba(77, 208, 255, 0.24)); + border-color: rgba(255, 255, 255, 0.16); +} + +.story-card ul { + padding-left: 1rem; + margin: 0; + color: var(--text-secondary); +} + +.catalog-grid { + display: grid; + gap: 1.1rem; +} + +.catalog-card { + border-radius: var(--radius-lg); + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(14, 16, 36, 0.78); + padding: 1.1rem 1.35rem; + display: grid; + gap: 0.55rem; +} + +.catalog-card header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.75rem; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--text-muted); +} + +.catalog-actions { + margin-top: 0.6rem; +} + +.catalog-actions .btn { + width: fit-content; + padding: 0.5rem 1.1rem; + font-size: 0.78rem; +} + +.form-grid { + display: grid; + gap: 1.1rem; +} + +.form-field { + display: grid; + gap: 0.45rem; +} + +.form-label { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.22em; + color: var(--text-muted); +} + +.form-control, +.form-select, +textarea { + width: 100%; + padding: 0.85rem 1rem; + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(10, 12, 28, 0.72); + color: var(--text-primary); + font-size: 0.95rem; + font-family: var(--font-sans); +} + +.form-control:focus, +.form-select:focus, +textarea:focus { + outline: none; + border-color: rgba(111, 139, 255, 0.6); + box-shadow: 0 0 0 3px rgba(111, 139, 255, 0.25); +} + +.form-select[multiple] { + min-height: 180px; +} + +.template-tray { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin-top: 1.2rem; +} + +.template-chip { + padding: 0.45rem 1.2rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary); + font-size: 0.8rem; + cursor: pointer; + transition: transform 0.25s ease; +} + +.template-chip:hover { + transform: translateY(-2px); +} + +.metric-board { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.metric-card { + border-radius: var(--radius-lg); + background: rgba(12, 16, 36, 0.78); + border: 1px solid rgba(255, 255, 255, 0.08); + padding: 1rem 1.2rem; + display: grid; + gap: 0.4rem; +} + +.metric-card .label { + font-size: 0.72rem; + letter-spacing: 0.32em; + text-transform: uppercase; + color: var(--text-muted); +} + +.metric-card .value { + font-size: 1.6rem; + font-weight: 600; +} + +.metric-dial { + position: relative; + width: 130px; + height: 130px; + border-radius: 50%; + display: grid; + place-items: center; + background: conic-gradient( + var(--accent) calc(var(--score, 0) * 1%), + rgba(255, 255, 255, 0.08) 0 + ); +} + +.metric-dial::after { + content: ""; + position: absolute; + inset: 12px; + border-radius: 50%; + background: rgba(10, 12, 28, 0.9); +} + +.metric-dial .value { + position: relative; + font-size: 2rem; + font-weight: 700; +} + +.metric-dial[data-score] .value::after { + content: "%"; + font-size: 1rem; + font-weight: 600; + margin-left: 2px; +} + +.inline-alert { + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.12); + padding: 0.85rem 1rem; + font-size: 0.9rem; + background: rgba(255, 255, 255, 0.06); +} + +.inline-alert.error { + border-color: rgba(255, 136, 156, 0.55); + background: rgba(255, 136, 156, 0.18); +} + +.inline-alert.warning { + border-color: rgba(255, 210, 128, 0.45); + background: rgba(255, 210, 128, 0.18); +} + +.loading-spinner { + width: 48px; + height: 48px; + border-radius: 50%; + border: 4px solid rgba(255, 255, 255, 0.12); + border-top-color: var(--accent); + animation: spin 1s linear infinite; + margin: 0 auto; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.list-stack { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.65rem; +} + +.list-stack__item { + padding: 0.75rem 0.95rem; + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + font-size: 0.9rem; +} + +.insight-pillboard { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + margin-top: 1.5rem; +} + +.insight-pill { + border-radius: var(--radius-lg); + padding: 1.15rem 1.3rem; + background: rgba(12, 16, 36, 0.78); + border: 1px solid rgba(255, 255, 255, 0.1); + display: grid; + gap: 0.45rem; +} + +.ai-panel { + border-radius: var(--radius-lg); + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(10, 12, 28, 0.72); + padding: 1.3rem 1.5rem; + display: grid; + gap: 0.8rem; +} + +.summary-grid { + display: grid; + gap: 1.2rem; +} + +.summary-block { + display: grid; + gap: 0.6rem; +} + +.summary-block p { + margin: 0; +} + +.summary-block strong { + font-size: 1rem; +} + +.tag-cloud { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.quality-dial { + display: grid; + place-items: center; + width: 160px; + height: 160px; + margin: 1.5rem auto 0; + position: relative; + --score: 0; + background: conic-gradient(var(--accent-strong) calc(var(--score) * 1%), rgba(255, 255, 255, 0.08) 0); + border-radius: 50%; +} + +.quality-dial::after { + content: ""; + position: absolute; + inset: 16px; + border-radius: 50%; + background: rgba(6, 8, 20, 0.92); +} + +.quality-dial .value { + position: relative; + font-size: 2.2rem; + font-weight: 700; +} + +.chat-wrapper { + display: grid; + gap: 1.5rem; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.chat-log { + border-radius: var(--radius-lg); + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(10, 12, 28, 0.72); + padding: 1.25rem; + display: grid; + gap: 1rem; + max-height: 540px; + overflow-y: auto; +} + +.chat-message { + border-radius: var(--radius-md); + padding: 0.9rem 1rem; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.chat-message.me { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.16); +} + +.chat-message .label { + display: block; + font-size: 0.75rem; + letter-spacing: 0.28em; + text-transform: uppercase; + margin-bottom: 0.4rem; + color: var(--text-muted); +} + +.chat-console { + display: grid; + gap: 1rem; +} + +.chat-console textarea { + min-height: 120px; +} + +.prompt-chip { + padding: 0.45rem 1rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.08); + font-size: 0.78rem; + cursor: pointer; +} + +.prompt-chip:hover { + transform: translateY(-2px); +} + +.comparison-grid { + display: grid; + gap: 1.2rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + margin-top: 1.5rem; +} + +.difference-group { + border-radius: var(--radius-lg); + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(12, 16, 36, 0.78); + padding: 1.2rem 1.4rem; + display: grid; + gap: 0.65rem; +} + +.difference-group h4 { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.22em; + font-size: 0.78rem; + color: var(--text-muted); +} + +.difference-group ul { + margin: 0; + padding-left: 1rem; + color: var(--text-secondary); +} + +.playbook-drawer { + position: fixed; + inset: 0; + background: rgba(3, 5, 16, 0.75); + backdrop-filter: blur(14px); + display: grid; + place-items: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.35s ease; + z-index: 50; +} + +.playbook-drawer.open { + opacity: 1; + pointer-events: auto; +} + +.playbook-panel { + width: min(520px, 92vw); + border-radius: var(--radius-xl); + background: rgba(14, 16, 36, 0.92); + border: 1px solid rgba(255, 255, 255, 0.12); + padding: 2.4rem; + box-shadow: var(--shadow-soft); + display: grid; + gap: 1.6rem; +} + +.playbook-panel header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.icon-btn { + width: 42px; + height: 42px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(16, 18, 34, 0.75); + color: var(--text-primary); + font-size: 1.1rem; + display: grid; + place-items: center; + cursor: pointer; +} + +.icon-btn:hover { + background: rgba(111, 139, 255, 0.2); +} + +.aurora-toast { + position: fixed; + bottom: 2rem; + right: 2rem; + padding: 0.85rem 1.1rem; + border-radius: var(--radius-md); + background: rgba(12, 16, 38, 0.95); + border: 1px solid rgba(255, 255, 255, 0.12); + opacity: 0; + transform: translateY(12px); + pointer-events: none; + transition: opacity 0.3s ease, transform 0.3s ease; + z-index: 60; +} + +.aurora-toast.visible { + opacity: 1; + transform: translateY(0); +} + +@media (max-width: 960px) { + .shell-nav { + display: none; + flex-direction: column; + gap: 0.6rem; + padding: 0 0 1.4rem; + } + + .shell-nav.open { + display: flex; + } + + .nav-toggle { + display: grid; + } + + .chat-wrapper { + grid-template-columns: 1fr; + } +} + +@media (max-width: 720px) { + .command-strip { + flex-direction: column; + align-items: stretch; + } + + .hero-actions { + flex-direction: column; + align-items: stretch; + } + + .metric-board, + .feature-grid, + .story-grid { + grid-template-columns: 1fr; + } + + .hero-grid { + grid-template-columns: 1fr; + } +} diff --git a/app/static/js/advisor.js b/app/static/js/advisor.js new file mode 100644 index 0000000..e26f44d --- /dev/null +++ b/app/static/js/advisor.js @@ -0,0 +1,104 @@ +const chatLog = document.getElementById('chatLog'); +const chatForm = document.getElementById('chatForm'); +const chatPrompt = document.getElementById('chatPrompt'); +const chatStatus = document.getElementById('chatStatus'); +const advisorContext = document.getElementById('advisorContext'); +const advisorDocuments = document.getElementById('advisorDocuments'); +const loadContextBtn = document.getElementById('loadContext'); +const advisorContextSummary = document.getElementById('advisorContextSummary'); +const promptChips = document.querySelectorAll('.prompt-chip'); +const conversation = []; + +function renderMessage(role, content) { + const wrapper = document.createElement('div'); + wrapper.classList.add('chat-message', role === 'user' ? 'me' : 'aurora'); + wrapper.innerHTML = ` + ${role === 'user' ? 'You' : 'Aurora'} +
${content}
+ `; + chatLog.appendChild(wrapper); + chatLog.scrollTop = chatLog.scrollHeight; +} + +async function sendMessage(message) { + chatStatus.textContent = 'Thinking…'; + conversation.push({ role: 'user', content: message }); + renderMessage('user', message); + chatPrompt.value = ''; + try { + const payload = { + conversation, + context: advisorContext.value, + }; + const response = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Unable to complete chat request'); + const reply = data.reply || 'No response received.'; + conversation.push({ role: 'assistant', content: reply }); + renderMessage('assistant', reply); + chatStatus.textContent = 'Ready'; + } catch (error) { + chatStatus.textContent = 'Error'; + renderMessage('assistant', `
${error.message}
`); + } +} + +async function loadSummaries() { + advisorContextSummary.innerHTML = ''; + const selected = Array.from(advisorDocuments?.selectedOptions || []).map((option) => option.value); + if (!selected.length) { + advisorContextSummary.innerHTML = '

Select documents to load quick summaries.

'; + return; + } + try { + const results = []; + for (const filename of selected) { + const response = await fetch('/api/data/summarize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || `Unable to summarize ${filename}`); + results.push({ filename, summary: data.summary, tags: data.tags }); + } + advisorContextSummary.innerHTML = results + .map( + (item) => ` +
+

${item.filename}

+

${item.summary || 'No summary'}

+
${(item.tags || []).map((tag) => `${tag}`).join('')}
+
+ `, + ) + .join(''); + } catch (error) { + advisorContextSummary.innerHTML = `
${error.message}
`; + } +} + +chatForm?.addEventListener('submit', (event) => { + event.preventDefault(); + const message = chatPrompt.value.trim(); + if (!message) return; + sendMessage(message); +}); + +loadContextBtn?.addEventListener('click', (event) => { + event.preventDefault(); + loadSummaries(); +}); + +promptChips.forEach((chip) => { + chip.addEventListener('click', () => { + const prompt = chip.getAttribute('data-prompt'); + if (!prompt) return; + chatPrompt.value = prompt; + chatPrompt.focus(); + }); +}); diff --git a/app/static/js/comparison.js b/app/static/js/comparison.js new file mode 100644 index 0000000..4102589 --- /dev/null +++ b/app/static/js/comparison.js @@ -0,0 +1,96 @@ +const leftDoc = document.getElementById('leftDoc'); +const rightDoc = document.getElementById('rightDoc'); +const compareBtn = document.getElementById('compareBtn'); +const comparisonOutput = document.getElementById('comparisonOutput'); +const comparisonDial = document.getElementById('comparisonDial'); +const comparisonScore = document.getElementById('comparisonScore'); +const comparisonSynopsis = document.getElementById('comparisonSynopsis'); + +function updateDial(score) { + const numeric = Number.parseInt(score, 10) || 0; + const bounded = Math.max(0, Math.min(numeric, 100)); + comparisonDial?.style.setProperty('--score', bounded); + comparisonDial?.setAttribute('data-score', bounded); + if (comparisonScore) { + comparisonScore.textContent = Number.isFinite(numeric) ? bounded : '—'; + } +} + +function renderComparison(data) { + comparisonSynopsis.textContent = data.synopsis || 'No synopsis available.'; + updateDial(data.comparison_score ?? 0); + comparisonOutput.innerHTML = ` +
+
+

Alignment

+ ${renderList(data.alignment, 'theme', 'assessment', false)} +
+
+

Divergence

+ ${renderList(data.divergence, 'issue', 'detail', true)} +
+
+

Recommendations

+ ${renderRecommendations(data.recommendations)} +
+
+ `; +} + +function renderList(items = [], primaryKey, secondaryKey, includeSeverity = false) { + if (!items.length) { + return '

No items available.

'; + } + return ( + '
    ' + + items + .map((item) => { + const severity = includeSeverity && item.severity ? `${item.severity}` : ''; + return ` +
  • +
    + ${item[primaryKey] || ''} ${severity} +

    ${item[secondaryKey] || ''}

    +
    +
  • + `; + }) + .join('') + + '
' + ); +} + +function renderRecommendations(items = []) { + if (!items.length) { + return '

No recommendations provided.

'; + } + return ( + '
    ' + + items + .map((item) => `
  • ${item}
  • `) + .join('') + + '
' + ); +} + +compareBtn?.addEventListener('click', async () => { + const left = leftDoc.value; + const right = rightDoc.value; + if (!left || !right) { + comparisonOutput.innerHTML = '
Choose two documents to compare.
'; + return; + } + comparisonOutput.innerHTML = ''; + try { + const response = await fetch('/api/compare', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ left, right }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Unable to compare documents'); + renderComparison(data); + } catch (error) { + comparisonOutput.innerHTML = `
${error.message}
`; + } +}); diff --git a/app/static/js/core.js b/app/static/js/core.js new file mode 100644 index 0000000..1f9220b --- /dev/null +++ b/app/static/js/core.js @@ -0,0 +1,99 @@ +const commandElements = document.querySelectorAll('[data-command]'); +const drawer = document.getElementById('playbookDrawer'); +const navToggle = document.getElementById('navToggle'); +const shellNav = document.getElementById('shellNav'); +let toastTimeout; + +function showNotification(message) { + if (!message) return; + let toast = document.querySelector('.aurora-toast'); + if (!toast) { + toast = document.createElement('div'); + toast.className = 'aurora-toast'; + document.body.appendChild(toast); + } + toast.textContent = message; + toast.classList.add('visible'); + clearTimeout(toastTimeout); + toastTimeout = setTimeout(() => { + toast.classList.remove('visible'); + }, 2800); +} + +function toggleFocusMode() { + document.body.classList.toggle('focus-mode'); + const enabled = document.body.classList.contains('focus-mode'); + showNotification(enabled ? 'Focus mode enabled' : 'Focus mode disabled'); +} + +function syncCatalog() { + document.dispatchEvent(new CustomEvent('aurora:refresh-catalog')); + showNotification('Catalog sync triggered'); +} + +function toggleDrawer(forceOpen = null) { + if (!drawer) return; + const shouldOpen = forceOpen ?? !drawer.classList.contains('open'); + drawer.classList.toggle('open', shouldOpen); +} + +commandElements.forEach((element) => { + element.addEventListener('click', (event) => { + event.preventDefault(); + const command = event.currentTarget.getAttribute('data-command'); + switch (command) { + case 'focus': + toggleFocusMode(); + break; + case 'refresh': + syncCatalog(); + break; + case 'docs': + toggleDrawer(true); + break; + case 'close-drawer': + toggleDrawer(false); + break; + default: + break; + } + }); +}); + +if (drawer) { + drawer.addEventListener('click', (event) => { + if (event.target === drawer) { + toggleDrawer(false); + } + }); +} + +navToggle?.addEventListener('click', () => { + const expanded = navToggle.getAttribute('aria-expanded') === 'true'; + const shouldOpen = !expanded; + navToggle.setAttribute('aria-expanded', String(shouldOpen)); + shellNav?.classList.toggle('open', shouldOpen); +}); + +if (shellNav) { + shellNav.querySelectorAll('a').forEach((link) => { + link.addEventListener('click', () => { + navToggle?.setAttribute('aria-expanded', 'false'); + shellNav.classList.remove('open'); + }); + }); +} + +document.addEventListener('keydown', (event) => { + if (event.ctrlKey && !event.shiftKey && event.key.toLowerCase() === 'b') { + event.preventDefault(); + toggleFocusMode(); + } + if (event.ctrlKey && event.altKey && event.key.toLowerCase() === 'r') { + event.preventDefault(); + syncCatalog(); + } + if (event.key === 'Escape' && drawer?.classList.contains('open')) { + toggleDrawer(false); + } +}); diff --git a/app/static/js/data-management.js b/app/static/js/data-management.js new file mode 100644 index 0000000..5503f80 --- /dev/null +++ b/app/static/js/data-management.js @@ -0,0 +1,131 @@ +const catalogList = document.getElementById('catalogList'); +const refreshCatalogBtn = document.getElementById('refreshCatalog'); +const summarySelect = document.getElementById('summarySelect'); +const summaryOutput = document.getElementById('summaryOutput'); +const qualityDial = document.getElementById('documentScore'); + +async function refreshCatalog() { + if (!catalogList) return; + refreshCatalogBtn?.classList.add('disabled'); + try { + const response = await fetch('/api/catalog'); + if (!response.ok) throw new Error('Failed to load catalog'); + const data = await response.json(); + catalogList.innerHTML = buildCatalogGrid(data.documents || []); + wirePreviewButtons(); + if (summarySelect) { + const current = summarySelect.value; + summarySelect.innerHTML = '' + + (data.documents || []) + .map((doc) => ``) + .join(''); + if (current) { + summarySelect.value = current; + } + } + } catch (error) { + catalogList.innerHTML = `
${error.message}
`; + } finally { + refreshCatalogBtn?.classList.remove('disabled'); + } +} + +function buildCatalogGrid(documents) { + if (!documents.length) { + return '

No documents yet. Upload a file to populate the catalog.

'; + } + return documents + .map( + (doc) => ` +
+
+ ${doc.type || 'DOC'} + ${doc.updated} +
+

${doc.name}

+

${doc.size}

+
+ +
+
+ `, + ) + .join(''); +} + +function updateQualityDial(score) { + if (!qualityDial) return; + const value = qualityDial.querySelector('.value'); + const numericScore = typeof score === 'number' ? score : parseInt(score, 10) || 0; + qualityDial.dataset.score = numericScore; + qualityDial.style.setProperty('--score', Math.max(0, Math.min(numericScore, 100))); + if (value) { + value.textContent = Number.isFinite(numericScore) ? `${numericScore}` : '—'; + } +} + +function renderSummary(payload) { + summaryOutput.innerHTML = ` +
+
+

${payload.summary || 'Summary unavailable'}

+

Grounded in uploaded context with AI-generated highlights.

+
+
+

Tags

+
${(payload.tags || []).map((tag) => `${tag}`).join('') || 'No tags provided'}
+
+
+

Opportunities

+
    ${(payload.opportunities || []).map((item) => `
  • ${item}
  • `).join('') || '
  • None detected
  • '}
+
+
+ `; + updateQualityDial(payload.data_quality ?? 0); +} + +async function summarizeDocument(filename) { + if (!filename) { + summaryOutput.innerHTML = '

Summaries will appear here once generated.

'; + updateQualityDial(0); + return; + } + summaryOutput.innerHTML = ''; + try { + const response = await fetch('/api/data/summarize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename }), + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || 'Unable to summarize document'); + renderSummary(payload); + } catch (error) { + summaryOutput.innerHTML = `
${error.message}
`; + updateQualityDial(0); + } +} + +function wirePreviewButtons() { + if (!catalogList) return; + catalogList.querySelectorAll('[data-preview]').forEach((button) => { + button.addEventListener('click', (event) => { + const name = event.currentTarget.getAttribute('data-preview'); + if (summarySelect) { + summarySelect.value = name; + } + summarizeDocument(name); + }); + }); +} + +refreshCatalogBtn?.addEventListener('click', refreshCatalog); +summarySelect?.addEventListener('change', (event) => summarizeDocument(event.target.value)); + +document.addEventListener('aurora:refresh-catalog', refreshCatalog); + +wirePreviewButtons(); + +if (summarySelect?.value) { + summarizeDocument(summarySelect.value); +} diff --git a/app/static/js/prediction.js b/app/static/js/prediction.js new file mode 100644 index 0000000..e77cfd4 --- /dev/null +++ b/app/static/js/prediction.js @@ -0,0 +1,144 @@ +const predictionForm = document.getElementById('predictionForm'); +const predictionSummary = document.getElementById('predictionSummary'); +const insightCards = document.getElementById('insightCards'); +const initiativeList = document.getElementById('initiativeList'); +const confidenceBadge = document.getElementById('confidenceBadge'); +const outlookDial = document.getElementById('outlookDial'); +const outlookValue = document.getElementById('outlookValue'); +const runwayIndicator = document.getElementById('runwayIndicator'); +const metricHealth = document.getElementById('metricHealth'); +const impactGrid = document.getElementById('impactGrid'); + +function extractFormData(form) { + const formData = new FormData(form); + const values = Object.fromEntries(formData.entries()); + values.documents = formData.getAll('documents'); + return values; +} + +function renderImpactGrid(dataset) { + if (!impactGrid) return; + const labels = dataset?.chart?.labels || []; + const series = dataset?.chart?.series || []; + if (!labels.length || !series.length) { + impactGrid.innerHTML = '

No impact metrics provided.

'; + return; + } + impactGrid.innerHTML = labels + .map((label, index) => { + const value = Number.parseFloat(series[index]) || 0; + const bounded = Math.max(0, Math.min(value, 100)); + return ` +
+ ${label} +
+ ${value.toFixed(1)} +
+ `; + }) + .join(''); +} + +function updateOutlook(score) { + if (!outlookDial) return; + const numeric = Number.parseInt(score, 10) || 0; + const bounded = Math.max(0, Math.min(numeric, 100)); + outlookDial.dataset.score = bounded; + outlookDial.style.setProperty('--score', bounded); + if (outlookValue) { + outlookValue.textContent = Number.isFinite(bounded) ? bounded : '—'; + } + if (!runwayIndicator || !metricHealth) return; + if (bounded >= 70) { + runwayIndicator.textContent = 'Accelerate'; + metricHealth.textContent = 'Momentum suggests aggressive plays.'; + } else if (bounded >= 40) { + runwayIndicator.textContent = 'Stabilize'; + metricHealth.textContent = 'Blend growth with retention safeguards.'; + } else { + runwayIndicator.textContent = 'Recalibrate'; + metricHealth.textContent = 'Revisit fundamentals and cost structure.'; + } +} + +function renderInsightCards(cards = []) { + if (!cards.length) { + insightCards.innerHTML = '

No insights yet.

'; + return; + } + insightCards.innerHTML = cards + .map( + (card) => ` +
+

${card.title || 'Insight'}

+

${card.detail || ''}

+
+ `, + ) + .join(''); +} + +function renderInitiatives(items = []) { + if (!items.length) { + initiativeList.innerHTML = '
  • No initiatives supplied.
  • '; + return; + } + initiativeList.innerHTML = items.map((item) => `
  • ${item}
  • `).join(''); +} + +function applyTemplate(button) { + const template = button.getAttribute('data-template'); + if (!template) return; + try { + const values = JSON.parse(template); + Object.entries(values).forEach(([key, value]) => { + const field = predictionForm?.elements.namedItem(key); + if (!field) return; + if (field instanceof HTMLSelectElement || field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) { + field.value = value; + } + }); + } catch (error) { + console.error('Unable to apply template', error); + } +} + +predictionForm?.addEventListener('submit', async (event) => { + event.preventDefault(); + predictionSummary.innerHTML = ''; + insightCards.innerHTML = ''; + initiativeList.innerHTML = ''; + confidenceBadge.textContent = '—'; + runwayIndicator.textContent = '—'; + metricHealth.textContent = 'Awaiting forecast'; + updateOutlook(0); + try { + const payload = extractFormData(predictionForm); + const response = await fetch('/api/predict', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Unable to generate forecast'); + predictionSummary.textContent = data.summary || 'No summary provided.'; + const confidence = data.confidence ?? '—'; + const numericConfidence = Number.parseFloat(confidence); + confidenceBadge.textContent = Number.isFinite(numericConfidence) + ? `${numericConfidence} / 100` + : confidence; + updateOutlook(data.growth_outlook ?? 0); + renderInsightCards(data.insight_cards || []); + renderInitiatives(data.key_initiatives || []); + renderImpactGrid(data); + } catch (error) { + predictionSummary.innerHTML = `
    ${error.message}
    `; + renderImpactGrid({ chart: { labels: [], series: [] } }); + } +}); + +document.querySelectorAll('.template-chip').forEach((button) => { + button.addEventListener('click', () => applyTemplate(button)); +}); + +renderImpactGrid(); diff --git a/app/static/js/risk.js b/app/static/js/risk.js new file mode 100644 index 0000000..9cc9296 --- /dev/null +++ b/app/static/js/risk.js @@ -0,0 +1,118 @@ +const riskForm = document.getElementById('riskForm'); +const riskSummary = document.getElementById('riskSummary'); +const riskMatrix = document.getElementById('riskMatrix'); +const warningList = document.getElementById('warningList'); +const riskScoreValue = document.getElementById('riskScore'); +const riskDial = document.getElementById('riskDial'); +const riskPriority = document.getElementById('riskPriority'); +const warningCount = document.getElementById('warningCount'); +const exposureGrid = document.getElementById('exposureGrid'); + +function getRiskFormData(form) { + const formData = new FormData(form); + const payload = Object.fromEntries(formData.entries()); + payload.documents = formData.getAll('riskDocuments'); + return payload; +} + +function renderExposureGrid(heatmap = {}) { + if (!exposureGrid) return; + const labels = heatmap.labels || []; + const series = heatmap.series || []; + if (!labels.length || !series.length) { + exposureGrid.innerHTML = '

    No exposure distribution provided.

    '; + return; + } + exposureGrid.innerHTML = labels + .map((label, index) => { + const value = Number.parseFloat(series[index]) || 0; + const bounded = Math.max(0, Math.min(value, 100)); + return ` +
    + ${label} +
    + ${value.toFixed(1)} +
    + `; + }) + .join(''); +} + +function renderMatrix(items = []) { + if (!items.length) { + riskMatrix.innerHTML = '

    No risks surfaced yet.

    '; + return; + } + riskMatrix.innerHTML = items + .map((item) => { + const mitigation = item.mitigation ? `${item.mitigation}` : ''; + return ` +
    +

    ${item.name || 'Risk'}

    +

    Likelihood ${item.likelihood ?? '—'} · Impact ${item.impact ?? '—'}

    +

    ${item.detail || ''}

    + ${mitigation} +
    + `; + }) + .join(''); +} + +function renderWarnings(items = []) { + if (!items.length) { + warningList.innerHTML = '
  • No immediate warnings detected.
  • '; + warningCount.textContent = '0'; + return; + } + warningList.innerHTML = items.map((item) => `
  • ${item}
  • `).join(''); + warningCount.textContent = items.length; +} + +function updateRiskDial(score) { + const numeric = Number.parseInt(score, 10) || 0; + const bounded = Math.max(0, Math.min(numeric, 100)); + riskDial?.style.setProperty('--score', bounded); + riskDial?.setAttribute('data-score', bounded); + if (riskScoreValue) { + riskScoreValue.textContent = Number.isFinite(bounded) ? bounded : '—'; + } +} + +function extractPriority(items = []) { + if (!items.length) { + riskPriority.textContent = '—'; + return; + } + const prioritized = [...items].sort((a, b) => (Number(b.impact) || 0) - (Number(a.impact) || 0)); + riskPriority.textContent = prioritized[0]?.name || '—'; +} + +riskForm?.addEventListener('submit', async (event) => { + event.preventDefault(); + riskSummary.innerHTML = ''; + riskMatrix.innerHTML = ''; + warningList.innerHTML = ''; + warningCount.textContent = '—'; + updateRiskDial(0); + try { + const payload = getRiskFormData(riskForm); + const response = await fetch('/api/risk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Unable to complete risk analysis'); + riskSummary.textContent = data.risk_summary || 'No summary provided.'; + updateRiskDial(data.overall_score ?? 0); + renderExposureGrid(data.heatmap || {}); + renderMatrix(data.risk_matrix || []); + extractPriority(data.risk_matrix || []); + renderWarnings(data.early_warnings || []); + } catch (error) { + riskSummary.innerHTML = `
    ${error.message}
    `; + renderExposureGrid({ labels: [], series: [] }); + } +}); + +renderExposureGrid(); diff --git a/app/templates/advisor.html b/app/templates/advisor.html new file mode 100644 index 0000000..583e319 --- /dev/null +++ b/app/templates/advisor.html @@ -0,0 +1,61 @@ +{% extends 'base.html' %} +{% block title %}Advisor Lounge · Aurora CRM{% endblock %} +{% block content %} +
    +
    +
    +

    Advisor lounge

    +

    Chat with the embedded strategist for pitch angles, rollout plans, and follow-up actions. Add document context to ground the guidance.

    +
    +
    +
    + Response status + Ready +

    Local model orchestrates every reply.

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    + + + +
    + +
    +
    +
    +

    Context vault

    + + + +
    +

    Summaries will appear here after loading documents.

    +
    +
    +
    +
    +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..ebea236 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,116 @@ + + + + + + {% block title %}Aurora CRM Intelligence{% endblock %} + + {% block head %}{% endblock %} + + +
    +
    +
    + + A + Aurora CRM + +
    + + + + +
    +
    + +
    + +
    +
    +
    +
    + Neural core +
    + + {{ 'Local model ready' if api_ready else 'Model offline' }} +
    +

    + {% if api_ready %} + Running inference from your GPU-hosted model. + {% else %} + Launch Ollama and configure LLM_MODEL in your .env file. + {% endif %} +

    +
    +
    + Catalog +
    + {{ catalog_summary.count if catalog_summary else 0 }} files +
    +

    {{ catalog_summary.total_size if catalog_summary else '0 B' }} indexed footprint

    +
    +
    + Median artifact +
    + {{ catalog_summary.average_size if catalog_summary else '0 B' }} +
    +

    Coverage across {{ catalog_summary.coverage|length if catalog_summary else 0 }} sources

    +
    +
    +
    + Upload data + + + +
    +
    +
    + +
    +
    +
    +
    + Playbook +

    Power-user shortcuts

    +
    + +
    +
    +

    Instant actions

    +
      +
    • Upload CRM context from the Data screen to expand the intelligence corpus.
    • +
    • Run Predictive Strategy after each pipeline update to recalibrate growth tracks.
    • +
    • Use Comparison Lab before executive reviews to surface deltas and alignment gaps.
    • +
    +
    +
    +

    Keyboard tips

    +

    Focus mode toggles with Ctrl + B. Refresh catalog with Ctrl + Alt + R.

    +
    +
    +
    + +
    + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
    + {% for category, message in messages %} +
    {{ message }}
    + {% endfor %} +
    + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
    + + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/comparison.html b/app/templates/comparison.html new file mode 100644 index 0000000..ad6f6b6 --- /dev/null +++ b/app/templates/comparison.html @@ -0,0 +1,65 @@ +{% extends 'base.html' %} +{% block title %}Comparison Lab · Aurora CRM{% endblock %} +{% block content %} +
    +
    +
    +

    Deep comparison lab

    +

    Select any two catalogued documents to pinpoint alignment, divergence, and recommended remediation – all computed locally.

    +
    +
    +
    + Local inference + {{ 'Active' if api_ready else 'Offline' }} +

    GPU-powered differential analysis on your machine.

    +
    +
    +
    +
    + +
    +
    + + +
    + +
    +
    +
    +
    + Alignment index +
    +
    0
    +
    +

    Higher is better alignment

    +
    +
    + Synopsis +
    Awaiting analysis
    +

    High-level summary

    +
    +
    +
    +

    Select two files to run a deep comparison.

    +
    +
    +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/app/templates/data_management.html b/app/templates/data_management.html new file mode 100644 index 0000000..d8cf95b --- /dev/null +++ b/app/templates/data_management.html @@ -0,0 +1,99 @@ +{% extends 'base.html' %} +{% block title %}Data Steward · Aurora CRM{% endblock %} +{% block content %} +
    +
    +
    +

    Curate your CRM intelligence

    +

    + Upload structured notes, CSV exports, enablement decks, or customer research. Everything stays local while the GPU-backed + model builds richer predictions, risks, and advisor summaries. +

    +
    + {{ catalog_summary.count if catalog_summary else 0 }} files + {{ catalog_summary.total_size if catalog_summary else '0 B' }} footprint + Median {{ catalog_summary.average_size if catalog_summary else '0 B' }} +
    +
    +
    +
    + Neural status + {{ 'Ready' if api_ready else 'Offline' }} +

    + {% if api_ready %} + Model streaming from your 4090 – no external API calls required. + {% else %} + Start ollama and set LLM_MODEL to a pulled model such as llama3.2:latest. + {% endif %} +

    +
    +
    +
    +
    + +
    +
    +
    +

    Upload document

    +

    Supported formats: TXT, MD, CSV, JSON. Files remain on disk inside the uploads folder.

    +
    + + +
    +
    +

    Tips

    +
      +
    • Use descriptive filenames so the advisor can cite sources.
    • +
    • Upload paired documents to unlock deeper comparisons.
    • +
    • Trigger a catalog refresh after large batches.
    • +
    +
    +
    +
    +
    +
    +

    Catalog

    +

    Your local corpus

    +
    + +
    +
    + {% include 'partials/catalog_list.html' %} +
    +
    +
    +
    + +
    +
    +
    +

    Document profile

    +

    Select a file to generate a synopsis, tags, and opportunity prompts powered by the local model.

    + +
    +
    +

    Quality score

    +
    +
    +
    +
    +

    Summaries will appear here once generated.

    +
    +
    +
    +
    +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/app/templates/overview.html b/app/templates/overview.html new file mode 100644 index 0000000..73c7d23 --- /dev/null +++ b/app/templates/overview.html @@ -0,0 +1,174 @@ +{% extends 'base.html' %} +{% block title %}Aurora CRM Intelligence{% endblock %} +{% block content %} +
    +
    +
    + Aurora command +

    Run revenue, risk, and relationships from one neural workspace

    +

    + Bring pipeline exports, field research, and operator notes together. Aurora synthesizes every upload into prediction arcs, + mitigation blueprints, advisor-grade briefings, and deep comparisons – entirely on your RTX-powered workstation. +

    + {% if not api_ready %} + + {% endif %} + +
    +
    + Catalog depth · {{ catalog_summary.count if catalog_summary else 0 }} documents + Aggregate footprint · {{ catalog_summary.total_size if catalog_summary else '0 B' }} + {% for segment in (catalog_summary.coverage or [])[:6] %} + {{ segment.label }} coverage · {{ segment.percent }}% + {% endfor %} +
    +
    +
    + {% if recent_documents %} + {% for doc in recent_documents %} +
    +
    + {{ doc.type }} + {{ doc.updated }} +
    +

    {{ doc.name }}

    +

    {{ doc.size }}

    +
    + {% endfor %} + {% else %} +
    +

    No uploads yet

    +

    Add discovery notes, renewal playbooks, or pipeline exports to activate the cockpit.

    +
    + {% endif %} +
    +
    +
    +
    +
    + Workflows engaged + 5 +

    Prediction studio · Risk command · Advisor lounge · Comparison lab · Data steward

    +
    +
    + Median artifact + {{ catalog_summary.average_size if catalog_summary else '0 B' }} +

    Sized for rapid GPU inference and comparison.

    +
    +
    + Advisor responsiveness + ~6s +

    Round-trip response target for on-box chat.

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    Workflow coverage

    +

    Operate every intelligence lane without leaving the cockpit

    +
    + Open data steward +
    +
    +
    +

    Intelligence loop

    +

    Feed documents, evolve strategy, evaluate risk, and brief stakeholders in one place.

    +
      +
    • Ingest, tag, and classify uploads for instant retrieval.
    • +
    • Project growth arcs with sensitivity sweeps and initiative scoring.
    • +
    • Surface exposure, early warnings, and mitigation owners.
    • +
    • Generate advisor briefs and alignment deltas on demand.
    • +
    +
    +
    +
    +

    Decision runway

    +

    Sync weekly execution and quarterly planning off the same digital twin.

    +
      +
    • Scenario diffs spotlight revenue deltas in seconds.
    • +
    • Risk sweeps connect mitigation owners with impact timing.
    • +
    +
    +
    +

    Relationship intelligence

    +

    Ground every customer conversation with comparative context and advisor prompts.

    +
      +
    • Comparison lab reveals overlaps, conflicts, and contradictions.
    • +
    • Advisor lounge turns feedback into prioritized plays.
    • +
    +
    +
    +

    Catalog health

    + {% if catalog_summary.coverage %} +

    Coverage spans {{ catalog_summary.coverage|length }} archetypes totalling {{ catalog_summary.total_size }}.

    +
    + {% for segment in catalog_summary.coverage %} +
    + {{ segment.label }} +
    + {{ segment.value }} files +
    + {% endfor %} +
    + {% else %} +

    Upload files to unlock coverage analytics and metadata insights.

    + {% endif %} +
    +
    +
    +
    + +
    +
    +
    📊
    +

    Predictive momentum

    +

    Model multi-horizon forecasts, detect inflection points, and surface initiatives that move the needle.

    +
    + Scenario scoring + Sensitivity sweeps +
    + Scenario console +
    +
    +
    🛡️
    +

    Risk command

    +

    Translate telemetry into mitigation playbooks with automated triage, scoring, and early warning systems.

    +
    + Exposure radar + Mitigation routing +
    + Risk dashboard +
    +
    +
    🤝
    +

    Advisor insights

    +

    Prompt the embedded strategist for pitch angles, rollout plans, and board-ready narratives.

    +
    + Context aware chat + Action playbooks +
    + Advisor lounge +
    +
    +
    🧬
    +

    Deep comparison lab

    +

    Upload two files to receive redline-style differentials, SWOT signals, and alignment recommendations.

    +
    + Delta explorer + SWOT synthesis +
    + Open lab +
    +
    +{% endblock %} diff --git a/app/templates/partials/catalog_list.html b/app/templates/partials/catalog_list.html new file mode 100644 index 0000000..c429cfa --- /dev/null +++ b/app/templates/partials/catalog_list.html @@ -0,0 +1,17 @@ +{% if catalog %} + {% for doc in catalog %} +
    +
    + {{ doc.type }} + {{ doc.updated }} +
    +

    {{ doc.name }}

    +

    {{ doc.size }}

    +
    + +
    +
    + {% endfor %} +{% else %} +

    No documents yet. Upload a file to populate the catalog.

    +{% endif %} diff --git a/app/templates/prediction.html b/app/templates/prediction.html new file mode 100644 index 0000000..c741969 --- /dev/null +++ b/app/templates/prediction.html @@ -0,0 +1,108 @@ +{% extends 'base.html' %} +{% block title %}Predictive Strategy · Aurora CRM{% endblock %} +{% block content %} +
    +
    +
    +

    Predict revenue arcs

    +

    Blend qualitative assumptions with catalogued evidence to receive quantified growth outlooks, confidence signals, and initiative guidance.

    +
    + Jump start: + + +
    +
    +
    +
    + Local inference + {{ 'Active' if api_ready else 'Offline' }} +

    Powered by your configured GGUF model for fully local forecasting.

    +
    +
    +
    +
    + +
    +
    +
    +

    Scenario designer

    +
    + +
    + + +
    + + + + +
    +
    +
    +
    +
    + Growth outlook +
    +
    0
    +
    +

    Composite momentum score

    +
    +
    + Confidence +
    +

    Model-rated scenario certainty

    +
    +
    + Runway focus +
    +

    Awaiting forecast

    +
    +
    +
    +

    Run a scenario to populate insights and priorities.

    +
    +
    +
    +

    Initiatives

    +
      +
      +
      +

      Impact distribution

      +
      +
      +
      +
      +
      +
      +
      +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/app/templates/risk.html b/app/templates/risk.html new file mode 100644 index 0000000..4f764db --- /dev/null +++ b/app/templates/risk.html @@ -0,0 +1,82 @@ +{% extends 'base.html' %} +{% block title %}Risk Command · Aurora CRM{% endblock %} +{% block content %} +
      +
      +
      +

      De-risk your roadmap

      +

      Submit scenario details and optionally connect documents from the catalog. The local model quantifies exposure, recommends mitigations, and surfaces early warnings.

      +
      +
      +
      + Local inference + {{ 'Active' if api_ready else 'Offline' }} +

      GPU-accelerated risk synthesis without cloud latency.

      +
      +
      +
      +
      + +
      +
      +
      +

      Risk intake

      +
      + + + + +
      +
      +
      +
      +
      + Risk score +
      +
      0
      +
      +

      Composite exposure

      +
      +
      + Priority threat +
      +

      Highest impact risk

      +
      +
      + Early warnings +
      +

      Signals requiring monitoring

      +
      +
      +
      +

      Run an assessment to populate risk narratives.

      +
      +
      +

      Exposure radar

      +
      +
      +
      +

      Early warnings

      +
        +
        +
        +
        +
        +
        +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/install.bat b/install.bat new file mode 100644 index 0000000..f866d6a --- /dev/null +++ b/install.bat @@ -0,0 +1,2 @@ +@echo off +powershell -ExecutionPolicy Bypass -File "%~dp0install.ps1" %* diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..71c2b63 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,4 @@ +param() + +$script = Join-Path $PSScriptRoot 'scripts/windows/install.ps1' +PowerShell -ExecutionPolicy Bypass -File $script @args diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3f22680 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.3 +python-dotenv==1.0.1 +requests==2.32.3 diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..b74eb6f --- /dev/null +++ b/run.bat @@ -0,0 +1,2 @@ +@echo off +powershell -ExecutionPolicy Bypass -File "%~dp0run.ps1" %* diff --git a/run.ps1 b/run.ps1 new file mode 100644 index 0000000..23cf335 --- /dev/null +++ b/run.ps1 @@ -0,0 +1,4 @@ +param() + +$script = Join-Path $PSScriptRoot 'scripts/windows/run.ps1' +PowerShell -ExecutionPolicy Bypass -File $script @args diff --git a/scripts/windows/common.ps1 b/scripts/windows/common.ps1 new file mode 100644 index 0000000..b25a620 --- /dev/null +++ b/scripts/windows/common.ps1 @@ -0,0 +1,113 @@ +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-ProjectRoot { + param() + + $scriptPath = $PSCommandPath + if (-not $scriptPath) { + return (Get-Location).Path + } + + $scriptDirectory = Split-Path -Path $scriptPath -Parent + + $parent = [System.IO.Directory]::GetParent($scriptDirectory) + if (-not $parent) { + return (Resolve-Path $scriptDirectory).Path + } + + $grandParent = [System.IO.Directory]::GetParent($parent.FullName) + if (-not $grandParent) { + return (Resolve-Path $parent.FullName).Path + } + + return (Resolve-Path $grandParent.FullName).Path +} + +function Write-Section { + param([string]$Label) + Write-Host "`n=== $Label ===" -ForegroundColor Cyan +} + +function Assert-Python { + param() + $python = Get-Command python3 -ErrorAction SilentlyContinue + if (-not $python) { + $python = Get-Command python -ErrorAction SilentlyContinue + } + if (-not $python) { + throw "Python 3 was not found. Install it from https://www.python.org/downloads/ and ensure it is on PATH." + } + return $python.Path +} + +function Ensure-Venv { + param( + [string]$ProjectRoot, + [string]$PythonPath + ) + $venvPath = Join-Path $ProjectRoot '.venv' + if (-not (Test-Path $venvPath)) { + Write-Host "Creating virtual environment at $venvPath" -ForegroundColor Green + & $PythonPath -m venv $venvPath + } + return $venvPath +} + +function Install-Dependencies { + param( + [string]$ProjectRoot, + [string]$VenvPath + ) + + $pipPath = Join-Path $VenvPath 'Scripts\pip.exe' + if (-not (Test-Path $pipPath)) { + $pipPath = Join-Path $VenvPath 'bin/pip' + } + + Write-Section 'Installing Python requirements' + & $pipPath install --upgrade pip | Out-Host + & $pipPath install -r (Join-Path $ProjectRoot 'requirements.txt') | Out-Host +} + +function Load-DotEnv { + param( + [string]$ProjectRoot + ) + $envFile = Join-Path $ProjectRoot '.env' + if (-not (Test-Path $envFile)) { + Write-Warning 'No .env file found. Intelligence endpoints will remain offline until OLLAMA_HOST and LLM_MODEL are set.' + return + } + Get-Content $envFile | Where-Object { $_ -match '=' -and $_ -notmatch '^#' } | ForEach-Object { + $parts = $_.Split('=', 2) + $name = $parts[0].Trim() + $value = $parts[1].Trim() + if ($name) { + Set-Item -Path ("Env:{0}" -f $name) -Value $value + } + } +} + +function Start-Flask { + param( + [string]$ProjectRoot, + [string]$VenvPath + ) + + $pythonExe = Join-Path $VenvPath 'Scripts\python.exe' + if (-not (Test-Path $pythonExe)) { + $pythonExe = Join-Path $VenvPath 'bin/python' + } + + Push-Location $ProjectRoot + try { + Write-Section 'Launching Flask' + & $pythonExe -m flask --app app --debug run + } + finally { + Pop-Location + } +} diff --git a/scripts/windows/install.ps1 b/scripts/windows/install.ps1 new file mode 100644 index 0000000..d45aa4b --- /dev/null +++ b/scripts/windows/install.ps1 @@ -0,0 +1,15 @@ +param() + +. "$PSScriptRoot/common.ps1" + +try { + $projectRoot = Resolve-ProjectRoot + Write-Section 'Aurora CRM Installer' + $pythonPath = Assert-Python + $venvPath = Ensure-Venv -ProjectRoot $projectRoot -PythonPath $pythonPath + Install-Dependencies -ProjectRoot $projectRoot -VenvPath $venvPath + Write-Host "Installation complete." -ForegroundColor Green +} catch { + Write-Error $_ + exit 1 +} diff --git a/scripts/windows/run.ps1 b/scripts/windows/run.ps1 new file mode 100644 index 0000000..eaacd7d --- /dev/null +++ b/scripts/windows/run.ps1 @@ -0,0 +1,22 @@ +param() + +. "$PSScriptRoot/common.ps1" + +try { + $projectRoot = Resolve-ProjectRoot + Write-Section 'Aurora CRM Runner' + $pythonPath = Assert-Python + $venvPath = Ensure-Venv -ProjectRoot $projectRoot -PythonPath $pythonPath + Install-Dependencies -ProjectRoot $projectRoot -VenvPath $venvPath + Load-DotEnv -ProjectRoot $projectRoot + if (-not $env:LLM_MODEL) { + Write-Warning 'LLM_MODEL is missing; set it to an Ollama model name such as llama3.2:latest.' + } + if (-not $env:OLLAMA_HOST) { + Write-Warning 'OLLAMA_HOST is missing; defaulting to http://localhost:11434.' + } + Start-Flask -ProjectRoot $projectRoot -VenvPath $venvPath +} catch { + Write-Error $_ + exit 1 +} diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000..e69de29