Skip to content
Merged
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
95 changes: 95 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
name: CI

on:
push:
branches: [main]
paths:
- 'app/**'
- 'tests/**'
- 'pyproject.toml'
- 'Makefile'
- '.github/workflows/ci.yaml'
pull_request:
branches: ['**']
paths:
- 'app/**'
- 'tests/**'
- 'pyproject.toml'
- 'Makefile'
- '.github/workflows/ci.yaml'
workflow_dispatch:

concurrency:
group: 'ci-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}'
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

permissions:
contents: read

jobs:
lint:
name: Lint
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set up Python 3.12
uses: actions/setup-python@v7
with:
python-version: '3.12'

- name: Install uv
uses: astral-sh/setup-uv@v8.3.2
with:
version: "0.11.28"

- name: Install dependencies
run: uv sync

- name: Lint
run: make lint

test:
name: Test
timeout-minutes: 15
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set up Python 3.12
uses: actions/setup-python@v7
with:
python-version: '3.12'

- name: Install uv
uses: astral-sh/setup-uv@v8.3.2
with:
version: "0.11.28"

- name: Install dependencies
run: uv sync

- name: Test
run: make test

- name: Upload coverage artifacts
uses: actions/upload-artifact@v7
with:
name: coverage
path: |
coverage.xml
coverage/
if-no-files-found: error

- name: Coverage
if: github.event_name == 'pull_request'
uses: orgoro/coverage@v3.3.1
with:
coverageFile: coverage.xml
token: ${{ secrets.GITHUB_TOKEN }}
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
# Pycache
__pycache__

# Test and coverage artifacts
.coverage
coverage/
coverage.xml
.ruff_cache

# Config files (might include sensitive information or depend on the deployment)
config.ini
config.docker.ini
Expand All @@ -25,6 +31,9 @@ data
# Python package egg folder
*egg-info

# Lock files
uv.lock

# Python virtual environment
venv

Expand Down
10 changes: 10 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.22
hooks:
# Run the linter.
- id: ruff-check
args: [ --fix ]
# Run the formatter.
- id: ruff-format
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Each fragment is resolved by walking up the directory tree, so a bot can overrid
## Key Conventions

- **Async everywhere**: Node functions and tools are `async`
- **Type hints**: Use `list[str]`, `dict[str, ...]`, `str | None` (Python 3.11+)
- **Type hints**: Use `list[str]`, `dict[str, ...]`, `str | None` (Python 3.12)
- **Models**: `langchain_openai.ChatOpenAI`. Credentials (`base_url`, `api_key`) are read from `config.ini`; the shared base model name and generation parameters in `app.bots.base.Bot` are currently hardcoded.
- **Graphs**: Stateless, compiled at startup via `@cached_property`, reused per request
- **Streaming**: Use `stream_mode="messages"`, filter by `metadata["langgraph_node"]`
Expand Down
39 changes: 39 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
.PHONY: help install-uv install-dev install lint lint-fix test clean

help:
@echo "Available targets:"
@echo " install-uv Install uv (if not already present)"
@echo " install-dev Install development dependencies"
@echo " install Install production dependencies"
@echo " lint Run ruff check and format check"
@echo " lint-fix Run ruff check --fix and apply formatting"
@echo " test Run unit tests with coverage"
@echo " clean Remove test/coverage artifacts"

install-uv:
curl -LsSf https://astral.sh/uv/0.11.28/install.sh | sh

install-dev: install-uv
uv sync
uv run pre-commit install
Comment on lines +2 to +18

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Praise: flowless 0 tests ran 🔥


install: install-uv
uv sync --no-dev
uv run pre-commit install

lint:
uv run ruff check app tests
uv run ruff format app tests --check

lint-fix:
uv run ruff check app tests --fix
uv run ruff format app tests

test:
uv run coverage run -m unittest discover -s tests -t . -p "test_*.py"
uv run coverage report -m
uv run coverage html -d coverage
uv run coverage xml

clean:
rm -rf .coverage coverage coverage.xml .ruff_cache
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# EPFL Graph and CEDE Chatbots

[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/downloads/)
[![CI](https://github.com/epflgraph/graphchatbot/actions/workflows/ci.yaml/badge.svg)](https://github.com/epflgraph/graphchatbot/actions/workflows/ci.yaml)
[![FastAPI](https://img.shields.io/badge/FastAPI-009688?logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com/)
[![LangGraph](https://img.shields.io/badge/LangGraph-1C3C3C?logo=langchain&logoColor=white)](https://langchain-ai.github.io/langgraph/)

Expand Down Expand Up @@ -56,7 +57,7 @@ app/

### Prerequisites

- **Python** >= 3.11
- **Python** 3.12
- A running RAG backend (GraphAI / Elasticsearch) if using RAG-enabled bots
- An [RCP API key](https://portal.rcp.epfl.ch)

Expand Down Expand Up @@ -155,13 +156,17 @@ print(bot.graph) # Verify graph compiles

### Run the Test Suite

To be added.
```bash
make test # run unittest discover over tests/ with coverage
make lint # check linting and formatting (no writes)
make lint-fix # auto-fix lint issues and reformat
```

---

## Development Guidelines

- **Async everywhere**: All node functions and tools must be `async`
- **Python 3.11+ types**: Use `list[str]`, `dict[str, ...]`, `str | None`
- **Python 3.12 types**: Use `list[str]`, `dict[str, ...]`, `str | None`
- **No hardcoded secrets**: Always pull from `config.ini` / `.env` via `config.get("section", {}).get("key")`
- **Logging**: Use `logging.getLogger(__name__)`; the logging format is configured in `app.logging_config`
32 changes: 16 additions & 16 deletions app/bots/admin/admin_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from langgraph.graph import StateGraph
from langgraph.graph.state import CompiledStateGraph

from app.bots.base import Bot, BotState, BOTS_ROOT
from app.bots.base import BOTS_ROOT, Bot, BotState
from app.bots.nodes.classify import make_classify_node
from app.bots.nodes.model import make_model_node
from app.bots.nodes.tools import make_tools_node
Expand All @@ -17,17 +17,17 @@


CATEGORIES = {
'greeting': {
'description': "The user is just greeting the assistant or similar.",
'tool_choice': None,
"greeting": {
"description": "The user is just greeting the assistant or similar.",
"tool_choice": None,
},
'main': {
'description': "The user has a substantive request within the bot's domain.",
'tool_choice': 'any',
"main": {
"description": "The user has a substantive request within the bot's domain.",
"tool_choice": "any",
},
'unrelated': {
'description': "The user's request is completely unrelated to the bot's domain.",
'tool_choice': None,
"unrelated": {
"description": "The user's request is completely unrelated to the bot's domain.",
"tool_choice": None,
},
}

Expand Down Expand Up @@ -61,17 +61,17 @@ async def _search(self, query: str) -> list:

def build_tools(self) -> list:
subclass_dir = Path(inspect.getfile(type(self))).parent
description = resolve('tool_description', subclass_dir, BOTS_ROOT)
description = resolve("tool_description", subclass_dir, BOTS_ROOT)
return [tool(self.tool_name, description=description)(self._search)]

def build_graph(self) -> CompiledStateGraph:
tools = self.build_tools()

workflow = StateGraph(BotState, context_schema=Bot)
workflow.add_node('classify', make_classify_node(self.CATEGORIES))
workflow.add_node('model', make_model_node(tools))
workflow.add_node('tools', make_tools_node(tools, back_to='model'))
workflow.set_entry_point('classify')
workflow.add_edge('classify', 'model')
workflow.add_node("classify", make_classify_node(self.CATEGORIES))
workflow.add_node("model", make_model_node(tools))
workflow.add_node("tools", make_tools_node(tools, back_to="model"))
workflow.set_entry_point("classify")
workflow.add_edge("classify", "model")

return workflow.compile()
8 changes: 4 additions & 4 deletions app/bots/admin/cmi/cmi_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@


class CMiBot(AdminBot):
name = 'cmi'
index = 'course_cmi'
groups = ['graph-chatbot-admins', 'graph-rag-vip', 'cmi-chat-bot']
tool_name = 'search_cmi'
name = "cmi"
index = "course_cmi"
groups = ["graph-chatbot-admins", "graph-rag-vip", "cmi-chat-bot"]
tool_name = "search_cmi"
8 changes: 4 additions & 4 deletions app/bots/admin/cmi/cmi_restricted/cmi_restricted_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@


class CMiRestrictedBot(AdminBot):
name = 'cmi_restricted'
index = 'course_cmirestricted'
groups = ['graph-chatbot-admins', 'graph-rag-vip', 'cmi-chat-bot-private']
tool_name = 'search_cmi_restricted'
name = "cmi_restricted"
index = "course_cmirestricted"
groups = ["graph-chatbot-admins", "graph-rag-vip", "cmi-chat-bot-private"]
tool_name = "search_cmi_restricted"
48 changes: 34 additions & 14 deletions app/bots/admin/lex/lex_bot.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,44 @@
from app.bots.admin.admin_bot import AdminBot


CATEGORIES = {
'greeting': {'description': "The user is just greeting the assistant or similar.", 'tool_choice': None},
'recruiting': {'description': "Requests about recruitment at EPFL, including PhD students, postdocs, researchers or any other EPFL staff member.", 'tool_choice': 'any'},
'contract-management': {'description': "Requests about the EPFL work contract for all kind of staff members.", 'tool_choice': 'any'},
'internal-processes': {'description': "Requests about internal processes at EPFL, like mandatory trainings or electing people for management positions.", 'tool_choice': 'any'},
'equipment': {'description': "Requests about equipment or material at EPFL, like purchasing some piece of equipment for research in a lab or regulations on office material.", 'tool_choice': 'any'},
'absences': {'description': "Requests about absences at EPFL, including paid leaves (holidays, medical leaves, maternity or paternity leaves, accidents, etc.) unpaid leaves, teleworking or other absences.", 'tool_choice': 'any'},
'epfl-presidency': {'description': "Explicit requests about the presidency of EPFL.", 'tool_choice': 'any'},
'epfl-vice-presidencies': {'description': "Explicit requests about the vice-presidencies of EPFL.", 'tool_choice': 'any'},
'unrelated': {'description': "The user's request is completely unrelated to EPFL Polylex or EPFL laws and regulations.", 'tool_choice': None},
"greeting": {"description": "The user is just greeting the assistant or similar.", "tool_choice": None},
"recruiting": {
"description": "Requests about recruitment at EPFL, including PhD students, postdocs, researchers or any other EPFL staff member.",
"tool_choice": "any",
},
"contract-management": {
"description": "Requests about the EPFL work contract for all kind of staff members.",
"tool_choice": "any",
},
"internal-processes": {
"description": "Requests about internal processes at EPFL, like mandatory trainings or electing people for management positions.",
"tool_choice": "any",
},
"equipment": {
"description": "Requests about equipment or material at EPFL, like purchasing some piece of equipment for research in a lab or regulations on office material.",
"tool_choice": "any",
},
"absences": {
"description": "Requests about absences at EPFL, including paid leaves (holidays, medical leaves, maternity or paternity leaves, accidents, etc.) unpaid leaves, teleworking or other absences.",
"tool_choice": "any",
},
"epfl-presidency": {"description": "Explicit requests about the presidency of EPFL.", "tool_choice": "any"},
"epfl-vice-presidencies": {
"description": "Explicit requests about the vice-presidencies of EPFL.",
"tool_choice": "any",
},
"unrelated": {
"description": "The user's request is completely unrelated to EPFL Polylex or EPFL laws and regulations.",
"tool_choice": None,
},
}


class LexBot(AdminBot):
name = 'lex'
index = 'lex'
groups = ['graph-chatbot-admins', 'graph-rag-vip', 'graph-rag-lex']
name = "lex"
index = "lex"
groups = ["graph-chatbot-admins", "graph-rag-vip", "graph-rag-lex"]

tool_name = 'search_lex'
tool_name = "search_lex"

CATEGORIES = CATEGORIES
8 changes: 4 additions & 4 deletions app/bots/admin/plasma/plasma_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@


class PlasmaBot(AdminBot):
name = 'plasma'
index = 'course_plasma'
groups = ['graph-chatbot-admins', 'graph-rag-vip', 'graph-rag-plasma']
tool_name = 'search_spc'
name = "plasma"
index = "course_plasma"
groups = ["graph-chatbot-admins", "graph-rag-vip", "graph-rag-plasma"]
tool_name = "search_spc"
Loading