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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,7 @@ phd-advisor-frontend/firebase.json

# Python virtual environments
**/venv/
.venv/
.venv/

# Cursor docs
.cursor/
10 changes: 10 additions & 0 deletions multi_llm_chatbot_backend/app/api/routes/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
78 changes: 78 additions & 0 deletions multi_llm_chatbot_backend/app/api/routes/preferences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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),
):
"""
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)


@router.put("/me/advisor-preferences", response_model=AdvisorPreferencesResponse)
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]
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)
18 changes: 17 additions & 1 deletion multi_llm_chatbot_backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,16 @@ class PersonasConfig(BaseModel):
personas_dir: str = ""
config_dir: str = ""
items: List[PersonaItemConfig] = []
allowed_advisors: Optional[List[str]] = None

@model_validator(mode='after')
def _validate_allowed_advisors(self):
if self.allowed_advisors is not None and len(self.allowed_advisors) == 0:
raise ValueError(
"allowed_advisors must not be an empty list; "
"omit the setting or set to null to allow all advisors"
)
return self

@model_validator(mode='after')
def _load_personas_from_directory(self):
Expand Down Expand Up @@ -320,13 +330,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],
},
}

Expand Down
27 changes: 17 additions & 10 deletions multi_llm_chatbot_backend/app/core/improved_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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

Expand Down Expand Up @@ -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]
29 changes: 29 additions & 0 deletions multi_llm_chatbot_backend/app/core/persona_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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:
ids = [pid for pid in ids if pid in system_allowed]

if user_disabled is not None:
ids = [pid for pid in ids if pid not in user_disabled]

return ids
2 changes: 2 additions & 0 deletions multi_llm_chatbot_backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions multi_llm_chatbot_backend/app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading