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:
DJP 2026-05-07 11:06:36 -04:00
parent d032b0c9f2
commit b70d148b94
62 changed files with 9311 additions and 45 deletions

18
.dockerignore Normal file
View 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
View 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
View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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"}

View 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
View file

View 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
View 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

View 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
View 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"
}
}

View 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
View 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:

15
frontend/.env.example Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

29
frontend/package.json Normal file
View 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
View 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>
</>
);
}

View 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 };
}

View 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);

View file

@ -0,0 +1,2 @@
export const DEV_AUTH_BYPASS =
(import.meta.env.VITE_DEV_AUTH_BYPASS ?? '').toLowerCase() === 'true';

View 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 };
}

View 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 2030 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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);
})();

View 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
View 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
View 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
View 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
View file

@ -0,0 +1,5 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*

0
reports/.gitkeep Normal file
View file

21
requirements.txt Normal file
View 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
View file

80
src/airtable_client.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
34 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 35 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
23 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 23 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
View 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
View 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

File diff suppressed because it is too large Load diff

0
tests/__init__.py Normal file
View file

33
tests/conftest.py Normal file
View 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
View 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"
}
]

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)