From c0b7bd580eca451f6c207ab675bb2b25b5af514b Mon Sep 17 00:00:00 2001 From: "Neon:ryan" Date: Tue, 12 May 2026 10:08:00 -0600 Subject: [PATCH 01/12] Frontend made for turning on and off advisors --- .../src/components/AdvisorStatusDropdown.js | 61 +++++++---- .../src/components/SettingsModal.js | 100 +++++++++++++++++- phd-advisor-frontend/src/components/Toggle.js | 65 ++++++++++++ .../src/contexts/AppConfigContext.js | 20 ++++ 4 files changed, 224 insertions(+), 22 deletions(-) create mode 100644 phd-advisor-frontend/src/components/Toggle.js diff --git a/phd-advisor-frontend/src/components/AdvisorStatusDropdown.js b/phd-advisor-frontend/src/components/AdvisorStatusDropdown.js index d1d5663d..49d7f2bd 100644 --- a/phd-advisor-frontend/src/components/AdvisorStatusDropdown.js +++ b/phd-advisor-frontend/src/components/AdvisorStatusDropdown.js @@ -1,11 +1,14 @@ import React, { useState, useEffect } from 'react'; import { Users, ChevronDown, Pencil } from 'lucide-react'; import AvatarPickerModal from './AvatarPickerModal'; +import Toggle from './Toggle'; +import { useAppConfig } from '../contexts/AppConfigContext'; const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, isDark }) => { const [isOpen, setIsOpen] = useState(false); const [hoveredId, setHoveredId] = useState(null); const [pickerAdvisor, setPickerAdvisor] = useState(null); + const { isAdvisorEnabled, setAdvisorEnabled } = useAppConfig(); // Close dropdown when clicking outside useEffect(() => { @@ -24,10 +27,11 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i } const advisorEntries = Object.entries(advisors); - const thinkingCount = Array.isArray(thinkingAdvisors) - ? thinkingAdvisors.filter(id => id !== 'system').length + const thinkingCount = Array.isArray(thinkingAdvisors) + ? thinkingAdvisors.filter(id => id !== 'system').length : 0; const totalAdvisors = advisorEntries.length; + const enabledCount = advisorEntries.filter(([id]) => isAdvisorEnabled(id)).length; const handleToggle = () => { setIsOpen(!isOpen); @@ -42,7 +46,7 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i
- {totalAdvisors} Advisor{totalAdvisors !== 1 ? 's' : ''} + {enabledCount} of {totalAdvisors} Advisor{totalAdvisors !== 1 ? 's' : ''} {thinkingCount > 0 && (
@@ -67,11 +71,12 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i const IconComponent = advisor.icon; const colors = getAdvisorColors(id, isDark); const isThinking = Array.isArray(thinkingAdvisors) && thinkingAdvisors.includes(id); - + const enabled = isAdvisorEnabled(id); + return (
{advisor.name}
-
{advisor.description}
-
-
- {isThinking ? ( -
-
-
-
-
-
-
- ) : ( -
Ready
- )} +
+ {!enabled + ? Off — won't reply + : isThinking + ? Thinking… + : advisor.description} +
+ setAdvisorEnabled(id, next)} + size="sm" + label={`Toggle ${advisor.name}`} + />
); })} @@ -240,6 +244,25 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i .advisor-item.thinking { background: var(--advisor-bg); } + + .advisor-item.disabled .advisor-icon, + .advisor-item.disabled .advisor-name { + opacity: 0.45; + } + + .advisor-item.disabled .advisor-description { + opacity: 0.7; + } + + .advisor-off-label { + color: var(--text-tertiary, #9ca3af); + font-style: italic; + } + + .advisor-thinking-label { + color: var(--advisor-color); + font-weight: 500; + } .advisor-icon { width: 32px; diff --git a/phd-advisor-frontend/src/components/SettingsModal.js b/phd-advisor-frontend/src/components/SettingsModal.js index 5d418613..d9aabf29 100644 --- a/phd-advisor-frontend/src/components/SettingsModal.js +++ b/phd-advisor-frontend/src/components/SettingsModal.js @@ -1,6 +1,8 @@ import React, { useState, useRef } from 'react'; import ReactDOM from 'react-dom'; -import { X, User as UserIcon, Lock, Trash2, AlertTriangle } from 'lucide-react'; +import { X, User as UserIcon, Lock, Trash2, AlertTriangle, Users } from 'lucide-react'; +import Toggle from './Toggle'; +import { useAppConfig } from '../contexts/AppConfigContext'; const overlay = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', @@ -54,8 +56,20 @@ const dangerBtn = { cursor: 'pointer', fontSize: 14, fontWeight: 500, }; +const miniBtn = { + background: 'transparent', + border: '1px solid var(--border-primary)', + color: 'var(--text-secondary)', + fontSize: 12, + padding: '5px 10px', + borderRadius: 6, + cursor: 'pointer', + fontFamily: 'inherit', +}; + const SettingsModal = ({ user, authToken, onUserUpdate, onSignOut, onClose }) => { const [activeTab, setActiveTab] = useState('profile'); + const { advisors, isAdvisorEnabled, setAdvisorEnabled } = useAppConfig(); // Track where the mouse went DOWN so we don't close the modal when a user // drags to select text inside an input and the mouseup happens outside the modal. @@ -224,12 +238,18 @@ const SettingsModal = ({ user, authToken, onUserUpdate, onSignOut, onClose }) => }`, }); + const advisorEntries = Object.entries(advisors || {}); + const enabledCount = advisorEntries.filter(([id]) => isAdvisorEnabled(id)).length; + const setAll = (enabled) => { + advisorEntries.forEach(([id]) => setAdvisorEnabled(id, enabled)); + }; + return ReactDOM.createPortal(
-

Account Settings

-
@@ -241,6 +261,9 @@ const SettingsModal = ({ user, authToken, onUserUpdate, onSignOut, onClose }) => + @@ -291,6 +314,77 @@ const SettingsModal = ({ user, authToken, onUserUpdate, onSignOut, onClose }) => )} + {activeTab === 'advisors' && ( + <> +
+
+
+ Active advisors +
+
+ {enabledCount} of {advisorEntries.length} active · turn an advisor off to keep them out of your conversations +
+
+
+ + +
+
+ +
+ {advisorEntries.length === 0 && ( +
+ No advisors configured. +
+ )} + {advisorEntries.map(([id, advisor]) => { + const IconComponent = advisor.icon; + const enabled = isAdvisorEnabled(id); + return ( +
+
+ {advisor.avatarUrl + ? {advisor.name} + : } +
+
+
+ {advisor.name} +
+
+ {advisor.description || advisor.role || ''} +
+
+ setAdvisorEnabled(id, next)} + label={`Toggle ${advisor.name}`} + /> +
+ ); + })} +
+ + )} + {activeTab === 'danger' && (
void + * size — 'sm' (32×18) | 'md' (38×22, default) + * disabled — boolean + * label — optional aria-label for screen readers + */ +const Toggle = ({ checked, onChange, size = 'md', disabled = false, label }) => { + const dims = size === 'sm' + ? { w: 32, h: 18, knob: 14, off: 2, on: 16 } + : { w: 38, h: 22, knob: 18, off: 2, on: 18 }; + + const handleClick = (e) => { + e.stopPropagation(); + if (!disabled) onChange(!checked); + }; + + return ( + + ); +}; + +export default Toggle; diff --git a/phd-advisor-frontend/src/contexts/AppConfigContext.js b/phd-advisor-frontend/src/contexts/AppConfigContext.js index c68bfa31..fb0f0509 100644 --- a/phd-advisor-frontend/src/contexts/AppConfigContext.js +++ b/phd-advisor-frontend/src/contexts/AppConfigContext.js @@ -89,6 +89,12 @@ export const AppConfigProvider = ({ children }) => { try { return JSON.parse(localStorage.getItem('myCustomAvatars') || '[]'); } catch { return []; } }); + // Per-user enable/disable for each advisor. Missing key = enabled by default + // so new advisors light up automatically when added on the backend. + const [disabledAdvisors, setDisabledAdvisors] = useState(() => { + try { return JSON.parse(localStorage.getItem('disabledAdvisors') || '{}'); } + catch { return {}; } + }); useEffect(() => { const fetchConfig = async () => { @@ -125,6 +131,17 @@ export const AppConfigProvider = ({ children }) => { localStorage.setItem('myCustomAvatars', JSON.stringify(next)); }; + // Advisor enable/disable. Disabled advisors are filtered out of orchestrator + // calls (TODO backend wiring) and visually dimmed in the UI. + const isAdvisorEnabled = (id) => !disabledAdvisors[id]; + const setAdvisorEnabled = (id, enabled) => { + const next = { ...disabledAdvisors }; + if (enabled) delete next[id]; + else next[id] = true; + setDisabledAdvisors(next); + localStorage.setItem('disabledAdvisors', JSON.stringify(next)); + }; + // Inject the primary colour as a CSS custom property on so it is // available everywhere without prop-drilling. useEffect(() => { @@ -156,6 +173,9 @@ export const AppConfigProvider = ({ children }) => { setAdvisorAvatar, addMyAvatar, myCustomAvatars, + disabledAdvisors, + isAdvisorEnabled, + setAdvisorEnabled, }; if (loading) { From 1299383db445a65a1e4beb3ae085d24c316d67f5 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Wed, 13 May 2026 11:52:40 -0700 Subject: [PATCH 02/12] added system-level whitelist to config. --- .gitignore | 5 ++- multi_llm_chatbot_backend/app/config.py | 9 +++++ .../app/tests/unit/test_persona_config.py | 39 +++++++++++++++++++ phd_config.yaml | 6 +++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8f7973cf..9d871b02 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,7 @@ phd-advisor-frontend/firebase.json # Python virtual environments **/venv/ -.venv/ \ No newline at end of file +.venv/ + +# Cursor docs +.cursor/ \ No newline at end of file diff --git a/multi_llm_chatbot_backend/app/config.py b/multi_llm_chatbot_backend/app/config.py index 25c0a1a9..c685d0ad 100644 --- a/multi_llm_chatbot_backend/app/config.py +++ b/multi_llm_chatbot_backend/app/config.py @@ -169,6 +169,15 @@ class PersonasConfig(BaseModel): personas_dir: str = "" config_dir: str = "" items: List[PersonaItemConfig] = [] + allowed_advisors: Optional[List[str]] = None + + @model_validator(mode='after') + def _warn_empty_allowed_advisors(self): + if self.allowed_advisors is not None and len(self.allowed_advisors) == 0: + logger.warning( + "allowed_advisors is set to an empty list; no advisors will be available" + ) + return self @model_validator(mode='after') def _load_personas_from_directory(self): diff --git a/multi_llm_chatbot_backend/app/tests/unit/test_persona_config.py b/multi_llm_chatbot_backend/app/tests/unit/test_persona_config.py index 1762bfa1..6dc96238 100644 --- a/multi_llm_chatbot_backend/app/tests/unit/test_persona_config.py +++ b/multi_llm_chatbot_backend/app/tests/unit/test_persona_config.py @@ -63,6 +63,45 @@ def test_uses_personas_dir(self): ids = {p.id for p in settings.personas.items} self.assertEqual(ids, {"one", "two"}) + def test_allowed_advisors_defaults_to_none(self): + cfg_path = _write_config(self.tmp_path, { + "personas": { + "items": [ + {"id": "a", "name": "A"}, + ] + } + }) + settings = load_settings(cfg_path) + self.assertIsNone(settings.personas.allowed_advisors) + + def test_allowed_advisors_populated(self): + cfg_path = _write_config(self.tmp_path, { + "personas": { + "allowed_advisors": ["one", "two"], + "items": [ + {"id": "one", "name": "One"}, + ] + } + }) + settings = load_settings(cfg_path) + self.assertEqual(settings.personas.allowed_advisors, ["one", "two"]) + + def test_allowed_advisors_empty_list_warns(self): + cfg_path = _write_config(self.tmp_path, { + "personas": { + "allowed_advisors": [], + "items": [ + {"id": "a", "name": "A"}, + ] + } + }) + with self.assertLogs("app.config", level="WARNING") as cm: + settings = load_settings(cfg_path) + self.assertEqual(settings.personas.allowed_advisors, []) + self.assertTrue( + any("allowed_advisors is set to an empty list" in msg for msg in cm.output) + ) + def test_bad_persona_does_not_crash_everything(self): """Validates that a bad persona in the inline items list causes a validation error -- the directory loader solves this for file-based configs.""" diff --git a/phd_config.yaml b/phd_config.yaml index 37a5adbd..3db3ba30 100644 --- a/phd_config.yaml +++ b/phd_config.yaml @@ -102,6 +102,12 @@ personas: # Individual persona files are loaded from this directory (relative to this file). personas_dir: "personas/phd_advisors" + # Optional whitelist of advisor IDs. When set, only these advisors are + # available. Omit or leave unset to allow all enabled personas. + # allowed_advisors: + # - "pragmatist" + # - "theorist" + # ── Orchestrator / Clarification ─────────────────────────────────────────── orchestrator: From 41f56bb2f10ee2fb10e2aaebb9f5476ec958498d Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Wed, 13 May 2026 12:07:55 -0700 Subject: [PATCH 03/12] added persona filtering function. --- .../app/core/persona_filter.py | 31 ++++++++++ .../app/tests/unit/test_persona_filter.py | 57 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 multi_llm_chatbot_backend/app/core/persona_filter.py create mode 100644 multi_llm_chatbot_backend/app/tests/unit/test_persona_filter.py diff --git a/multi_llm_chatbot_backend/app/core/persona_filter.py b/multi_llm_chatbot_backend/app/core/persona_filter.py new file mode 100644 index 00000000..3a8bf9d1 --- /dev/null +++ b/multi_llm_chatbot_backend/app/core/persona_filter.py @@ -0,0 +1,31 @@ +from typing import List, Optional + + +def get_available_persona_ids( + registered_ids: List[str], + system_allowed: Optional[List[str]] = None, + user_disabled: Optional[List[str]] = None, +) -> List[str]: + """Return the persona IDs available after applying all filtering layers. + + Filtering is applied in order: + 1. System whitelist (``system_allowed``) — if not None, only IDs + present in this list survive. ``None`` means no restriction. + 2. User blocklist (``user_disabled``) — if not None, these IDs are + removed. ``None`` means no user overrides. + + The order of *registered_ids* is preserved in the result so that + downstream fallback logic (e.g. first-K when LLM ranking fails) + remains deterministic. + """ + ids = list(registered_ids) + + if system_allowed is not None: + allowed_set = set(system_allowed) + ids = [pid for pid in ids if pid in allowed_set] + + if user_disabled is not None: + disabled_set = set(user_disabled) + ids = [pid for pid in ids if pid not in disabled_set] + + return ids diff --git a/multi_llm_chatbot_backend/app/tests/unit/test_persona_filter.py b/multi_llm_chatbot_backend/app/tests/unit/test_persona_filter.py new file mode 100644 index 00000000..40dfe24b --- /dev/null +++ b/multi_llm_chatbot_backend/app/tests/unit/test_persona_filter.py @@ -0,0 +1,57 @@ +import unittest +from app.core.persona_filter import get_available_persona_ids + +ALL_IDS = ["pragmatist", "theorist", "methodologist", "mentor", "critic"] + + +class TestGetAvailablePersonaIds(unittest.TestCase): + + def test_no_filters_returns_all(self): + """None for both filters means no restrictions.""" + result = get_available_persona_ids(ALL_IDS) + self.assertEqual(result, ALL_IDS) + + def test_system_whitelist_filters(self): + result = get_available_persona_ids( + ALL_IDS, system_allowed=["theorist", "critic"] + ) + self.assertEqual(result, ["theorist", "critic"]) + + def test_user_disabled_filters(self): + result = get_available_persona_ids( + ALL_IDS, user_disabled=["mentor", "critic"] + ) + self.assertEqual(result, ["pragmatist", "theorist", "methodologist"]) + + def test_both_layers_cascade(self): + """System narrows first, then user narrows further.""" + result = get_available_persona_ids( + ALL_IDS, + system_allowed=["pragmatist", "theorist", "methodologist"], + user_disabled=["theorist"], + ) + self.assertEqual(result, ["pragmatist", "methodologist"]) + + def test_unknown_ids_in_user_disabled_ignored(self): + result = get_available_persona_ids( + ALL_IDS, user_disabled=["nonexistent", "also_fake"] + ) + self.assertEqual(result, ALL_IDS) + + def test_all_filtered_returns_empty(self): + result = get_available_persona_ids( + ALL_IDS, system_allowed=["theorist"], user_disabled=["theorist"] + ) + self.assertEqual(result, []) + + def test_order_preserved(self): + """Result order matches registered_ids, not system_allowed.""" + result = get_available_persona_ids( + ALL_IDS, system_allowed=["critic", "pragmatist"] + ) + self.assertEqual(result, ["pragmatist", "critic"]) + + def test_system_allowed_empty_list_allows_none(self): + """An explicit empty whitelist means no advisors are allowed.""" + result = get_available_persona_ids(ALL_IDS, system_allowed=[]) + self.assertEqual(result, []) From 15ae7f29e2a17209092d44853fe69fcb9d19ac95 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Wed, 13 May 2026 12:32:32 -0700 Subject: [PATCH 04/12] added per-user advisor preferences with API endpoints. --- .../app/api/routes/preferences.py | 66 +++++++++++++++++++ multi_llm_chatbot_backend/app/main.py | 2 + multi_llm_chatbot_backend/app/models/user.py | 1 + 3 files changed, 69 insertions(+) create mode 100644 multi_llm_chatbot_backend/app/api/routes/preferences.py diff --git a/multi_llm_chatbot_backend/app/api/routes/preferences.py b/multi_llm_chatbot_backend/app/api/routes/preferences.py new file mode 100644 index 00000000..7a22692a --- /dev/null +++ b/multi_llm_chatbot_backend/app/api/routes/preferences.py @@ -0,0 +1,66 @@ +import logging +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel + +from app.config import get_settings +from app.core.auth import get_current_active_user +from app.core.bootstrap import chat_orchestrator +from app.core.database import get_database +from app.core.persona_filter import get_available_persona_ids +from app.models.user import User + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class AdvisorPreferencesRequest(BaseModel): + disabled_advisors: Optional[List[str]] = None + + +class AdvisorPreferencesResponse(BaseModel): + disabled_advisors: Optional[List[str]] = None + available_advisors: List[str] = [] + + +def _build_response(user: User) -> AdvisorPreferencesResponse: + available = get_available_persona_ids( + registered_ids=chat_orchestrator.list_personas(), + system_allowed=get_settings().personas.allowed_advisors, + ) + return AdvisorPreferencesResponse( + disabled_advisors=user.disabled_advisors, + available_advisors=available, + ) + + +@router.get("/me/advisor-preferences", response_model=AdvisorPreferencesResponse) +async def get_advisor_preferences( + current_user: User = Depends(get_current_active_user), +): + return _build_response(current_user) + + +@router.put("/me/advisor-preferences", response_model=AdvisorPreferencesResponse) +async def update_advisor_preferences( + body: AdvisorPreferencesRequest, + current_user: User = Depends(get_current_active_user), +): + if body.disabled_advisors is not None: + known_ids = set(chat_orchestrator.list_personas()) + unknown = [aid for aid in body.disabled_advisors if aid not in known_ids] + if unknown: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unknown advisor IDs: {unknown}", + ) + + db = get_database() + await db.users.update_one( + {"_id": current_user.id}, + {"$set": {"disabled_advisors": body.disabled_advisors}}, + ) + current_user.disabled_advisors = body.disabled_advisors + return _build_response(current_user) diff --git a/multi_llm_chatbot_backend/app/main.py b/multi_llm_chatbot_backend/app/main.py index 3f366cb0..ef62eb13 100644 --- a/multi_llm_chatbot_backend/app/main.py +++ b/multi_llm_chatbot_backend/app/main.py @@ -22,6 +22,7 @@ from app.api.routes.auth import router as auth_router from app.api.routes.chat_sessions import router as chat_sessions_router from app.api.routes.phd_canvas import router as phd_canvas_router +from app.api.routes.preferences import router as preferences_router import logging @@ -60,6 +61,7 @@ async def lifespan(app: FastAPI): app.include_router(auth_router, prefix="/auth", tags=["authentication"]) app.include_router(chat_sessions_router, prefix="/api", tags=["chat-sessions"]) app.include_router(phd_canvas_router, prefix="/api", tags=["phd-canvas"]) +app.include_router(preferences_router, prefix="/api", tags=["preferences"]) # Serve bundled avatar images _avatars_dir = Path(__file__).resolve().parent / "assets" / "avatars" diff --git a/multi_llm_chatbot_backend/app/models/user.py b/multi_llm_chatbot_backend/app/models/user.py index 95d1067c..2a3c9e16 100644 --- a/multi_llm_chatbot_backend/app/models/user.py +++ b/multi_llm_chatbot_backend/app/models/user.py @@ -47,6 +47,7 @@ class User(BaseModel): hashed_password: str academicStage: Optional[str] = None researchArea: Optional[str] = None + disabled_advisors: Optional[List[str]] = None created_at: datetime = Field(default_factory=datetime.utcnow) last_login: Optional[datetime] = None is_active: bool = True From 724b7fde4d781573013476e1d61d6b9b4a04ef32 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Wed, 13 May 2026 12:51:19 -0700 Subject: [PATCH 05/12] wired persona filtering into chat-stream orchestrator. --- .../app/api/routes/chat.py | 10 +++++++ .../app/core/improved_orchestrator.py | 27 ++++++++++++------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/multi_llm_chatbot_backend/app/api/routes/chat.py b/multi_llm_chatbot_backend/app/api/routes/chat.py index 5e27b9b4..c6f6297c 100644 --- a/multi_llm_chatbot_backend/app/api/routes/chat.py +++ b/multi_llm_chatbot_backend/app/api/routes/chat.py @@ -12,8 +12,10 @@ from app.api.routes.chat_sessions import persist_message from app.api.utils import get_or_create_session_for_request_async from app.core.auth import get_current_active_user +from app.config import get_settings from app.core.bootstrap import chat_orchestrator from app.core.database import get_database +from app.core.persona_filter import get_available_persona_ids from app.core.session_manager import get_session_manager from app.models.user import User @@ -137,9 +139,17 @@ async def _event_generator(): ).to_ndjson() return + # Filter personas by system whitelist and user preferences + available = get_available_persona_ids( + registered_ids=chat_orchestrator.list_personas(), + system_allowed=get_settings().personas.allowed_advisors, + user_disabled=current_user.disabled_advisors, + ) + # Get personas most relevant to the current session top_personas = await chat_orchestrator.get_top_personas( session_id=sid, + allowed_ids=available, ) done_queue: asyncio.Queue = asyncio.Queue() diff --git a/multi_llm_chatbot_backend/app/core/improved_orchestrator.py b/multi_llm_chatbot_backend/app/core/improved_orchestrator.py index b474dfd7..2191b125 100644 --- a/multi_llm_chatbot_backend/app/core/improved_orchestrator.py +++ b/multi_llm_chatbot_backend/app/core/improved_orchestrator.py @@ -871,20 +871,27 @@ async def chat_with_persona(self, user_input: str, persona_id: str, session_id: } - async def get_top_personas(self, session_id: str, k: int = 3) -> List[str]: + async def get_top_personas(self, session_id: str, k: int = 3, + allowed_ids: Optional[List[str]] = None) -> List[str]: """ Use the LLM to rank personas based on current session context. Falls back to default persona order if LLM fails or returns invalid data. + + When *allowed_ids* is provided, only those personas are considered + (for system-level and user-level filtering). """ + pool_ids = allowed_ids if allowed_ids is not None else list(self.personas.keys()) + pool = {pid: self.personas[pid] for pid in pool_ids if pid in self.personas} + try: session = self.session_manager.get_session(session_id) - if not self.personas: - logger.warning("No personas registered.") + if not pool: + logger.warning("No personas available after filtering.") return [] # Use the LLM from one of the existing persona objects - llm = next(iter(self.personas.values())).llm + llm = next(iter(pool.values())).llm # Use recent conversation context (last 5 messages) recent_context = "\n".join( @@ -894,11 +901,11 @@ async def get_top_personas(self, session_id: str, k: int = 3) -> List[str]: # Format available persona descriptions persona_descriptions = "\n".join([ f"- ID: {p.id}\n Name: {p.name}\n Prompt: {p.system_prompt.strip()}" - for p in self.personas.values() + for p in pool.values() ]) # Ensure k does not exceed the number of available personas - k = min(k, len(self.personas)) + k = min(k, len(pool)) app_title = get_settings().app.title @@ -935,15 +942,15 @@ async def get_top_personas(self, session_id: str, k: int = 3) -> List[str]: if isinstance(top_ids, dict): top_ids = next(iter(top_ids.values()), []) - # Step 3: Filter valid persona IDs - valid_ids = [pid for pid in top_ids if pid in self.personas] + # Step 3: Filter valid persona IDs against the allowed pool + valid_ids = [pid for pid in top_ids if pid in pool] if len(valid_ids) < k: logger.warning(f"LLM returned insufficient or invalid IDs. Got: {valid_ids}") - return list(self.personas.keys())[:k] + return list(pool.keys())[:k] return valid_ids[:k] except Exception as e: logger.error(f"Error selecting top personas: {e}") - return list(self.personas.keys())[:k] + return list(pool.keys())[:k] From 69af61e56d2ea43c6c31b89439feda172820878e Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Wed, 13 May 2026 15:45:18 -0700 Subject: [PATCH 06/12] update system-level config to not even show disabled advisors to the frontend. --- multi_llm_chatbot_backend/app/config.py | 8 +++++++- phd_config.yaml | 13 ++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/multi_llm_chatbot_backend/app/config.py b/multi_llm_chatbot_backend/app/config.py index c685d0ad..2283751e 100644 --- a/multi_llm_chatbot_backend/app/config.py +++ b/multi_llm_chatbot_backend/app/config.py @@ -329,13 +329,19 @@ class AppSettings(BaseModel): def get_frontend_config(self) -> dict: """Return the subset of configuration safe to expose to the frontend via ``GET /api/config``. Secrets are excluded.""" + allowed = self.personas.allowed_advisors + persona_items = self.personas.items + if allowed is not None: + allowed_set = set(allowed) + persona_items = [p for p in persona_items if p.id in allowed_set] + return { "app": self.app.dict(), "homepage": self.homepage.dict(), "login": self.login.dict(), "chat_page": self.chat_page.dict(), "personas": { - "items": [p.to_frontend_config() for p in self.personas.items], + "items": [p.to_frontend_config() for p in persona_items], }, } diff --git a/phd_config.yaml b/phd_config.yaml index 3db3ba30..5ef70008 100644 --- a/phd_config.yaml +++ b/phd_config.yaml @@ -104,9 +104,16 @@ personas: # Optional whitelist of advisor IDs. When set, only these advisors are # available. Omit or leave unset to allow all enabled personas. - # allowed_advisors: - # - "pragmatist" - # - "theorist" + allowed_advisors: + - "critic" + - "empathetic" + - "methodologist" + - "minimalist" + - "motivator" + - "pragmatist" + - "socratic" + - "storyteller" + - "theorist" # ── Orchestrator / Clarification ─────────────────────────────────────────── From 749defcdcf75bd400dce2b2627131380c3835392 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Wed, 13 May 2026 16:05:18 -0700 Subject: [PATCH 07/12] added unit tests for the new advisor preferences endpoints. --- .../tests/unit/test_advisor_preferences.py | 186 ++++++++++++++++++ .../app/tests/unit/test_persona_config.py | 30 +++ 2 files changed, 216 insertions(+) create mode 100644 multi_llm_chatbot_backend/app/tests/unit/test_advisor_preferences.py diff --git a/multi_llm_chatbot_backend/app/tests/unit/test_advisor_preferences.py b/multi_llm_chatbot_backend/app/tests/unit/test_advisor_preferences.py new file mode 100644 index 00000000..b8c6b79f --- /dev/null +++ b/multi_llm_chatbot_backend/app/tests/unit/test_advisor_preferences.py @@ -0,0 +1,186 @@ +import asyncio +import sys +import unittest +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +from bson import ObjectId +from fastapi import HTTPException + +# Stub heavy modules before importing the preferences route module. +_mock_bootstrap = MagicMock() +_mock_bootstrap.chat_orchestrator.list_personas.return_value = [ + "pragmatist", "theorist", "methodologist", +] +sys.modules.setdefault("app.core.bootstrap", _mock_bootstrap) +sys.modules.setdefault("app.core.rag_manager", MagicMock()) + +from fastapi import APIRouter # noqa: E402 + +_stub_router_module = MagicMock(router=APIRouter()) +for _name in ( + "app.api.routes.chat", + "app.api.routes.documents", + "app.api.routes.sessions", + "app.api.routes.provider", + "app.api.routes.debug", + "app.api.routes.root", + "app.api.routes.phd_canvas", +): + sys.modules.setdefault(_name, _stub_router_module) + +from app.api.routes.preferences import ( # noqa: E402 + AdvisorPreferencesRequest, + get_advisor_preferences, + update_advisor_preferences, +) +from app.models.user import User # noqa: E402 + +FAKE_USER_ID = ObjectId() +ALL_IDS = ["pragmatist", "theorist", "methodologist"] + + +def _make_fake_user(**overrides): + defaults = dict( + _id=FAKE_USER_ID, + firstName="Test", + lastName="User", + email="test@example.com", + hashed_password="$2b$12$fakehash", + is_active=True, + created_at=datetime(2025, 1, 1), + ) + defaults.update(overrides) + return User(**defaults) + + +def _mock_db(): + db = MagicMock() + db.users.update_one = AsyncMock() + return db + + +def _mock_settings(allowed_advisors=None): + settings = MagicMock() + settings.personas.allowed_advisors = allowed_advisors + return settings + + +# ------------------------------------------------------------------ +# GET /api/me/advisor-preferences +# ------------------------------------------------------------------ + + +@patch("app.api.routes.preferences.get_settings") +@patch("app.api.routes.preferences.chat_orchestrator") +class TestGetAdvisorPreferences(unittest.TestCase): + + def test_returns_none_when_no_prefs_set(self, mock_orch, mock_settings): + mock_orch.list_personas.return_value = ALL_IDS + mock_settings.return_value = _mock_settings() + + user = _make_fake_user() + result = asyncio.run(get_advisor_preferences(current_user=user)) + + self.assertIsNone(result.disabled_advisors) + self.assertEqual(result.available_advisors, ALL_IDS) + + def test_returns_disabled_list(self, mock_orch, mock_settings): + mock_orch.list_personas.return_value = ALL_IDS + mock_settings.return_value = _mock_settings() + + user = _make_fake_user(disabled_advisors=["theorist"]) + result = asyncio.run(get_advisor_preferences(current_user=user)) + + self.assertEqual(result.disabled_advisors, ["theorist"]) + + def test_available_reflects_system_whitelist(self, mock_orch, mock_settings): + mock_orch.list_personas.return_value = ALL_IDS + mock_settings.return_value = _mock_settings( + allowed_advisors=["pragmatist", "theorist"], + ) + + user = _make_fake_user() + result = asyncio.run(get_advisor_preferences(current_user=user)) + + self.assertEqual(result.available_advisors, ["pragmatist", "theorist"]) + + +# ------------------------------------------------------------------ +# PUT /api/me/advisor-preferences +# ------------------------------------------------------------------ + + +@patch("app.api.routes.preferences.get_database") +@patch("app.api.routes.preferences.get_settings") +@patch("app.api.routes.preferences.chat_orchestrator") +class TestUpdateAdvisorPreferences(unittest.TestCase): + + def test_valid_ids_persisted(self, mock_orch, mock_settings, mock_get_db): + mock_orch.list_personas.return_value = ALL_IDS + mock_settings.return_value = _mock_settings() + db = _mock_db() + mock_get_db.return_value = db + + user = _make_fake_user() + body = AdvisorPreferencesRequest(disabled_advisors=["theorist"]) + result = asyncio.run( + update_advisor_preferences(body=body, current_user=user) + ) + + db.users.update_one.assert_called_once_with( + {"_id": user.id}, + {"$set": {"disabled_advisors": ["theorist"]}}, + ) + self.assertEqual(result.disabled_advisors, ["theorist"]) + + def test_unknown_ids_rejected(self, mock_orch, mock_settings, mock_get_db): + mock_orch.list_personas.return_value = ALL_IDS + mock_settings.return_value = _mock_settings() + + user = _make_fake_user() + body = AdvisorPreferencesRequest(disabled_advisors=["fake_advisor"]) + + with self.assertRaises(HTTPException) as ctx: + asyncio.run( + update_advisor_preferences(body=body, current_user=user) + ) + + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("fake_advisor", ctx.exception.detail) + + def test_null_clears_preferences(self, mock_orch, mock_settings, mock_get_db): + mock_orch.list_personas.return_value = ALL_IDS + mock_settings.return_value = _mock_settings() + db = _mock_db() + mock_get_db.return_value = db + + user = _make_fake_user(disabled_advisors=["theorist"]) + body = AdvisorPreferencesRequest(disabled_advisors=None) + result = asyncio.run( + update_advisor_preferences(body=body, current_user=user) + ) + + db.users.update_one.assert_called_once_with( + {"_id": user.id}, + {"$set": {"disabled_advisors": None}}, + ) + self.assertIsNone(result.disabled_advisors) + + def test_empty_list_accepted(self, mock_orch, mock_settings, mock_get_db): + mock_orch.list_personas.return_value = ALL_IDS + mock_settings.return_value = _mock_settings() + db = _mock_db() + mock_get_db.return_value = db + + user = _make_fake_user(disabled_advisors=["theorist"]) + body = AdvisorPreferencesRequest(disabled_advisors=[]) + result = asyncio.run( + update_advisor_preferences(body=body, current_user=user) + ) + + db.users.update_one.assert_called_once_with( + {"_id": user.id}, + {"$set": {"disabled_advisors": []}}, + ) + self.assertEqual(result.disabled_advisors, []) diff --git a/multi_llm_chatbot_backend/app/tests/unit/test_persona_config.py b/multi_llm_chatbot_backend/app/tests/unit/test_persona_config.py index 6dc96238..7fbd96a4 100644 --- a/multi_llm_chatbot_backend/app/tests/unit/test_persona_config.py +++ b/multi_llm_chatbot_backend/app/tests/unit/test_persona_config.py @@ -102,6 +102,36 @@ def test_allowed_advisors_empty_list_warns(self): any("allowed_advisors is set to an empty list" in msg for msg in cm.output) ) + def test_frontend_config_includes_all_when_no_whitelist(self): + cfg_path = _write_config(self.tmp_path, { + "personas": { + "items": [ + {"id": "one", "name": "One"}, + {"id": "two", "name": "Two"}, + ] + } + }) + settings = load_settings(cfg_path) + frontend = settings.get_frontend_config() + ids = [p["id"] for p in frontend["personas"]["items"]] + self.assertEqual(ids, ["one", "two"]) + + def test_frontend_config_filters_by_whitelist(self): + cfg_path = _write_config(self.tmp_path, { + "personas": { + "allowed_advisors": ["two"], + "items": [ + {"id": "one", "name": "One"}, + {"id": "two", "name": "Two"}, + {"id": "three", "name": "Three"}, + ] + } + }) + settings = load_settings(cfg_path) + frontend = settings.get_frontend_config() + ids = [p["id"] for p in frontend["personas"]["items"]] + self.assertEqual(ids, ["two"]) + def test_bad_persona_does_not_crash_everything(self): """Validates that a bad persona in the inline items list causes a validation error -- the directory loader solves this for file-based configs.""" From 449221ab8679d3b04b5a23f3cbc439d74aac0d1e Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Wed, 13 May 2026 16:16:41 -0700 Subject: [PATCH 08/12] added docstrings to the new endpoints in preferences.py. --- .../app/api/routes/preferences.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/multi_llm_chatbot_backend/app/api/routes/preferences.py b/multi_llm_chatbot_backend/app/api/routes/preferences.py index 7a22692a..08ec4593 100644 --- a/multi_llm_chatbot_backend/app/api/routes/preferences.py +++ b/multi_llm_chatbot_backend/app/api/routes/preferences.py @@ -40,6 +40,11 @@ def _build_response(user: User) -> AdvisorPreferencesResponse: async def get_advisor_preferences( current_user: User = Depends(get_current_active_user), ): + """ + Retrieve advisor preferences for the authenticated user. + @param current_user: Authenticated user from dependency injection + @return: AdvisorPreferencesResponse containing disabled and available advisor IDs + """ return _build_response(current_user) @@ -48,6 +53,13 @@ async def update_advisor_preferences( body: AdvisorPreferencesRequest, current_user: User = Depends(get_current_active_user), ): + """ + Update advisor preferences for the authenticated user. + @param body: AdvisorPreferencesRequest containing the list of advisor IDs to disable + @param current_user: Authenticated user from dependency injection + @return: AdvisorPreferencesResponse with updated disabled and available advisor IDs + @raises HTTPException 400: If any provided advisor ID is not recognized + """ if body.disabled_advisors is not None: known_ids = set(chat_orchestrator.list_personas()) unknown = [aid for aid in body.disabled_advisors if aid not in known_ids] From 72de25cdcb331a57a0b51a28081f494d1565f6b0 Mon Sep 17 00:00:00 2001 From: "Neon:ryan" Date: Sat, 16 May 2026 10:01:55 -0600 Subject: [PATCH 09/12] Enhance advisor preferences management in SettingsModal and AppConfigContext - Added useEffect to hydrate advisor preferences on modal open. - Introduced setAllAdvisorsEnabled function for bulk enabling/disabling advisors. - Updated AppConfigContext to handle fetching and persisting advisor preferences from the backend. - Improved local state management for disabled advisors with optimistic updates. --- .../src/components/SettingsModal.js | 21 +++- .../src/contexts/AppConfigContext.js | 108 +++++++++++++++++- 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/phd-advisor-frontend/src/components/SettingsModal.js b/phd-advisor-frontend/src/components/SettingsModal.js index d9aabf29..553d8262 100644 --- a/phd-advisor-frontend/src/components/SettingsModal.js +++ b/phd-advisor-frontend/src/components/SettingsModal.js @@ -1,4 +1,4 @@ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import ReactDOM from 'react-dom'; import { X, User as UserIcon, Lock, Trash2, AlertTriangle, Users } from 'lucide-react'; import Toggle from './Toggle'; @@ -69,7 +69,20 @@ const miniBtn = { const SettingsModal = ({ user, authToken, onUserUpdate, onSignOut, onClose }) => { const [activeTab, setActiveTab] = useState('profile'); - const { advisors, isAdvisorEnabled, setAdvisorEnabled } = useAppConfig(); + const { + advisors, + isAdvisorEnabled, + setAdvisorEnabled, + setAllAdvisorsEnabled, + hydrateAdvisorPreferences, + } = useAppConfig(); + + // Reconcile with the backend whenever the user opens Settings (covers fresh + // logins and changes made on another device). + useEffect(() => { + hydrateAdvisorPreferences(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Track where the mouse went DOWN so we don't close the modal when a user // drags to select text inside an input and the mouseup happens outside the modal. @@ -240,9 +253,7 @@ const SettingsModal = ({ user, authToken, onUserUpdate, onSignOut, onClose }) => const advisorEntries = Object.entries(advisors || {}); const enabledCount = advisorEntries.filter(([id]) => isAdvisorEnabled(id)).length; - const setAll = (enabled) => { - advisorEntries.forEach(([id]) => setAdvisorEnabled(id, enabled)); - }; + const setAll = (enabled) => setAllAdvisorsEnabled(enabled); return ReactDOM.createPortal(
diff --git a/phd-advisor-frontend/src/contexts/AppConfigContext.js b/phd-advisor-frontend/src/contexts/AppConfigContext.js index fb0f0509..d205e089 100644 --- a/phd-advisor-frontend/src/contexts/AppConfigContext.js +++ b/phd-advisor-frontend/src/contexts/AppConfigContext.js @@ -3,6 +3,23 @@ import * as LucideIcons from 'lucide-react'; const AppConfigContext = createContext(null); +const ADVISOR_PREFS_URL = `${process.env.REACT_APP_API_URL}/api/me/advisor-preferences`; + +// The frontend tracks disabled advisors as an object keyed by id +// ({ critic: true }) for fast lookups; the backend speaks a flat string[]. +// These two helpers translate between the shapes. A null/undefined array +// from the backend means "no preferences set" → nothing disabled. +const disabledObjToArray = (obj) => + Object.keys(obj || {}).filter((id) => obj[id]); +const disabledArrayToObj = (arr) => + Array.isArray(arr) + ? arr.reduce((acc, id) => { acc[id] = true; return acc; }, {}) + : {}; + +const getAuthToken = () => { + try { return localStorage.getItem('authToken'); } catch { return null; } +}; + /** * Resolve a Lucide icon name string (e.g. "BookOpen") to the actual React * component. Falls back to HelpCircle if the name isn't found. @@ -95,6 +112,8 @@ export const AppConfigProvider = ({ children }) => { try { return JSON.parse(localStorage.getItem('disabledAdvisors') || '{}'); } catch { return {}; } }); + // Advisor ids the backend considers selectable (system-level allow list). + const [availableAdvisors, setAvailableAdvisors] = useState([]); useEffect(() => { const fetchConfig = async () => { @@ -132,16 +151,96 @@ export const AppConfigProvider = ({ children }) => { }; // Advisor enable/disable. Disabled advisors are filtered out of orchestrator - // calls (TODO backend wiring) and visually dimmed in the UI. + // calls (server-side, per user) and visually dimmed in the UI. const isAdvisorEnabled = (id) => !disabledAdvisors[id]; + + // Apply a disabled map locally + cache it. localStorage keeps the last known + // state so the UI is correct instantly on reload before the backend answers. + const applyDisabled = (obj) => { + setDisabledAdvisors(obj); + try { localStorage.setItem('disabledAdvisors', JSON.stringify(obj)); } + catch { /* storage full / unavailable — non-fatal */ } + }; + + // Reconcile local state with whatever the backend returns (it is the source + // of truth; it also distinguishes "no prefs / null" from an explicit list). + const applyServerResponse = (data) => { + applyDisabled(disabledArrayToObj(data?.disabled_advisors)); + if (Array.isArray(data?.available_advisors)) { + setAvailableAdvisors(data.available_advisors); + } + }; + + // Pull the authenticated user's preferences from the backend. + const hydrateAdvisorPreferences = async () => { + const token = getAuthToken(); + if (!token) return; + try { + const res = await fetch(ADVISOR_PREFS_URL, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + console.error('Failed to load advisor preferences:', res.status); + return; + } + applyServerResponse(await res.json()); + } catch (err) { + // Offline / network error — keep the cached localStorage state. + console.error('Failed to load advisor preferences:', err); + } + }; + + // Persist the full disabled set to the backend. We send the whole array + // (not a delta) so the PUT is idempotent and the server stays authoritative. + const persistAdvisorPreferences = async (obj) => { + const token = getAuthToken(); + if (!token) return; + try { + const res = await fetch(ADVISOR_PREFS_URL, { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ disabled_advisors: disabledObjToArray(obj) }), + }); + if (!res.ok) { + console.error('Failed to save advisor preferences:', res.status); + return; + } + applyServerResponse(await res.json()); + } catch (err) { + // Optimistic local state is already applied; surface the failure only. + console.error('Failed to save advisor preferences:', err); + } + }; + const setAdvisorEnabled = (id, enabled) => { const next = { ...disabledAdvisors }; if (enabled) delete next[id]; else next[id] = true; - setDisabledAdvisors(next); - localStorage.setItem('disabledAdvisors', JSON.stringify(next)); + applyDisabled(next); // optimistic + persistAdvisorPreferences(next); // sync (reconciles on response) }; + // Bulk enable/disable in one shot — a single state update and one PUT, + // instead of N racing requests when toggling every advisor. + const setAllAdvisorsEnabled = (enabled) => { + const next = enabled + ? {} + : Object.keys(advisors || {}).reduce( + (acc, id) => { acc[id] = true; return acc; }, {}); + applyDisabled(next); + persistAdvisorPreferences(next); + }; + + // Load preferences once on mount when a session token is already present + // (returning user). Fresh logins reconcile when the Settings modal opens. + useEffect(() => { + hydrateAdvisorPreferences(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Inject the primary colour as a CSS custom property on so it is // available everywhere without prop-drilling. useEffect(() => { @@ -174,8 +273,11 @@ export const AppConfigProvider = ({ children }) => { addMyAvatar, myCustomAvatars, disabledAdvisors, + availableAdvisors, isAdvisorEnabled, setAdvisorEnabled, + setAllAdvisorsEnabled, + hydrateAdvisorPreferences, }; if (loading) { From 1a26709b7d59ba9eebf69545e9e011491284dd08 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Fri, 22 May 2026 14:41:33 -0700 Subject: [PATCH 10/12] removed unnecessary set conversion in persona_filter.py. --- multi_llm_chatbot_backend/app/core/persona_filter.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/multi_llm_chatbot_backend/app/core/persona_filter.py b/multi_llm_chatbot_backend/app/core/persona_filter.py index 3a8bf9d1..819883d7 100644 --- a/multi_llm_chatbot_backend/app/core/persona_filter.py +++ b/multi_llm_chatbot_backend/app/core/persona_filter.py @@ -21,11 +21,9 @@ def get_available_persona_ids( ids = list(registered_ids) if system_allowed is not None: - allowed_set = set(system_allowed) - ids = [pid for pid in ids if pid in allowed_set] + ids = [pid for pid in ids if pid in system_allowed] if user_disabled is not None: - disabled_set = set(user_disabled) - ids = [pid for pid in ids if pid not in disabled_set] + ids = [pid for pid in ids if pid not in user_disabled] return ids From f1229064edd5d1adc8f290664f29fce6fd5037fd Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Fri, 22 May 2026 15:00:00 -0700 Subject: [PATCH 11/12] throw exception of empty allowed_advisors list at startup. --- multi_llm_chatbot_backend/app/config.py | 7 ++++--- .../app/tests/unit/test_persona_config.py | 11 ++++------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/multi_llm_chatbot_backend/app/config.py b/multi_llm_chatbot_backend/app/config.py index 2283751e..aca5a307 100644 --- a/multi_llm_chatbot_backend/app/config.py +++ b/multi_llm_chatbot_backend/app/config.py @@ -172,10 +172,11 @@ class PersonasConfig(BaseModel): allowed_advisors: Optional[List[str]] = None @model_validator(mode='after') - def _warn_empty_allowed_advisors(self): + def _validate_allowed_advisors(self): if self.allowed_advisors is not None and len(self.allowed_advisors) == 0: - logger.warning( - "allowed_advisors is set to an empty list; no advisors will be available" + raise ValueError( + "allowed_advisors must not be an empty list; " + "omit the setting or set to null to allow all advisors" ) return self diff --git a/multi_llm_chatbot_backend/app/tests/unit/test_persona_config.py b/multi_llm_chatbot_backend/app/tests/unit/test_persona_config.py index 7fbd96a4..f44ba1d4 100644 --- a/multi_llm_chatbot_backend/app/tests/unit/test_persona_config.py +++ b/multi_llm_chatbot_backend/app/tests/unit/test_persona_config.py @@ -3,6 +3,7 @@ import tempfile import yaml import app.config +from pydantic import ValidationError from app.config import load_settings, load_personas_from_dir, PersonasConfig @@ -86,7 +87,7 @@ def test_allowed_advisors_populated(self): settings = load_settings(cfg_path) self.assertEqual(settings.personas.allowed_advisors, ["one", "two"]) - def test_allowed_advisors_empty_list_warns(self): + def test_allowed_advisors_empty_list_raises(self): cfg_path = _write_config(self.tmp_path, { "personas": { "allowed_advisors": [], @@ -95,12 +96,8 @@ def test_allowed_advisors_empty_list_warns(self): ] } }) - with self.assertLogs("app.config", level="WARNING") as cm: - settings = load_settings(cfg_path) - self.assertEqual(settings.personas.allowed_advisors, []) - self.assertTrue( - any("allowed_advisors is set to an empty list" in msg for msg in cm.output) - ) + with self.assertRaises(ValidationError): + load_settings(cfg_path) def test_frontend_config_includes_all_when_no_whitelist(self): cfg_path = _write_config(self.tmp_path, { From 75b8b2944dbf4458db8cbdeaeae47a5b806c61a9 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Fri, 22 May 2026 15:47:40 -0700 Subject: [PATCH 12/12] unset allowed_advisors list in phd_config.yaml so all personas are loaded by default. --- phd_config.yaml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/phd_config.yaml b/phd_config.yaml index 5ef70008..c06bf852 100644 --- a/phd_config.yaml +++ b/phd_config.yaml @@ -105,15 +105,6 @@ personas: # Optional whitelist of advisor IDs. When set, only these advisors are # available. Omit or leave unset to allow all enabled personas. allowed_advisors: - - "critic" - - "empathetic" - - "methodologist" - - "minimalist" - - "motivator" - - "pragmatist" - - "socratic" - - "storyteller" - - "theorist" # ── Orchestrator / Clarification ───────────────────────────────────────────