Productionise Programme Pulse
Backend - Routes moved under /api/, JWT bearer auth via @before_request - DEV_AUTH_BYPASS escape hatch for local dev - In-memory chat history and report state replaced with Postgres tables (preferences, chat_messages, reports, feedback_events) keyed on user - SQLAlchemy 2.x + Alembic migrations run on container start - Graceful Airtable failure handling — bad creds no longer 500 the API - Per-user data isolation via g.user_email from validated token Frontend - React + Vite + TypeScript SPA at /programme-pulse/ - MSAL.js (PKCE, sessionStorage, ID token to backend) - VITE_DEV_AUTH_BYPASS mirrors backend bypass for local dev - Streaming chat via fetch ReadableStream + SSE parsing - Charts via chart.js, markdown via react-markdown + remark-gfm - Full UI parity with the original templates/index.html Deploy (optical-dev split-build pattern) - Dockerfile + docker-compose.yml (name: programme-pulse pinned; app + Postgres; 127.0.0.1 binding only) - deploy/apache-programme-pulse.conf.tmpl with flushpackets=on for SSE - deploy/deploy.sh mirrors OSOP — port auto-pick (5051..5099), apache conf render, frontend build in throwaway node container, rsync to /var/www/html/programme-pulse, /api/health poll Tests - 49 passing; new tests for DB-backed preferences and JWT auth helpers - SQLite-backed test fixture in tests/conftest.py Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d032b0c9f2
commit
b70d148b94
62 changed files with 9311 additions and 45 deletions
18
.dockerignore
Normal file
18
.dockerignore
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.git/
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.pytest_cache/
|
||||
.DS_Store
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
logs/
|
||||
reports/*.docx
|
||||
reports/*.md
|
||||
*.egg-info/
|
||||
34
.env.example
Normal file
34
.env.example
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# --- Anthropic ---
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# --- Airtable: tasks (Master tracker) ---
|
||||
PULSE_AIRTABLE_API_KEY=pat...
|
||||
PULSE_AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX
|
||||
PULSE_AIRTABLE_TABLE_ID=tblXXXXXXXXXXXXXX
|
||||
|
||||
# --- Airtable: resource bookings ---
|
||||
# Falls back to PULSE_AIRTABLE_API_KEY if PULSE_RESOURCE_API_KEY is unset
|
||||
PULSE_RESOURCE_API_KEY=pat...
|
||||
PULSE_RESOURCE_BASE_ID=appXXXXXXXXXXXXXX
|
||||
PULSE_RESOURCE_TABLE_ID=tblXXXXXXXXXXXXXX
|
||||
|
||||
# --- Azure AD (Microsoft SSO) ---
|
||||
# Single-page application registration in your Azure tenant.
|
||||
# Add redirect URIs for both prod and local dev.
|
||||
AZURE_TENANT_ID=
|
||||
AZURE_CLIENT_ID=
|
||||
# Comma-separated list (e.g. oliver.agency). Empty = allow any tenant user.
|
||||
AUTH_ALLOWED_DOMAINS=oliver.agency
|
||||
# Local dev only — skips token validation, sets g.user_email = 'dev@oliver.agency'
|
||||
DEV_AUTH_BYPASS=false
|
||||
|
||||
# --- Postgres ---
|
||||
# Inside the compose network the host is `db`; override for local-against-host runs.
|
||||
DATABASE_URL=postgresql+psycopg://pulse:pulse@db:5432/pulse
|
||||
POSTGRES_USER=pulse
|
||||
POSTGRES_PASSWORD=pulse
|
||||
POSTGRES_DB=pulse
|
||||
|
||||
# --- Server ---
|
||||
# Host port the deploy script picks; the container always listens on 5051.
|
||||
PROGRAMME_PULSE_PORT=5051
|
||||
70
.gitignore
vendored
70
.gitignore
vendored
|
|
@ -1,50 +1,30 @@
|
|||
# These are some examples of commonly ignored file patterns.
|
||||
# You should customize this list as applicable to your project.
|
||||
# Learn more about .gitignore:
|
||||
# https://www.atlassian.com/git/tutorials/saving-changes/gitignore
|
||||
|
||||
# Node artifact files
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Compiled Java class files
|
||||
*.class
|
||||
|
||||
# Compiled Python bytecode
|
||||
*.py[cod]
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
|
||||
# Package files
|
||||
*.jar
|
||||
|
||||
# Maven
|
||||
target/
|
||||
dist/
|
||||
|
||||
# JetBrains IDE
|
||||
.idea/
|
||||
|
||||
# Unit test reports
|
||||
TEST*.xml
|
||||
|
||||
# Generated by MacOS
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
|
||||
# Generated by Windows
|
||||
Thumbs.db
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.log
|
||||
logs/
|
||||
reports/*.docx
|
||||
reports/*.md
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Applications
|
||||
*.app
|
||||
*.exe
|
||||
*.war
|
||||
# deploy artefacts
|
||||
deploy/apache-programme-pulse.conf
|
||||
.deployed
|
||||
|
||||
# Large media files
|
||||
*.mp4
|
||||
*.tiff
|
||||
*.avi
|
||||
*.flv
|
||||
*.mov
|
||||
*.wmv
|
||||
# frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.env
|
||||
frontend/.env.local
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
|
||||
# editor / tooling
|
||||
.claude/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
|
|
|||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gcc libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV PORT=5051
|
||||
EXPOSE 5051
|
||||
|
||||
# Run migrations on every start (idempotent), then gunicorn with gevent for SSE.
|
||||
CMD ["sh", "-c", "alembic upgrade head && exec gunicorn web_app:app --bind 0.0.0.0:${PORT} --worker-class gevent --workers 1 --timeout 300 --access-logfile -"]
|
||||
1
Procfile
Normal file
1
Procfile
Normal file
|
|
@ -0,0 +1 @@
|
|||
web: gunicorn web_app:app --bind 0.0.0.0:$PORT --worker-class gevent --workers 1 --timeout 300
|
||||
137
README.md
Normal file
137
README.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Programme Pulse
|
||||
|
||||
A small Flask app for chatting with live Airtable programme data and generating two-tier status reports (Manager Summary + Full Report) as Word documents.
|
||||
|
||||
Runs locally on port 5051 by default.
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
|
||||
- Loads tasks from one Airtable base (the Master tracker) and resource bookings from a second Airtable base.
|
||||
- Lets you chat with the data through Claude. Streaming response, stop button, thumbs up/down feedback.
|
||||
- Reads `.docx` meeting transcripts from `docs/Programme Pulse transcripts/` and uses them as supporting context.
|
||||
- Generates two reports on demand: a Manager Summary and a Full Report. Both saved as `.docx` in `reports/`.
|
||||
- Saves your stated preferences to `data/preferences.md` so future sessions remember them.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.11 or newer
|
||||
- An Anthropic API key
|
||||
- Two Airtable bases with personal access tokens
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create and activate a virtual environment.
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
2. Install dependencies.
|
||||
|
||||
```sh
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. Copy the example env file and fill in your own keys.
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Open `.env` and add:
|
||||
- `ANTHROPIC_API_KEY`
|
||||
- `PULSE_AIRTABLE_API_KEY`, `PULSE_AIRTABLE_BASE_ID`, `PULSE_AIRTABLE_TABLE_ID` — the tasks base
|
||||
- `PULSE_RESOURCE_API_KEY`, `PULSE_RESOURCE_BASE_ID`, `PULSE_RESOURCE_TABLE_ID` — the resource booking base
|
||||
- `FLASK_SECRET_KEY` — any random string
|
||||
|
||||
4. Run the app.
|
||||
|
||||
```sh
|
||||
python web_app.py
|
||||
```
|
||||
|
||||
Or with gunicorn (recommended — streaming works better):
|
||||
|
||||
```sh
|
||||
gunicorn web_app:app --bind 0.0.0.0:5051 --worker-class gevent --workers 1 --timeout 300
|
||||
```
|
||||
|
||||
5. Open http://localhost:5051 in your browser.
|
||||
|
||||
---
|
||||
|
||||
## Adding meeting transcripts
|
||||
|
||||
Drop Teams `.docx` transcript exports into `docs/Programme Pulse transcripts/`. The app picks them up on restart and feeds them to the chat and the reports as supporting narrative.
|
||||
|
||||
Each `.docx` is parsed paragraph by paragraph. One speaker turn per paragraph.
|
||||
|
||||
---
|
||||
|
||||
## Folder layout
|
||||
|
||||
```
|
||||
web_app.py Flask entry point
|
||||
src/
|
||||
airtable_client.py Pulls tasks and bookings from Airtable
|
||||
analyzer.py Health signal logic
|
||||
claude_client.py Anthropic SDK wrapper, streaming
|
||||
preferences.py Saves liked/disliked patterns
|
||||
prompts.py System prompts and snapshot builders
|
||||
reporter.py Word document generation
|
||||
transcripts.py Parses .docx meeting transcripts
|
||||
templates/index.html The single-page UI
|
||||
design/ Fonts and design tokens
|
||||
data/ preferences.md lives here
|
||||
docs/Programme Pulse transcripts/ Drop .docx files here
|
||||
reports/ Generated Word files land here
|
||||
logs/ Runtime logs
|
||||
tests/ Pytest suite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Airtable schema notes
|
||||
|
||||
The tasks base must include these fields (rename the constants in `src/airtable_client.py` if your column names differ):
|
||||
- Title / task name
|
||||
- Owner (collaborator)
|
||||
- Status / Progress
|
||||
- Priority
|
||||
- Due date
|
||||
- Notes
|
||||
- Last modified
|
||||
|
||||
The resource base needs:
|
||||
- Resource name
|
||||
- Project
|
||||
- Division
|
||||
- Start date, end date
|
||||
- Hours
|
||||
- Status
|
||||
- Conflict flag
|
||||
|
||||
Open `src/airtable_client.py` to map field names to your own base.
|
||||
|
||||
---
|
||||
|
||||
## Running tests
|
||||
|
||||
```sh
|
||||
pytest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deploying elsewhere
|
||||
|
||||
The app is stateless apart from `data/preferences.md`, `reports/`, and `logs/`. Mount those as volumes if you containerise it. The `Procfile` is set up for any platform that respects it (Heroku, Render, Fly, etc).
|
||||
|
||||
Set `PORT` via env var. Everything else is read from `.env` or the platform's secret store.
|
||||
38
alembic.ini
Normal file
38
alembic.ini
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
49
alembic/env.py
Normal file
49
alembic/env.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from src.db import Base
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# DATABASE_URL from env wins over alembic.ini
|
||||
db_url = os.getenv("DATABASE_URL")
|
||||
if db_url:
|
||||
config.set_main_option("sqlalchemy.url", db_url)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
27
alembic/script.py.mako
Normal file
27
alembic/script.py.mako
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
70
alembic/versions/0001_initial.py
Normal file
70
alembic/versions/0001_initial.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""initial schema
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-05-07
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"preferences",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("user_email", sa.String(320), nullable=False),
|
||||
sa.Column("text", sa.Text, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_preferences_user_email", "preferences", ["user_email"])
|
||||
|
||||
op.create_table(
|
||||
"chat_messages",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("user_email", sa.String(320), nullable=False),
|
||||
sa.Column("role", sa.String(16), nullable=False),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_chat_user_created", "chat_messages", ["user_email", "created_at"])
|
||||
|
||||
op.create_table(
|
||||
"reports",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("user_email", sa.String(320), nullable=False),
|
||||
sa.Column("summary_md", sa.Text, nullable=False),
|
||||
sa.Column("summary_doc_path", sa.String(512), nullable=False),
|
||||
sa.Column("full_doc_path", sa.String(512), nullable=False),
|
||||
sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_reports_user_email", "reports", ["user_email"])
|
||||
|
||||
op.create_table(
|
||||
"feedback_events",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("user_email", sa.String(320), nullable=False),
|
||||
sa.Column("rating", sa.String(8), nullable=False),
|
||||
sa.Column("message_text", sa.Text, nullable=False),
|
||||
sa.Column("extracted_insight", sa.Text, nullable=False),
|
||||
sa.Column("preference_id", sa.Integer, sa.ForeignKey("preferences.id"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_feedback_user_email", "feedback_events", ["user_email"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("feedback_events")
|
||||
op.drop_table("reports")
|
||||
op.drop_table("chat_messages")
|
||||
op.drop_table("preferences")
|
||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
21
deploy/apache-programme-pulse.conf.tmpl
Normal file
21
deploy/apache-programme-pulse.conf.tmpl
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
ProxyTimeout 300
|
||||
TimeOut 300
|
||||
|
||||
# Backend API — proxied to the Flask container on the chosen host port.
|
||||
# flushpackets=on is essential for the SSE chat stream; without it Apache
|
||||
# buffers and tokens stutter.
|
||||
ProxyPass /programme-pulse/api/ http://127.0.0.1:__APP_PORT__/api/ timeout=300 flushpackets=on
|
||||
ProxyPassReverse /programme-pulse/api/ http://127.0.0.1:__APP_PORT__/api/
|
||||
|
||||
# SPA — static files served by Apache directly from the build output.
|
||||
Alias /programme-pulse /var/www/html/programme-pulse
|
||||
<Directory /var/www/html/programme-pulse>
|
||||
Options -Indexes +FollowSymLinks
|
||||
AllowOverride None
|
||||
Require all granted
|
||||
RewriteEngine On
|
||||
RewriteBase /programme-pulse/
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ index.html [L]
|
||||
</Directory>
|
||||
278
deploy/deploy.sh
Executable file
278
deploy/deploy.sh
Executable file
|
|
@ -0,0 +1,278 @@
|
|||
#!/usr/bin/env bash
|
||||
# Programme Pulse — deploy script.
|
||||
#
|
||||
# Idempotent. Safe to re-run on the dev server.
|
||||
# Public URL: https://optical-dev.oliver.solutions/programme-pulse/
|
||||
#
|
||||
# Server layout (mirrors /oliver-sales-ops-platform/, /gsb/, etc.):
|
||||
# /opt/programme-pulse/ — repo + docker-compose
|
||||
# /var/www/html/programme-pulse/ — built SPA, served by Apache
|
||||
#
|
||||
# What it does:
|
||||
# 1. Sanity (.env, docker, git on PATH).
|
||||
# 2. Auto-pick free host port (preferred: PROGRAMME_PULSE_PORT, default 5051).
|
||||
# If taken, scans 5051..5099 and persists the chosen value to .env.
|
||||
# 3. Render deploy/apache-programme-pulse.conf from .tmpl.
|
||||
# 4. git pull (--no-pull to skip).
|
||||
# 5. docker compose build && up -d (--no-build to skip).
|
||||
# 6. Build frontend SPA in a one-shot node:20 container and rsync
|
||||
# dist/ to /var/www/html/programme-pulse/ (--no-frontend to skip).
|
||||
# 7. Poll /api/health until ready.
|
||||
# 8. Print the apache Include line and reload reminder.
|
||||
#
|
||||
# Usage:
|
||||
# ./deploy/deploy.sh # full deploy
|
||||
# ./deploy/deploy.sh --no-pull # skip git pull
|
||||
# ./deploy/deploy.sh --no-build # skip docker rebuild
|
||||
# ./deploy/deploy.sh --no-frontend # skip SPA build/copy
|
||||
# ./deploy/deploy.sh --logs # tail backend logs after deploy
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
COMPOSE_PROJECT="programme-pulse"
|
||||
URL_PATH="/programme-pulse"
|
||||
WEB_ROOT="/var/www/html/programme-pulse"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
log() { printf '\033[1;36m[deploy]\033[0m %s\n' "$*"; }
|
||||
err() { printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2; }
|
||||
ok() { printf '\033[1;32m[deploy]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m[deploy]\033[0m %s\n' "$*"; }
|
||||
|
||||
DO_PULL=1
|
||||
DO_BUILD=1
|
||||
DO_FRONTEND=1
|
||||
TAIL_LOGS=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-pull) DO_PULL=0 ;;
|
||||
--no-build) DO_BUILD=0 ;;
|
||||
--no-frontend) DO_FRONTEND=0 ;;
|
||||
--logs) TAIL_LOGS=1 ;;
|
||||
--help|-h)
|
||||
sed -n '2,/^set/p' "$0" | grep -E '^# ' | sed 's/^# //'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
err "Unknown flag: $arg (try --help)"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 1. Sanity
|
||||
[[ -f docker-compose.yml ]] || { err "docker-compose.yml not found in $REPO_ROOT"; exit 1; }
|
||||
if [[ ! -f .env ]]; then
|
||||
err ".env not found. Copy .env.example and fill it in:"
|
||||
err " cp .env.example .env && \$EDITOR .env"
|
||||
exit 1
|
||||
fi
|
||||
command -v docker >/dev/null 2>&1 || { err "docker not on PATH"; exit 1; }
|
||||
command -v git >/dev/null 2>&1 || { err "git not on PATH"; exit 1; }
|
||||
|
||||
# ---------- helpers ----------
|
||||
|
||||
port_in_use() {
|
||||
local port=$1
|
||||
local pid=""
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
pid=$( { lsof -nP -iTCP:"$port" -sTCP:LISTEN 2>/dev/null || true; } | awk 'NR>1 {print $2}' | head -1 )
|
||||
else
|
||||
pid=$( { ss -ltnp "sport = :$port" 2>/dev/null || true; } | awk -F'pid=' 'NR>1 {print $2}' | cut -d, -f1 | head -1 )
|
||||
fi
|
||||
[[ -n "$pid" ]]
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local preferred=$1
|
||||
local start=$2
|
||||
local end=$3
|
||||
if ! port_in_use "$preferred"; then
|
||||
printf '%s' "$preferred"
|
||||
return 0
|
||||
fi
|
||||
local p
|
||||
for ((p=start; p<=end; p++)); do
|
||||
if ! port_in_use "$p"; then
|
||||
printf '%s' "$p"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
set_env_var() {
|
||||
local key=$1
|
||||
local value=$2
|
||||
local file="${REPO_ROOT}/.env"
|
||||
if grep -q "^${key}=" "$file" 2>/dev/null; then
|
||||
sed -i.bak "s#^${key}=.*#${key}=${value}#" "$file"
|
||||
rm -f "${file}.bak"
|
||||
else
|
||||
printf '%s=%s\n' "$key" "$value" >> "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
get_env_var() {
|
||||
grep -E "^${1}=" "${REPO_ROOT}/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"' || true
|
||||
}
|
||||
|
||||
# ---------- 2. Pick port ----------
|
||||
|
||||
DEFAULT_PORT=5051
|
||||
APP_PORT=$(get_env_var PROGRAMME_PULSE_PORT); APP_PORT=${APP_PORT:-$DEFAULT_PORT}
|
||||
PREV_APP_PORT="$APP_PORT"
|
||||
|
||||
log "Resolving host port (preferred: $APP_PORT)…"
|
||||
|
||||
RUNNING=$(docker compose -p "$COMPOSE_PROJECT" ps -q 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [[ "$RUNNING" -gt 0 ]]; then
|
||||
ok "Project '$COMPOSE_PROJECT' already has $RUNNING containers running — keeping current port assignment."
|
||||
else
|
||||
NEW_PORT=$(find_free_port "$APP_PORT" 5051 5099) || NEW_PORT=""
|
||||
if [[ -z "$NEW_PORT" ]]; then
|
||||
err "Could not find a free port in 5051..5099."
|
||||
exit 1
|
||||
fi
|
||||
[[ "$NEW_PORT" != "$APP_PORT" ]] && warn "port $APP_PORT busy → using $NEW_PORT"
|
||||
APP_PORT=$NEW_PORT
|
||||
set_env_var PROGRAMME_PULSE_PORT "$APP_PORT"
|
||||
ok "Port: $APP_PORT (persisted to .env)"
|
||||
fi
|
||||
|
||||
# ---------- 3. Render apache conf ----------
|
||||
|
||||
APACHE_TMPL="$REPO_ROOT/deploy/apache-programme-pulse.conf.tmpl"
|
||||
APACHE_CONF="$REPO_ROOT/deploy/apache-programme-pulse.conf"
|
||||
if [[ -f "$APACHE_TMPL" ]]; then
|
||||
sed "s#__APP_PORT__#${APP_PORT}#g" "$APACHE_TMPL" > "$APACHE_CONF"
|
||||
ok "Rendered apache-programme-pulse.conf with app port $APP_PORT"
|
||||
else
|
||||
warn "apache-programme-pulse.conf.tmpl missing — leaving deploy/apache-programme-pulse.conf untouched."
|
||||
fi
|
||||
|
||||
# ---------- 4. git pull ----------
|
||||
|
||||
if (( DO_PULL )); then
|
||||
log "git pull origin main"
|
||||
git pull --ff-only origin main
|
||||
fi
|
||||
|
||||
# ---------- 5. Backend build + up ----------
|
||||
|
||||
if (( DO_BUILD )); then
|
||||
log "docker compose build"
|
||||
docker compose -p "$COMPOSE_PROJECT" build
|
||||
fi
|
||||
|
||||
log "docker compose up -d (db + app)"
|
||||
docker compose -p "$COMPOSE_PROJECT" up -d
|
||||
|
||||
# ---------- 6. Frontend build + sync ----------
|
||||
|
||||
if (( DO_FRONTEND )); then
|
||||
AZ_TENANT=$(get_env_var VITE_AZURE_TENANT_ID); AZ_TENANT=${AZ_TENANT:-$(get_env_var AZURE_TENANT_ID)}
|
||||
AZ_CLIENT=$(get_env_var VITE_AZURE_CLIENT_ID); AZ_CLIENT=${AZ_CLIENT:-$(get_env_var AZURE_CLIENT_ID)}
|
||||
if [[ -z "$AZ_TENANT" || -z "$AZ_CLIENT" ]]; then
|
||||
err "AZURE_TENANT_ID and AZURE_CLIENT_ID must be set in .env to build the SPA."
|
||||
exit 1
|
||||
fi
|
||||
# Mirror DEV_AUTH_BYPASS into VITE_DEV_AUTH_BYPASS so a single env var
|
||||
# decides both backend token validation and frontend MSAL gating.
|
||||
VITE_BYPASS=$(get_env_var VITE_DEV_AUTH_BYPASS)
|
||||
[[ -z "$VITE_BYPASS" ]] && VITE_BYPASS=$(get_env_var DEV_AUTH_BYPASS)
|
||||
[[ -z "$VITE_BYPASS" ]] && VITE_BYPASS="false"
|
||||
|
||||
log "Building Vite SPA (VITE_DEV_AUTH_BYPASS=${VITE_BYPASS}) in a one-shot node:20 container…"
|
||||
docker run --rm \
|
||||
-v "$REPO_ROOT/frontend:/app" \
|
||||
-w /app \
|
||||
-e VITE_AZURE_TENANT_ID="$AZ_TENANT" \
|
||||
-e VITE_AZURE_CLIENT_ID="$AZ_CLIENT" \
|
||||
-e VITE_API_BASE="/programme-pulse/api" \
|
||||
-e VITE_DEV_AUTH_BYPASS="$VITE_BYPASS" \
|
||||
node:20-alpine \
|
||||
sh -c "npm install --silent && npm run build"
|
||||
|
||||
if [[ ! -d "$REPO_ROOT/frontend/dist" ]]; then
|
||||
err "Vite build did not produce frontend/dist — aborting frontend sync."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Syncing frontend/dist/ → $WEB_ROOT/"
|
||||
if [[ ! -d "$WEB_ROOT" ]]; then
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo mkdir -p "$WEB_ROOT"
|
||||
else
|
||||
mkdir -p "$WEB_ROOT"
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
if [[ -w "$WEB_ROOT" ]]; then
|
||||
rsync -a --delete "$REPO_ROOT/frontend/dist/" "$WEB_ROOT/"
|
||||
else
|
||||
sudo rsync -a --delete "$REPO_ROOT/frontend/dist/" "$WEB_ROOT/"
|
||||
fi
|
||||
else
|
||||
if [[ -w "$WEB_ROOT" ]]; then
|
||||
rm -rf "$WEB_ROOT"/*
|
||||
cp -a "$REPO_ROOT/frontend/dist/." "$WEB_ROOT/"
|
||||
else
|
||||
sudo rm -rf "$WEB_ROOT"/*
|
||||
sudo cp -a "$REPO_ROOT/frontend/dist/." "$WEB_ROOT/"
|
||||
fi
|
||||
fi
|
||||
ok "SPA synced to $WEB_ROOT"
|
||||
fi
|
||||
|
||||
# ---------- 7. Health poll ----------
|
||||
|
||||
log "Waiting for /api/health on :$APP_PORT (max 60s)…"
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS "http://127.0.0.1:${APP_PORT}/api/health" >/dev/null 2>&1; then
|
||||
ok "Backend healthy"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
if (( i == 30 )); then
|
||||
err "Backend did not become healthy within 60s. Recent logs:"
|
||||
docker compose -p "$COMPOSE_PROJECT" logs app --tail 40 || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------- 8. Report ----------
|
||||
|
||||
ok "Deploy complete."
|
||||
echo
|
||||
echo " Backend (local): http://127.0.0.1:${APP_PORT}/api/health"
|
||||
echo " Public URL: https://optical-dev.oliver.solutions${URL_PATH}/"
|
||||
echo " SPA on disk: $WEB_ROOT"
|
||||
echo " Port: $APP_PORT"
|
||||
echo
|
||||
echo " Apache include line for the merged vhost:"
|
||||
echo " Include $REPO_ROOT/deploy/apache-programme-pulse.conf"
|
||||
if [[ "$APP_PORT" != "$PREV_APP_PORT" ]] || ! grep -qF "$REPO_ROOT/deploy/apache-programme-pulse.conf" /etc/apache2/sites-enabled/*.conf 2>/dev/null; then
|
||||
echo
|
||||
warn "Backend port changed (or first deploy). Add the Include line above to:"
|
||||
echo " /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf"
|
||||
echo " inside </VirtualHost>, then reload Apache:"
|
||||
echo " sudo apachectl configtest && sudo systemctl reload apache2"
|
||||
fi
|
||||
TRANSCRIPTS_DIR="$REPO_ROOT/docs/Programme Pulse transcripts"
|
||||
if [[ ! -d "$TRANSCRIPTS_DIR" ]] || [[ -z "$(ls -A "$TRANSCRIPTS_DIR" 2>/dev/null)" ]]; then
|
||||
echo
|
||||
warn "Transcripts folder is empty: $TRANSCRIPTS_DIR"
|
||||
echo " Drop Teams .docx exports there to give the chat narrative context."
|
||||
fi
|
||||
echo
|
||||
|
||||
if (( TAIL_LOGS )); then
|
||||
log "Tailing app logs (Ctrl-C to stop)…"
|
||||
docker compose -p "$COMPOSE_PROJECT" logs -f app
|
||||
fi
|
||||
13
design/fonts/typography.css
Normal file
13
design/fonts/typography.css
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Merriweather:wght@700;900&family=Inter:wght@400;500;600&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@400;500;600&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:wght@700;900&family=Commissioner:wght@400;500&display=swap');
|
||||
|
||||
/* Recommended pairing: Merriweather + Inter */
|
||||
:root {
|
||||
--font-heading: 'Merriweather', serif;
|
||||
--font-body: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Alternatives */
|
||||
/* Option 2: DM Serif Display + DM Sans */
|
||||
/* Option 3: Fraunces + Commissioner */
|
||||
18
design/tokens/colors.json
Normal file
18
design/tokens/colors.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"primary": "#0071e3",
|
||||
"secondary": "#cc7217",
|
||||
"accent": "#7d00fa",
|
||||
"neutrals": [
|
||||
"#171f27",
|
||||
"#51575d",
|
||||
"#8b8f93",
|
||||
"#c5c7c9",
|
||||
"#ffffff"
|
||||
],
|
||||
"semantic": {
|
||||
"success": "#22c55e",
|
||||
"warning": "#f59e0b",
|
||||
"error": "#ef4444",
|
||||
"info": "#3b82f6"
|
||||
}
|
||||
}
|
||||
26
design/tokens/tailwind.config.ts
Normal file
26
design/tokens/tailwind.config.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#0071e3',
|
||||
secondary: '#cc7217',
|
||||
accent: '#7d00fa',
|
||||
neutral: {
|
||||
100: '#ffffff',
|
||||
200: '#c5c7c9',
|
||||
300: '#8b8f93',
|
||||
400: '#51575d',
|
||||
500: '#171f27',
|
||||
},
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
info: '#3b82f6',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Top-level project name pinned per global Docker policy — without this, the
|
||||
# compose file (when run from /opt/programme-pulse via deploy.sh) would default
|
||||
# to project name "programme-pulse" anyway, but pinning it is explicit and
|
||||
# survives if the file ever moves under deploy/.
|
||||
name: programme-pulse
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Bind only to loopback — Apache fronts the container.
|
||||
- "127.0.0.1:${PROGRAMME_PULSE_PORT:-5051}:5051"
|
||||
environment:
|
||||
- PORT=5051
|
||||
- DATABASE_URL=postgresql+psycopg://${POSTGRES_USER:-pulse}:${POSTGRES_PASSWORD:-pulse}@db:5432/${POSTGRES_DB:-pulse}
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./reports:/app/reports
|
||||
- ./docs/Programme Pulse transcripts:/app/docs/Programme Pulse transcripts:ro
|
||||
- ./logs:/app/logs
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-pulse}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pulse}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-pulse}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-pulse} -d ${POSTGRES_DB:-pulse}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
0
docs/Programme Pulse transcripts/.gitkeep
Normal file
0
docs/Programme Pulse transcripts/.gitkeep
Normal file
15
frontend/.env.example
Normal file
15
frontend/.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Azure AD app registration (Single-page application platform).
|
||||
# Add these as redirect URIs in the registration:
|
||||
# https://optical-dev.oliver.solutions/programme-pulse/
|
||||
# http://localhost:5173/programme-pulse/
|
||||
VITE_AZURE_TENANT_ID=
|
||||
VITE_AZURE_CLIENT_ID=
|
||||
|
||||
# API base. Production: same-origin via Apache proxy → /programme-pulse/api.
|
||||
# Dev: Vite proxy forwards /programme-pulse/api → http://localhost:5051/api.
|
||||
VITE_API_BASE=/programme-pulse/api
|
||||
|
||||
# Set to "true" to skip the MSAL sign-in gate locally. Must be paired with
|
||||
# DEV_AUTH_BYPASS=true on the backend. The SPA will render straight to the
|
||||
# signed-in UI and apiFetch will send requests without an Authorization header.
|
||||
VITE_DEV_AUTH_BYPASS=false
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Programme Pulse</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3265
frontend/package-lock.json
generated
Normal file
3265
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "programme-pulse-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^3.27.0",
|
||||
"@azure/msal-react": "^2.2.0",
|
||||
"chart.js": "^4.4.7",
|
||||
"react": "^18.3.1",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
49
frontend/src/App.tsx
Normal file
49
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { AuthenticatedTemplate, UnauthenticatedTemplate, useMsal } from '@azure/msal-react';
|
||||
import { loginRequest } from './auth/authConfig';
|
||||
import { DEV_AUTH_BYPASS } from './auth/devBypass';
|
||||
import Header from './components/Header';
|
||||
import ChatPanel from './components/ChatPanel';
|
||||
import ReportsCard from './components/ReportsCard';
|
||||
|
||||
export default function App() {
|
||||
if (DEV_AUTH_BYPASS) return <SignedInApp />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<UnauthenticatedTemplate>
|
||||
<SignIn />
|
||||
</UnauthenticatedTemplate>
|
||||
<AuthenticatedTemplate>
|
||||
<SignedInApp />
|
||||
</AuthenticatedTemplate>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SignIn() {
|
||||
const { instance } = useMsal();
|
||||
return (
|
||||
<div className="signin-screen">
|
||||
<div className="signin-card">
|
||||
<div className="signin-dot" />
|
||||
<h1>Programme Pulse</h1>
|
||||
<p>Sign in with your OLIVER account to continue.</p>
|
||||
<button className="btn btn-primary" onClick={() => instance.loginRedirect(loginRequest)}>
|
||||
Sign in with Microsoft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignedInApp() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<ChatPanel />
|
||||
<ReportsCard />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
118
frontend/src/auth/apiFetch.ts
Normal file
118
frontend/src/auth/apiFetch.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { InteractionRequiredAuthError } from '@azure/msal-browser';
|
||||
import { loginRequest, msalInstance } from './authConfig';
|
||||
import { DEV_AUTH_BYPASS } from './devBypass';
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE as string) || '/programme-pulse/api';
|
||||
|
||||
async function getIdToken(): Promise<string | null> {
|
||||
if (DEV_AUTH_BYPASS) return null;
|
||||
|
||||
const account = msalInstance.getActiveAccount() ?? msalInstance.getAllAccounts()[0];
|
||||
if (!account) throw new Error('No active account — sign in required');
|
||||
|
||||
try {
|
||||
const result = await msalInstance.acquireTokenSilent({ ...loginRequest, account });
|
||||
// Send the ID token, NOT the access token (per azure-ad-msal-auth skill gotcha).
|
||||
return result.idToken;
|
||||
} catch (err) {
|
||||
if (err instanceof InteractionRequiredAuthError) {
|
||||
await msalInstance.acquireTokenRedirect({ ...loginRequest, account });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(path: string): string {
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/api/')) return API_BASE + path.slice(4);
|
||||
if (path.startsWith('/')) return API_BASE + path;
|
||||
return API_BASE + '/' + path;
|
||||
}
|
||||
|
||||
function on401() {
|
||||
if (DEV_AUTH_BYPASS) return;
|
||||
msalInstance.loginRedirect(loginRequest);
|
||||
}
|
||||
|
||||
export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const idToken = await getIdToken();
|
||||
const headers = new Headers(init.headers);
|
||||
if (idToken) headers.set('Authorization', `Bearer ${idToken}`);
|
||||
if (init.body && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const res = await fetch(buildUrl(path), { ...init, headers });
|
||||
if (res.status === 401) {
|
||||
on401();
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function apiJson<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await apiFetch(path, init);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function apiUrl(path: string): string {
|
||||
return buildUrl(path);
|
||||
}
|
||||
|
||||
export async function streamSSE(
|
||||
path: string,
|
||||
body: unknown,
|
||||
onChunk: (text: string) => void,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ aborted: boolean; error?: string }> {
|
||||
const idToken = await getIdToken();
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (idToken) headers.Authorization = `Bearer ${idToken}`;
|
||||
|
||||
const res = await fetch(buildUrl(path), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
on401();
|
||||
return { aborted: false, error: 'Authentication required' };
|
||||
}
|
||||
if (!res.ok || !res.body) {
|
||||
return { aborted: false, error: `${res.status} ${res.statusText}` };
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const payload = line.slice(6);
|
||||
if (payload === '[DONE]') return { aborted: false };
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
if (data.error) return { aborted: false, error: String(data.error) };
|
||||
if (typeof data.chunk === 'string') onChunk(data.chunk);
|
||||
} catch {
|
||||
// Partial JSON between chunks — ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as Error).name === 'AbortError') return { aborted: true };
|
||||
throw e;
|
||||
}
|
||||
return { aborted: false };
|
||||
}
|
||||
34
frontend/src/auth/authConfig.ts
Normal file
34
frontend/src/auth/authConfig.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { Configuration, LogLevel, PublicClientApplication } from '@azure/msal-browser';
|
||||
|
||||
const tenantId = import.meta.env.VITE_AZURE_TENANT_ID as string;
|
||||
const clientId = import.meta.env.VITE_AZURE_CLIENT_ID as string;
|
||||
|
||||
export const loginRequest = {
|
||||
scopes: ['User.Read'],
|
||||
};
|
||||
|
||||
const msalConfig: Configuration = {
|
||||
auth: {
|
||||
clientId,
|
||||
authority: `https://login.microsoftonline.com/${tenantId}`,
|
||||
// Vite's `base` makes window.location.origin + import.meta.env.BASE_URL the SPA root.
|
||||
redirectUri: window.location.origin + (import.meta.env.BASE_URL || '/'),
|
||||
postLogoutRedirectUri: window.location.origin + (import.meta.env.BASE_URL || '/'),
|
||||
navigateToLoginRequestUrl: true,
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: 'sessionStorage',
|
||||
storeAuthStateInCookie: false,
|
||||
},
|
||||
system: {
|
||||
loggerOptions: {
|
||||
logLevel: LogLevel.Warning,
|
||||
piiLoggingEnabled: false,
|
||||
loggerCallback: (level, message) => {
|
||||
if (level === LogLevel.Error) console.error(message);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const msalInstance = new PublicClientApplication(msalConfig);
|
||||
2
frontend/src/auth/devBypass.ts
Normal file
2
frontend/src/auth/devBypass.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const DEV_AUTH_BYPASS =
|
||||
(import.meta.env.VITE_DEV_AUTH_BYPASS ?? '').toLowerCase() === 'true';
|
||||
113
frontend/src/components/ChartBlock.tsx
Normal file
113
frontend/src/components/ChartBlock.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
Legend,
|
||||
LineController,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Tooltip,
|
||||
} from 'chart.js';
|
||||
|
||||
ChartJS.register(
|
||||
BarController,
|
||||
BarElement,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Legend,
|
||||
Tooltip,
|
||||
);
|
||||
|
||||
interface ChartSpec {
|
||||
type: 'bar' | 'hbar' | 'line';
|
||||
title?: string;
|
||||
labels?: (string | number)[];
|
||||
series?: { label?: string; data: number[]; color?: string }[];
|
||||
}
|
||||
|
||||
interface ChartBlockProps {
|
||||
spec: ChartSpec;
|
||||
}
|
||||
|
||||
export default function ChartBlock({ spec }: ChartBlockProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const chartRef = useRef<ChartJS | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
const isHbar = spec.type === 'hbar';
|
||||
const chartType = spec.type === 'line' ? 'line' : 'bar';
|
||||
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = new ChartJS(canvasRef.current, {
|
||||
type: chartType,
|
||||
data: {
|
||||
labels: spec.labels ?? [],
|
||||
datasets: (spec.series ?? []).map((s) => ({
|
||||
label: s.label ?? '',
|
||||
data: s.data ?? [],
|
||||
backgroundColor: s.color ?? '#0071e3',
|
||||
borderColor: s.color ?? '#0071e3',
|
||||
borderWidth: chartType === 'line' ? 2 : 0,
|
||||
borderRadius: chartType === 'bar' ? 3 : 0,
|
||||
fill: false,
|
||||
})),
|
||||
},
|
||||
options: {
|
||||
indexAxis: isHbar ? 'y' : 'x',
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: 'top', labels: { font: { size: 11 }, padding: 10, boxWidth: 10 } },
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { font: { size: 11 } } },
|
||||
y: { grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { font: { size: 11 } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, [spec]);
|
||||
|
||||
return (
|
||||
<div className="chart-wrap">
|
||||
{spec.title && <div className="chart-title">{spec.title}</div>}
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull `chart` fenced JSON blocks out of markdown text. Returns an array of
|
||||
* { spec, raw } and the markdown with each chart block replaced by a
|
||||
* placeholder of the form `\n\n[[CHART:N]]\n\n`. The renderer then splits on
|
||||
* those placeholders and inlines real <ChartBlock> components.
|
||||
*/
|
||||
export function extractCharts(markdown: string): { cleaned: string; specs: ChartSpec[] } {
|
||||
const fence = /```chart\s*\n([\s\S]*?)```/g;
|
||||
const specs: ChartSpec[] = [];
|
||||
let cleaned = markdown;
|
||||
let m: RegExpExecArray | null;
|
||||
let i = 0;
|
||||
// Replace one at a time so indices stay valid.
|
||||
while ((m = fence.exec(markdown)) !== null) {
|
||||
try {
|
||||
const spec = JSON.parse(m[1]) as ChartSpec;
|
||||
specs.push(spec);
|
||||
cleaned = cleaned.replace(m[0], `\n\n[[CHART:${i}]]\n\n`);
|
||||
i += 1;
|
||||
} catch {
|
||||
// Streaming may produce partial JSON — leave as a code block.
|
||||
}
|
||||
}
|
||||
return { cleaned, specs };
|
||||
}
|
||||
178
frontend/src/components/ChatPanel.tsx
Normal file
178
frontend/src/components/ChatPanel.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import { apiFetch, streamSSE } from '../auth/apiFetch';
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
role: 'user' | 'assistant' | 'system-msg';
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
aborted?: boolean;
|
||||
}
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
export default function ChatPanel() {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{ id: nextId++, role: 'system-msg', content: 'Ask me anything about the programme.' },
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [genStatus, setGenStatus] = useState<{ text: string; ready?: boolean }>({ text: '' });
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onSystem(e: Event) {
|
||||
const detail = (e as CustomEvent<string>).detail;
|
||||
setMessages((prev) => [...prev, { id: nextId++, role: 'system-msg', content: detail }]);
|
||||
}
|
||||
window.addEventListener('pp:system-message', onSystem);
|
||||
return () => window.removeEventListener('pp:system-message', onSystem);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}, [messages]);
|
||||
|
||||
function autosize() {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = '40px';
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
||||
}
|
||||
|
||||
function stop() {
|
||||
abortRef.current?.abort();
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = input.trim();
|
||||
if (!text || streaming) return;
|
||||
setInput('');
|
||||
requestAnimationFrame(autosize);
|
||||
|
||||
const userMsg: Message = { id: nextId++, role: 'user', content: text };
|
||||
const assistantId = nextId++;
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
userMsg,
|
||||
{ id: assistantId, role: 'assistant', content: '', streaming: true },
|
||||
]);
|
||||
setStreaming(true);
|
||||
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
let acc = '';
|
||||
const result = await streamSSE(
|
||||
'/chat/stream',
|
||||
{ message: text },
|
||||
(chunk) => {
|
||||
acc += chunk;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantId ? { ...m, content: acc } : m)),
|
||||
);
|
||||
},
|
||||
ctrl.signal,
|
||||
).catch((e) => ({ aborted: false, error: (e as Error).message }));
|
||||
|
||||
abortRef.current = null;
|
||||
setStreaming(false);
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId
|
||||
? {
|
||||
...m,
|
||||
content: acc || (result.error ?? ''),
|
||||
streaming: false,
|
||||
aborted: result.aborted,
|
||||
}
|
||||
: m,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function generateReports() {
|
||||
setGenerating(true);
|
||||
setGenStatus({ text: 'Generating — this takes about 20–30 seconds…' });
|
||||
try {
|
||||
const res = await apiFetch('/generate', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
setGenStatus({ text: '✓ Reports ready.', ready: true });
|
||||
window.dispatchEvent(new CustomEvent('pp:report-generated'));
|
||||
} else {
|
||||
setGenStatus({ text: 'Error: ' + (data.error || 'Unknown error') });
|
||||
}
|
||||
} catch {
|
||||
setGenStatus({ text: 'Error reaching the server.' });
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="messages">
|
||||
{messages.map((m) => (
|
||||
<MessageBubble
|
||||
key={m.id}
|
||||
role={m.role}
|
||||
content={m.content}
|
||||
streaming={m.streaming}
|
||||
aborted={m.aborted}
|
||||
/>
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
<div className="input-bar">
|
||||
<div className="input-inner">
|
||||
<div className="input-row">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="chat-input"
|
||||
placeholder="Ask about the programme…"
|
||||
rows={1}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
requestAnimationFrame(autosize);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (streaming) stop();
|
||||
else send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="input-actions">
|
||||
{streaming ? (
|
||||
<button className="btn btn-stop" onClick={stop}>
|
||||
◼ Stop
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={send} disabled={!input.trim()}>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={generateReports}
|
||||
disabled={generating || streaming}
|
||||
>
|
||||
Generate reports
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`generate-status${genStatus.ready ? ' ready' : ''}`}>{genStatus.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
72
frontend/src/components/Header.tsx
Normal file
72
frontend/src/components/Header.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useMsal } from '@azure/msal-react';
|
||||
import { apiJson, apiFetch } from '../auth/apiFetch';
|
||||
import { DEV_AUTH_BYPASS } from '../auth/devBypass';
|
||||
import PreferencesModal from './PreferencesModal';
|
||||
|
||||
interface Me {
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function Header() {
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [prefsOpen, setPrefsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiJson<Me>('/me').then(setMe).catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function refreshData() {
|
||||
try {
|
||||
const res = await apiFetch('/refresh', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('pp:system-message', { detail: `Data refreshed — ${data.tasks} tasks loaded.` }),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('pp:system-message', { detail: 'Refresh failed.' }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="app-header">
|
||||
<div className="header-left">
|
||||
<div className="header-dot" />
|
||||
<h1>Programme Pulse</h1>
|
||||
<div className="header-sep" />
|
||||
<span className="subtitle">L'Oréal OLIVER</span>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
{me && <span className="header-user">{me.name}</span>}
|
||||
<button className="header-btn" onClick={() => setPrefsOpen(true)}>
|
||||
Preferences
|
||||
</button>
|
||||
<button className="header-btn" onClick={refreshData}>
|
||||
<span>↻</span> Refresh
|
||||
</button>
|
||||
{!DEV_AUTH_BYPASS && <SignOutButton />}
|
||||
</div>
|
||||
</header>
|
||||
{prefsOpen && <PreferencesModal onClose={() => setPrefsOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SignOutButton() {
|
||||
const { instance } = useMsal();
|
||||
function logout() {
|
||||
instance.clearCache();
|
||||
instance.logoutRedirect();
|
||||
}
|
||||
return (
|
||||
<button className="header-btn" onClick={logout}>
|
||||
Sign out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
113
frontend/src/components/MessageBubble.tsx
Normal file
113
frontend/src/components/MessageBubble.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { Fragment, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import ChartBlock, { extractCharts } from './ChartBlock';
|
||||
import { apiFetch } from '../auth/apiFetch';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
role: 'user' | 'assistant' | 'system-msg';
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
aborted?: boolean;
|
||||
}
|
||||
|
||||
export default function MessageBubble({ role, content, streaming, aborted }: MessageBubbleProps) {
|
||||
if (role === 'user') {
|
||||
return <div className="msg user">{content}</div>;
|
||||
}
|
||||
if (role === 'system-msg') {
|
||||
return <div className="msg system-msg">{content}</div>;
|
||||
}
|
||||
return <AssistantBubble content={content} streaming={streaming} aborted={aborted} />;
|
||||
}
|
||||
|
||||
function AssistantBubble({
|
||||
content,
|
||||
streaming,
|
||||
aborted,
|
||||
}: {
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
aborted?: boolean;
|
||||
}) {
|
||||
const [feedback, setFeedback] = useState<'up' | 'down' | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
if (streaming && !content) {
|
||||
return (
|
||||
<div className="msg-wrap">
|
||||
<div className="msg assistant streaming" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!streaming && !content) {
|
||||
return (
|
||||
<div className="msg-wrap">
|
||||
<div className="msg assistant">
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: '0.835rem' }}>Response cancelled.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { cleaned, specs } = extractCharts(content);
|
||||
const segments = cleaned.split(/\[\[CHART:(\d+)\]\]/g);
|
||||
|
||||
async function send(rating: 'up' | 'down') {
|
||||
if (submitting || feedback) return;
|
||||
setSubmitting(true);
|
||||
setFeedback(rating);
|
||||
try {
|
||||
await apiFetch('/feedback', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ rating, message: content }),
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="msg-wrap">
|
||||
<div className={`msg assistant${streaming ? ' streaming' : ''}`}>
|
||||
{segments.map((seg, idx) => {
|
||||
if (idx % 2 === 1) {
|
||||
const specIdx = parseInt(seg, 10);
|
||||
const spec = specs[specIdx];
|
||||
return spec ? <ChartBlock key={`c${idx}`} spec={spec} /> : null;
|
||||
}
|
||||
return (
|
||||
<Fragment key={`md${idx}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{seg}</ReactMarkdown>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{aborted && (
|
||||
<p style={{ fontSize: '0.72rem', color: 'var(--text-tertiary)', marginTop: 8 }}>— stopped</p>
|
||||
)}
|
||||
</div>
|
||||
{!streaming && (
|
||||
<div className="feedback-row">
|
||||
<button
|
||||
type="button"
|
||||
className={`thumb-btn${feedback === 'up' ? ' active-up' : ''}`}
|
||||
disabled={feedback !== null}
|
||||
onClick={() => send('up')}
|
||||
title="Good response"
|
||||
>
|
||||
👍
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`thumb-btn${feedback === 'down' ? ' active-down' : ''}`}
|
||||
disabled={feedback !== null}
|
||||
onClick={() => send('down')}
|
||||
title="Not helpful"
|
||||
>
|
||||
👎
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
frontend/src/components/PreferencesModal.tsx
Normal file
70
frontend/src/components/PreferencesModal.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { apiFetch, apiJson } from '../auth/apiFetch';
|
||||
|
||||
interface Preference {
|
||||
id: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface PreferencesModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PreferencesModal({ onClose }: PreferencesModalProps) {
|
||||
const [prefs, setPrefs] = useState<Preference[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apiJson<{ preferences: Preference[] }>('/preferences');
|
||||
setPrefs(data.preferences ?? []);
|
||||
} catch {
|
||||
setError('Could not load preferences.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function remove(id: number) {
|
||||
await apiFetch(`/preferences/${id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2>Preferences</h2>
|
||||
<p>Saved from thumbs feedback. Applied to every chat session.</p>
|
||||
</div>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{error && <div className="pref-empty">{error}</div>}
|
||||
{!error && prefs === null && <div className="pref-empty">Loading…</div>}
|
||||
{!error && prefs && prefs.length === 0 && (
|
||||
<div className="pref-empty">
|
||||
No preferences saved yet. Use 👍 and 👎 on responses to build them up.
|
||||
</div>
|
||||
)}
|
||||
{!error &&
|
||||
prefs &&
|
||||
prefs.map((p) => (
|
||||
<div key={p.id} className="pref-item">
|
||||
<span>{p.text}</span>
|
||||
<button className="pref-delete" title="Delete" onClick={() => remove(p.id)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/ReportsCard.tsx
Normal file
135
frontend/src/components/ReportsCard.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { apiFetch, apiJson } from '../auth/apiFetch';
|
||||
|
||||
interface HistoryRun {
|
||||
id: number;
|
||||
label: string;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export default function ReportsCard() {
|
||||
const [runs, setRuns] = useState<HistoryRun[]>([]);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [copyStatus, setCopyStatus] = useState('');
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiJson<{ runs: HistoryRun[] }>('/history');
|
||||
setRuns(data.runs ?? []);
|
||||
} catch {
|
||||
// ignore — card stays hidden
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
function onGenerated() {
|
||||
loadHistory();
|
||||
}
|
||||
window.addEventListener('pp:report-generated', onGenerated);
|
||||
return () => window.removeEventListener('pp:report-generated', onGenerated);
|
||||
}, [loadHistory]);
|
||||
|
||||
if (runs.length === 0) return null;
|
||||
|
||||
async function copyForTeams() {
|
||||
try {
|
||||
const data = await apiJson<{ markdown: string }>('/copy/summary');
|
||||
await navigator.clipboard.writeText(data.markdown);
|
||||
setCopyStatus('✓ Copied — paste into Teams or email.');
|
||||
setTimeout(() => setCopyStatus(''), 4000);
|
||||
} catch {
|
||||
setCopyStatus('Copy failed — download the Word doc instead.');
|
||||
}
|
||||
}
|
||||
|
||||
async function authedDownload(path: string, downloadName: string) {
|
||||
const res = await apiFetch(path);
|
||||
if (!res.ok) return;
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = downloadName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const older = runs.slice(1);
|
||||
|
||||
return (
|
||||
<div className="reports-card">
|
||||
<div className="reports-card-header">
|
||||
<div className="icon">📋</div>
|
||||
<div>
|
||||
<h2>Reports ready</h2>
|
||||
<div className="subtitle">Manager Summary and Full Report</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="reports-row">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => authedDownload('/download/summary', 'programme-pulse-summary.docx')}
|
||||
>
|
||||
↓ Manager Summary
|
||||
</button>
|
||||
<button className="btn btn-green" onClick={copyForTeams}>
|
||||
⎘ Copy for Teams
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => authedDownload('/download/full', 'programme-pulse-full.docx')}
|
||||
>
|
||||
↓ Full Report
|
||||
</button>
|
||||
</div>
|
||||
<div id="copy-status">{copyStatus}</div>
|
||||
{older.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="history-toggle"
|
||||
onClick={() => setHistoryOpen((v) => !v)}
|
||||
>
|
||||
{historyOpen ? '▾' : '▸'} Past reports
|
||||
</button>
|
||||
{historyOpen && (
|
||||
<div className="history-list">
|
||||
{older.map((run) => (
|
||||
<div key={run.id} className="history-item">
|
||||
<span className="history-label">{run.label}</span>
|
||||
<span className="history-links">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
authedDownload(
|
||||
`/download/history/${run.id}/summary`,
|
||||
`programme-pulse-summary-${run.ts}.docx`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Summary
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
authedDownload(
|
||||
`/download/history/${run.id}/full`,
|
||||
`programme-pulse-full-${run.ts}.docx`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Full Report
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
frontend/src/main.tsx
Normal file
37
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { MsalProvider } from '@azure/msal-react';
|
||||
import { EventType, type AuthenticationResult } from '@azure/msal-browser';
|
||||
|
||||
import { msalInstance } from './auth/authConfig';
|
||||
import { DEV_AUTH_BYPASS } from './auth/devBypass';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
|
||||
(async () => {
|
||||
if (!DEV_AUTH_BYPASS) {
|
||||
await msalInstance.initialize();
|
||||
|
||||
msalInstance.addEventCallback((event) => {
|
||||
if (event.eventType === EventType.LOGIN_SUCCESS && event.payload) {
|
||||
const result = event.payload as AuthenticationResult;
|
||||
if (result.account) msalInstance.setActiveAccount(result.account);
|
||||
}
|
||||
});
|
||||
|
||||
const accounts = msalInstance.getAllAccounts();
|
||||
if (accounts.length > 0 && !msalInstance.getActiveAccount()) {
|
||||
msalInstance.setActiveAccount(accounts[0]);
|
||||
}
|
||||
|
||||
await msalInstance.handleRedirectPromise();
|
||||
}
|
||||
|
||||
const root = (
|
||||
<React.StrictMode>
|
||||
{DEV_AUTH_BYPASS ? <App /> : <MsalProvider instance={msalInstance}><App /></MsalProvider>}
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(root);
|
||||
})();
|
||||
639
frontend/src/styles/global.css
Normal file
639
frontend/src/styles/global.css
Normal file
|
|
@ -0,0 +1,639 @@
|
|||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f7;
|
||||
--surface: #ffffff;
|
||||
--surface-secondary: #f9f9fb;
|
||||
--border: rgba(0,0,0,0.08);
|
||||
--border-strong: rgba(0,0,0,0.14);
|
||||
--text-primary: #1d1d1f;
|
||||
--text-secondary: #6e6e73;
|
||||
--text-tertiary: #aeaeb2;
|
||||
--accent: #0071e3;
|
||||
--accent-hover: #0077ed;
|
||||
--accent-soft: #e8f0fd;
|
||||
--green: #28a745;
|
||||
--green-soft: #edfaf1;
|
||||
--green-border: #c3e6cb;
|
||||
--red: #d70015;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-md: 0 4px 16px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.04);
|
||||
--radius: 14px;
|
||||
--radius-sm: 10px;
|
||||
--radius-xs: 8px;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sign-in */
|
||||
.signin-screen {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.signin-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 36px 32px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-md);
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.signin-dot {
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.signin-card h1 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.signin-card p {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header.app-header {
|
||||
background: rgba(245,245,247,0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 24px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 10px; }
|
||||
.header-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
|
||||
header.app-header h1 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
header.app-header .subtitle {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 400;
|
||||
}
|
||||
.header-sep { width: 1px; height: 14px; background: var(--border-strong); }
|
||||
.header-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-secondary);
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.header-btn:hover {
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-tertiary);
|
||||
}
|
||||
.header-right { display: flex; align-items: center; gap: 8px; }
|
||||
.header-user {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 6px;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
main {
|
||||
flex: 1;
|
||||
max-width: 760px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 20px 20px 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
max-width: 82%;
|
||||
padding: 11px 15px;
|
||||
border-radius: var(--radius);
|
||||
line-height: 1.5;
|
||||
font-size: 0.875rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.msg.user {
|
||||
align-self: flex-end;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
font-weight: 400;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.msg.assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-left-radius: 4px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.msg.assistant.streaming {
|
||||
border-top: 2px solid var(--accent);
|
||||
}
|
||||
.msg.assistant.streaming::after {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 2px;
|
||||
width: 32px;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
margin-top: 10px;
|
||||
animation: pulse-bar 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-bar {
|
||||
0%, 100% { opacity: 0.3; transform: scaleX(0.6); }
|
||||
50% { opacity: 1; transform: scaleX(1); }
|
||||
}
|
||||
|
||||
.msg.assistant h1, .msg.assistant h2 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
margin: 14px 0 6px;
|
||||
}
|
||||
.msg.assistant h1:first-child, .msg.assistant h2:first-child { margin-top: 0; }
|
||||
.msg.assistant h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
.msg.assistant h3:first-child { margin-top: 0; }
|
||||
.msg.assistant p { margin: 0 0 8px; }
|
||||
.msg.assistant p:last-child { margin-bottom: 0; }
|
||||
.msg.assistant ul, .msg.assistant ol {
|
||||
padding-left: 18px;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
.msg.assistant li { margin: 3px 0; }
|
||||
.msg.assistant strong { font-weight: 600; }
|
||||
.msg.assistant em { font-style: italic; }
|
||||
.msg.assistant hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
.msg.assistant code {
|
||||
background: var(--surface-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-size: 0.82rem;
|
||||
font-family: "SF Mono", monospace;
|
||||
}
|
||||
|
||||
.msg-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: flex-start;
|
||||
max-width: 82%;
|
||||
}
|
||||
|
||||
.msg.system-msg {
|
||||
align-self: center;
|
||||
background: none;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 0;
|
||||
border-radius: 0;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Feedback */
|
||||
.feedback-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
.thumb-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 5px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
color: var(--text-tertiary);
|
||||
transition: all 0.12s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
.thumb-btn:hover { background: var(--surface-secondary); color: var(--text-secondary); }
|
||||
.thumb-btn.active-up { color: var(--green); }
|
||||
.thumb-btn.active-down { color: var(--red); }
|
||||
.thumb-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* Reports card */
|
||||
.reports-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.reports-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.reports-card-header .icon {
|
||||
width: 28px; height: 28px;
|
||||
background: var(--accent-soft);
|
||||
border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.reports-card-header h2 {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.reports-card-header .subtitle {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.reports-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* History */
|
||||
.history-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 8px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: inherit;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
.history-toggle:hover { color: var(--text-secondary); }
|
||||
.history-list {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.history-item .history-label {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.history-item .history-links {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.history-item button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-family: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
.history-item button:hover { text-decoration: underline; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:disabled { opacity: 0.38; cursor: default; }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.btn-ghost {
|
||||
background: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--surface);
|
||||
border-color: var(--text-tertiary);
|
||||
}
|
||||
.btn-green {
|
||||
background: var(--green-soft);
|
||||
color: var(--green);
|
||||
border: 1px solid var(--green-border);
|
||||
}
|
||||
.btn-green:hover:not(:disabled) { background: #d4f5de; }
|
||||
.btn-stop {
|
||||
background: #fff0f2;
|
||||
color: var(--red);
|
||||
border: 1px solid rgba(215,0,21,0.2);
|
||||
}
|
||||
.btn-stop:hover { background: #ffe0e4; }
|
||||
|
||||
#copy-status {
|
||||
font-size: 0.75rem;
|
||||
color: var(--green);
|
||||
margin-top: 10px;
|
||||
min-height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Input bar */
|
||||
.input-bar {
|
||||
position: fixed;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
background: rgba(245,245,247,0.92);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 12px 20px 16px;
|
||||
}
|
||||
.input-inner {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 12px;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface);
|
||||
resize: none;
|
||||
min-height: 40px;
|
||||
max-height: 120px;
|
||||
line-height: 1.4;
|
||||
transition: border-color 0.15s;
|
||||
outline: none;
|
||||
}
|
||||
.chat-input:focus { border-color: var(--accent); }
|
||||
.chat-input::placeholder { color: var(--text-tertiary); }
|
||||
.input-actions { display: flex; gap: 6px; }
|
||||
.generate-status {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
min-height: 16px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.generate-status.ready { color: var(--green); }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.3);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-header h2 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.modal-header p {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
border-radius: 6px;
|
||||
transition: all 0.12s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-close:hover { background: var(--surface-secondary); color: var(--text-primary); }
|
||||
.modal-body {
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
flex: 1;
|
||||
}
|
||||
.pref-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: var(--radius-xs);
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-secondary);
|
||||
font-size: 0.835rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.pref-item span { flex: 1; }
|
||||
.pref-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
transition: all 0.12s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.pref-delete:hover { background: #fef0f0; color: var(--red); }
|
||||
.pref-empty {
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.835rem;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
/* Tables in markdown output */
|
||||
.msg.assistant table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
overflow: hidden;
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
.msg.assistant th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 7px 12px;
|
||||
background: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.msg.assistant td {
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.msg.assistant tbody tr:last-child td { border-bottom: none; }
|
||||
.msg.assistant tbody tr:hover td { background: rgba(0,0,0,0.016); }
|
||||
|
||||
/* Charts */
|
||||
.chart-wrap {
|
||||
margin: 10px 0 14px;
|
||||
padding: 14px 16px 10px;
|
||||
background: var(--surface-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.chart-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.chart-wrap canvas { max-height: 340px; }
|
||||
|
||||
/* Typing dots — kept around for future use */
|
||||
.typing {
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
border-bottom-left-radius: 4px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.typing span {
|
||||
width: 6px; height: 6px;
|
||||
background: var(--text-tertiary);
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
.typing span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: translateY(0); opacity: 0.5; }
|
||||
40% { transform: translateY(-5px); opacity: 1; }
|
||||
}
|
||||
12
frontend/src/vite-env.d.ts
vendored
Normal file
12
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_AZURE_TENANT_ID: string;
|
||||
readonly VITE_AZURE_CLIENT_ID: string;
|
||||
readonly VITE_API_BASE: string;
|
||||
readonly VITE_DEV_AUTH_BYPASS?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
22
frontend/tsconfig.json
Normal file
22
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"useDefineForClassFields": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
25
frontend/vite.config.ts
Normal file
25
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// Path-prefix-aware build. Apache serves the SPA at /programme-pulse/.
|
||||
// Vite bakes this into asset URLs so the manual API_BASE hack isn't needed.
|
||||
export default defineConfig({
|
||||
base: '/programme-pulse/',
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
// Mirror prod paths in local dev: API calls hit /programme-pulse/api/* → forwarded to Flask :5051.
|
||||
proxy: {
|
||||
'/programme-pulse/api': {
|
||||
target: 'http://localhost:5051',
|
||||
changeOrigin: true,
|
||||
// Strip the /programme-pulse prefix so Flask sees /api/...
|
||||
rewrite: (p) => p.replace(/^\/programme-pulse/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
5
pytest.ini
Normal file
5
pytest.ini
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
0
reports/.gitkeep
Normal file
0
reports/.gitkeep
Normal file
21
requirements.txt
Normal file
21
requirements.txt
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
pyairtable>=2.3.0
|
||||
anthropic>=0.40.0
|
||||
python-dotenv>=1.0.0
|
||||
python-docx>=1.1.0
|
||||
flask>=3.0.0
|
||||
flask-cors>=4.0.0
|
||||
gunicorn>=21.2.0
|
||||
gevent>=23.9.0
|
||||
|
||||
# Auth
|
||||
pyjwt[crypto]>=2.8.0
|
||||
requests>=2.31.0
|
||||
|
||||
# Postgres
|
||||
sqlalchemy>=2.0.0
|
||||
psycopg[binary]>=3.1.0
|
||||
alembic>=1.13.0
|
||||
|
||||
# Tests
|
||||
pytest>=8.0.0
|
||||
pytest-mock>=3.14.0
|
||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
80
src/airtable_client.py
Normal file
80
src/airtable_client.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pyairtable import Api
|
||||
|
||||
|
||||
class ResourceAirtableClient:
|
||||
def __init__(self, api_key: str, base_id: str, table_id: str):
|
||||
self._api = Api(api_key)
|
||||
self._table = self._api.table(base_id, table_id)
|
||||
|
||||
def fetch_all_bookings(self) -> list[dict]:
|
||||
"""Fetch all resource bookings and return normalised dicts."""
|
||||
records = self._table.all()
|
||||
bookings = []
|
||||
for r in records:
|
||||
f = r["fields"]
|
||||
resource = (f.get("Airtable User (from Resource)") or "").strip()
|
||||
if not resource:
|
||||
continue
|
||||
bookings.append({
|
||||
"id": r["id"],
|
||||
"task": (f.get("Task") or "").strip(),
|
||||
"resource": resource,
|
||||
"project": (f.get("OMG Project Name") or "").strip(),
|
||||
"division": f.get("Division", ""),
|
||||
"start_date": f.get("Start Date", ""),
|
||||
"end_date": f.get("End Date", ""),
|
||||
"hours": f.get("Total Hours Booked") or 0,
|
||||
"status": f.get("Booking Status", ""),
|
||||
"availability": f.get("Availibility (per week) (from Resource)") or "40",
|
||||
"conflict_notes": (f.get("Conflict Notes") or "").strip(),
|
||||
})
|
||||
return bookings
|
||||
|
||||
|
||||
def _clean_task_name(raw: str) -> str:
|
||||
return raw.lstrip("\ufeff").strip()
|
||||
|
||||
|
||||
def _clean_related_item(raw: str | None) -> str:
|
||||
if not raw:
|
||||
return ""
|
||||
return re.sub(r"\s*\(https?://\S+?\)", "", raw).strip().strip(",").strip()
|
||||
|
||||
|
||||
class PulseAirtableClient:
|
||||
def __init__(self, api_key: str, base_id: str, table_id: str):
|
||||
self._api = Api(api_key)
|
||||
self._table = self._api.table(base_id, table_id)
|
||||
|
||||
def fetch_all_tasks(self) -> list[dict]:
|
||||
"""Fetch all records from Programme Pulse and return normalized task dicts."""
|
||||
records = self._table.all()
|
||||
tasks = []
|
||||
for r in records:
|
||||
f = r["fields"]
|
||||
task_raw = f.get("\ufeffTask") or f.get("Task") or ""
|
||||
owners_raw = f.get("Owner", [])
|
||||
owners = [o.get("name") for o in owners_raw if isinstance(o, dict) and o.get("name")]
|
||||
tasks.append({
|
||||
"id": r["id"],
|
||||
"task": _clean_task_name(task_raw),
|
||||
"progress": f.get("Progress", ""),
|
||||
"priority": f.get("Priority", ""),
|
||||
"rag": f.get("RAG", ""),
|
||||
"owners": owners,
|
||||
"owner": owners[0] if owners else "Unassigned",
|
||||
"related_item": _clean_related_item(f.get("Related item", "")),
|
||||
"category": f.get("Category", []),
|
||||
"start_date": f.get("Start Date", ""),
|
||||
"end_date": f.get("End Date", ""),
|
||||
"deadline": f.get("Deadline", ""),
|
||||
"doing": f.get("Doing", ""),
|
||||
"blocked_by": f.get("Blocked by", ""),
|
||||
"blocking": f.get("Blocking", ""),
|
||||
"notes": f.get("Notes", ""),
|
||||
"hours": f.get("Hours"),
|
||||
})
|
||||
return tasks
|
||||
64
src/analyzer.py
Normal file
64
src/analyzer.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
COMPLETE_PROGRESS = {"Complete", "Cancelled"}
|
||||
RAG_ORDER = {"Red": 0, "Amber": 1, "Green": 2, "": 3}
|
||||
|
||||
|
||||
def _parse_date(raw: str | None) -> date | None:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(str(raw)[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def analyze(tasks: list[dict]) -> dict:
|
||||
"""Produce structured analysis from Programme Pulse task list."""
|
||||
today = datetime.now(timezone.utc).date()
|
||||
active = [t for t in tasks if t["progress"] not in COMPLETE_PROGRESS]
|
||||
|
||||
progress_counts: dict[str, int] = {}
|
||||
priority_counts: dict[str, int] = {}
|
||||
for t in tasks:
|
||||
p = t["progress"] or "(empty)"
|
||||
pr = t["priority"] or "(empty)"
|
||||
progress_counts[p] = progress_counts.get(p, 0) + 1
|
||||
priority_counts[pr] = priority_counts.get(pr, 0) + 1
|
||||
|
||||
red_flags = [t for t in tasks if t["progress"] in {"Blocked", "Pending Feedback"}]
|
||||
|
||||
p1_watchlist = sorted(
|
||||
[t for t in tasks if t["priority"] == "P1" and t["progress"] not in COMPLETE_PROGRESS],
|
||||
key=lambda t: (RAG_ORDER.get(t["rag"], 3), t["task"]),
|
||||
)
|
||||
|
||||
by_owner: dict[str, list[dict]] = {}
|
||||
for t in active:
|
||||
by_owner.setdefault(t["owner"], []).append(t)
|
||||
|
||||
overdue = []
|
||||
for t in active:
|
||||
raw = t["deadline"] or t["end_date"]
|
||||
d = _parse_date(raw)
|
||||
if d and d < today:
|
||||
overdue.append({**t, "_days_overdue": (today - d).days})
|
||||
|
||||
wins = [
|
||||
t for t in tasks
|
||||
if t["progress"] == "Complete" and t["priority"] in ("P1", "P2")
|
||||
][:20]
|
||||
|
||||
return {
|
||||
"total": len(tasks),
|
||||
"active_total": len(active),
|
||||
"progress_counts": progress_counts,
|
||||
"priority_counts": priority_counts,
|
||||
"red_flags": red_flags,
|
||||
"p1_watchlist": p1_watchlist,
|
||||
"by_owner": by_owner,
|
||||
"overdue": overdue,
|
||||
"wins": wins,
|
||||
}
|
||||
95
src/auth.py
Normal file
95
src/auth.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
def __init__(self, message: str, status_code: int = 401):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def _tenant() -> str:
|
||||
t = os.getenv("AZURE_TENANT_ID", "").strip()
|
||||
if not t:
|
||||
raise AuthError("AZURE_TENANT_ID not configured", 500)
|
||||
return t
|
||||
|
||||
|
||||
def _client_id() -> str:
|
||||
c = os.getenv("AZURE_CLIENT_ID", "").strip()
|
||||
if not c:
|
||||
raise AuthError("AZURE_CLIENT_ID not configured", 500)
|
||||
return c
|
||||
|
||||
|
||||
def dev_bypass_enabled() -> bool:
|
||||
return os.getenv("DEV_AUTH_BYPASS", "").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _jwks_client() -> PyJWKClient:
|
||||
url = f"https://login.microsoftonline.com/{_tenant()}/discovery/v2.0/keys"
|
||||
# cache_keys keeps the parsed keys; lifespan ~1h matches Azure rotation cadence
|
||||
return PyJWKClient(url, cache_keys=True, lifespan=3600)
|
||||
|
||||
|
||||
def _allowed_domains() -> list[str]:
|
||||
raw = os.getenv("AUTH_ALLOWED_DOMAINS", "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
return [d.strip().lower() for d in raw.split(",") if d.strip()]
|
||||
|
||||
|
||||
def _check_domain(email: str) -> None:
|
||||
domains = _allowed_domains()
|
||||
if not domains:
|
||||
return
|
||||
domain = email.rsplit("@", 1)[-1].lower() if "@" in email else ""
|
||||
if domain not in domains:
|
||||
raise AuthError(f"Domain '{domain}' not allowed", 403)
|
||||
|
||||
|
||||
def validate_bearer_token(token: str) -> dict[str, Any]:
|
||||
"""Validate an Azure AD ID token. Return the claims dict on success."""
|
||||
if not token:
|
||||
raise AuthError("Missing bearer token")
|
||||
|
||||
try:
|
||||
signing_key = _jwks_client().get_signing_key_from_jwt(token).key
|
||||
except jwt.exceptions.PyJWKClientError as e:
|
||||
raise AuthError(f"Could not resolve signing key: {e}")
|
||||
|
||||
issuer = f"https://login.microsoftonline.com/{_tenant()}/v2.0"
|
||||
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
signing_key,
|
||||
algorithms=["RS256"],
|
||||
audience=_client_id(),
|
||||
issuer=issuer,
|
||||
options={"require": ["exp", "iat", "aud", "iss"]},
|
||||
)
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise AuthError("Token expired")
|
||||
except jwt.InvalidTokenError as e:
|
||||
raise AuthError(f"Invalid token: {e}")
|
||||
|
||||
email = (claims.get("preferred_username") or claims.get("email") or "").strip()
|
||||
if not email:
|
||||
raise AuthError("Token has no preferred_username or email claim")
|
||||
_check_domain(email)
|
||||
|
||||
claims["_email"] = email
|
||||
claims["_name"] = claims.get("name") or email
|
||||
return claims
|
||||
|
||||
|
||||
def dev_user_claims() -> dict[str, Any]:
|
||||
return {"_email": "dev@oliver.agency", "_name": "Dev User"}
|
||||
42
src/claude_client.py
Normal file
42
src/claude_client.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from anthropic import Anthropic
|
||||
|
||||
MODEL = "claude-sonnet-4-6"
|
||||
|
||||
|
||||
class ClaudeClient:
|
||||
def __init__(self, api_key: str):
|
||||
self._client = Anthropic(api_key=api_key)
|
||||
|
||||
def generate_report(self, system_prompt: str, user_prompt: str) -> str:
|
||||
response = self._client.messages.create(
|
||||
model=MODEL,
|
||||
max_tokens=4096,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
)
|
||||
return "".join(
|
||||
block.text for block in response.content if block.type == "text"
|
||||
)
|
||||
|
||||
def chat(self, messages: list[dict], system_prompt: str) -> str:
|
||||
"""Send a conversational turn and return the assistant response."""
|
||||
response = self._client.messages.create(
|
||||
model=MODEL,
|
||||
max_tokens=8192,
|
||||
system=system_prompt,
|
||||
messages=messages,
|
||||
)
|
||||
return "".join(
|
||||
block.text for block in response.content if block.type == "text"
|
||||
)
|
||||
|
||||
def chat_stream(self, messages: list[dict], system_prompt: str):
|
||||
"""Return a streaming context manager that yields text chunks."""
|
||||
return self._client.messages.stream(
|
||||
model=MODEL,
|
||||
max_tokens=8192,
|
||||
system=system_prompt,
|
||||
messages=messages,
|
||||
)
|
||||
108
src/db.py
Normal file
108
src/db.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterator
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Preference(Base):
|
||||
__tablename__ = "preferences"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_email: Mapped[str] = mapped_column(String(320), index=True)
|
||||
text: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
__tablename__ = "chat_messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_email: Mapped[str] = mapped_column(String(320))
|
||||
role: Mapped[str] = mapped_column(String(16)) # 'user' | 'assistant'
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
__table_args__ = (Index("ix_chat_user_created", "user_email", "created_at"),)
|
||||
|
||||
|
||||
class Report(Base):
|
||||
__tablename__ = "reports"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_email: Mapped[str] = mapped_column(String(320), index=True)
|
||||
summary_md: Mapped[str] = mapped_column(Text)
|
||||
summary_doc_path: Mapped[str] = mapped_column(String(512))
|
||||
full_doc_path: Mapped[str] = mapped_column(String(512))
|
||||
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
|
||||
class FeedbackEvent(Base):
|
||||
__tablename__ = "feedback_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_email: Mapped[str] = mapped_column(String(320), index=True)
|
||||
rating: Mapped[str] = mapped_column(String(8)) # 'up' | 'down'
|
||||
message_text: Mapped[str] = mapped_column(Text)
|
||||
extracted_insight: Mapped[str] = mapped_column(Text)
|
||||
preference_id: Mapped[int | None] = mapped_column(ForeignKey("preferences.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
|
||||
_engine = None
|
||||
_SessionLocal: sessionmaker[Session] | None = None
|
||||
|
||||
|
||||
def _database_url() -> str | None:
|
||||
return os.getenv("DATABASE_URL") or None
|
||||
|
||||
|
||||
def get_engine():
|
||||
"""Lazy engine — returns None when DATABASE_URL is unset (e.g. unit tests)."""
|
||||
global _engine, _SessionLocal
|
||||
if _engine is not None:
|
||||
return _engine
|
||||
url = _database_url()
|
||||
if not url:
|
||||
return None
|
||||
_engine = create_engine(url, pool_pre_ping=True, future=True)
|
||||
_SessionLocal = sessionmaker(
|
||||
bind=_engine,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
future=True,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session_scope() -> Iterator[Session]:
|
||||
"""Yield a transactional Session. Commits on success, rolls back on error."""
|
||||
if get_engine() is None or _SessionLocal is None:
|
||||
raise RuntimeError("DATABASE_URL is not configured")
|
||||
s = _SessionLocal()
|
||||
try:
|
||||
yield s
|
||||
s.commit()
|
||||
except Exception:
|
||||
s.rollback()
|
||||
raise
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def db_available() -> bool:
|
||||
return get_engine() is not None
|
||||
77
src/preferences.py
Normal file
77
src/preferences.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.db import Preference, db_available, session_scope
|
||||
|
||||
LEGACY_PREFS_PATH = Path(__file__).parent.parent / "data" / "preferences.md"
|
||||
|
||||
|
||||
def load_preferences(user_email: str | None) -> str:
|
||||
"""Return the user's preferences as a single string, or empty if none / no DB."""
|
||||
if not user_email or not db_available():
|
||||
return ""
|
||||
rows = list_preferences(user_email)
|
||||
if not rows:
|
||||
return ""
|
||||
return "\n".join(f"- {r['text']}" for r in rows)
|
||||
|
||||
|
||||
def list_preferences(user_email: str | None) -> list[dict]:
|
||||
"""Return preferences as list of {id, text}. Empty if no user or no DB."""
|
||||
if not user_email or not db_available():
|
||||
return []
|
||||
with session_scope() as s:
|
||||
rows = s.scalars(
|
||||
select(Preference)
|
||||
.where(Preference.user_email == user_email)
|
||||
.order_by(Preference.created_at.asc())
|
||||
).all()
|
||||
return [{"id": r.id, "text": r.text} for r in rows]
|
||||
|
||||
|
||||
def append_preference(user_email: str, insight: str) -> int:
|
||||
"""Insert a preference and return its id."""
|
||||
if not user_email:
|
||||
raise ValueError("user_email is required")
|
||||
insight = insight.strip()
|
||||
if not insight:
|
||||
raise ValueError("insight is empty")
|
||||
with session_scope() as s:
|
||||
pref = Preference(user_email=user_email, text=insight)
|
||||
s.add(pref)
|
||||
s.flush()
|
||||
return pref.id
|
||||
|
||||
|
||||
def delete_preference(user_email: str, pref_id: int) -> bool:
|
||||
"""Delete a preference owned by the given user. Returns True if deleted."""
|
||||
if not user_email:
|
||||
return False
|
||||
with session_scope() as s:
|
||||
pref = s.get(Preference, pref_id)
|
||||
if pref is None or pref.user_email != user_email:
|
||||
return False
|
||||
s.delete(pref)
|
||||
return True
|
||||
|
||||
|
||||
def import_legacy_markdown(user_email: str) -> int:
|
||||
"""One-shot: import lines from data/preferences.md as the given user's prefs.
|
||||
|
||||
Idempotent — skips if the user already has preferences. Returns count imported.
|
||||
"""
|
||||
if not LEGACY_PREFS_PATH.exists():
|
||||
return 0
|
||||
if list_preferences(user_email):
|
||||
return 0
|
||||
lines = [
|
||||
line[2:].strip()
|
||||
for line in LEGACY_PREFS_PATH.read_text(encoding="utf-8").splitlines()
|
||||
if line.startswith("- ") and line[2:].strip()
|
||||
]
|
||||
for line in lines:
|
||||
append_preference(user_email, line)
|
||||
return len(lines)
|
||||
351
src/prompts.py
Normal file
351
src/prompts.py
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
PULSE_SYSTEM_PROMPT = """You are Programme Pulse — a senior Programme Director and trusted adviser to Tony Coppola, Agency Lead and Operations Director at OLIVER agency.
|
||||
|
||||
You have access to Tony's live task tracker. It covers the full L'Oréal OLIVER programme: team tasks, priorities, blockers, and progress across 10 team members.
|
||||
|
||||
Your role is expert analysis, not data readout. You understand content production operations, agency workflows, client relationships, and what actually matters to a programme lead. You read the data the way an experienced director would — looking for risk, pattern, and implication, not just status.
|
||||
|
||||
How to use your three sources:
|
||||
- The Airtable task data is your primary source of truth. It tells you what the programme looks like right now — status, priority, blockers, ownership.
|
||||
- The resource utilisation data shows who is booked on what, how heavily, and where there are double-book conflicts. Use it when asked about capacity, workload, or team availability. Cross-reference resource names with task owners to connect delivery risk with team load.
|
||||
- The meeting transcripts are supporting context. They tell you why things are the way they are — the decisions made, the tensions surfaced, the things that haven't made it into the tracker yet.
|
||||
- When a transcript connects to something in the data, use it. If a task is blocked and the transcript explains the reason, say so. If the team raised a concern in a meeting that shows up as a risk in the data, connect the two.
|
||||
- Don't quote transcripts for their own sake. Only surface transcript content when it adds genuine meaning to what the data is already showing.
|
||||
|
||||
How to analyse:
|
||||
- Never just describe what the data says. Interpret it. What does this task actually mean in context?
|
||||
- When a deadline has passed, say so directly and flag the impact.
|
||||
- When tasks have no notes, flag that visibility is missing — don't just skip it.
|
||||
- When two tasks are related or owned by the same person, connect the dots.
|
||||
- When something is blocked, explain what that means for the programme — not just that it's blocked.
|
||||
- When a P1 has no recent movement, call it out. That's the most important signal.
|
||||
- Look for patterns: overloaded owners, clusters of risk, workstreams stalling together.
|
||||
|
||||
Interpreting resource utilisation:
|
||||
- Each person has 40h/week availability. The data shows booked hours per week — compare directly to 40h capacity.
|
||||
- Over 40h in a week = overloaded. Double-books (⚠️) are the usual cause — overlapping bookings on the same day inflate the count.
|
||||
- A double-book is a real scheduling conflict: the same person is booked on two projects at the same time. Flag these by name.
|
||||
- Next week typically looks under-booked — entries may not have been added yet. Don't treat it as spare capacity.
|
||||
- When asked about utilisation or capacity, always show week-by-week numbers (last / this / next). Name people. Flag anyone over 40h or with double-books.
|
||||
- Connect resource load to programme delivery where you can — an overloaded owner with blocked tasks is a compounding risk.
|
||||
|
||||
Visualisation — use these when they make the data clearer:
|
||||
- Tables: use markdown tables for comparisons across people, tasks, or weeks.
|
||||
- Charts: use when showing distribution, ranking, or trend across multiple data points. Output a fenced code block with language "chart" and this exact JSON structure:
|
||||
{"type":"bar","title":"...","labels":[...],"series":[{"label":"...","data":[...],"color":"#hex"}]}
|
||||
Use "hbar" for horizontal bars (better when labels are long names). Use "line" for trends over time.
|
||||
Colour guidance: #0071e3 = primary series, #aeaeb2 = secondary/capacity, #d70015 = overload/risk.
|
||||
One chart per response. Don't force a chart when a table or plain text is clearer.
|
||||
|
||||
Writing style — follow this exactly:
|
||||
- Keep it simple. Concise. Specific. Short sentences. No compound structures.
|
||||
- Must feel human, warm and natural — not like AI output. Write for a global, multilingual audience. Personality matters — engage, don't just inform.
|
||||
- Never repeat a shorthand task title without explaining what it means.
|
||||
- When the notes field gives context, use it. Surface the real story.
|
||||
- If you cannot interpret a task name, say so and describe what you can infer.
|
||||
|
||||
Formatting — use markdown in all responses:
|
||||
- Use ## for main sections, ### for sub-sections
|
||||
- Use **bold** for task names, owners, and key risk signals
|
||||
- Use bullet lists for grouped items
|
||||
- Use --- to separate major sections
|
||||
- Keep structure clean and scannable."""
|
||||
|
||||
|
||||
_CHAT_FIELDS = {"task", "progress", "priority", "rag", "owner", "notes", "blocked_by", "deadline", "end_date"}
|
||||
|
||||
|
||||
_COMPLETE_PROGRESS = {"Complete", "Cancelled"}
|
||||
|
||||
|
||||
def _slim_tasks(tasks: list[dict]) -> list[dict]:
|
||||
"""Active tasks only, essential fields, trimmed notes — reduces token usage."""
|
||||
return [
|
||||
{k: (v[:150] if k == "notes" and v else v[:80] if k == "blocked_by" and v else v)
|
||||
for k, v in t.items() if k in _CHAT_FIELDS}
|
||||
for t in tasks
|
||||
if t.get("progress") not in _COMPLETE_PROGRESS
|
||||
]
|
||||
|
||||
|
||||
def _build_resource_snapshot(bookings: list[dict]) -> str:
|
||||
from collections import defaultdict
|
||||
from datetime import date as _date, timedelta
|
||||
|
||||
if not bookings:
|
||||
return ""
|
||||
|
||||
today = _date.today()
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
|
||||
weeks = {
|
||||
"last_week": (monday - timedelta(7), monday - timedelta(1)),
|
||||
"this_week": (monday, monday + timedelta(6)),
|
||||
"next_week": (monday + timedelta(7), monday + timedelta(13)),
|
||||
}
|
||||
window_start = weeks["last_week"][0]
|
||||
window_end = weeks["next_week"][1]
|
||||
|
||||
# A booking counts toward a week if it overlaps any day of that week
|
||||
def hours_in_week(b: dict, wstart: _date, wend: _date) -> int:
|
||||
try:
|
||||
s = _date.fromisoformat(b["start_date"])
|
||||
e = _date.fromisoformat(b["end_date"]) if b.get("end_date") else s
|
||||
return b["hours"] if s <= wend and e >= wstart else 0
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
# Filter: named resource, not cancelled/on-hold, overlaps window
|
||||
relevant = []
|
||||
for b in bookings:
|
||||
if not b.get("resource") or b.get("status") in ("Cancelled", "On Hold"):
|
||||
continue
|
||||
if any(hours_in_week(b, ws, we) for ws, we in weeks.values()):
|
||||
relevant.append(b)
|
||||
|
||||
if not relevant:
|
||||
return ""
|
||||
|
||||
by_resource: dict[str, list] = defaultdict(list)
|
||||
for b in relevant:
|
||||
by_resource[b["resource"]].append(b)
|
||||
|
||||
by_division: dict[str, list] = defaultdict(list)
|
||||
for resource, rbooks in sorted(by_resource.items()):
|
||||
div = rbooks[0].get("division") or "Other"
|
||||
avail = rbooks[0].get("availability", "40")
|
||||
|
||||
week_hours = {
|
||||
label: sum(hours_in_week(b, ws, we) for b in rbooks)
|
||||
for label, (ws, we) in weeks.items()
|
||||
}
|
||||
total = sum(week_hours.values())
|
||||
double_books = sum(1 for b in rbooks if b.get("status") == "Double Book")
|
||||
|
||||
wk = (
|
||||
f"last:{week_hours['last_week']}h "
|
||||
f"this:{week_hours['this_week']}h "
|
||||
f"next:{week_hours['next_week']}h"
|
||||
)
|
||||
line = f"- {resource} | avail:{avail}h/wk | {wk} | total:{total}h"
|
||||
if double_books:
|
||||
line += f" | ⚠️{double_books} dbl-book"
|
||||
by_division[div].append(line)
|
||||
|
||||
lw_label = f"{weeks['last_week'][0].strftime('%-d %b')}–{weeks['last_week'][1].strftime('%-d %b')}"
|
||||
tw_label = f"{weeks['this_week'][0].strftime('%-d %b')}–{weeks['this_week'][1].strftime('%-d %b')}"
|
||||
nw_label = f"{weeks['next_week'][0].strftime('%-d %b')}–{weeks['next_week'][1].strftime('%-d %b')}"
|
||||
|
||||
lines = [
|
||||
f"\n\n---\n\nResource Utilisation (avail = capacity per week; hours shown per week):"
|
||||
f"\nlast = {lw_label} | this = {tw_label} | next = {nw_label}"
|
||||
f"\nDouble-books = overlapping bookings on the same day. "
|
||||
f"Note: next week is often under-booked as entries haven't been added yet."
|
||||
]
|
||||
for div in sorted(by_division.keys()):
|
||||
lines.append(f"\n**{div}:**")
|
||||
lines.extend(by_division[div])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_chat_system_prompt(
|
||||
tasks: list[dict],
|
||||
bookings: list[dict] | None = None,
|
||||
user_email: str | None = None,
|
||||
) -> str:
|
||||
from src.preferences import load_preferences
|
||||
from src.transcripts import load_transcripts
|
||||
task_data = json.dumps(_slim_tasks(tasks), indent=2, default=str)
|
||||
prefs = load_preferences(user_email)
|
||||
prefs_section = f"\n\n---\n\nLearned preferences (apply these to every response):\n\n{prefs}" if prefs else ""
|
||||
transcript_section = load_transcripts()
|
||||
resource_section = _build_resource_snapshot(bookings) if bookings else ""
|
||||
return f"""{PULSE_SYSTEM_PROMPT}{prefs_section}{transcript_section}{resource_section}
|
||||
|
||||
---
|
||||
|
||||
Current snapshot of all programme tasks:
|
||||
|
||||
{task_data}"""
|
||||
|
||||
|
||||
def build_manager_summary_prompt(analysis: dict) -> str:
|
||||
"""Build the prompt for Claude to generate the Manager Summary."""
|
||||
from src.transcripts import load_transcripts
|
||||
transcript_section = load_transcripts()
|
||||
data = {
|
||||
"total_tasks": analysis["total"],
|
||||
"active_tasks": analysis["active_total"],
|
||||
"progress_counts": analysis["progress_counts"],
|
||||
"priority_counts": analysis["priority_counts"],
|
||||
"red_flags": [
|
||||
{
|
||||
"task": t["task"], "owner": t["owner"],
|
||||
"progress": t["progress"], "priority": t["priority"],
|
||||
"notes": (t["notes"] or "")[:100],
|
||||
"blocked_by": (t["blocked_by"] or "")[:80],
|
||||
}
|
||||
for t in analysis["red_flags"][:15]
|
||||
],
|
||||
"p1_watchlist": [
|
||||
{
|
||||
"task": t["task"], "owner": t["owner"],
|
||||
"rag": t["rag"], "progress": t["progress"],
|
||||
"notes": (t["notes"] or "")[:100],
|
||||
}
|
||||
for t in analysis["p1_watchlist"][:20]
|
||||
],
|
||||
"wins": [
|
||||
{
|
||||
"task": t["task"], "owner": t["owner"],
|
||||
"notes": (t["notes"] or "")[:80],
|
||||
}
|
||||
for t in analysis.get("wins", [])[:10]
|
||||
],
|
||||
}
|
||||
return f"""Write a Manager Summary for Tony Coppola's weekly update to his manager.{transcript_section}
|
||||
|
||||
---
|
||||
|
||||
Tony is Agency Lead and Operations Director at OLIVER, running content production for L'Oréal.
|
||||
|
||||
The reader is a senior manager. They do not know internal shorthand or task codes. Write in plain language throughout.
|
||||
|
||||
Writing style: keep it simple, concise, specific. Short sentences. No compound structures. Human, warm, natural — not like AI output. Write for a global, multilingual audience. Personality matters — engage, don't just inform.
|
||||
|
||||
Data:
|
||||
|
||||
{json.dumps(data, indent=2)}
|
||||
|
||||
Write the following sections using these exact headings.
|
||||
|
||||
## Programme Overview
|
||||
|
||||
3–4 sentences. Give a genuine read of the week — not just a description. What is the mood of the programme? What is moving, and what is the one thing that needs most attention? Be direct. Write like a trusted colleague briefing their manager, not like a status report. End with a single clear overall health signal: On Track, At Risk, or Critical — and a one-sentence reason.
|
||||
|
||||
## Key Wins
|
||||
|
||||
Pick 3–5 of the most significant completed P1/P2 items from the wins data. One line each:
|
||||
"- [Plain English description of what this work was]: done"
|
||||
|
||||
If there are no notable wins, write: "Nothing significant completed this period."
|
||||
|
||||
## P1 Watch List
|
||||
|
||||
Group P1 items by topic. Infer topic headings from the task names, notes, and context — do not use internal codes. Use logical groupings such as Automation & AI, Client Delivery, Platform & Infrastructure, Operations, or whatever best fits the data. Use ### for each topic heading.
|
||||
|
||||
Under each heading, one line per task:
|
||||
"- [Plain English description]: [status] — [what is really happening or what needs to happen next]"
|
||||
|
||||
Use notes to add real context. Make it clear why each item matters. Red RAG first within each group. Maximum 15 items total.
|
||||
|
||||
## Blockers
|
||||
|
||||
Group blocked and pending feedback items by topic, using the same approach as the P1 Watch List. Use ### for each topic heading.
|
||||
|
||||
Under each heading, one line per item:
|
||||
"- [Plain English description]: [what is stopping it — from notes/blocked_by, or 'reason unclear — needs follow-up']"
|
||||
|
||||
If there are no blockers, write a single sentence saying so.
|
||||
|
||||
## Actions Required
|
||||
|
||||
2–3 concrete, specific asks. What needs to happen this week? Frame each as an action — who needs to do what and why it matters.
|
||||
"- [Action]: [reason / urgency]"
|
||||
"""
|
||||
|
||||
|
||||
def build_full_report_prompt(analysis: dict) -> str:
|
||||
"""Build the prompt for Claude to generate the Full Report."""
|
||||
from src.transcripts import load_transcripts
|
||||
transcript_section = load_transcripts()
|
||||
# Per owner: P1 and blocked tasks first, cap at 8, notes at 120 chars
|
||||
by_owner_summary = {}
|
||||
for owner, tasks in analysis["by_owner"].items():
|
||||
priority_order = {"P1": 0, "P2": 1, "P3": 2, "": 3}
|
||||
blocked_first = sorted(tasks, key=lambda t: (
|
||||
0 if t["progress"] in ("Blocked", "Pending Feedback") else 1,
|
||||
priority_order.get(t["priority"], 3),
|
||||
))
|
||||
by_owner_summary[owner] = [
|
||||
{
|
||||
"task": t["task"], "progress": t["progress"],
|
||||
"priority": t["priority"], "rag": t["rag"],
|
||||
"notes": (t["notes"] or "")[:120],
|
||||
}
|
||||
for t in blocked_first[:8]
|
||||
]
|
||||
|
||||
data = {
|
||||
"total_tasks": analysis["total"],
|
||||
"active_tasks": analysis["active_total"],
|
||||
"progress_counts": analysis["progress_counts"],
|
||||
"priority_counts": analysis["priority_counts"],
|
||||
"red_flags": [
|
||||
{"task": t["task"], "owner": t["owner"], "progress": t["progress"],
|
||||
"priority": t["priority"],
|
||||
"notes": (t["notes"] or "")[:120],
|
||||
"blocked_by": (t["blocked_by"] or "")[:80]}
|
||||
for t in analysis["red_flags"][:20]
|
||||
],
|
||||
"p1_watchlist": [
|
||||
{"task": t["task"], "owner": t["owner"], "rag": t["rag"],
|
||||
"progress": t["progress"], "notes": (t["notes"] or "")[:120]}
|
||||
for t in analysis["p1_watchlist"][:25]
|
||||
],
|
||||
"by_owner": by_owner_summary,
|
||||
"overdue": [
|
||||
{"task": t["task"], "owner": t["owner"],
|
||||
"days_overdue": t["_days_overdue"],
|
||||
"deadline": t["deadline"] or t["end_date"],
|
||||
"priority": t["priority"]}
|
||||
for t in analysis["overdue"][:15]
|
||||
],
|
||||
}
|
||||
return f"""Write a full programme health report for Tony Coppola's personal reference.{transcript_section}
|
||||
|
||||
---
|
||||
|
||||
Tony is Agency Lead and Operations Director at OLIVER. This is his detailed view of the programme — used alongside the Manager Summary.
|
||||
|
||||
Interpret all shorthand task names using Notes, Owner, and context. No internal codes or abbreviations anywhere in the output.
|
||||
|
||||
Writing style: keep it simple, concise, specific. Short sentences. No compound structures. Human, warm, natural — not like AI output. Write for a global, multilingual audience. Cut the filler.
|
||||
|
||||
Data:
|
||||
|
||||
{json.dumps(data, indent=2)}
|
||||
|
||||
Write the following sections using these exact headings.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Start with the numbers: total tasks, active, complete, breakdown by progress status and priority. Then 2–3 sentences giving a genuine read of the programme's health — not just a restatement of counts. What does this data actually tell us?
|
||||
|
||||
## Red Flags
|
||||
|
||||
For each blocked or pending feedback task, one line:
|
||||
"- [Owner] — [Plain English description of the work]: [status] — [what is causing the block, from notes]"
|
||||
|
||||
Lead with the most serious items. If a pattern is visible across multiple blockers, call it out after the list in a single sentence.
|
||||
|
||||
## Team Breakdown
|
||||
|
||||
For each owner, a short paragraph. Cover: what they are actively working on right now, what looks at risk, and whether anything needs attention. Use plain language throughout. Read their notes and interpret what is actually happening — do not just list task titles.
|
||||
|
||||
## P1 Watch List
|
||||
|
||||
For each P1 task not yet complete (Red → Amber → Green), one line:
|
||||
"- [Owner] — [Plain English description] [RAG status]: [progress] — [what the notes tell us]"
|
||||
|
||||
## Overdue Tasks
|
||||
|
||||
For each task past its deadline, one line:
|
||||
"- [Owner] — [Plain English description]: [N] days overdue"
|
||||
|
||||
If there are no overdue tasks, say so in one sentence.
|
||||
"""
|
||||
196
src/reporter.py
Normal file
196
src/reporter.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from docx.shared import Pt, RGBColor, Inches
|
||||
from docx.oxml.ns import qn
|
||||
from docx.oxml import OxmlElement
|
||||
|
||||
_BLUE = RGBColor(0x00, 0x71, 0xE3)
|
||||
_DARK = RGBColor(0x1D, 0x1D, 0x1F)
|
||||
_GRAY = RGBColor(0x6E, 0x6E, 0x73)
|
||||
_LIGHT_GRAY = RGBColor(0xAE, 0xAE, 0xB2)
|
||||
|
||||
|
||||
def _setup_doc(doc: Document):
|
||||
section = doc.sections[0]
|
||||
section.left_margin = Inches(1.1)
|
||||
section.right_margin = Inches(1.1)
|
||||
section.top_margin = Inches(0.9)
|
||||
section.bottom_margin = Inches(0.9)
|
||||
|
||||
normal = doc.styles["Normal"]
|
||||
normal.font.name = "Calibri"
|
||||
normal.font.size = Pt(10)
|
||||
normal.font.color.rgb = _DARK
|
||||
normal.paragraph_format.space_after = Pt(4)
|
||||
|
||||
for level, size, color, bold, space_before in [
|
||||
(1, 22, _DARK, True, 0),
|
||||
(2, 12, _BLUE, False, 18),
|
||||
(3, 10, _DARK, True, 12),
|
||||
]:
|
||||
h = doc.styles[f"Heading {level}"]
|
||||
h.font.name = "Calibri"
|
||||
h.font.size = Pt(size)
|
||||
h.font.color.rgb = color
|
||||
h.font.bold = bold
|
||||
h.paragraph_format.space_before = Pt(space_before)
|
||||
h.paragraph_format.space_after = Pt(4)
|
||||
|
||||
try:
|
||||
lb = doc.styles["List Bullet"]
|
||||
lb.font.name = "Calibri"
|
||||
lb.font.size = Pt(10)
|
||||
lb.font.color.rgb = _DARK
|
||||
lb.paragraph_format.space_before = Pt(2)
|
||||
lb.paragraph_format.space_after = Pt(2)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
def _add_rule(doc: Document):
|
||||
para = doc.add_paragraph()
|
||||
para.paragraph_format.space_before = Pt(4)
|
||||
para.paragraph_format.space_after = Pt(4)
|
||||
pPr = para._p.get_or_add_pPr()
|
||||
pBdr = OxmlElement("w:pBdr")
|
||||
bottom = OxmlElement("w:bottom")
|
||||
bottom.set(qn("w:val"), "single")
|
||||
bottom.set(qn("w:sz"), "4")
|
||||
bottom.set(qn("w:space"), "1")
|
||||
bottom.set(qn("w:color"), "E5E5E7")
|
||||
pBdr.append(bottom)
|
||||
pPr.append(pBdr)
|
||||
|
||||
|
||||
def _add_stats_bar(doc: Document, stats: list[tuple[str, str]]):
|
||||
"""Add a clean inline stats bar: bold blue numbers, gray labels."""
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.space_before = Pt(6)
|
||||
p.paragraph_format.space_after = Pt(8)
|
||||
for i, (label, value) in enumerate(stats):
|
||||
if i > 0:
|
||||
sep = p.add_run(" · ")
|
||||
sep.font.name = "Calibri"
|
||||
sep.font.size = Pt(10)
|
||||
sep.font.color.rgb = _LIGHT_GRAY
|
||||
num = p.add_run(value)
|
||||
num.font.name = "Calibri"
|
||||
num.font.size = Pt(10)
|
||||
num.font.bold = True
|
||||
num.font.color.rgb = _BLUE
|
||||
lbl = p.add_run(f" {label}")
|
||||
lbl.font.name = "Calibri"
|
||||
lbl.font.size = Pt(10)
|
||||
lbl.font.color.rgb = _GRAY
|
||||
|
||||
|
||||
def _render_claude_sections(doc: Document, claude_text: str):
|
||||
"""Write Claude's markdown text into the Word doc."""
|
||||
for line in claude_text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if stripped.startswith("## "):
|
||||
doc.add_heading(stripped[3:], level=2)
|
||||
elif stripped.startswith("### "):
|
||||
doc.add_heading(stripped[4:], level=3)
|
||||
elif stripped.startswith("- "):
|
||||
content = stripped[2:]
|
||||
try:
|
||||
p = doc.add_paragraph(style="List Bullet")
|
||||
except KeyError:
|
||||
p = doc.add_paragraph()
|
||||
# Parse inline bold (**text**)
|
||||
_add_inline_text(p, content)
|
||||
else:
|
||||
p = doc.add_paragraph()
|
||||
_add_inline_text(p, stripped)
|
||||
|
||||
|
||||
def _add_inline_text(para, text: str):
|
||||
"""Add text to paragraph, rendering **bold** markers."""
|
||||
parts = text.split("**")
|
||||
for i, part in enumerate(parts):
|
||||
if not part:
|
||||
continue
|
||||
run = para.add_run(part)
|
||||
run.font.name = "Calibri"
|
||||
run.font.size = Pt(10)
|
||||
run.font.color.rgb = _DARK
|
||||
if i % 2 == 1:
|
||||
run.font.bold = True
|
||||
|
||||
|
||||
def manager_summary_to_markdown(analysis: dict, claude_text: str) -> str:
|
||||
today = date.today().strftime("%d %B %Y")
|
||||
header = f"# Programme Pulse — Manager Summary\n**{today}**\n\n"
|
||||
stats = (
|
||||
f"**{analysis['total']}** Total · "
|
||||
f"**{analysis['active_total']}** Active · "
|
||||
f"**{len(analysis['red_flags'])}** Blocked/Pending\n\n---\n\n"
|
||||
)
|
||||
return header + stats + claude_text.strip()
|
||||
|
||||
|
||||
def build_manager_summary_docx(analysis: dict, claude_text: str, output_dir: Path, filename: str | None = None) -> Path:
|
||||
doc = Document()
|
||||
_setup_doc(doc)
|
||||
|
||||
title = doc.add_heading("Programme Pulse — Manager Summary", level=1)
|
||||
for run in title.runs:
|
||||
run.font.color.rgb = _DARK
|
||||
|
||||
today = date.today().strftime("%d %B %Y")
|
||||
date_para = doc.add_paragraph(today)
|
||||
for run in date_para.runs:
|
||||
run.font.name = "Calibri"
|
||||
run.font.size = Pt(9)
|
||||
run.font.color.rgb = _GRAY
|
||||
|
||||
_add_stats_bar(doc, [
|
||||
("Total tasks", str(analysis["total"])),
|
||||
("Active", str(analysis["active_total"])),
|
||||
("Blocked/Pending", str(len(analysis["red_flags"]))),
|
||||
("P1 items", str(analysis["priority_counts"].get("P1", 0))),
|
||||
])
|
||||
_add_rule(doc)
|
||||
_render_claude_sections(doc, claude_text)
|
||||
|
||||
path = output_dir / (filename or "programme-pulse-manager-summary.docx")
|
||||
doc.save(path)
|
||||
return path
|
||||
|
||||
|
||||
def build_full_report_docx(analysis: dict, claude_text: str, output_dir: Path, filename: str | None = None) -> Path:
|
||||
doc = Document()
|
||||
_setup_doc(doc)
|
||||
|
||||
title = doc.add_heading("Programme Pulse — Full Report", level=1)
|
||||
for run in title.runs:
|
||||
run.font.color.rgb = _DARK
|
||||
|
||||
today = date.today().strftime("%d %B %Y")
|
||||
date_para = doc.add_paragraph(today)
|
||||
for run in date_para.runs:
|
||||
run.font.name = "Calibri"
|
||||
run.font.size = Pt(9)
|
||||
run.font.color.rgb = _GRAY
|
||||
|
||||
blocked = sum(1 for t in analysis["red_flags"] if t["progress"] == "Blocked")
|
||||
_add_stats_bar(doc, [
|
||||
("Total tasks", str(analysis["total"])),
|
||||
("Active", str(analysis["active_total"])),
|
||||
("P1 items", str(analysis["priority_counts"].get("P1", 0))),
|
||||
("Blocked", str(blocked)),
|
||||
("Overdue", str(len(analysis["overdue"]))),
|
||||
])
|
||||
_add_rule(doc)
|
||||
_render_claude_sections(doc, claude_text)
|
||||
|
||||
path = output_dir / (filename or "programme-pulse-full-report.docx")
|
||||
doc.save(path)
|
||||
return path
|
||||
131
src/transcripts.py
Normal file
131
src/transcripts.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from docx import Document
|
||||
_DOCX_AVAILABLE = True
|
||||
except ImportError:
|
||||
_DOCX_AVAILABLE = False
|
||||
|
||||
_TRANSCRIPTS_DIR = Path(__file__).parent.parent / "docs" / "Programme Pulse transcripts"
|
||||
|
||||
# Short utterances that add no signal — skip them
|
||||
_NOISE_PATTERNS = re.compile(
|
||||
r"^(yeah|yes|no|ok|okay|mm+|mhm|uh|oh|ah|hi|hello|morning|good morning|"
|
||||
r"right|sure|great|thanks|thank you|bye|goodbye|perfect|exactly|absolutely|"
|
||||
r"i see|i know|of course|sounds good|got it|noted|ok so|so yeah|all right|"
|
||||
r"alright|yep|nope|cool|nice|fine)[\.\!\?]?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_SPEAKER_LINE = re.compile(r"^(.+?)\s{2,}\d+:\d+$")
|
||||
_MIN_CONTENT_LEN = 40 # chars — below this is likely noise
|
||||
|
||||
|
||||
def _extract_text(docx_path: Path) -> tuple[str, str]:
|
||||
"""Extract date and substantive text from a transcript .docx file.
|
||||
|
||||
Returns (date_str, filtered_text).
|
||||
|
||||
Each docx paragraph holds one speaker turn in the format:
|
||||
\\nName timestamp\\nline1\\nline2\\n...
|
||||
"""
|
||||
if not _DOCX_AVAILABLE:
|
||||
return "", ""
|
||||
|
||||
doc = Document(str(docx_path))
|
||||
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||
|
||||
date_str = ""
|
||||
content_lines: list[str] = []
|
||||
|
||||
for i, para in enumerate(paragraphs):
|
||||
# Header block: title, date, duration
|
||||
if i == 0:
|
||||
continue
|
||||
if i == 1:
|
||||
m = re.match(r"(\d+ \w+ \d{4})", para.strip())
|
||||
if m:
|
||||
date_str = m.group(1)
|
||||
continue
|
||||
if i == 2:
|
||||
continue
|
||||
|
||||
# Split paragraph into lines, strip leading/trailing whitespace
|
||||
lines = [l.strip() for l in para.split("\n") if l.strip()]
|
||||
if not lines:
|
||||
continue
|
||||
|
||||
# First line is always "Speaker Name timestamp"
|
||||
speaker = ""
|
||||
start = 0
|
||||
if _SPEAKER_LINE.match(lines[0]):
|
||||
speaker = lines[0].split(" ")[0].strip()
|
||||
start = 1
|
||||
|
||||
# Process remaining lines as speech content
|
||||
for line in lines[start:]:
|
||||
if _NOISE_PATTERNS.match(line):
|
||||
continue
|
||||
if len(line) < _MIN_CONTENT_LEN:
|
||||
continue
|
||||
prefix = f"{speaker}: " if speaker else ""
|
||||
content_lines.append(f"{prefix}{line}")
|
||||
|
||||
return date_str, "\n".join(content_lines)
|
||||
|
||||
|
||||
def _trim(text: str, max_chars: int = 2000) -> str:
|
||||
"""Take content from throughout the transcript, not just the start."""
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
lines = text.splitlines()
|
||||
# Sample evenly across the full transcript
|
||||
step = max(1, len(lines) // 40)
|
||||
sampled = lines[::step]
|
||||
result = "\n".join(sampled)
|
||||
return result[:max_chars]
|
||||
|
||||
|
||||
def load_transcripts(max_transcripts: int = 4) -> str:
|
||||
"""Load and format recent meeting transcripts for injection into prompts.
|
||||
|
||||
Returns a formatted string ready to append to a system prompt.
|
||||
Returns empty string if folder doesn't exist or no files found.
|
||||
"""
|
||||
if not _TRANSCRIPTS_DIR.exists() or not _DOCX_AVAILABLE:
|
||||
return ""
|
||||
|
||||
files = sorted(
|
||||
[f for f in _TRANSCRIPTS_DIR.iterdir() if f.suffix == ".docx"],
|
||||
key=lambda f: f.stat().st_mtime,
|
||||
reverse=True,
|
||||
)[:max_transcripts]
|
||||
|
||||
if not files:
|
||||
return ""
|
||||
|
||||
sections: list[str] = []
|
||||
for f in reversed(files): # chronological order
|
||||
date_str, text = _extract_text(f)
|
||||
if not text:
|
||||
continue
|
||||
label = date_str or f.stem
|
||||
trimmed = _trim(text, max_chars=1800)
|
||||
sections.append(f"### {label}\n\n{trimmed}")
|
||||
|
||||
if not sections:
|
||||
return ""
|
||||
|
||||
return (
|
||||
"\n\n---\n\n"
|
||||
"## Recent Meeting Transcripts\n\n"
|
||||
"Use these to add narrative depth to your analysis of the task data — not as standalone content. "
|
||||
"When something in a transcript explains, confirms, or complicates what the data is showing, bring it in. "
|
||||
"If a blocker has a reason discussed here, name it. If a concern raised in a meeting maps to a risk in the tracker, connect them. "
|
||||
"Do not surface transcript content unless it adds genuine meaning to the data picture.\n\n"
|
||||
+ "\n\n---\n\n".join(sections)
|
||||
)
|
||||
1168
templates/index.html
Normal file
1168
templates/index.html
Normal file
File diff suppressed because it is too large
Load diff
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
33
tests/conftest.py
Normal file
33
tests/conftest.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Shared pytest fixtures.
|
||||
|
||||
Provides a SQLite-backed test database. We point DATABASE_URL at a temp file
|
||||
before any `src.db` import so the engine binds to the right URL.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _set_database_url(tmp_path_factory):
|
||||
"""Set DATABASE_URL → SQLite temp file for the whole test session."""
|
||||
db_path = tmp_path_factory.mktemp("db") / "test.sqlite"
|
||||
os.environ["DATABASE_URL"] = f"sqlite+pysqlite:///{db_path}"
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_db():
|
||||
"""Create a fresh schema for each test."""
|
||||
# Import here so DATABASE_URL has been set first.
|
||||
from src import db as db_module
|
||||
|
||||
# Force a fresh engine in case a previous test mutated module state.
|
||||
db_module._engine = None
|
||||
db_module._SessionLocal = None
|
||||
engine = db_module.get_engine()
|
||||
db_module.Base.metadata.drop_all(engine)
|
||||
db_module.Base.metadata.create_all(engine)
|
||||
yield
|
||||
166
tests/fixtures/sample_projects.json
vendored
Normal file
166
tests/fixtures/sample_projects.json
vendored
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
[
|
||||
{
|
||||
"Project Number": "LLD-001",
|
||||
"Project Name": "Lancôme Visionnaire Campaign",
|
||||
"Project Type": "Campaign",
|
||||
"Brand": "LANCOME",
|
||||
"Hub": "Global",
|
||||
"Country": "FR",
|
||||
"Division": "LLD",
|
||||
"PM": "Sarah Jones",
|
||||
"Current Stage": "Stage 3 - Production",
|
||||
"Project Status": "On Track - OLIVER",
|
||||
"Project Risk - NEW": "On Track - OLIVER",
|
||||
"Current Stage Status": "In Progress",
|
||||
"Project End Date": "2026-05-30",
|
||||
"Current Stage Deadline": "2026-04-20",
|
||||
"Action Required": "",
|
||||
"Last Modified Time (on row)": "2026-04-10T09:00:00.000Z",
|
||||
"Asset Count (Deliverable Roll Up)": 10,
|
||||
"Completed Deliverable Count": 7,
|
||||
"7. Opera Upload": "Not Started",
|
||||
"7. Opera Upload DDL": "2026-05-25",
|
||||
"7. Opera Upload Status": "Not Started",
|
||||
"Has Deadline Slipped": false,
|
||||
"Syndication Deadline Slip (Days)": 0,
|
||||
"New Deadline Proposed by Oliver": "",
|
||||
"Brief Submitted Date": "2026-02-01",
|
||||
"Brief Acceptance Date (from Briefs Link) 2": "2026-02-03",
|
||||
"Market's Action Needed": false,
|
||||
"1. Missing DMI Asset Creation DDL": "2026-02-10",
|
||||
"1. Missing DMI Asset Creation Status": "Complete",
|
||||
"2. Mastering, Copy Creation / Extraction DDL": "2026-03-01",
|
||||
"2. Mastering, Copy Creation / Extraction Status": "Complete",
|
||||
"3. Global Rollout Invitation DDL": "2026-03-15",
|
||||
"3. Global Rollout Invitation Status": "Complete",
|
||||
"4. Translation (Salsify PDP) DDL": "2026-04-01",
|
||||
"4. Translation (Salsify PDP) Status": "Complete",
|
||||
"5. Translation (Asset) DDL": "2026-04-10",
|
||||
"5. Translation (Asset) Status": "In Progress",
|
||||
"6. Production DDL": "2026-04-20",
|
||||
"6. Production Status": "In Progress"
|
||||
},
|
||||
{
|
||||
"Project Number": "LLD-002",
|
||||
"Project Name": "YSL Beauty Hero Asset",
|
||||
"Project Type": "Asset Production",
|
||||
"Brand": "YSL",
|
||||
"Hub": "EMEA",
|
||||
"Country": "UK",
|
||||
"Division": "LLD",
|
||||
"PM": "",
|
||||
"Current Stage": "Stage 2 - Development",
|
||||
"Project Status": "Attention Required - ECF",
|
||||
"Project Risk - NEW": "Attention Required - OLIVER",
|
||||
"Current Stage Status": "Delayed",
|
||||
"Project End Date": "2026-04-01",
|
||||
"Current Stage Deadline": "2026-03-25",
|
||||
"Action Required": "Awaiting client feedback",
|
||||
"Last Modified Time (on row)": "2026-03-28T14:00:00.000Z",
|
||||
"Asset Count (Deliverable Roll Up)": 5,
|
||||
"Completed Deliverable Count": 1,
|
||||
"7. Opera Upload": "Not Started",
|
||||
"7. Opera Upload DDL": "2026-03-28",
|
||||
"7. Opera Upload Status": "Not Started",
|
||||
"Has Deadline Slipped": true,
|
||||
"Syndication Deadline Slip (Days)": 18,
|
||||
"New Deadline Proposed by Oliver": "2026-04-15",
|
||||
"Brief Submitted Date": "2026-01-15",
|
||||
"Brief Acceptance Date (from Briefs Link) 2": "2026-01-22",
|
||||
"Market's Action Needed": false,
|
||||
"1. Missing DMI Asset Creation DDL": "2026-02-01",
|
||||
"1. Missing DMI Asset Creation Status": "Complete",
|
||||
"2. Mastering, Copy Creation / Extraction DDL": "2026-03-01",
|
||||
"2. Mastering, Copy Creation / Extraction Status": "Delayed",
|
||||
"3. Global Rollout Invitation DDL": "2026-03-15",
|
||||
"3. Global Rollout Invitation Status": "Not Started",
|
||||
"4. Translation (Salsify PDP) DDL": "2026-03-20",
|
||||
"4. Translation (Salsify PDP) Status": "Not Started",
|
||||
"5. Translation (Asset) DDL": "2026-03-25",
|
||||
"5. Translation (Asset) Status": "Not Started",
|
||||
"6. Production DDL": "2026-03-28",
|
||||
"6. Production Status": "Not Started"
|
||||
},
|
||||
{
|
||||
"Project Number": "LLD-003",
|
||||
"Project Name": "Armani Beauty Refresh",
|
||||
"Project Type": "Refresh",
|
||||
"Brand": "ARMANI",
|
||||
"Hub": "Global",
|
||||
"Country": "IT",
|
||||
"Division": "LLD",
|
||||
"PM": "Tom Baker",
|
||||
"Current Stage": "Stage 4 - Review",
|
||||
"Project Status": "On Track - Market",
|
||||
"Project Risk - NEW": "On Track - Market",
|
||||
"Current Stage Status": "In Review",
|
||||
"Project End Date": "2026-06-15",
|
||||
"Current Stage Deadline": "2026-04-30",
|
||||
"Action Required": "",
|
||||
"Last Modified Time (on row)": "2026-04-11T11:00:00.000Z",
|
||||
"Asset Count (Deliverable Roll Up)": 8,
|
||||
"Completed Deliverable Count": 8,
|
||||
"7. Opera Upload": "Not Started",
|
||||
"7. Opera Upload DDL": "2026-06-10",
|
||||
"7. Opera Upload Status": "Not Started",
|
||||
"Has Deadline Slipped": false,
|
||||
"Syndication Deadline Slip (Days)": 0,
|
||||
"New Deadline Proposed by Oliver": "",
|
||||
"Brief Submitted Date": "2026-01-20",
|
||||
"Brief Acceptance Date (from Briefs Link) 2": "2026-01-21",
|
||||
"Market's Action Needed": true,
|
||||
"1. Missing DMI Asset Creation DDL": "2026-02-15",
|
||||
"1. Missing DMI Asset Creation Status": "Complete",
|
||||
"2. Mastering, Copy Creation / Extraction DDL": "2026-03-10",
|
||||
"2. Mastering, Copy Creation / Extraction Status": "Complete",
|
||||
"3. Global Rollout Invitation DDL": "2026-03-25",
|
||||
"3. Global Rollout Invitation Status": "Complete",
|
||||
"4. Translation (Salsify PDP) DDL": "2026-04-15",
|
||||
"4. Translation (Salsify PDP) Status": "In Review",
|
||||
"5. Translation (Asset) DDL": "2026-04-20",
|
||||
"5. Translation (Asset) Status": "Not Started",
|
||||
"6. Production DDL": "2026-04-30",
|
||||
"6. Production Status": "Not Started"
|
||||
},
|
||||
{
|
||||
"Project Number": "LLD-004",
|
||||
"Project Name": "Biotherm Blue Therapy",
|
||||
"Project Type": "Campaign",
|
||||
"Brand": "BIOTHERM",
|
||||
"Hub": "APAC",
|
||||
"Country": "CN",
|
||||
"Division": "LLD",
|
||||
"PM": "Emma Liu",
|
||||
"Current Stage": "Stage 1 - Briefing",
|
||||
"Project Status": "Attention Required - Market",
|
||||
"Project Risk - NEW": "",
|
||||
"Current Stage Status": "",
|
||||
"Project End Date": "2026-04-05",
|
||||
"Current Stage Deadline": "2026-03-30",
|
||||
"Action Required": "",
|
||||
"Last Modified Time (on row)": "2026-03-20T08:00:00.000Z",
|
||||
"Asset Count (Deliverable Roll Up)": 12,
|
||||
"Completed Deliverable Count": 0,
|
||||
"7. Opera Upload": "Not Started",
|
||||
"7. Opera Upload DDL": "2026-04-01",
|
||||
"7. Opera Upload Status": "Not Started",
|
||||
"Has Deadline Slipped": true,
|
||||
"Syndication Deadline Slip (Days)": 7,
|
||||
"New Deadline Proposed by Oliver": "",
|
||||
"Brief Submitted Date": "2026-02-10",
|
||||
"Brief Acceptance Date (from Briefs Link) 2": "",
|
||||
"Market's Action Needed": true,
|
||||
"1. Missing DMI Asset Creation DDL": "2026-03-01",
|
||||
"1. Missing DMI Asset Creation Status": "Not Started",
|
||||
"2. Mastering, Copy Creation / Extraction DDL": "2026-03-15",
|
||||
"2. Mastering, Copy Creation / Extraction Status": "Not Started",
|
||||
"3. Global Rollout Invitation DDL": "2026-03-25",
|
||||
"3. Global Rollout Invitation Status": "Not Started",
|
||||
"4. Translation (Salsify PDP) DDL": "2026-03-30",
|
||||
"4. Translation (Salsify PDP) Status": "Not Started",
|
||||
"5. Translation (Asset) DDL": "2026-04-01",
|
||||
"5. Translation (Asset) Status": "Not Started",
|
||||
"6. Production DDL": "2026-04-05",
|
||||
"6. Production Status": "Not Started"
|
||||
}
|
||||
]
|
||||
100
tests/test_airtable_client.py
Normal file
100
tests/test_airtable_client.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from src.airtable_client import PulseAirtableClient, _clean_task_name, _clean_related_item
|
||||
|
||||
|
||||
def test_clean_task_name_strips_bom():
|
||||
assert _clean_task_name("\ufeffMy Task") == "My Task"
|
||||
|
||||
|
||||
def test_clean_task_name_strips_whitespace():
|
||||
assert _clean_task_name(" Task Name ") == "Task Name"
|
||||
|
||||
|
||||
def test_clean_related_item_strips_notion_url():
|
||||
raw = "Automation (Enhancement) (https://www.notion.so/Automation-317003329b1e804c9115e1a92617fb01?pvs=21)"
|
||||
assert _clean_related_item(raw) == "Automation (Enhancement)"
|
||||
|
||||
|
||||
def test_clean_related_item_empty():
|
||||
assert _clean_related_item("") == ""
|
||||
assert _clean_related_item(None) == ""
|
||||
|
||||
|
||||
def test_clean_related_item_multiple_workstreams():
|
||||
raw = "Tools / Technology (https://www.notion.so/Tools?pvs=21), Syndication (Enhancement) (https://www.notion.so/Syndication?pvs=21)"
|
||||
result = _clean_related_item(raw)
|
||||
assert "https" not in result
|
||||
assert "Tools / Technology" in result
|
||||
assert "Syndication (Enhancement)" in result
|
||||
assert not result.endswith(",")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_table(mocker):
|
||||
mock_api = mocker.patch("src.airtable_client.Api")
|
||||
mock_table = MagicMock()
|
||||
mock_api.return_value.table.return_value = mock_table
|
||||
return mock_table
|
||||
|
||||
|
||||
def test_fetch_all_tasks_normalizes_fields(mock_table):
|
||||
mock_table.all.return_value = [
|
||||
{
|
||||
"id": "rec1",
|
||||
"createdTime": "2026-04-08T04:32:44.000Z",
|
||||
"fields": {
|
||||
"\ufeffTask": "\ufeffBuild reporting tool",
|
||||
"Progress": "In Progress",
|
||||
"Priority": "P1",
|
||||
"RAG": "Red",
|
||||
"Owner": [{"id": "usr1", "name": "Tony Coppola", "email": "tony@oliver.agency"}],
|
||||
"Notes": "In good shape",
|
||||
},
|
||||
}
|
||||
]
|
||||
client = PulseAirtableClient("fake_key", "base_id", "table_id")
|
||||
tasks = client.fetch_all_tasks()
|
||||
|
||||
assert len(tasks) == 1
|
||||
t = tasks[0]
|
||||
assert t["task"] == "Build reporting tool"
|
||||
assert t["progress"] == "In Progress"
|
||||
assert t["priority"] == "P1"
|
||||
assert t["rag"] == "Red"
|
||||
assert t["owner"] == "Tony Coppola"
|
||||
assert t["owners"] == ["Tony Coppola"]
|
||||
assert t["notes"] == "In good shape"
|
||||
|
||||
|
||||
def test_fetch_all_tasks_handles_missing_owner(mock_table):
|
||||
mock_table.all.return_value = [
|
||||
{"id": "rec2", "createdTime": "2026-04-08T00:00:00.000Z",
|
||||
"fields": {"\ufeffTask": "Some task", "Progress": "Not Started"}}
|
||||
]
|
||||
client = PulseAirtableClient("fake_key", "base_id", "table_id")
|
||||
tasks = client.fetch_all_tasks()
|
||||
assert tasks[0]["owner"] == "Unassigned"
|
||||
assert tasks[0]["owners"] == []
|
||||
|
||||
|
||||
def test_fetch_all_tasks_fallback_task_key(mock_table):
|
||||
mock_table.all.return_value = [
|
||||
{"id": "rec3", "createdTime": "2026-04-08T00:00:00.000Z",
|
||||
"fields": {"Task": "Plain task name"}}
|
||||
]
|
||||
client = PulseAirtableClient("fake_key", "base_id", "table_id")
|
||||
tasks = client.fetch_all_tasks()
|
||||
assert tasks[0]["task"] == "Plain task name"
|
||||
|
||||
|
||||
def test_fetch_all_tasks_filters_empty_owner_names(mock_table):
|
||||
mock_table.all.return_value = [
|
||||
{"id": "rec4", "createdTime": "2026-04-08T00:00:00.000Z",
|
||||
"fields": {"\ufeffTask": "Some task",
|
||||
"Owner": [{"id": "usr1"}, {"id": "usr2", "name": "Alice"}]}}
|
||||
]
|
||||
client = PulseAirtableClient("fake_key", "base_id", "table_id")
|
||||
tasks = client.fetch_all_tasks()
|
||||
assert tasks[0]["owner"] == "Alice"
|
||||
assert tasks[0]["owners"] == ["Alice"]
|
||||
111
tests/test_analyzer.py
Normal file
111
tests/test_analyzer.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import pytest
|
||||
from datetime import date, timedelta
|
||||
from src.analyzer import analyze
|
||||
|
||||
TODAY = date.today().isoformat()
|
||||
YESTERDAY = (date.today() - timedelta(days=1)).isoformat()
|
||||
NEXT_WEEK = (date.today() + timedelta(days=7)).isoformat()
|
||||
|
||||
|
||||
def _task(**kwargs):
|
||||
defaults = {
|
||||
"id": "rec1", "task": "Test task", "progress": "In Progress",
|
||||
"priority": "P1", "rag": "Red", "owner": "Tony Coppola",
|
||||
"owners": ["Tony Coppola"], "related_item": "", "category": [],
|
||||
"start_date": "", "end_date": "", "deadline": "",
|
||||
"doing": "", "blocked_by": "", "blocking": "", "notes": "", "hours": None,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return defaults
|
||||
|
||||
|
||||
def test_analyze_counts_total():
|
||||
tasks = [_task(id="r1"), _task(id="r2"), _task(id="r3", progress="Complete")]
|
||||
result = analyze(tasks)
|
||||
assert result["total"] == 3
|
||||
assert result["active_total"] == 2
|
||||
|
||||
|
||||
def test_analyze_excludes_complete_and_cancelled_from_active():
|
||||
tasks = [
|
||||
_task(id="r1", progress="Complete"),
|
||||
_task(id="r2", progress="Cancelled"),
|
||||
_task(id="r3", progress="In Progress"),
|
||||
]
|
||||
result = analyze(tasks)
|
||||
assert result["active_total"] == 1
|
||||
|
||||
|
||||
def test_analyze_red_flags_blocked_and_pending():
|
||||
tasks = [
|
||||
_task(id="r1", progress="Blocked"),
|
||||
_task(id="r2", progress="Pending Feedback"),
|
||||
_task(id="r3", progress="In Progress"),
|
||||
]
|
||||
result = analyze(tasks)
|
||||
assert len(result["red_flags"]) == 2
|
||||
flags = {t["id"] for t in result["red_flags"]}
|
||||
assert flags == {"r1", "r2"}
|
||||
|
||||
|
||||
def test_analyze_p1_watchlist_excludes_complete():
|
||||
tasks = [
|
||||
_task(id="r1", priority="P1", progress="In Progress", rag="Red"),
|
||||
_task(id="r2", priority="P1", progress="Complete", rag="Red"),
|
||||
_task(id="r3", priority="P2", progress="In Progress", rag="Red"),
|
||||
]
|
||||
result = analyze(tasks)
|
||||
assert len(result["p1_watchlist"]) == 1
|
||||
assert result["p1_watchlist"][0]["id"] == "r1"
|
||||
|
||||
|
||||
def test_analyze_p1_watchlist_ordered_by_rag():
|
||||
tasks = [
|
||||
_task(id="r1", priority="P1", progress="In Progress", rag="Green"),
|
||||
_task(id="r2", priority="P1", progress="In Progress", rag="Red"),
|
||||
_task(id="r3", priority="P1", progress="In Progress", rag="Amber"),
|
||||
]
|
||||
result = analyze(tasks)
|
||||
rags = [t["rag"] for t in result["p1_watchlist"]]
|
||||
assert rags == ["Red", "Amber", "Green"]
|
||||
|
||||
|
||||
def test_analyze_groups_by_owner():
|
||||
tasks = [
|
||||
_task(id="r1", owner="Alice", progress="In Progress"),
|
||||
_task(id="r2", owner="Bob", progress="In Progress"),
|
||||
_task(id="r3", owner="Alice", progress="Not Started"),
|
||||
]
|
||||
result = analyze(tasks)
|
||||
assert set(result["by_owner"].keys()) == {"Alice", "Bob"}
|
||||
assert len(result["by_owner"]["Alice"]) == 2
|
||||
assert len(result["by_owner"]["Bob"]) == 1
|
||||
|
||||
|
||||
def test_analyze_detects_overdue_by_deadline():
|
||||
tasks = [
|
||||
_task(id="r1", progress="In Progress", deadline=YESTERDAY),
|
||||
_task(id="r2", progress="In Progress", deadline=NEXT_WEEK),
|
||||
_task(id="r3", progress="Complete", deadline=YESTERDAY),
|
||||
]
|
||||
result = analyze(tasks)
|
||||
assert len(result["overdue"]) == 1
|
||||
assert result["overdue"][0]["id"] == "r1"
|
||||
assert result["overdue"][0]["_days_overdue"] >= 1
|
||||
|
||||
|
||||
def test_analyze_overdue_falls_back_to_end_date():
|
||||
tasks = [_task(id="r1", progress="In Progress", deadline="", end_date=YESTERDAY)]
|
||||
result = analyze(tasks)
|
||||
assert len(result["overdue"]) == 1
|
||||
|
||||
|
||||
def test_analyze_progress_counts():
|
||||
tasks = [
|
||||
_task(id="r1", progress="In Progress"),
|
||||
_task(id="r2", progress="In Progress"),
|
||||
_task(id="r3", progress="Complete"),
|
||||
]
|
||||
result = analyze(tasks)
|
||||
assert result["progress_counts"]["In Progress"] == 2
|
||||
assert result["progress_counts"]["Complete"] == 1
|
||||
48
tests/test_auth.py
Normal file
48
tests/test_auth.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from src import auth
|
||||
|
||||
|
||||
def test_dev_bypass_disabled_by_default(monkeypatch):
|
||||
monkeypatch.delenv("DEV_AUTH_BYPASS", raising=False)
|
||||
assert auth.dev_bypass_enabled() is False
|
||||
|
||||
|
||||
def test_dev_bypass_enabled(monkeypatch):
|
||||
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
|
||||
assert auth.dev_bypass_enabled() is True
|
||||
monkeypatch.setenv("DEV_AUTH_BYPASS", "1")
|
||||
assert auth.dev_bypass_enabled() is True
|
||||
|
||||
|
||||
def test_dev_user_claims_shape():
|
||||
claims = auth.dev_user_claims()
|
||||
assert claims["_email"] == "dev@oliver.agency"
|
||||
assert claims["_name"] == "Dev User"
|
||||
|
||||
|
||||
def test_check_domain_allows_when_no_allow_list(monkeypatch):
|
||||
monkeypatch.delenv("AUTH_ALLOWED_DOMAINS", raising=False)
|
||||
auth._check_domain("anyone@example.com") # no raise
|
||||
|
||||
|
||||
def test_check_domain_allows_listed(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ALLOWED_DOMAINS", "oliver.agency,oliver.com")
|
||||
auth._check_domain("alice@oliver.agency")
|
||||
auth._check_domain("bob@oliver.com")
|
||||
|
||||
|
||||
def test_check_domain_rejects_unlisted(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ALLOWED_DOMAINS", "oliver.agency")
|
||||
with pytest.raises(auth.AuthError) as exc:
|
||||
auth._check_domain("intruder@evil.com")
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_validate_bearer_token_rejects_empty(monkeypatch):
|
||||
monkeypatch.setenv("AZURE_TENANT_ID", "00000000-0000-0000-0000-000000000000")
|
||||
monkeypatch.setenv("AZURE_CLIENT_ID", "11111111-1111-1111-1111-111111111111")
|
||||
with pytest.raises(auth.AuthError):
|
||||
auth.validate_bearer_token("")
|
||||
130
tests/test_claude_client.py
Normal file
130
tests/test_claude_client.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from src.claude_client import ClaudeClient, MODEL
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anthropic(mocker):
|
||||
return mocker.patch("src.claude_client.Anthropic")
|
||||
|
||||
|
||||
def _make_response(mock_anthropic, text: str):
|
||||
text_block = MagicMock()
|
||||
text_block.type = "text"
|
||||
text_block.text = text
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = [text_block]
|
||||
mock_anthropic.return_value.messages.create.return_value = mock_response
|
||||
return mock_response
|
||||
|
||||
|
||||
def test_generate_report_returns_text_content(mock_anthropic):
|
||||
_make_response(mock_anthropic, "## Executive Summary\n\nTest content.")
|
||||
client = ClaudeClient("fake_key")
|
||||
result = client.generate_report("system", "user prompt")
|
||||
assert result == "## Executive Summary\n\nTest content."
|
||||
|
||||
|
||||
def test_generate_report_skips_thinking_blocks(mock_anthropic):
|
||||
thinking_block = MagicMock()
|
||||
thinking_block.type = "thinking"
|
||||
thinking_block.text = "internal reasoning not for output"
|
||||
|
||||
text_block = MagicMock()
|
||||
text_block.type = "text"
|
||||
text_block.text = "Report output only"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = [thinking_block, text_block]
|
||||
mock_anthropic.return_value.messages.create.return_value = mock_response
|
||||
|
||||
client = ClaudeClient("fake_key")
|
||||
result = client.generate_report("system", "user prompt")
|
||||
|
||||
assert "internal reasoning" not in result
|
||||
assert result == "Report output only"
|
||||
|
||||
|
||||
def test_generate_report_uses_correct_model(mock_anthropic):
|
||||
_make_response(mock_anthropic, "report")
|
||||
client = ClaudeClient("fake_key")
|
||||
client.generate_report("sys", "user")
|
||||
|
||||
call_kwargs = mock_anthropic.return_value.messages.create.call_args.kwargs
|
||||
assert call_kwargs["model"] == MODEL
|
||||
|
||||
|
||||
def test_generate_report_uses_max_tokens(mock_anthropic):
|
||||
_make_response(mock_anthropic, "report")
|
||||
client = ClaudeClient("fake_key")
|
||||
client.generate_report("sys", "user")
|
||||
|
||||
call_kwargs = mock_anthropic.return_value.messages.create.call_args.kwargs
|
||||
assert call_kwargs["max_tokens"] == 4096
|
||||
|
||||
|
||||
def test_generate_report_passes_system_prompt(mock_anthropic):
|
||||
_make_response(mock_anthropic, "report")
|
||||
client = ClaudeClient("fake_key")
|
||||
client.generate_report("my system prompt", "user message")
|
||||
|
||||
call_kwargs = mock_anthropic.return_value.messages.create.call_args.kwargs
|
||||
assert call_kwargs["system"] == "my system prompt"
|
||||
|
||||
|
||||
def test_generate_report_concatenates_multiple_text_blocks(mock_anthropic):
|
||||
block_a = MagicMock()
|
||||
block_a.type = "text"
|
||||
block_a.text = "Part one. "
|
||||
|
||||
block_b = MagicMock()
|
||||
block_b.type = "text"
|
||||
block_b.text = "Part two."
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = [block_a, block_b]
|
||||
mock_anthropic.return_value.messages.create.return_value = mock_response
|
||||
|
||||
client = ClaudeClient("fake_key")
|
||||
result = client.generate_report("sys", "user")
|
||||
assert result == "Part one. Part two."
|
||||
|
||||
|
||||
def test_chat_returns_text(mocker):
|
||||
mock_anthropic = mocker.patch("src.claude_client.Anthropic")
|
||||
mock_client = MagicMock()
|
||||
mock_anthropic.return_value = mock_client
|
||||
|
||||
mock_block = MagicMock()
|
||||
mock_block.type = "text"
|
||||
mock_block.text = "Here is the answer."
|
||||
mock_client.messages.create.return_value.content = [mock_block]
|
||||
|
||||
from src.claude_client import ClaudeClient
|
||||
client = ClaudeClient("fake_key")
|
||||
result = client.chat(
|
||||
messages=[{"role": "user", "content": "What is blocked?"}],
|
||||
system_prompt="You are an assistant."
|
||||
)
|
||||
assert result == "Here is the answer."
|
||||
|
||||
|
||||
def test_chat_passes_messages_and_system(mocker):
|
||||
mock_anthropic = mocker.patch("src.claude_client.Anthropic")
|
||||
mock_client = MagicMock()
|
||||
mock_anthropic.return_value = mock_client
|
||||
|
||||
mock_block = MagicMock()
|
||||
mock_block.type = "text"
|
||||
mock_block.text = "response"
|
||||
mock_client.messages.create.return_value.content = [mock_block]
|
||||
|
||||
from src.claude_client import ClaudeClient
|
||||
client = ClaudeClient("fake_key")
|
||||
messages = [{"role": "user", "content": "hello"}]
|
||||
client.chat(messages=messages, system_prompt="sys")
|
||||
|
||||
call_kwargs = mock_client.messages.create.call_args.kwargs
|
||||
assert call_kwargs["system"] == "sys"
|
||||
assert call_kwargs["messages"] == messages
|
||||
assert call_kwargs["max_tokens"] == 8192
|
||||
53
tests/test_preferences.py
Normal file
53
tests/test_preferences.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from src.preferences import (
|
||||
append_preference,
|
||||
delete_preference,
|
||||
list_preferences,
|
||||
load_preferences,
|
||||
)
|
||||
|
||||
|
||||
def test_load_preferences_empty():
|
||||
assert load_preferences("user@oliver.agency") == ""
|
||||
|
||||
|
||||
def test_append_and_list():
|
||||
pid1 = append_preference("user@oliver.agency", "Prefer concise answers")
|
||||
pid2 = append_preference("user@oliver.agency", "Always cite the data")
|
||||
rows = list_preferences("user@oliver.agency")
|
||||
assert [r["text"] for r in rows] == ["Prefer concise answers", "Always cite the data"]
|
||||
assert {r["id"] for r in rows} == {pid1, pid2}
|
||||
|
||||
|
||||
def test_load_preferences_formats_as_bullets():
|
||||
append_preference("user@oliver.agency", "First")
|
||||
append_preference("user@oliver.agency", "Second")
|
||||
text = load_preferences("user@oliver.agency")
|
||||
assert "- First" in text
|
||||
assert "- Second" in text
|
||||
|
||||
|
||||
def test_per_user_isolation():
|
||||
append_preference("alice@oliver.agency", "Alice's preference")
|
||||
append_preference("bob@oliver.agency", "Bob's preference")
|
||||
alice = list_preferences("alice@oliver.agency")
|
||||
bob = list_preferences("bob@oliver.agency")
|
||||
assert [r["text"] for r in alice] == ["Alice's preference"]
|
||||
assert [r["text"] for r in bob] == ["Bob's preference"]
|
||||
|
||||
|
||||
def test_delete_preference():
|
||||
pid = append_preference("user@oliver.agency", "To be deleted")
|
||||
assert delete_preference("user@oliver.agency", pid) is True
|
||||
assert list_preferences("user@oliver.agency") == []
|
||||
|
||||
|
||||
def test_delete_other_users_preference_fails():
|
||||
pid = append_preference("alice@oliver.agency", "Alice's secret")
|
||||
assert delete_preference("bob@oliver.agency", pid) is False
|
||||
# Still there
|
||||
assert len(list_preferences("alice@oliver.agency")) == 1
|
||||
|
||||
|
||||
def test_load_preferences_no_user_returns_empty():
|
||||
assert load_preferences(None) == ""
|
||||
assert load_preferences("") == ""
|
||||
52
tests/test_prompts.py
Normal file
52
tests/test_prompts.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import json
|
||||
from src.prompts import build_chat_system_prompt, build_manager_summary_prompt, build_full_report_prompt
|
||||
|
||||
SAMPLE_TASKS = [
|
||||
{
|
||||
"id": "rec1", "task": "Build reporting tool", "progress": "In Progress",
|
||||
"priority": "P1", "rag": "Red", "owner": "Tony Coppola", "owners": ["Tony Coppola"],
|
||||
"related_item": "Reporting", "category": ["Airtable"], "start_date": "",
|
||||
"end_date": "2026-04-30", "deadline": "2026-04-25", "doing": "",
|
||||
"blocked_by": "", "blocking": "", "notes": "On track", "hours": None,
|
||||
}
|
||||
]
|
||||
|
||||
SAMPLE_ANALYSIS = {
|
||||
"total": 1, "active_total": 1,
|
||||
"progress_counts": {"In Progress": 1},
|
||||
"priority_counts": {"P1": 1},
|
||||
"red_flags": [],
|
||||
"p1_watchlist": [SAMPLE_TASKS[0]],
|
||||
"by_owner": {"Tony Coppola": [SAMPLE_TASKS[0]]},
|
||||
"overdue": [],
|
||||
}
|
||||
|
||||
|
||||
def test_build_chat_system_prompt_includes_task_data():
|
||||
prompt = build_chat_system_prompt(SAMPLE_TASKS)
|
||||
assert "Build reporting tool" in prompt
|
||||
assert "Tony Coppola" in prompt
|
||||
|
||||
|
||||
def test_build_chat_system_prompt_includes_instructions():
|
||||
prompt = build_chat_system_prompt(SAMPLE_TASKS)
|
||||
assert "shorthand" in prompt.lower() or "plain language" in prompt.lower()
|
||||
|
||||
|
||||
def test_build_manager_summary_prompt_includes_p1_watchlist():
|
||||
prompt = build_manager_summary_prompt(SAMPLE_ANALYSIS)
|
||||
assert "P1" in prompt or "Watch List" in prompt
|
||||
|
||||
|
||||
def test_build_manager_summary_prompt_has_required_sections():
|
||||
prompt = build_manager_summary_prompt(SAMPLE_ANALYSIS)
|
||||
assert "Programme Overview" in prompt
|
||||
assert "Blockers" in prompt
|
||||
|
||||
|
||||
def test_build_full_report_prompt_has_required_sections():
|
||||
prompt = build_full_report_prompt(SAMPLE_ANALYSIS)
|
||||
assert "Executive Summary" in prompt
|
||||
assert "Team Breakdown" in prompt
|
||||
assert "Red Flags" in prompt
|
||||
assert "Overdue" in prompt
|
||||
70
tests/test_reporter.py
Normal file
70
tests/test_reporter.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import pytest
|
||||
from pathlib import Path
|
||||
from src.reporter import manager_summary_to_markdown, build_manager_summary_docx, build_full_report_docx
|
||||
|
||||
SAMPLE_ANALYSIS = {
|
||||
"total": 5, "active_total": 3,
|
||||
"progress_counts": {"In Progress": 2, "Not Started": 1, "Complete": 2},
|
||||
"priority_counts": {"P1": 3, "P2": 2},
|
||||
"red_flags": [],
|
||||
"p1_watchlist": [],
|
||||
"by_owner": {},
|
||||
"overdue": [],
|
||||
}
|
||||
|
||||
SAMPLE_CLAUDE_TEXT = """## Programme Overview
|
||||
|
||||
Things are moving well this week.
|
||||
|
||||
## P1 Watch List
|
||||
|
||||
- Build reporting tool: In Progress — on track
|
||||
|
||||
## Blockers
|
||||
|
||||
No blockers this week.
|
||||
"""
|
||||
|
||||
SAMPLE_FULL_CLAUDE_TEXT = """## Executive Summary
|
||||
|
||||
5 total tasks, 3 active.
|
||||
|
||||
## Red Flags
|
||||
|
||||
No red flags.
|
||||
|
||||
## Team Breakdown
|
||||
|
||||
Tony Coppola is working on the reporting tool.
|
||||
|
||||
## P1 Watch List
|
||||
|
||||
- Tony — Build reporting tool Red: In Progress — on track
|
||||
|
||||
## Overdue Tasks
|
||||
|
||||
No overdue tasks.
|
||||
"""
|
||||
|
||||
|
||||
def test_manager_summary_to_markdown_contains_header(tmp_path):
|
||||
md = manager_summary_to_markdown(SAMPLE_ANALYSIS, SAMPLE_CLAUDE_TEXT)
|
||||
assert "Programme Pulse" in md
|
||||
assert "Programme Overview" in md
|
||||
|
||||
|
||||
def test_manager_summary_to_markdown_includes_stats(tmp_path):
|
||||
md = manager_summary_to_markdown(SAMPLE_ANALYSIS, SAMPLE_CLAUDE_TEXT)
|
||||
assert "5" in md # total tasks
|
||||
|
||||
|
||||
def test_build_manager_summary_docx_creates_file(tmp_path):
|
||||
path = build_manager_summary_docx(SAMPLE_ANALYSIS, SAMPLE_CLAUDE_TEXT, tmp_path)
|
||||
assert path.exists()
|
||||
assert path.suffix == ".docx"
|
||||
|
||||
|
||||
def test_build_full_report_docx_creates_file(tmp_path):
|
||||
path = build_full_report_docx(SAMPLE_ANALYSIS, SAMPLE_FULL_CLAUDE_TEXT, tmp_path)
|
||||
assert path.exists()
|
||||
assert path.suffix == ".docx"
|
||||
450
web_app.py
Normal file
450
web_app.py
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from flask import Flask, Response, g, jsonify, request, send_file, stream_with_context
|
||||
from flask_cors import CORS
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.auth import AuthError, dev_bypass_enabled, dev_user_claims, validate_bearer_token
|
||||
from src.db import ChatMessage, FeedbackEvent, Preference, Report, db_available, session_scope
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# CORS — frontend lives at /programme-pulse/ on optical-dev (same origin in prod via Apache),
|
||||
# and at http://localhost:5173 in dev. Authorization header must be allowed.
|
||||
CORS(
|
||||
app,
|
||||
origins=[
|
||||
"https://optical-dev.oliver.solutions",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
expose_headers=["Content-Disposition"],
|
||||
supports_credentials=False,
|
||||
)
|
||||
|
||||
PULSE_API_KEY = os.getenv("PULSE_AIRTABLE_API_KEY", "")
|
||||
PULSE_BASE_ID = os.getenv("PULSE_AIRTABLE_BASE_ID", "")
|
||||
PULSE_TABLE_ID = os.getenv("PULSE_AIRTABLE_TABLE_ID", "")
|
||||
RESOURCE_API_KEY = os.getenv("PULSE_RESOURCE_API_KEY", "") or PULSE_API_KEY
|
||||
RESOURCE_BASE_ID = os.getenv("PULSE_RESOURCE_BASE_ID", "")
|
||||
RESOURCE_TABLE_ID = os.getenv("PULSE_RESOURCE_TABLE_ID", "")
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
|
||||
OUTPUT_DIR = Path("reports")
|
||||
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# Airtable caches — not user data, fine to keep in-process. /api/refresh rebuilds them.
|
||||
_snapshot: list[dict] = []
|
||||
_snapshot_lock = threading.Lock()
|
||||
_resource_snapshot: list[dict] = []
|
||||
_resource_lock = threading.Lock()
|
||||
|
||||
|
||||
def _load_snapshot():
|
||||
global _snapshot
|
||||
if not PULSE_API_KEY:
|
||||
return
|
||||
from src.airtable_client import PulseAirtableClient
|
||||
try:
|
||||
client = PulseAirtableClient(PULSE_API_KEY, PULSE_BASE_ID, PULSE_TABLE_ID)
|
||||
data = client.fetch_all_tasks()
|
||||
except Exception as e:
|
||||
app.logger.warning("Airtable tasks load failed: %s", e)
|
||||
return
|
||||
with _snapshot_lock:
|
||||
_snapshot = data
|
||||
|
||||
|
||||
def _get_snapshot() -> list[dict]:
|
||||
with _snapshot_lock:
|
||||
if _snapshot:
|
||||
return _snapshot
|
||||
_load_snapshot()
|
||||
with _snapshot_lock:
|
||||
return _snapshot
|
||||
|
||||
|
||||
def _load_resource_snapshot():
|
||||
global _resource_snapshot
|
||||
if not RESOURCE_API_KEY or not RESOURCE_BASE_ID:
|
||||
return
|
||||
from src.airtable_client import ResourceAirtableClient
|
||||
try:
|
||||
client = ResourceAirtableClient(RESOURCE_API_KEY, RESOURCE_BASE_ID, RESOURCE_TABLE_ID)
|
||||
data = client.fetch_all_bookings()
|
||||
except Exception as e:
|
||||
app.logger.warning("Airtable bookings load failed: %s", e)
|
||||
return
|
||||
with _resource_lock:
|
||||
_resource_snapshot = data
|
||||
|
||||
|
||||
def _get_resource_snapshot() -> list[dict]:
|
||||
with _resource_lock:
|
||||
if _resource_snapshot:
|
||||
return _resource_snapshot
|
||||
_load_resource_snapshot()
|
||||
with _resource_lock:
|
||||
return _resource_snapshot
|
||||
|
||||
|
||||
# Eagerly load both data sources in background on startup
|
||||
threading.Thread(target=_load_snapshot, daemon=True).start()
|
||||
threading.Thread(target=_load_resource_snapshot, daemon=True).start()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PUBLIC_PATHS = {"/api/health"}
|
||||
|
||||
|
||||
@app.before_request
|
||||
def _auth_gate():
|
||||
if request.method == "OPTIONS":
|
||||
return None
|
||||
if request.path in PUBLIC_PATHS:
|
||||
return None
|
||||
if not request.path.startswith("/api/"):
|
||||
# We only serve API routes; the SPA is served by Apache from /var/www/html.
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
if dev_bypass_enabled():
|
||||
claims = dev_user_claims()
|
||||
else:
|
||||
header = request.headers.get("Authorization", "")
|
||||
if not header.startswith("Bearer "):
|
||||
return jsonify({"error": "Missing Authorization: Bearer header"}), 401
|
||||
token = header[7:].strip()
|
||||
try:
|
||||
claims = validate_bearer_token(token)
|
||||
except AuthError as e:
|
||||
return jsonify({"error": str(e)}), e.status_code
|
||||
|
||||
g.user_email = claims["_email"]
|
||||
g.user_name = claims.get("_name") or claims["_email"]
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"db": db_available(),
|
||||
"tasks_loaded": len(_snapshot),
|
||||
"bookings_loaded": len(_resource_snapshot),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/me")
|
||||
def me():
|
||||
return jsonify({"email": g.user_email, "name": g.user_name})
|
||||
|
||||
|
||||
@app.post("/api/chat")
|
||||
def chat():
|
||||
message = (request.json or {}).get("message", "").strip()
|
||||
if not message:
|
||||
return jsonify({"error": "Empty message"}), 400
|
||||
|
||||
tasks = _get_snapshot()
|
||||
if not tasks:
|
||||
return jsonify({"response": "No data loaded. Check Airtable credentials."})
|
||||
|
||||
from src.claude_client import ClaudeClient
|
||||
from src.prompts import build_chat_system_prompt
|
||||
|
||||
history = _load_history(g.user_email)
|
||||
history.append({"role": "user", "content": message})
|
||||
_save_message(g.user_email, "user", message)
|
||||
|
||||
system_prompt = build_chat_system_prompt(tasks, _get_resource_snapshot(), user_email=g.user_email)
|
||||
claude = ClaudeClient(ANTHROPIC_API_KEY)
|
||||
response_text = claude.chat(messages=history[-20:], system_prompt=system_prompt)
|
||||
_save_message(g.user_email, "assistant", response_text)
|
||||
return jsonify({"response": response_text})
|
||||
|
||||
|
||||
@app.post("/api/chat/stream")
|
||||
def chat_stream():
|
||||
user_email = g.user_email
|
||||
message = (request.json or {}).get("message", "").strip()
|
||||
if not message:
|
||||
return jsonify({"error": "Empty message"}), 400
|
||||
|
||||
tasks = _get_snapshot()
|
||||
if not tasks:
|
||||
def _no_data():
|
||||
yield "data: " + json.dumps({"chunk": "No data loaded. Check Airtable credentials."}) + "\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
return Response(stream_with_context(_no_data()), mimetype="text/event-stream")
|
||||
|
||||
from src.claude_client import ClaudeClient
|
||||
from src.prompts import build_chat_system_prompt
|
||||
|
||||
history = _load_history(user_email)
|
||||
history.append({"role": "user", "content": message})
|
||||
_save_message(user_email, "user", message)
|
||||
|
||||
system_prompt = build_chat_system_prompt(tasks, _get_resource_snapshot(), user_email=user_email)
|
||||
claude = ClaudeClient(ANTHROPIC_API_KEY)
|
||||
|
||||
def generate():
|
||||
full_response: list[str] = []
|
||||
try:
|
||||
with claude.chat_stream(messages=history[-20:], system_prompt=system_prompt) as stream:
|
||||
for text in stream.text_stream:
|
||||
full_response.append(text)
|
||||
yield "data: " + json.dumps({"chunk": text}) + "\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
except Exception as e:
|
||||
yield "data: " + json.dumps({"error": str(e)}) + "\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
finally:
|
||||
response_text = "".join(full_response)
|
||||
if response_text:
|
||||
_save_message(user_email, "assistant", response_text)
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
mimetype="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/generate")
|
||||
def generate():
|
||||
tasks = _get_snapshot()
|
||||
if not tasks:
|
||||
return jsonify({"error": "No data loaded"}), 500
|
||||
|
||||
from src.analyzer import analyze
|
||||
from src.claude_client import ClaudeClient
|
||||
from src.prompts import PULSE_SYSTEM_PROMPT, build_full_report_prompt, build_manager_summary_prompt
|
||||
from src.reporter import build_full_report_docx, build_manager_summary_docx, manager_summary_to_markdown
|
||||
|
||||
analysis = analyze(tasks)
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
def _gen_summary():
|
||||
return ClaudeClient(ANTHROPIC_API_KEY).generate_report(
|
||||
PULSE_SYSTEM_PROMPT, build_manager_summary_prompt(analysis)
|
||||
)
|
||||
|
||||
def _gen_full():
|
||||
return ClaudeClient(ANTHROPIC_API_KEY).generate_report(
|
||||
PULSE_SYSTEM_PROMPT, build_full_report_prompt(analysis)
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
f_summary = pool.submit(_gen_summary)
|
||||
f_full = pool.submit(_gen_full)
|
||||
summary_text = f_summary.result()
|
||||
full_text = f_full.result()
|
||||
|
||||
summary_md = manager_summary_to_markdown(analysis, summary_text)
|
||||
summary_doc = build_manager_summary_docx(analysis, summary_text, OUTPUT_DIR, f"summary-{ts}.docx")
|
||||
full_doc = build_full_report_docx(analysis, full_text, OUTPUT_DIR, f"full-{ts}.docx")
|
||||
|
||||
with session_scope() as s:
|
||||
report = Report(
|
||||
user_email=g.user_email,
|
||||
summary_md=summary_md,
|
||||
summary_doc_path=str(summary_doc),
|
||||
full_doc_path=str(full_doc),
|
||||
)
|
||||
s.add(report)
|
||||
s.flush()
|
||||
report_id = report.id
|
||||
|
||||
return jsonify({"status": "ok", "report_id": report_id})
|
||||
|
||||
|
||||
@app.get("/api/copy/summary")
|
||||
def copy_summary():
|
||||
report = _latest_report(g.user_email)
|
||||
if not report:
|
||||
return jsonify({"error": "No report generated yet"}), 404
|
||||
return jsonify({"markdown": report.summary_md})
|
||||
|
||||
|
||||
@app.get("/api/download/summary")
|
||||
def download_summary():
|
||||
report = _latest_report(g.user_email)
|
||||
if not report:
|
||||
return jsonify({"error": "No report generated yet"}), 404
|
||||
return _send_report_file(report.summary_doc_path, f"programme-pulse-summary-{date.today()}.docx")
|
||||
|
||||
|
||||
@app.get("/api/download/full")
|
||||
def download_full():
|
||||
report = _latest_report(g.user_email)
|
||||
if not report:
|
||||
return jsonify({"error": "No report generated yet"}), 404
|
||||
return _send_report_file(report.full_doc_path, f"programme-pulse-full-{date.today()}.docx")
|
||||
|
||||
|
||||
@app.get("/api/download/history/<int:report_id>/<which>")
|
||||
def download_history(report_id: int, which: str):
|
||||
if which not in ("summary", "full"):
|
||||
return jsonify({"error": "Invalid type"}), 400
|
||||
with session_scope() as s:
|
||||
report = s.get(Report, report_id)
|
||||
if report is None or report.user_email != g.user_email:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
path = report.summary_doc_path if which == "summary" else report.full_doc_path
|
||||
download_name = f"programme-pulse-{which}-{report.generated_at.strftime('%Y%m%d-%H%M%S')}.docx"
|
||||
return _send_report_file(path, download_name)
|
||||
|
||||
|
||||
@app.get("/api/history")
|
||||
def history():
|
||||
with session_scope() as s:
|
||||
rows = s.scalars(
|
||||
select(Report)
|
||||
.where(Report.user_email == g.user_email)
|
||||
.order_by(Report.generated_at.desc())
|
||||
.limit(10)
|
||||
).all()
|
||||
runs = [
|
||||
{
|
||||
"id": r.id,
|
||||
"label": r.generated_at.strftime("%-d %b %Y, %H:%M"),
|
||||
"ts": r.generated_at.strftime("%Y%m%d-%H%M%S"),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return jsonify({"runs": runs})
|
||||
|
||||
|
||||
@app.get("/api/preferences")
|
||||
def get_preferences():
|
||||
from src.preferences import list_preferences
|
||||
return jsonify({"preferences": list_preferences(g.user_email)})
|
||||
|
||||
|
||||
@app.delete("/api/preferences/<int:pref_id>")
|
||||
def remove_preference(pref_id: int):
|
||||
from src.preferences import delete_preference
|
||||
ok = delete_preference(g.user_email, pref_id)
|
||||
if not ok:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@app.post("/api/feedback")
|
||||
def feedback():
|
||||
body = request.json or {}
|
||||
rating = body.get("rating")
|
||||
message_text = (body.get("message") or "").strip()
|
||||
if not message_text or rating not in ("up", "down"):
|
||||
return jsonify({"error": "Invalid feedback"}), 400
|
||||
|
||||
from src.claude_client import ClaudeClient
|
||||
from src.preferences import append_preference
|
||||
|
||||
direction = "liked" if rating == "up" else "disliked"
|
||||
extraction_prompt = f"""The user {direction} this assistant response. Extract one concise preference insight that should guide future responses.
|
||||
|
||||
Response the user {direction}:
|
||||
\"\"\"{message_text}\"\"\"
|
||||
|
||||
Write a single plain sentence starting with an action word, e.g. "Prefer...", "Avoid...", "Always...", "Lead with...". No preamble. Max 20 words."""
|
||||
|
||||
claude = ClaudeClient(ANTHROPIC_API_KEY)
|
||||
insight = claude.chat(
|
||||
messages=[{"role": "user", "content": extraction_prompt}],
|
||||
system_prompt="You extract concise preference insights from user feedback. One sentence only.",
|
||||
)
|
||||
insight = insight.strip()
|
||||
pref_id = append_preference(g.user_email, insight)
|
||||
|
||||
with session_scope() as s:
|
||||
s.add(FeedbackEvent(
|
||||
user_email=g.user_email,
|
||||
rating=rating,
|
||||
message_text=message_text,
|
||||
extracted_insight=insight,
|
||||
preference_id=pref_id,
|
||||
))
|
||||
|
||||
return jsonify({"status": "ok", "insight": insight})
|
||||
|
||||
|
||||
@app.post("/api/refresh")
|
||||
def refresh():
|
||||
global _snapshot, _resource_snapshot
|
||||
with _snapshot_lock:
|
||||
_snapshot = []
|
||||
with _resource_lock:
|
||||
_resource_snapshot = []
|
||||
_load_snapshot()
|
||||
_load_resource_snapshot()
|
||||
with _snapshot_lock:
|
||||
count = len(_snapshot)
|
||||
return jsonify({"status": "ok", "tasks": count})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_history(user_email: str) -> list[dict]:
|
||||
"""Last 20 chat turns for this user, oldest-first, in Anthropic message format."""
|
||||
if not db_available():
|
||||
return []
|
||||
with session_scope() as s:
|
||||
rows = s.scalars(
|
||||
select(ChatMessage)
|
||||
.where(ChatMessage.user_email == user_email)
|
||||
.order_by(ChatMessage.created_at.desc())
|
||||
.limit(20)
|
||||
).all()
|
||||
return [{"role": r.role, "content": r.content} for r in reversed(rows)]
|
||||
|
||||
|
||||
def _save_message(user_email: str, role: str, content: str) -> None:
|
||||
if not db_available():
|
||||
return
|
||||
with session_scope() as s:
|
||||
s.add(ChatMessage(user_email=user_email, role=role, content=content))
|
||||
|
||||
|
||||
def _latest_report(user_email: str) -> Report | None:
|
||||
if not db_available():
|
||||
return None
|
||||
with session_scope() as s:
|
||||
return s.scalars(
|
||||
select(Report)
|
||||
.where(Report.user_email == user_email)
|
||||
.order_by(Report.generated_at.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
|
||||
def _send_report_file(path: str, download_name: str):
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return jsonify({"error": "File missing on disk"}), 410
|
||||
return send_file(str(p), as_attachment=True, download_name=download_name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.getenv("PORT", "5051"))
|
||||
app.run(host="0.0.0.0", port=port, debug=False)
|
||||
Loading…
Add table
Reference in a new issue