Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added 26.0.1
Empty file.
77 changes: 77 additions & 0 deletions SETUP_CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# PaCo — Local Setup Checklist

Things a new developer needs to run PaCo locally. The environment (Python 3.12 venv +
dependencies) is already built and verified on this machine; what remains is supplying
secrets that are **not** stored in the repo.

## 🔴 Required — backend will not start without these

| Item | Env variable | Where to get it |
|------|-------------|-----------------|
| Database connection string (incl. password) | `DATABASE_URL` | Render dashboard → `paco-api` service → Environment, **or** the Neon console. The existing study DB is `ep-wandering-sea-a5xid0o5.us-east-2.aws.neon.tech/neondb` (user `neondb_owner`). |
| Groq API key | `GROQ_API_KEY` | https://console.groq.com |
| Gemini API key | `GEMINI_API_KEY` | https://aistudio.google.com/apikey |
| OpenAI API key | `OPENAI_API_KEY` | https://platform.openai.com/api-keys |

> At least one LLM key is mandatory; this project uses all three (fallback order: Gemini → OpenAI → Groq).

## 🟡 Required for voice features

| Item | Env variable | Notes |
|------|-------------|-------|
| ElevenLabs API key | `ELEVENLABS_API_KEY` (backend) + `NEXT_PUBLIC_ELEVENLABS_API_KEY` (frontend) | https://elevenlabs.io |
| ElevenLabs Agent ID | `ELEVENLABS_AGENT_ID` (backend) + `NEXT_PUBLIC_ELEVENLABS_AGENT_ID` (frontend) | The Conversational AI agent for this study |

## 🟢 Account/login access to request

- **Render** — backend host + production env vars (and the `DATABASE_URL`)
- **Vercel** — frontend host + its env vars
- **Neon** — the database (connection string, backups, SQL access)
- **ElevenLabs** — to view/manage the conversational agent
- (Optional) the production `SECRET_KEY`, only if you need local JWTs to match production

## ⚪ You do NOT need to ask anyone for these

- `SECRET_KEY` — generate locally: `python -c "import secrets; print(secrets.token_urlsafe(32))"`
- `ADMIN_PASSWORD` — pick any value (used by the admin API)
- `ALGORITHM`, token expiry, `CORS_ORIGINS`, ElevenLabs voice/model IDs — defaults exist
- Research login IDs (RID001–RID005) — seeded into the DB by `scripts/seed_research_ids.py`

## ❓ Ask the team explicitly

> Is there a separate **dev/staging database**, or is `ep-wandering-sea-a5xid0o5` the **live study database**?

If it's the live DB with real research data, do **not** point local dev at it (migrations/seed
scripts could modify it). Request a dev database or permission to create a fresh empty one.

---

## Repository fixes already applied

`paco-api/requirements.txt` was internally broken (the multi-LLM commit added Gemini/OpenAI
providers the app imports on startup, but their packages and a compatible `httpx` were missing).
Fixed on this machine:

- `httpx==0.27.0` → `httpx>=0.28.1` (required by `google-genai`)
- `websockets==12.0` → `websockets>=13.0` (required by `google-genai`)
- `groq==0.5.0` → `groq>=0.13.0` (old groq breaks on httpx ≥ 0.28: `proxies` kwarg removed)
- added `openai>=1.30.0` and `google-genai>=1.0.0` (imported by the code, previously undeclared)

These changes should be committed and reflected in the Render build.

## Run order (once secrets are in `.env`)

```powershell
cd paco-api
# .venv already created with Python 3.12
.\.venv\Scripts\Activate.ps1
$env:PYTHONIOENCODING="utf-8" # Windows: avoids emoji-in-print crash at startup
alembic upgrade head # create tables
python scripts\seed_research_ids.py
uvicorn app.main:app --reload --port 8000

# frontend (separate terminal)
cd ..\paco-frontend
npm install
npm run dev # http://localhost:3000
```
Empty file added httpx)
Empty file.
161 changes: 104 additions & 57 deletions paco-api/app/api/endpoints/medication_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,61 +11,102 @@
from app.schemas.medication_analysis import (
AnalysisRequest,
AnalysisResponse,
AnalysisResult,
QuilamAnalysisResult,
QuilamDomain,
QuilamDomains,
AnalysisHistoryResponse,
AnalysisHistoryItem,
MedicationInfo,
TimingSchedule,
SideEffect,
AdherenceDifficulty,
AdherenceStrategy,
QuestionConcern,
OverallAdherence
AnalysisHistoryItem
)
from app.services.medication_analysis_service import medication_analysis_service
from app.core.security import verify_admin_password

router = APIRouter()


def parse_analysis_result(detailed_analysis: str) -> AnalysisResult:
"""Parse the detailed analysis JSON into structured format"""
VALID_FLAGS = {"surfaced", "not surfaced", "concern flagged"}


def parse_domain(domains_data: dict, key: str) -> QuilamDomain:
"""Parse a single QUILAM domain, tolerant of LLM output variance.

Handles missing keys, explicit nulls, wrong-typed values, and flag
casing/whitespace. A malformed individual domain degrades to a single
error domain rather than discarding the whole analysis.
"""
try:
d = domains_data.get(key)
if not isinstance(d, dict):
d = {}

raw_flag = d.get("flag") or "not surfaced"
flag = raw_flag.lower().strip() if isinstance(raw_flag, str) else "not surfaced"
if flag not in VALID_FLAGS:
flag = "not surfaced"

finding = d.get("finding") or "Not discussed"
if not isinstance(finding, str):
finding = str(finding)

details = d.get("details") or []
if not isinstance(details, list):
details = [str(details)]
else:
details = [str(item) for item in details]

return QuilamDomain(finding=finding, flag=flag, details=details)
except (ValueError, TypeError, AttributeError):
return QuilamDomain(finding="Could not parse this domain.", flag="not surfaced", details=[])


def parse_analysis_result(detailed_analysis: str) -> QuilamAnalysisResult:
"""Parse the QUILAM analysis JSON into structured format"""
try:
data = json.loads(detailed_analysis)

return AnalysisResult(
medications=[MedicationInfo(**med) for med in data.get("medications", [])],
timing_schedule=TimingSchedule(**data.get("timing_schedule", {})),
side_effects=[SideEffect(**se) for se in data.get("side_effects", [])],
adherence_difficulties=[
AdherenceDifficulty(**diff) for diff in data.get("adherence_difficulties", [])
],
adherence_strategies=[
AdherenceStrategy(**strat) for strat in data.get("adherence_strategies", [])
],
questions_concerns=[
QuestionConcern(**qc) for qc in data.get("questions_concerns", [])
],
overall_adherence=OverallAdherence(**data.get("overall_adherence", {})),
confidence_score=data.get("confidence_score", 0),
summary=data.get("summary", ""),
key_concerns=data.get("key_concerns", []),
recommendations=data.get("recommendations", [])
if not isinstance(data, dict):
raise ValueError("Top-level analysis JSON is not an object")

domains_data = data.get("domains")
if not isinstance(domains_data, dict):
domains_data = {}

try:
confidence_score = int(data.get("confidence_score", 0))
except (ValueError, TypeError):
confidence_score = 0
confidence_score = max(0, min(100, confidence_score))

key_concerns = data.get("key_concerns") or []
if not isinstance(key_concerns, list):
key_concerns = [str(key_concerns)]
else:
key_concerns = [str(item) for item in key_concerns]

overall_summary = data.get("overall_summary") or ""
if not isinstance(overall_summary, str):
overall_summary = str(overall_summary)

return QuilamAnalysisResult(
domains=QuilamDomains(
general_beliefs=parse_domain(domains_data, "general_beliefs"),
self_management=parse_domain(domains_data, "self_management"),
specific_beliefs=parse_domain(domains_data, "specific_beliefs"),
provider_relationship=parse_domain(domains_data, "provider_relationship")
),
overall_summary=overall_summary,
key_concerns=key_concerns,
confidence_score=confidence_score
)
except (json.JSONDecodeError, ValueError) as e:
# Return a minimal result if parsing fails
return AnalysisResult(
medications=[],
timing_schedule=TimingSchedule(),
side_effects=[],
adherence_difficulties=[],
adherence_strategies=[],
questions_concerns=[],
overall_adherence=OverallAdherence(),
confidence_score=0,
summary="Error parsing analysis results. Check detailed_analysis field.",
except (json.JSONDecodeError, ValueError, TypeError):
return QuilamAnalysisResult(
domains=QuilamDomains(
general_beliefs=QuilamDomain(finding="Error parsing analysis results.", flag="not surfaced"),
self_management=QuilamDomain(finding="Error parsing analysis results.", flag="not surfaced"),
specific_beliefs=QuilamDomain(finding="Error parsing analysis results.", flag="not surfaced"),
provider_relationship=QuilamDomain(finding="Error parsing analysis results.", flag="not surfaced")
),
overall_summary="Error parsing analysis results. Check detailed_analysis field.",
key_concerns=["Analysis parsing error"],
recommendations=["Re-run analysis"]
confidence_score=0
)


Expand Down Expand Up @@ -157,21 +198,27 @@ async def get_analysis_history(
limit=limit
)

# Format response
history_items = [
AnalysisHistoryItem(
analysis_id=analysis.id,
analysis_date=analysis.analysis_date,
analyzed_from=analysis.analyzed_from,
analyzed_to=analysis.analyzed_to,
conversation_count=analysis.conversation_count,
confidence_score=analysis.confidence_score,
summary=analysis.summary,
is_taking_medications=analysis.is_taking_medications,
taking_as_prescribed=analysis.taking_as_prescribed
# Format response — surface each QUILAM domain's flag per analysis
history_items = []
for analysis in analyses:
result = parse_analysis_result(analysis.detailed_analysis)
history_items.append(
AnalysisHistoryItem(
analysis_id=analysis.id,
analysis_date=analysis.analysis_date,
analyzed_from=analysis.analyzed_from,
analyzed_to=analysis.analyzed_to,
conversation_count=analysis.conversation_count,
confidence_score=analysis.confidence_score,
summary=analysis.summary,
domain_flags={
"general_beliefs": result.domains.general_beliefs.flag,
"self_management": result.domains.self_management.flag,
"specific_beliefs": result.domains.specific_beliefs.flag,
"provider_relationship": result.domains.provider_relationship.flag,
},
)
)
for analysis in analyses
]

return AnalysisHistoryResponse(
research_id=research_id,
Expand Down
18 changes: 10 additions & 8 deletions paco-api/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from pydantic_settings import BaseSettings
from pydantic import field_validator
from functools import lru_cache
from typing import List, Union
from typing import List, Optional, Union


class Settings(BaseSettings):
Expand All @@ -16,27 +16,29 @@ class Settings(BaseSettings):
API_V1_PREFIX: str = "/api/v1"

# Security
SECRET_KEY: str
SECRET_KEY: Optional[str] = None
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 24 hours

# Database
DATABASE_URL: str
DATABASE_URL: Optional[str] = None

# LLM API Keys
GROQ_API_KEY: str # Primary LLM provider
OPENROUTER_API_KEY: str = "" # Optional alternative
GROQ_API_KEY: Optional[str] = None # Primary LLM provider
OPENROUTER_API_KEY: Optional[str] = None # Optional alternative
GEMINI_API_KEY: Optional[str] = None
OPENAI_API_KEY: Optional[str] = None

# ElevenLabs
ELEVENLABS_API_KEY: str
ELEVENLABS_API_KEY: Optional[str] = None
ELEVENLABS_VOICE_ID: str = "9BWtsMINqrJLrRacOk9x" # Aria voice
ELEVENLABS_MODEL_ID: str = "eleven_multilingual_v2"

# CORS - accepts comma-separated string or list
CORS_ORIGINS: Union[str, List[str]] = "http://localhost:3000,http://localhost:5173,https://paco.vercel.app"

# Admin
ADMIN_PASSWORD: str = ""
ADMIN_PASSWORD: Optional[str] = None

# Research IDs (used by seed script only)
RESEARCH_IDS: str = ""
Expand All @@ -57,4 +59,4 @@ class Config:
@lru_cache()
def get_settings() -> Settings:
"""Cached settings instance"""
return Settings()
return Settings()
Loading