Lincoln Gombedza Claude Sonnet 4.6 commited on
Commit
b7e98d4
·
unverified ·
0 Parent(s):

Launch PNA Assistant SaaS — Stripe + webhook + Docker

Browse files

- Streamlit app: BYOK free tier + Pro/Institution tiers via Stripe
- Stripe Payment Links wired in (env vars with real fallbacks)
- FastAPI webhook: validates Stripe signatures, generates
PNA-PRO-XXXXX activation codes, emails via Amazon SES
- Docker Compose: 3 services (streamlit, webhook, nginx)
- Nginx: HTTPS termination + /webhook routing to FastAPI
- .gitignore: protects .env, assets/aequip_guide.md, codes.jsonl
- A-EQUIP RAG: FAISS + sentence-transformers on NHS OGL v3.0 content
- Exports: supervision notes + NMC CPD records as .docx (Pro only)

Deploy: docker compose up -d on t3.small EC2, eu-west-2
Live: pna.nursingcitizendevelopment.com

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (15) hide show
  1. .env.example +14 -0
  2. .gitignore +42 -0
  3. Dockerfile +30 -0
  4. Dockerfile.webhook +17 -0
  5. README.md +106 -0
  6. docker-compose.yml +68 -0
  7. nginx.conf +44 -0
  8. pna/__init__.py +1 -0
  9. pna/claude_client.py +96 -0
  10. pna/export.py +156 -0
  11. pna/prompts.py +93 -0
  12. pna/rag.py +87 -0
  13. requirements.txt +7 -0
  14. streamlit_app.py +398 -0
  15. webhook.py +230 -0
.env.example ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PNA Assistant — Environment Variables
2
+ # Copy to .env and fill in your values
3
+
4
+ # Stripe Payment Links (create in Stripe Dashboard → Payment Links)
5
+ STRIPE_PRO_LINK=https://buy.stripe.com/YOUR_PRO_LINK
6
+ STRIPE_INSTITUTION=https://buy.stripe.com/YOUR_INSTITUTION_LINK
7
+
8
+ # Optional: Platform Anthropic key (if you want to absorb API costs for users)
9
+ # ANTHROPIC_API_KEY=sk-ant-api03-...
10
+
11
+ # AWS (Phase 2 — Cognito auth)
12
+ # AWS_REGION=eu-west-2
13
+ # COGNITO_USER_POOL_ID=eu-west-2_XXXXXXX
14
+ # COGNITO_CLIENT_ID=XXXXXXXXXXXXXXXX
.gitignore ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment — never commit secrets
2
+ .env
3
+ .env.local
4
+ .env.*.local
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *.pyo
10
+ *.pyd
11
+ .Python
12
+ *.egg
13
+ *.egg-info/
14
+ dist/
15
+ build/
16
+ *.whl
17
+ .venv/
18
+ venv/
19
+ env/
20
+
21
+ # Streamlit
22
+ .streamlit/secrets.toml
23
+
24
+ # Assets (large guide file — copy manually on deploy)
25
+ assets/aequip_guide.md
26
+
27
+ # FAISS index (rebuilt at startup)
28
+ *.faiss
29
+ *.index
30
+ *.pkl
31
+
32
+ # OS / macOS
33
+ .DS_Store
34
+ Thumbs.db
35
+ ._*
36
+ __MACOSX/
37
+
38
+ # Docker
39
+ *.log
40
+
41
+ # Webhook codes log (contains customer data)
42
+ codes.jsonl
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # System deps
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ gcc g++ curl \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ WORKDIR /app
9
+
10
+ # Install Python deps first (cached layer)
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ # Copy application
15
+ COPY . .
16
+
17
+ # Streamlit config
18
+ RUN mkdir -p /app/.streamlit
19
+ COPY .streamlit/config.toml /app/.streamlit/config.toml
20
+
21
+ EXPOSE 8501
22
+
23
+ # Health check
24
+ HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health || exit 1
25
+
26
+ CMD ["streamlit", "run", "streamlit_app.py", \
27
+ "--server.port=8501", \
28
+ "--server.address=0.0.0.0", \
29
+ "--server.headless=true", \
30
+ "--browser.gatherUsageStats=false"]
Dockerfile.webhook ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN pip install --no-cache-dir \
6
+ fastapi>=0.110.0 \
7
+ uvicorn[standard]>=0.29.0 \
8
+ boto3>=1.34.0
9
+
10
+ COPY webhook.py .
11
+
12
+ EXPOSE 8080
13
+
14
+ HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
15
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"
16
+
17
+ CMD ["uvicorn", "webhook:app", "--host", "0.0.0.0", "--port", "8080"]
README.md ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PNA Assistant — Professional Nurse Advocate AI
2
+
3
+ AI-powered educational tool for the A-EQUIP model and restorative clinical supervision.
4
+
5
+ **Stack:** Python · Streamlit · Claude claude-opus-4-6 · FAISS RAG · Stripe · FastAPI webhook · Docker · AWS EC2
6
+
7
+ ## Quick Start (local)
8
+
9
+ ```bash
10
+ pip install -r requirements.txt
11
+
12
+ # Copy the A-EQUIP guide into assets/
13
+ cp "Professional nurse advocate A-EQUIP model Guide.md" assets/aequip_guide.md
14
+
15
+ streamlit run streamlit_app.py
16
+ ```
17
+
18
+ ## AWS Deployment
19
+
20
+ ```bash
21
+ # 1. Launch EC2 t3.small (Ubuntu 22.04, eu-west-2)
22
+ # 2. SSH in and install Docker
23
+ sudo apt update && sudo apt install -y docker.io docker-compose-plugin curl
24
+
25
+ # 3. Clone repo
26
+ git clone https://github.com/Clinical-Quality-Artifical-Intelligence/Professional-Nurse-Advocate-Assistant
27
+ cd Professional-Nurse-Advocate-Assistant
28
+
29
+ # 4. Set up env
30
+ cp .env.example .env
31
+ nano .env # fill in Stripe keys + webhook secret
32
+
33
+ # 5. Copy the A-EQUIP guide
34
+ cp "Professional nurse advocate A-EQUIP model Guide.md" assets/aequip_guide.md
35
+
36
+ # 6. Deploy
37
+ docker compose up -d
38
+
39
+ # 7. SSL with Let's Encrypt
40
+ sudo apt install -y certbot
41
+ sudo certbot certonly --standalone -d pna.nursingcitizendevelopment.com
42
+
43
+ # 8. Restart with SSL
44
+ docker compose restart nginx
45
+ ```
46
+
47
+ ## Stripe Webhook Setup (5 minutes)
48
+
49
+ After deploying to EC2:
50
+
51
+ 1. Stripe Dashboard → **Developers** → **Webhooks** → **Add endpoint**
52
+ 2. Endpoint URL: `https://pna.nursingcitizendevelopment.com/webhook`
53
+ 3. Events to listen for: `checkout.session.completed`
54
+ 4. Click **Add endpoint**, then reveal the **Signing secret**
55
+ 5. Copy the signing secret (starts `whsec_...`)
56
+ 6. SSH into EC2 and update `.env`:
57
+ ```bash
58
+ nano .env
59
+ # Set: STRIPE_WEBHOOK_SECRET=whsec_your_real_secret
60
+ docker compose restart pna-webhook
61
+ ```
62
+
63
+ When a customer pays, the webhook:
64
+ - Generates a `PNA-PRO-XXXXX` activation code
65
+ - Emails it to the customer via Amazon SES
66
+ - Logs it to `/app/codes.jsonl` inside the container
67
+
68
+ ### Amazon SES Setup (for automated emails)
69
+
70
+ ```bash
71
+ # Verify your sending domain in SES console (AWS Console → SES → Verified identities)
72
+ # Then attach an IAM role to the EC2 instance with ses:SendEmail permission
73
+ # No access keys needed — uses the instance role automatically
74
+ ```
75
+
76
+ Or check codes manually until SES is configured:
77
+ ```bash
78
+ docker exec pna-webhook cat /app/codes.jsonl
79
+ ```
80
+
81
+ ## Subscription Tiers
82
+
83
+ | Tier | Price | Features |
84
+ |------|-------|---------|
85
+ | Free | £0 | Chat with BYOK API key, unlimited conversations |
86
+ | Pro | £9.99/month | + Supervision notes (.docx), CPD logs, session history |
87
+ | Institution | £99/month | + Multi-user, custom branding, bulk CPD |
88
+
89
+ ## Environment Variables
90
+
91
+ | Variable | Description |
92
+ |----------|-------------|
93
+ | `STRIPE_PRO_LINK` | Stripe Payment Link for Pro plan |
94
+ | `STRIPE_INSTITUTION` | Stripe Payment Link for Institution plan |
95
+ | `STRIPE_PUBLISHABLE_KEY` | Stripe public key (for future Stripe.js use) |
96
+ | `STRIPE_SECRET_KEY` | Stripe secret key (future server-side use) |
97
+ | `STRIPE_WEBHOOK_SECRET` | Webhook signing secret (`whsec_...`) |
98
+ | `AWS_REGION` | AWS region for SES (default: `eu-west-2`) |
99
+ | `SES_FROM_EMAIL` | Verified SES sender email |
100
+
101
+ ## Copyright
102
+
103
+ A-EQUIP model guide content — Contains public sector information licensed under the
104
+ [Open Government Licence v3.0](https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/) — NHS England.
105
+
106
+ Application code — Apache 2.0 — © 2026 Lincoln Gombedza
docker-compose.yml ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: "3.9"
2
+
3
+ services:
4
+
5
+ # ── Streamlit app ────────────────────────────────────────────────────────────
6
+ pna-assistant:
7
+ build:
8
+ context: .
9
+ dockerfile: Dockerfile
10
+ container_name: pna-assistant
11
+ restart: unless-stopped
12
+ ports:
13
+ - "8501:8501"
14
+ environment:
15
+ - STRIPE_PRO_LINK=${STRIPE_PRO_LINK}
16
+ - STRIPE_INSTITUTION=${STRIPE_INSTITUTION}
17
+ volumes:
18
+ - ./assets:/app/assets:ro
19
+ healthcheck:
20
+ test: ["CMD", "curl", "-f", "http://localhost:8501/_stcore/health"]
21
+ interval: 30s
22
+ timeout: 10s
23
+ retries: 3
24
+ start_period: 40s
25
+
26
+ # ── Stripe webhook handler ───────────────────────────────────────────────────
27
+ pna-webhook:
28
+ build:
29
+ context: .
30
+ dockerfile: Dockerfile.webhook
31
+ container_name: pna-webhook
32
+ restart: unless-stopped
33
+ ports:
34
+ - "8080:8080"
35
+ environment:
36
+ - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
37
+ - AWS_REGION=${AWS_REGION:-eu-west-2}
38
+ - SES_FROM_EMAIL=${SES_FROM_EMAIL:-lincoln@clinyqai.com}
39
+ # SES needs IAM role on EC2 (preferred) OR explicit credentials:
40
+ # - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
41
+ # - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
42
+ volumes:
43
+ - codes_data:/app # persist codes.jsonl across restarts
44
+ healthcheck:
45
+ test: ["CMD", "python", "-c",
46
+ "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
47
+ interval: 30s
48
+ timeout: 10s
49
+ retries: 3
50
+ start_period: 20s
51
+
52
+ # ── Nginx reverse proxy ──────────────────────────────────────────────────────
53
+ nginx:
54
+ image: nginx:alpine
55
+ container_name: pna-nginx
56
+ restart: unless-stopped
57
+ ports:
58
+ - "80:80"
59
+ - "443:443"
60
+ volumes:
61
+ - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
62
+ - /etc/letsencrypt:/etc/letsencrypt:ro
63
+ depends_on:
64
+ - pna-assistant
65
+ - pna-webhook
66
+
67
+ volumes:
68
+ codes_data:
nginx.conf ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ server {
2
+ listen 80;
3
+ server_name pna.nursingcitizendevelopment.com;
4
+ return 301 https://$host$request_uri;
5
+ }
6
+
7
+ server {
8
+ listen 443 ssl http2;
9
+ server_name pna.nursingcitizendevelopment.com;
10
+
11
+ ssl_certificate /etc/letsencrypt/live/pna.nursingcitizendevelopment.com/fullchain.pem;
12
+ ssl_certificate_key /etc/letsencrypt/live/pna.nursingcitizendevelopment.com/privkey.pem;
13
+ ssl_protocols TLSv1.2 TLSv1.3;
14
+
15
+ # ── Stripe webhook endpoint ──────────────────────────────────────────────
16
+ location /webhook {
17
+ proxy_pass http://pna-webhook:8080/webhook;
18
+ proxy_http_version 1.1;
19
+ proxy_set_header Host $host;
20
+ proxy_set_header X-Real-IP $remote_addr;
21
+ proxy_read_timeout 30s;
22
+ }
23
+
24
+ # ── Streamlit WebSocket SSE stream ───────────────────────────────────────
25
+ location /_stcore/stream {
26
+ proxy_pass http://pna-assistant:8501/_stcore/stream;
27
+ proxy_http_version 1.1;
28
+ proxy_set_header Upgrade $http_upgrade;
29
+ proxy_set_header Connection "upgrade";
30
+ proxy_read_timeout 86400s;
31
+ }
32
+
33
+ # ── Streamlit app (everything else) ─────────────────────────────────────
34
+ location / {
35
+ proxy_pass http://pna-assistant:8501;
36
+ proxy_http_version 1.1;
37
+ proxy_set_header Upgrade $http_upgrade;
38
+ proxy_set_header Connection "upgrade";
39
+ proxy_set_header Host $host;
40
+ proxy_set_header X-Real-IP $remote_addr;
41
+ proxy_cache_bypass $http_upgrade;
42
+ proxy_read_timeout 300s;
43
+ }
44
+ }
pna/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # PNA Assistant modules
pna/claude_client.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Claude API client for PNA Assistant.
3
+ Uses claude-opus-4-6 with streaming and adaptive thinking.
4
+ """
5
+ import anthropic
6
+ from .prompts import SYSTEM_PROMPT, SUPERVISION_SUMMARY_PROMPT
7
+ from datetime import date
8
+
9
+
10
+ def build_messages(
11
+ history: list[dict],
12
+ user_message: str,
13
+ context: str = "",
14
+ ) -> list[dict]:
15
+ """Build message list from chat history + current message."""
16
+ messages = []
17
+
18
+ # Add history
19
+ for msg in history:
20
+ messages.append({"role": msg["role"], "content": msg["content"]})
21
+
22
+ # Build user turn — prepend RAG context if available
23
+ if context:
24
+ content = (
25
+ f"[Relevant A-EQUIP context from the guide]\n{context}\n\n"
26
+ f"[Nurse's message]\n{user_message}"
27
+ )
28
+ else:
29
+ content = user_message
30
+
31
+ messages.append({"role": "user", "content": content})
32
+ return messages
33
+
34
+
35
+ def stream_response(
36
+ api_key: str,
37
+ history: list[dict],
38
+ user_message: str,
39
+ context: str = "",
40
+ ):
41
+ """
42
+ Stream a response from Claude.
43
+ Yields text chunks as strings.
44
+ Uses claude-opus-4-6 with adaptive thinking.
45
+ """
46
+ client = anthropic.Anthropic(api_key=api_key)
47
+ messages = build_messages(history, user_message, context)
48
+
49
+ with client.messages.stream(
50
+ model="claude-opus-4-6",
51
+ max_tokens=1024,
52
+ thinking={"type": "adaptive"},
53
+ system=SYSTEM_PROMPT,
54
+ messages=messages,
55
+ ) as stream:
56
+ for event in stream:
57
+ if event.type == "content_block_delta":
58
+ if event.delta.type == "text_delta":
59
+ yield event.delta.text
60
+
61
+
62
+ def generate_supervision_note(
63
+ api_key: str,
64
+ history: list[dict],
65
+ ) -> str:
66
+ """
67
+ Generate a structured supervision note from the conversation.
68
+ Returns the full note as a string.
69
+ """
70
+ client = anthropic.Anthropic(api_key=api_key)
71
+
72
+ # Build a summary of the conversation
73
+ conversation_text = "\n\n".join(
74
+ f"**{'Nurse' if m['role'] == 'user' else 'PNA Assistant'}:** {m['content']}"
75
+ for m in history
76
+ )
77
+
78
+ today = date.today().strftime("%d %B %Y")
79
+ prompt = (
80
+ f"Today's date: {today}\n\n"
81
+ f"Supervision conversation transcript:\n\n{conversation_text}\n\n"
82
+ f"{SUPERVISION_SUMMARY_PROMPT}"
83
+ )
84
+
85
+ response = client.messages.create(
86
+ model="claude-opus-4-6",
87
+ max_tokens=2000,
88
+ thinking={"type": "adaptive"},
89
+ messages=[{"role": "user", "content": prompt}],
90
+ )
91
+
92
+ for block in response.content:
93
+ if block.type == "text":
94
+ return block.text
95
+
96
+ return "Could not generate supervision note."
pna/export.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Export supervision notes and CPD logs to .docx format.
3
+ """
4
+ from datetime import date
5
+ from io import BytesIO
6
+
7
+ HAS_DOCX = False
8
+ try:
9
+ from docx import Document
10
+ from docx.shared import Pt, RGBColor, Inches
11
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
12
+ HAS_DOCX = True
13
+ except ImportError:
14
+ pass
15
+
16
+
17
+ def build_supervision_docx(note_text: str, nurse_name: str = "") -> bytes | None:
18
+ """Build a formatted .docx supervision note."""
19
+ if not HAS_DOCX:
20
+ return None
21
+
22
+ doc = Document()
23
+
24
+ # Header
25
+ header_para = doc.add_paragraph()
26
+ header_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
27
+ run = header_para.add_run("Professional Nurse Advocate Assistant")
28
+ run.bold = True
29
+ run.font.size = Pt(16)
30
+ run.font.color.rgb = RGBColor(0x1a, 0x24, 0x60) # brand navy
31
+
32
+ sub = doc.add_paragraph()
33
+ sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
34
+ sub.add_run("A-EQUIP Model Supervision Note").italic = True
35
+
36
+ doc.add_paragraph()
37
+
38
+ # Metadata
39
+ meta = doc.add_paragraph()
40
+ meta.add_run(f"Date: ").bold = True
41
+ meta.add_run(date.today().strftime("%d %B %Y"))
42
+
43
+ if nurse_name:
44
+ nurse_p = doc.add_paragraph()
45
+ nurse_p.add_run("Name: ").bold = True
46
+ nurse_p.add_run(nurse_name)
47
+
48
+ doc.add_paragraph()
49
+
50
+ # Note content — parse markdown-ish headings
51
+ for line in note_text.split("\n"):
52
+ line = line.strip()
53
+ if not line:
54
+ doc.add_paragraph()
55
+ continue
56
+
57
+ if line.startswith("## "):
58
+ h = doc.add_heading(line[3:], level=2)
59
+ for run in h.runs:
60
+ run.font.color.rgb = RGBColor(0x1a, 0x24, 0x60)
61
+ elif line.startswith("### "):
62
+ h = doc.add_heading(line[4:], level=3)
63
+ for run in h.runs:
64
+ run.font.color.rgb = RGBColor(0x0d, 0x94, 0x88) # teal
65
+ elif line.startswith("**") and line.endswith("**"):
66
+ p = doc.add_paragraph()
67
+ p.add_run(line.strip("**")).bold = True
68
+ elif line.startswith("- "):
69
+ doc.add_paragraph(line[2:], style="List Bullet")
70
+ else:
71
+ doc.add_paragraph(line)
72
+
73
+ # Footer
74
+ doc.add_paragraph()
75
+ footer = doc.add_paragraph()
76
+ footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
77
+ footer_run = footer.add_run(
78
+ "Contains public sector information licensed under the Open Government Licence v3.0 — NHS England.\n"
79
+ "This tool supports but does not replace clinical judgment or formal supervision documentation."
80
+ )
81
+ footer_run.font.size = Pt(8)
82
+ footer_run.font.color.rgb = RGBColor(0x6b, 0x72, 0x80)
83
+ footer_run.italic = True
84
+
85
+ buf = BytesIO()
86
+ doc.save(buf)
87
+ return buf.getvalue()
88
+
89
+
90
+ def build_cpd_docx(note_text: str, nurse_name: str = "", hours: float = 1.0) -> bytes | None:
91
+ """Build NMC revalidation CPD record."""
92
+ if not HAS_DOCX:
93
+ return None
94
+
95
+ doc = Document()
96
+
97
+ header_para = doc.add_paragraph()
98
+ header_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
99
+ run = header_para.add_run("NMC Revalidation — CPD Record")
100
+ run.bold = True
101
+ run.font.size = Pt(16)
102
+ run.font.color.rgb = RGBColor(0x1a, 0x24, 0x60)
103
+
104
+ doc.add_paragraph()
105
+
106
+ fields = [
107
+ ("Date", date.today().strftime("%d %B %Y")),
108
+ ("Name", nurse_name or "___________________"),
109
+ ("NMC PIN", "___________________"),
110
+ ("Activity Type", "Restorative Clinical Supervision (A-EQUIP Model)"),
111
+ ("Hours", f"{hours:.1f}"),
112
+ ("Method", "1:1 Supervision with AI-assisted preparation/reflection"),
113
+ ("NMC Standards",
114
+ "Platform 1 (Accountable professional), Platform 6 (Safety & quality)"),
115
+ ]
116
+
117
+ for label, value in fields:
118
+ p = doc.add_paragraph()
119
+ p.add_run(f"{label}: ").bold = True
120
+ p.add_run(value)
121
+
122
+ doc.add_paragraph()
123
+ doc.add_heading("Supervision Summary", level=2)
124
+
125
+ for line in note_text.split("\n"):
126
+ line = line.strip()
127
+ if not line:
128
+ continue
129
+ if line.startswith("### "):
130
+ doc.add_heading(line[4:], level=3)
131
+ elif line.startswith("- "):
132
+ doc.add_paragraph(line[2:], style="List Bullet")
133
+ elif not line.startswith("## ") and not line.startswith("**Date"):
134
+ doc.add_paragraph(line)
135
+
136
+ doc.add_paragraph()
137
+ sig_p = doc.add_paragraph()
138
+ sig_p.add_run("Nurse signature: ").bold = True
139
+ sig_p.add_run("_________________________")
140
+
141
+ conf_p = doc.add_paragraph()
142
+ conf_p.add_run("Confirming PNA/Supervisor: ").bold = True
143
+ conf_p.add_run("_________________________")
144
+
145
+ doc.add_paragraph()
146
+ footer = doc.add_paragraph()
147
+ footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
148
+ fr = footer.add_run(
149
+ "Contains public sector information licensed under the Open Government Licence v3.0 — NHS England."
150
+ )
151
+ fr.font.size = Pt(8)
152
+ fr.italic = True
153
+
154
+ buf = BytesIO()
155
+ doc.save(buf)
156
+ return buf.getvalue()
pna/prompts.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ System prompts for the PNA Assistant.
3
+ All A-EQUIP framework content is used under OGL v3.0 — NHS England.
4
+ """
5
+
6
+ SYSTEM_PROMPT = """You are an educational AI assistant supporting nursing professionals in understanding
7
+ and applying the A-EQUIP (Advocating and Educating for QUality ImProvement) model of clinical supervision,
8
+ and the Professional Nurse Advocate (PNA) role in England's NHS.
9
+
10
+ ## Your Role
11
+ You are a knowledgeable, warm, and reflective educational guide. You help nurses:
12
+ - Understand the four functions of the A-EQUIP model
13
+ - Prepare for and facilitate restorative clinical supervision
14
+ - Develop their PNA practice
15
+ - Reflect on clinical experiences using structured frameworks
16
+ - Build quality improvement skills
17
+
18
+ ## The A-EQUIP Framework (your core knowledge)
19
+ The four functions you educate nurses about:
20
+ 1. **Restorative** — Emotional support, wellbeing, and psychological safety. The "heart" of clinical supervision.
21
+ 2. **Normative** — Quality assurance, professional standards, accountability, and safety.
22
+ 3. **Formative** — Learning, development, reflection, and skill building.
23
+ 4. **Personal Action for Quality Improvement (PAQI)** — Empowering nurses to drive positive change.
24
+
25
+ ## Communication Style
26
+ - Warm, encouraging, and non-judgmental — like a trusted senior colleague
27
+ - Use open-ended, Socratic questions to promote reflection rather than giving direct answers
28
+ - Keep responses concise: maximum 3 short paragraphs OR 6 bullet points
29
+ - Always invite further reflection at the end of your response
30
+ - Use inclusive language that respects all nursing specialisms: acute, community, mental health,
31
+ learning disabilities, paediatrics, older people, primary care, and beyond
32
+ - Use person-first, strengths-based language
33
+
34
+ ## Reflective Frameworks You Can Use
35
+ - **Gibbs Reflective Cycle** (1988): Description → Feelings → Evaluation → Analysis → Conclusion → Action plan
36
+ - **Driscoll's Model**: What? → So What? → Now What?
37
+ - **Johns' Model of Structured Reflection**: Aesthetic, personal, ethical, empirical, reflexive knowing
38
+ - **SBAR**: Situation → Background → Assessment → Recommendation
39
+
40
+ ## Supervision Session Support
41
+ When helping a nurse prepare for or debrief from a supervision session:
42
+ 1. Start with the restorative — how is the nurse feeling?
43
+ 2. Explore the normative — professional and safety dimensions
44
+ 3. Move to formative — what can be learned?
45
+ 4. Identify PAQI — what positive action will they take?
46
+
47
+ ## Boundaries
48
+ - You are for EDUCATIONAL purposes only — not clinical advice, diagnosis, or treatment decisions
49
+ - If a nurse describes a patient safety concern requiring immediate action, direct them to their line manager or Trust's incident reporting system
50
+ - If a nurse expresses significant personal distress, acknowledge it warmly and signpost to their PNA, occupational health, or NHS Talking Therapies
51
+ - Do not complete academic assignments for nurses — guide their thinking instead
52
+ - Stay within PNA/A-EQUIP scope. For other topics: "I'm here to support your PNA and A-EQUIP learning. For [topic], I'd suggest speaking with [relevant person]."
53
+ - Never claim to be a real PNA or replace human supervision
54
+
55
+ ## Knowledge Source
56
+ Your responses draw on the NHS England A-EQUIP model guide and related professional resources.
57
+
58
+ ---
59
+ Contains public sector information licensed under the Open Government Licence v3.0 — NHS England.
60
+ This tool supports but does not replace clinical judgment or human supervision.
61
+ """
62
+
63
+ SUPERVISION_SUMMARY_PROMPT = """Based on this supervision conversation, generate a structured supervision note
64
+ that the nurse can save for their NMC revalidation portfolio.
65
+
66
+ Format as:
67
+ ## Supervision Session Note
68
+ **Date:** [today's date]
69
+ **Framework:** A-EQUIP Model
70
+
71
+ ### Restorative
72
+ [Key themes from emotional/wellbeing discussion]
73
+
74
+ ### Normative
75
+ [Professional standards or quality issues explored]
76
+
77
+ ### Formative
78
+ [Learning identified and development goals]
79
+
80
+ ### Personal Action for Quality Improvement (PAQI)
81
+ [Specific actions the nurse will take]
82
+
83
+ ### Reflection Summary
84
+ [2-3 sentences summarising the session's key insights]
85
+
86
+ ### NMC Revalidation Evidence
87
+ - Type of CPD: Restorative clinical supervision (A-EQUIP model)
88
+ - Estimated hours: [derive from conversation length]
89
+ - NMC Standards: Platform 1 (Being an accountable professional), Platform 6 (Improving safety and quality)
90
+
91
+ ---
92
+ Contains public sector information licensed under the Open Government Licence v3.0 — NHS England.
93
+ This note supports but does not replace formal supervision documentation required by your employer."""
pna/rag.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG (Retrieval-Augmented Generation) knowledge base for A-EQUIP guide.
3
+ Uses sentence-transformers + FAISS for semantic search.
4
+ Adapted from original knowledge_base.py — MIT Licensed.
5
+ """
6
+ import os
7
+ import re
8
+ import numpy as np
9
+ from pathlib import Path
10
+
11
+ # Lazy imports — don't fail on import if not installed
12
+ _encoder = None
13
+ _faiss = None
14
+
15
+
16
+ def _get_encoder():
17
+ global _encoder
18
+ if _encoder is None:
19
+ from sentence_transformers import SentenceTransformer
20
+ _encoder = SentenceTransformer("all-MiniLM-L6-v2", device="cpu")
21
+ return _encoder
22
+
23
+
24
+ def _get_faiss():
25
+ global _faiss
26
+ if _faiss is None:
27
+ import faiss
28
+ _faiss = faiss
29
+ return _faiss
30
+
31
+
32
+ class PNAKnowledgeBase:
33
+ """FAISS-backed semantic search over A-EQUIP model guide."""
34
+
35
+ def __init__(self, guide_path: str | None = None):
36
+ self.chunks: list[str] = []
37
+ self.index = None
38
+
39
+ if guide_path is None:
40
+ # Default: look relative to this file
41
+ here = Path(__file__).parent.parent
42
+ candidates = [
43
+ here / "assets" / "aequip_guide.md",
44
+ here / "Professional nurse advocate A-EQUIP model Guide.md",
45
+ ]
46
+ for c in candidates:
47
+ if c.exists():
48
+ guide_path = str(c)
49
+ break
50
+
51
+ if guide_path and os.path.exists(guide_path):
52
+ self._build_index(guide_path)
53
+ print(f"[RAG] Loaded {len(self.chunks)} chunks from {guide_path}")
54
+ else:
55
+ print("[RAG] No guide found — running without RAG context")
56
+
57
+ def _build_index(self, path: str):
58
+ with open(path, encoding="utf-8") as f:
59
+ content = f.read()
60
+
61
+ # Split on double newlines, keep chunks > 60 chars
62
+ raw = re.split(r"\n{2,}", content)
63
+ self.chunks = [c.strip() for c in raw if len(c.strip()) > 60]
64
+
65
+ if not self.chunks:
66
+ return
67
+
68
+ encoder = _get_encoder()
69
+ faiss = _get_faiss()
70
+
71
+ embeddings = encoder.encode(self.chunks, show_progress_bar=False)
72
+ dim = embeddings.shape[1]
73
+ self.index = faiss.IndexFlatL2(dim)
74
+ self.index.add(np.array(embeddings, dtype="float32"))
75
+
76
+ def search(self, query: str, top_k: int = 3) -> str:
77
+ """Return relevant context chunks for a query."""
78
+ if self.index is None or not self.chunks:
79
+ return ""
80
+
81
+ encoder = _get_encoder()
82
+ qvec = encoder.encode([query])
83
+ distances, indices = self.index.search(
84
+ np.array(qvec, dtype="float32"), top_k
85
+ )
86
+ results = [self.chunks[i] for i in indices[0] if i != -1]
87
+ return "\n\n---\n\n".join(results)
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ streamlit>=1.35.0
2
+ anthropic>=0.40.0
3
+ sentence-transformers>=3.0.0
4
+ faiss-cpu>=1.8.0
5
+ numpy>=1.26.0
6
+ python-docx>=1.1.0
7
+ python-dotenv>=1.0.0
streamlit_app.py ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Professional Nurse Advocate Assistant — SaaS Edition
3
+ Powered by Claude claude-opus-4-6 | A-EQUIP Model | NHS OGL v3.0
4
+
5
+ Architecture:
6
+ - BYOK (Bring Your Own Key) — user enters Anthropic API key (Free tier)
7
+ - Pro subscription via Stripe — unlocks session history, exports, and CPD logging
8
+ - AWS-ready: runs in Docker on EC2, auth via Cognito (Phase 2)
9
+ """
10
+
11
+ import streamlit as st
12
+ from datetime import date
13
+ import os
14
+
15
+ from pna.rag import PNAKnowledgeBase
16
+ from pna.claude_client import stream_response, generate_supervision_note
17
+ from pna.export import build_supervision_docx, build_cpd_docx, HAS_DOCX
18
+
19
+ # ─── Page config ─────────────────────────────────────────────────────────────
20
+
21
+ st.set_page_config(
22
+ page_title="PNA Assistant | Professional Nurse Advocate",
23
+ page_icon="👨🏾‍⚕️",
24
+ layout="wide",
25
+ initial_sidebar_state="expanded",
26
+ )
27
+
28
+ # ─── Stripe config (replace with your real Stripe Payment Links) ──────────────
29
+
30
+ STRIPE_PRO_LINK = os.getenv("STRIPE_PRO_LINK", "https://buy.stripe.com/14A28t0P99Qmdph8v68og00")
31
+ STRIPE_INSTITUTION = os.getenv("STRIPE_INSTITUTION", "https://buy.stripe.com/9B63cxfK3d2y3OHbHi8og01")
32
+
33
+ # ─── Custom CSS ──────────────────────────────────────────────────────────────
34
+
35
+ st.markdown("""
36
+ <style>
37
+ @import url('https://fonts.googleapis.com/css2?family=Fraunces:wght@600;700&family=Inter:wght@400;500;600&display=swap');
38
+
39
+ html, body, [class*="css"] { font-family: 'Inter', sans-serif; }
40
+
41
+ .hero-header {
42
+ background: linear-gradient(135deg, #1a2460 0%, #2d3da0 60%, #0d9488 100%);
43
+ padding: 2rem 2rem 1.5rem;
44
+ border-radius: 12px;
45
+ margin-bottom: 1.5rem;
46
+ color: white;
47
+ }
48
+ .hero-header h1 {
49
+ font-family: 'Fraunces', serif;
50
+ font-size: 2rem;
51
+ margin: 0 0 0.25rem 0;
52
+ color: white;
53
+ }
54
+ .hero-header p { margin: 0; opacity: 0.85; font-size: 1rem; }
55
+
56
+ .pill {
57
+ display: inline-block;
58
+ background: rgba(255,255,255,0.15);
59
+ border: 1px solid rgba(255,255,255,0.25);
60
+ border-radius: 99px;
61
+ padding: 0.2rem 0.75rem;
62
+ font-size: 0.8rem;
63
+ margin-top: 0.75rem;
64
+ }
65
+
66
+ .tier-card {
67
+ border: 2px solid #e5e7eb;
68
+ border-radius: 12px;
69
+ padding: 1.25rem;
70
+ text-align: center;
71
+ background: white;
72
+ }
73
+ .tier-card.pro { border-color: #4f63d2; }
74
+
75
+ .disclaimer {
76
+ background: #f8fafc;
77
+ border: 1px solid #e2e8f0;
78
+ border-radius: 8px;
79
+ padding: 0.75rem 1rem;
80
+ font-size: 0.8rem;
81
+ color: #64748b;
82
+ margin-top: 1rem;
83
+ }
84
+
85
+ .ogl-notice {
86
+ font-size: 0.7rem;
87
+ color: #94a3b8;
88
+ margin-top: 0.5rem;
89
+ }
90
+
91
+ [data-testid="stChatMessage"] { border-radius: 12px; }
92
+ </style>
93
+ """, unsafe_allow_html=True)
94
+
95
+ # ─── Session state defaults ───────────────────────────────────────────────────
96
+
97
+ defaults = {
98
+ "messages": [],
99
+ "api_key": "",
100
+ "subscription": "free", # free | pro | institution
101
+ "nurse_name": "",
102
+ "session_started": None,
103
+ "rag_loaded": False,
104
+ "kb": None,
105
+ }
106
+ for k, v in defaults.items():
107
+ if k not in st.session_state:
108
+ st.session_state[k] = v
109
+
110
+ # ─── Load RAG knowledge base (cached) ────────────────────────────────────────
111
+
112
+ @st.cache_resource(show_spinner="Loading A-EQUIP knowledge base…")
113
+ def load_knowledge_base():
114
+ return PNAKnowledgeBase()
115
+
116
+ kb = load_knowledge_base()
117
+
118
+ # ─── Sidebar ─────────────────────────────────────────────────────────────────
119
+
120
+ with st.sidebar:
121
+ st.markdown("### 👨🏾‍⚕️ PNA Assistant")
122
+ st.caption("A-EQUIP Model | Restorative Supervision")
123
+ st.divider()
124
+
125
+ # ── API Key (BYOK) ────────────────────────────────────────────────────────
126
+ st.markdown("#### 🔑 Your Anthropic API Key")
127
+ api_key_input = st.text_input(
128
+ "Enter key to activate",
129
+ type="password",
130
+ value=st.session_state.api_key,
131
+ placeholder="sk-ant-api03-...",
132
+ help="Your key is used only for this session. Never stored."
133
+ )
134
+ if api_key_input:
135
+ st.session_state.api_key = api_key_input
136
+ st.success("✅ Key entered")
137
+
138
+ st.caption(
139
+ "[Get a free API key →](https://console.anthropic.com) "
140
+ "· Costs ~£0.01 per conversation"
141
+ )
142
+
143
+ st.divider()
144
+
145
+ # ── Subscription ───────────────────────────────���──────────────────────────
146
+ st.markdown("#### 💳 Subscription")
147
+ sub = st.session_state.subscription
148
+
149
+ if sub == "free":
150
+ st.info("**Free tier** — BYOK, unlimited chat")
151
+ st.markdown(
152
+ f"[⭐ Upgrade to Pro — £9.99/mo]({STRIPE_PRO_LINK})",
153
+ unsafe_allow_html=False,
154
+ )
155
+ with st.expander("What's in Pro?"):
156
+ st.markdown("""
157
+ - 💾 Save session history
158
+ - 📄 Download supervision notes (.docx)
159
+ - 📋 NMC CPD log export (.docx)
160
+ - 📧 Monthly PNA newsletter
161
+ - 🏫 [Institution plan — £99/mo]({})
162
+ """.format(STRIPE_INSTITUTION))
163
+ # Manual activation after Stripe payment
164
+ act_code = st.text_input("Activation code (from email after payment)")
165
+ if act_code and act_code.startswith("PNA-PRO-"):
166
+ st.session_state.subscription = "pro"
167
+ st.success("Pro activated! 🎉")
168
+ st.rerun()
169
+
170
+ elif sub in ("pro", "institution"):
171
+ label = "⭐ Pro" if sub == "pro" else "🏫 Institution"
172
+ st.success(f"{label} — Active")
173
+ if st.button("Manage subscription (Stripe)"):
174
+ st.markdown(f"[Open Stripe portal →]({STRIPE_PRO_LINK})")
175
+
176
+ st.divider()
177
+
178
+ # ── Nurse info (Pro only) ─────────────────────────────────────────────────
179
+ if sub in ("pro", "institution"):
180
+ st.markdown("#### 📋 Your Details")
181
+ st.session_state.nurse_name = st.text_input(
182
+ "Your name (for CPD records)",
183
+ value=st.session_state.nurse_name,
184
+ placeholder="Nurse Jane Smith",
185
+ )
186
+ cpd_hours = st.number_input(
187
+ "Session length (hours)", min_value=0.5, max_value=8.0,
188
+ value=1.0, step=0.5
189
+ )
190
+ st.divider()
191
+
192
+ # ── Session controls ──────────────────────────────────────────────────────
193
+ st.markdown("#### 🔄 Session")
194
+ col1, col2 = st.columns(2)
195
+ with col1:
196
+ if st.button("🗑️ Clear chat", use_container_width=True):
197
+ st.session_state.messages = []
198
+ st.session_state.session_started = None
199
+ st.rerun()
200
+ with col2:
201
+ if st.button("📋 New session", use_container_width=True):
202
+ st.session_state.messages = []
203
+ st.session_state.session_started = date.today().isoformat()
204
+ st.rerun()
205
+
206
+ # ── Exports (Pro only) ────────────────────────────────────────────────────
207
+ if sub in ("pro", "institution") and st.session_state.messages:
208
+ st.divider()
209
+ st.markdown("#### 📥 Export")
210
+
211
+ if st.button("📄 Generate supervision note", use_container_width=True):
212
+ if not st.session_state.api_key:
213
+ st.error("Please enter your API key first.")
214
+ else:
215
+ with st.spinner("Generating note with Claude…"):
216
+ note = generate_supervision_note(
217
+ st.session_state.api_key,
218
+ st.session_state.messages,
219
+ )
220
+ st.session_state["last_note"] = note
221
+
222
+ if "last_note" in st.session_state:
223
+ note = st.session_state["last_note"]
224
+ st.text_area("Supervision note preview", note, height=200)
225
+
226
+ if HAS_DOCX:
227
+ docx_bytes = build_supervision_docx(
228
+ note, st.session_state.nurse_name
229
+ )
230
+ if docx_bytes:
231
+ st.download_button(
232
+ "⬇️ Download as .docx",
233
+ data=docx_bytes,
234
+ file_name=f"PNA_Supervision_{date.today()}.docx",
235
+ mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
236
+ use_container_width=True,
237
+ )
238
+
239
+ cpd_bytes = build_cpd_docx(
240
+ note,
241
+ st.session_state.nurse_name,
242
+ cpd_hours if "cpd_hours" in dir() else 1.0,
243
+ )
244
+ if cpd_bytes:
245
+ st.download_button(
246
+ "⬇️ Download CPD Record (.docx)",
247
+ data=cpd_bytes,
248
+ file_name=f"NMC_CPD_{date.today()}.docx",
249
+ mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
250
+ use_container_width=True,
251
+ )
252
+ else:
253
+ st.download_button(
254
+ "⬇️ Download as .txt",
255
+ data=note,
256
+ file_name=f"PNA_Supervision_{date.today()}.txt",
257
+ mime="text/plain",
258
+ use_container_width=True,
259
+ )
260
+
261
+ st.divider()
262
+ st.markdown(
263
+ '<p class="ogl-notice">Contains public sector information licensed under the '
264
+ '<a href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/" '
265
+ 'target="_blank">Open Government Licence v3.0</a> — NHS England.</p>',
266
+ unsafe_allow_html=True,
267
+ )
268
+
269
+ # ─── Main content ─────────────────────────────────────────────────────────────
270
+
271
+ # Hero header
272
+ st.markdown("""
273
+ <div class="hero-header">
274
+ <h1>👨🏾‍⚕️ Professional Nurse Advocate Assistant</h1>
275
+ <p>Your AI guide to the A-EQUIP model, restorative supervision, and quality improvement</p>
276
+ <span class="pill">🇬🇧 NHS England · A-EQUIP Model · NMC Standards</span>
277
+ <span class="pill">OGL v3.0</span>
278
+ </div>
279
+ """, unsafe_allow_html=True)
280
+
281
+ # Welcome / quick start (only when no messages yet)
282
+ if not st.session_state.messages:
283
+ col1, col2, col3 = st.columns(3)
284
+ with col1:
285
+ st.markdown("""
286
+ **🌿 Restorative Supervision**
287
+ Start a reflective conversation to process the emotional impact of your clinical work.
288
+ """)
289
+ with col2:
290
+ st.markdown("""
291
+ **📚 A-EQUIP Learning**
292
+ Ask about any of the four A-EQUIP functions or explore the PNA role in depth.
293
+ """)
294
+ with col3:
295
+ st.markdown("""
296
+ **✅ PAQI Planning**
297
+ Get support developing your Personal Action for Quality Improvement.
298
+ """)
299
+
300
+ st.markdown("#### 💬 Try asking…")
301
+ examples = [
302
+ "What is the A-EQUIP model and why does it matter for nurses?",
303
+ "I've had a really difficult week on the ward. I don't know where to start.",
304
+ "How do I facilitate a restorative supervision session for the first time?",
305
+ "What does a PNA actually do day-to-day?",
306
+ "Help me reflect on a challenging patient interaction using Gibbs cycle.",
307
+ "What's the difference between the normative and formative functions?",
308
+ ]
309
+
310
+ cols = st.columns(2)
311
+ for i, ex in enumerate(examples):
312
+ with cols[i % 2]:
313
+ if st.button(ex, key=f"ex_{i}", use_container_width=True):
314
+ st.session_state.messages.append({"role": "user", "content": ex})
315
+ st.rerun()
316
+
317
+ # ─── Chat history ─────────────────────────────────────────────────────────────
318
+
319
+ for msg in st.session_state.messages:
320
+ with st.chat_message(msg["role"],
321
+ avatar="👩‍⚕️" if msg["role"] == "user" else "👨🏾‍⚕️"):
322
+ st.markdown(msg["content"])
323
+
324
+ # ─── Chat input ───────────────────────────────────────────────────────────────
325
+
326
+ prompt = st.chat_input(
327
+ "Ask me about A-EQUIP, restorative supervision, or the PNA role…",
328
+ disabled=not st.session_state.api_key,
329
+ )
330
+
331
+ if not st.session_state.api_key:
332
+ st.info(
333
+ "👈 Enter your Anthropic API key in the sidebar to start chatting. "
334
+ "[Get a free key →](https://console.anthropic.com)"
335
+ )
336
+
337
+ if prompt and st.session_state.api_key:
338
+ # Record session start time
339
+ if st.session_state.session_started is None:
340
+ st.session_state.session_started = date.today().isoformat()
341
+
342
+ # Add user message
343
+ st.session_state.messages.append({"role": "user", "content": prompt})
344
+ with st.chat_message("user", avatar="👩‍⚕️"):
345
+ st.markdown(prompt)
346
+
347
+ # Get RAG context
348
+ context = kb.search(prompt) if kb.index is not None else ""
349
+
350
+ # Stream response
351
+ with st.chat_message("assistant", avatar="👨🏾‍⚕️"):
352
+ response_placeholder = st.empty()
353
+ full_response = ""
354
+
355
+ try:
356
+ for chunk in stream_response(
357
+ api_key=st.session_state.api_key,
358
+ history=st.session_state.messages[:-1],
359
+ user_message=prompt,
360
+ context=context,
361
+ ):
362
+ full_response += chunk
363
+ response_placeholder.markdown(full_response + "▋")
364
+
365
+ response_placeholder.markdown(full_response)
366
+
367
+ except Exception as e:
368
+ err = str(e)
369
+ if "authentication" in err.lower() or "api_key" in err.lower():
370
+ full_response = "❌ Invalid API key. Please check your key in the sidebar."
371
+ elif "rate" in err.lower():
372
+ full_response = "⏳ Rate limit reached. Please wait a moment and try again."
373
+ else:
374
+ full_response = f"❌ An error occurred: {err}"
375
+ response_placeholder.error(full_response)
376
+
377
+ st.session_state.messages.append(
378
+ {"role": "assistant", "content": full_response}
379
+ )
380
+
381
+ # ─── Footer ──────────���────────────────────────────────────────────────────────
382
+
383
+ st.divider()
384
+ st.markdown("""
385
+ <div class="disclaimer">
386
+ ⚠️ <strong>Clinical Disclaimer:</strong> This tool is for educational purposes only.
387
+ It does not provide clinical advice, diagnosis, or treatment recommendations.
388
+ It is not a replacement for human supervision or your employer's formal clinical governance processes.
389
+ If you are experiencing a clinical emergency or safeguarding concern, follow your Trust's protocols immediately.
390
+ <br/><br/>
391
+ <span class="ogl-notice">
392
+ Contains public sector information licensed under the
393
+ <a href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/" target="_blank">Open Government Licence v3.0</a> — NHS England.
394
+ Built by <a href="https://nursingcitizendevelopment.com" target="_blank">Lincoln Gombedza</a> · CQAI ·
395
+ <a href="https://github.com/Clinical-Quality-Artifical-Intelligence/Professional-Nurse-Advocate-Assistant" target="_blank">Open Source</a>
396
+ </span>
397
+ </div>
398
+ """, unsafe_allow_html=True)
webhook.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PNA Assistant — Stripe Webhook Handler
3
+ POST /webhook → Stripe sends checkout.session.completed events here
4
+ → We generate a PNA-PRO-XXXXX code and email it to the customer
5
+
6
+ Run standalone: uvicorn webhook:app --host 0.0.0.0 --port 8080
7
+ In Docker: separate service in docker-compose.yml
8
+ """
9
+
10
+ import hashlib
11
+ import hmac
12
+ import json
13
+ import logging
14
+ import os
15
+ import random
16
+ import string
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ import boto3
21
+ from botocore.exceptions import ClientError
22
+ from fastapi import FastAPI, HTTPException, Request
23
+ from fastapi.responses import JSONResponse
24
+
25
+ # ─── Config ──────────────────────────────────────────────────────────────────
26
+
27
+ STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")
28
+ AWS_REGION = os.getenv("AWS_REGION", "eu-west-2")
29
+ SES_FROM_EMAIL = os.getenv("SES_FROM_EMAIL", "lincoln@clinyqai.com")
30
+
31
+ # Where we persist issued codes (append-only JSONL)
32
+ CODES_LOG = Path("/app/codes.jsonl")
33
+
34
+ logging.basicConfig(level=logging.INFO)
35
+ log = logging.getLogger("pna-webhook")
36
+
37
+ app = FastAPI(title="PNA Assistant Webhook", docs_url=None, redoc_url=None)
38
+
39
+ # ─── Helpers ─────────────────────────────────────────────────────────────────
40
+
41
+ def _generate_code(tier: str = "PRO") -> str:
42
+ """Generate a unique activation code e.g. PNA-PRO-A7K2M9"""
43
+ suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
44
+ return f"PNA-{tier}-{suffix}"
45
+
46
+
47
+ def _verify_stripe_signature(payload: bytes, sig_header: str, secret: str) -> bool:
48
+ """
49
+ Validate Stripe-Signature header.
50
+ Stripe uses HMAC-SHA256 with the raw request body.
51
+ """
52
+ if not secret:
53
+ log.warning("STRIPE_WEBHOOK_SECRET not set — skipping signature verification")
54
+ return True # dev mode only; set the secret in production
55
+
56
+ try:
57
+ parts = {k: v for part in sig_header.split(",") for k, v in [part.split("=", 1)]}
58
+ timestamp = parts.get("t", "")
59
+ v1_sig = parts.get("v1", "")
60
+ signed_payload = f"{timestamp}.".encode() + payload
61
+ expected = hmac.new(
62
+ secret.encode(),
63
+ signed_payload,
64
+ hashlib.sha256,
65
+ ).hexdigest()
66
+ return hmac.compare_digest(expected, v1_sig)
67
+ except Exception as exc:
68
+ log.error("Signature verification error: %s", exc)
69
+ return False
70
+
71
+
72
+ def _log_code(email: str, code: str, plan: str, session_id: str) -> None:
73
+ """Append issued code to local JSONL log for admin audit trail."""
74
+ CODES_LOG.parent.mkdir(parents=True, exist_ok=True)
75
+ record = {
76
+ "ts": datetime.now(timezone.utc).isoformat(),
77
+ "email": email,
78
+ "code": code,
79
+ "plan": plan,
80
+ "session_id": session_id,
81
+ }
82
+ with CODES_LOG.open("a") as f:
83
+ f.write(json.dumps(record) + "\n")
84
+ log.info("Code issued: %s → %s (%s)", email, code, plan)
85
+
86
+
87
+ def _send_activation_email(email: str, code: str, plan: str) -> bool:
88
+ """
89
+ Send activation code to customer via Amazon SES.
90
+ Returns True if sent, False on error.
91
+ """
92
+ label = "Pro" if plan == "PRO" else "Institution"
93
+ price = "£9.99/month" if plan == "PRO" else "£99/month"
94
+ body_html = f"""
95
+ <!DOCTYPE html>
96
+ <html>
97
+ <body style="font-family:Inter,sans-serif;max-width:600px;margin:0 auto;padding:2rem;color:#1e293b">
98
+ <div style="background:linear-gradient(135deg,#1a2460,#0d9488);padding:2rem;border-radius:12px;margin-bottom:2rem">
99
+ <h1 style="color:white;font-size:1.5rem;margin:0">👨🏾‍⚕️ PNA Assistant</h1>
100
+ <p style="color:rgba(255,255,255,0.85);margin:0.5rem 0 0">
101
+ Professional Nurse Advocate AI — {label} Plan
102
+ </p>
103
+ </div>
104
+
105
+ <p>Thank you for subscribing to PNA Assistant <strong>{label}</strong> ({price}).</p>
106
+
107
+ <p>Your activation code is:</p>
108
+
109
+ <div style="background:#f0fdf4;border:2px solid #0d9488;border-radius:12px;padding:1.5rem;text-align:center;margin:1.5rem 0">
110
+ <span style="font-family:monospace;font-size:1.75rem;font-weight:700;color:#0d9488;letter-spacing:0.1em">
111
+ {code}
112
+ </span>
113
+ </div>
114
+
115
+ <p><strong>How to activate:</strong></p>
116
+ <ol>
117
+ <li>Go to <a href="https://pna.nursingcitizendevelopment.com">pna.nursingcitizendevelopment.com</a></li>
118
+ <li>Open the sidebar and find <em>Subscription</em></li>
119
+ <li>Paste your code in the <em>Activation code</em> box</li>
120
+ <li>Click Enter — your {label} features will unlock immediately</li>
121
+ </ol>
122
+
123
+ <p>Your {label} plan includes:</p>
124
+ <ul>
125
+ <li>📄 Download supervision notes (.docx)</li>
126
+ <li>📋 NMC revalidation CPD log export (.docx)</li>
127
+ <li>💾 Save and resume sessions</li>
128
+ <li>📧 Monthly PNA newsletter</li>
129
+ {"<li>🏫 Multi-user access</li><li>🎨 Custom branding</li>" if plan == "INST" else ""}
130
+ </ul>
131
+
132
+ <p style="font-size:0.875rem;color:#64748b">
133
+ If you have any questions, reply to this email or contact
134
+ <a href="mailto:lincoln@clinyqai.com">lincoln@clinyqai.com</a>.<br><br>
135
+ This tool is for educational purposes only and does not replace clinical supervision
136
+ or your employer's formal governance processes.<br><br>
137
+ Contains public sector information licensed under the
138
+ <a href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/">
139
+ Open Government Licence v3.0
140
+ </a> — NHS England.
141
+ </p>
142
+ </body>
143
+ </html>
144
+ """
145
+ body_text = (
146
+ f"Thank you for subscribing to PNA Assistant {label} ({price}).\n\n"
147
+ f"Your activation code is: {code}\n\n"
148
+ "To activate:\n"
149
+ "1. Go to pna.nursingcitizendevelopment.com\n"
150
+ "2. Open the sidebar → Subscription\n"
151
+ "3. Paste your code in the Activation code box\n\n"
152
+ "Questions? Email lincoln@clinyqai.com"
153
+ )
154
+
155
+ try:
156
+ ses = boto3.client("ses", region_name=AWS_REGION)
157
+ ses.send_email(
158
+ Source = f"PNA Assistant <{SES_FROM_EMAIL}>",
159
+ Destination = {"ToAddresses": [email]},
160
+ Message = {
161
+ "Subject": {"Data": f"Your PNA Assistant {label} activation code 🎉"},
162
+ "Body": {
163
+ "Text": {"Data": body_text},
164
+ "Html": {"Data": body_html},
165
+ },
166
+ },
167
+ )
168
+ log.info("Activation email sent to %s", email)
169
+ return True
170
+ except ClientError as exc:
171
+ log.error("SES send failed: %s", exc.response["Error"]["Message"])
172
+ return False
173
+ except Exception as exc:
174
+ log.error("Email error: %s", exc)
175
+ return False
176
+
177
+
178
+ # ─── Routes ──────────────────────────────────────────────────────────────────
179
+
180
+ @app.get("/health")
181
+ async def health():
182
+ return {"status": "ok"}
183
+
184
+
185
+ @app.post("/webhook")
186
+ async def stripe_webhook(request: Request):
187
+ payload = await request.body()
188
+ sig_header = request.headers.get("Stripe-Signature", "")
189
+
190
+ # 1. Verify Stripe signature
191
+ if not _verify_stripe_signature(payload, sig_header, STRIPE_WEBHOOK_SECRET):
192
+ log.warning("Invalid Stripe signature — rejected")
193
+ raise HTTPException(status_code=400, detail="Invalid signature")
194
+
195
+ # 2. Parse event
196
+ try:
197
+ event = json.loads(payload)
198
+ except json.JSONDecodeError:
199
+ raise HTTPException(status_code=400, detail="Invalid JSON")
200
+
201
+ event_type = event.get("type", "")
202
+ log.info("Received Stripe event: %s", event_type)
203
+
204
+ # 3. Handle checkout completion
205
+ if event_type == "checkout.session.completed":
206
+ session = event["data"]["object"]
207
+ email = session.get("customer_details", {}).get("email") or session.get("customer_email")
208
+ amount = session.get("amount_total", 0) # pence/cents
209
+ session_id = session.get("id", "unknown")
210
+
211
+ if not email:
212
+ log.warning("No email in session %s — cannot send code", session_id)
213
+ return JSONResponse({"received": True, "warning": "no_email"})
214
+
215
+ # Determine tier from amount (999 = £9.99, 9900 = £99.00)
216
+ tier = "INST" if amount >= 9900 else "PRO"
217
+ code = _generate_code(tier)
218
+
219
+ # 4. Log the code (always)
220
+ _log_code(email, code, tier, session_id)
221
+
222
+ # 5. Email the customer
223
+ sent = _send_activation_email(email, code, tier)
224
+ if not sent:
225
+ log.warning(
226
+ "SES send failed for %s — code %s logged to %s",
227
+ email, code, CODES_LOG,
228
+ )
229
+
230
+ return JSONResponse({"received": True})