From 22e5148dacadb01ab3c43e11f3cd6bdb992e9d7e Mon Sep 17 00:00:00 2001 From: Spiros Chalkias Date: Mon, 20 Jul 2026 15:57:16 +0200 Subject: [PATCH 01/10] :hammer: add Makefile and dev tooling (ruff, coverage, pre-commit) --- .gitignore | 9 ++++++++ .pre-commit-config.yaml | 10 +++++++++ Makefile | 39 +++++++++++++++++++++++++++++++++ README.md | 6 +++++- pyproject.toml | 48 ++++++++++++++++++++++++++++++++++++++--- tests/__init__.py | 0 6 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 Makefile create mode 100644 tests/__init__.py diff --git a/.gitignore b/.gitignore index 17cc99b..5e38cf2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 @@ -25,6 +31,9 @@ data # Python package egg folder *egg-info +# Lock files +uv.lock + # Python virtual environment venv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9bf5847 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d1ca179 --- /dev/null +++ b/Makefile @@ -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 diff" + @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 + +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 --diff + +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 diff --git a/README.md b/README.md index 8b84df7..90c4d65 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,11 @@ 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 +``` --- diff --git a/pyproject.toml b/pyproject.toml index 5dde782..c806e4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,11 +2,18 @@ requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["app"] + [project] name = "graphchatbot" version = "2.0.0" authors = [ - { name="Aitor Pérez", email="aitor.perez@epfl.ch" } + { name="Aitor Pérez", email="aitor.perez@epfl.ch" }, + { name="Spiros Chalkias", email="spyridon.chalkias@epfl.ch" } ] description = "FastAPI backend for the EPFL Graph and CEDE chatbots: a modular framework to build and serve educational tutors, the EPFL Graph chatbot and other administrative RAG assistants." readme = "README.md" @@ -35,14 +42,49 @@ dependencies = [ "langchain-core==1.4", "langchain-openai==1.2", "langgraph==1.2", - "langfuse==4.6", + "langfuse==4.14.1", "openai==2.29", "graphes-client @ git+https://github.com/epflgraph/graphes-client.git@search", ] +[dependency-groups] +dev = [ + "ruff==0.15.22", + "coverage==7.15.2", + "pre-commit==4.6.0", +] + +[tool.ruff] +line-length = 120 +src = ["app", "tests"] + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # isort + "I", + # Ban inline imports + "PLC0415", +] +ignore = [ + # E501: line length handled by the formatter + "E501", +] +exclude = ["*.pyi"] + +[tool.coverage.run] +source = ["app"] +branch = true + +[tool.coverage.report] +show_missing = true + [project.urls] "Homepage" = "https://github.com/epflgraph/graphchatbot" "Documentation" = "https://github.com/epflgraph/graphchatbot#readme" "Source code" = "https://github.com/epflgraph/graphchatbot" "Bug Tracker" = "https://github.com/epflgraph/graphchatbot/issues" -"Changelog" = "https://github.com/epflgraph/graphchatbot/blob/main/CHANGELOG.md" +"Changelog" = "https://github.com/epflgraph/graphchatbot/blob/main/CHANGELOG.md" \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 From 05d7f92c6efbd7bf109efbca5076b99429d093ba Mon Sep 17 00:00:00 2001 From: Spiros Chalkias Date: Mon, 20 Jul 2026 15:57:26 +0200 Subject: [PATCH 02/10] :art: apply ruff formatting to existing code --- app/bots/admin/admin_bot.py | 32 +++--- app/bots/admin/cmi/cmi_bot.py | 8 +- .../cmi/cmi_restricted/cmi_restricted_bot.py | 8 +- app/bots/admin/lex/lex_bot.py | 48 +++++--- app/bots/admin/plasma/plasma_bot.py | 8 +- app/bots/admin/sac/sac_bot.py | 22 ++-- app/bots/admin/servicedesk/servicedesk_bot.py | 28 ++--- app/bots/base.py | 8 +- app/bots/course/course_bot.py | 75 +++++++------ app/bots/course/debate/debate_bot.py | 67 +++++------ .../course/debate/micro452/micro452_bot.py | 13 ++- app/bots/course/direct/env342/env342_bot.py | 7 +- .../course/direct/math106e/math106e_bot.py | 7 +- .../direct/statistics/statistics_bot.py | 5 +- .../course/hinting/bioeng310/bioeng310_bot.py | 7 +- .../course/hinting/math240/math240_bot.py | 7 +- .../course/hinting/math261/math261_bot.py | 7 +- .../course/hinting/math535/math535_bot.py | 7 +- app/bots/course/hinting/me331/me331_bot.py | 7 +- .../course/hinting/micro315/micro315_bot.py | 36 ++++-- app/bots/graph_chat/graph_chat_bot.py | 104 +++++++++--------- .../graph_chat/graph_chat_tools/__init__.py | 2 +- .../graph_chat/graph_chat_tools/exoset.py | 71 +++++++----- .../graph_chat_tools/graph/clean.py | 53 ++++----- .../graph/organisational_links.py | 54 +++++---- .../graph_chat_tools/graph/search.py | 4 +- app/bots/graph_chat/graph_chat_tools/news.py | 35 +++--- app/bots/main.py | 55 ++++----- app/bots/nodes/classify.py | 11 +- app/bots/nodes/model.py | 8 +- app/bots/nodes/tools.py | 18 +-- app/bots/prompts.py | 10 +- app/bots/registry.py | 16 +-- app/config.py | 2 +- app/interfaces/graphai.py | 41 ++++--- app/llms/utils.py | 12 +- app/logging_config.py | 2 +- app/main.py | 3 +- app/routers/public.py | 18 ++- 39 files changed, 497 insertions(+), 429 deletions(-) diff --git a/app/bots/admin/admin_bot.py b/app/bots/admin/admin_bot.py index 40c0425..7544c81 100644 --- a/app/bots/admin/admin_bot.py +++ b/app/bots/admin/admin_bot.py @@ -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 @@ -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, }, } @@ -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() diff --git a/app/bots/admin/cmi/cmi_bot.py b/app/bots/admin/cmi/cmi_bot.py index c5af4ad..03fb444 100644 --- a/app/bots/admin/cmi/cmi_bot.py +++ b/app/bots/admin/cmi/cmi_bot.py @@ -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" diff --git a/app/bots/admin/cmi/cmi_restricted/cmi_restricted_bot.py b/app/bots/admin/cmi/cmi_restricted/cmi_restricted_bot.py index 3d0ceff..3f0d23c 100644 --- a/app/bots/admin/cmi/cmi_restricted/cmi_restricted_bot.py +++ b/app/bots/admin/cmi/cmi_restricted/cmi_restricted_bot.py @@ -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" diff --git a/app/bots/admin/lex/lex_bot.py b/app/bots/admin/lex/lex_bot.py index 1b30481..c39051f 100644 --- a/app/bots/admin/lex/lex_bot.py +++ b/app/bots/admin/lex/lex_bot.py @@ -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 diff --git a/app/bots/admin/plasma/plasma_bot.py b/app/bots/admin/plasma/plasma_bot.py index 4242a53..8cf176a 100644 --- a/app/bots/admin/plasma/plasma_bot.py +++ b/app/bots/admin/plasma/plasma_bot.py @@ -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" diff --git a/app/bots/admin/sac/sac_bot.py b/app/bots/admin/sac/sac_bot.py index 888ef27..c9a292c 100644 --- a/app/bots/admin/sac/sac_bot.py +++ b/app/bots/admin/sac/sac_bot.py @@ -1,20 +1,22 @@ from app.bots.admin.admin_bot import AdminBot - CATEGORIES = { - 'greeting': {'description': "The user is just greeting the assistant or similar.", 'tool_choice': None}, - 'guidelines': {'description': "Requests about guidelines and regulations.", 'tool_choice': 'any'}, - 'studies': {'description': "Requests about studies.", 'tool_choice': 'any'}, - 'other': {'description': "Other requests related to Service académique.", 'tool_choice': 'any'}, - 'unrelated': {'description': "The user's request is completely unrelated to Service académique or EPFL studies.", 'tool_choice': None}, + "greeting": {"description": "The user is just greeting the assistant or similar.", "tool_choice": None}, + "guidelines": {"description": "Requests about guidelines and regulations.", "tool_choice": "any"}, + "studies": {"description": "Requests about studies.", "tool_choice": "any"}, + "other": {"description": "Other requests related to Service académique.", "tool_choice": "any"}, + "unrelated": { + "description": "The user's request is completely unrelated to Service académique or EPFL studies.", + "tool_choice": None, + }, } class SacBot(AdminBot): - name = 'sac' - index = 'sac' - groups = ['graph-chatbot-admins', 'graph-rag-vip', 'graph-rag-sac'] + name = "sac" + index = "sac" + groups = ["graph-chatbot-admins", "graph-rag-vip", "graph-rag-sac"] - tool_name = 'search_sac' + tool_name = "search_sac" CATEGORIES = CATEGORIES diff --git a/app/bots/admin/servicedesk/servicedesk_bot.py b/app/bots/admin/servicedesk/servicedesk_bot.py index 590d9fe..4212cd3 100644 --- a/app/bots/admin/servicedesk/servicedesk_bot.py +++ b/app/bots/admin/servicedesk/servicedesk_bot.py @@ -1,23 +1,25 @@ from app.bots.admin.admin_bot import AdminBot - CATEGORIES = { - 'greeting': {'description': "The user is just greeting the assistant or similar.", 'tool_choice': None}, - 'epfl': {'description': "Requests about EPFL IT services and infrastructure.", 'tool_choice': 'any'}, - 'public': {'description': "Requests about public-facing IT services.", 'tool_choice': 'any'}, - 'finances': {'description': "Requests about financial IT tools or processes.", 'tool_choice': 'any'}, - 'research': {'description': "Requests about research IT support.", 'tool_choice': 'any'}, - 'human-resources': {'description': "Requests about HR IT tools or processes.", 'tool_choice': 'any'}, - 'servicedesk': {'description': "Requests about Service Desk procedures or support.", 'tool_choice': 'any'}, - 'unrelated': {'description': "The user's request is completely unrelated to EPFL IT or Service Desk topics.", 'tool_choice': None}, + "greeting": {"description": "The user is just greeting the assistant or similar.", "tool_choice": None}, + "epfl": {"description": "Requests about EPFL IT services and infrastructure.", "tool_choice": "any"}, + "public": {"description": "Requests about public-facing IT services.", "tool_choice": "any"}, + "finances": {"description": "Requests about financial IT tools or processes.", "tool_choice": "any"}, + "research": {"description": "Requests about research IT support.", "tool_choice": "any"}, + "human-resources": {"description": "Requests about HR IT tools or processes.", "tool_choice": "any"}, + "servicedesk": {"description": "Requests about Service Desk procedures or support.", "tool_choice": "any"}, + "unrelated": { + "description": "The user's request is completely unrelated to EPFL IT or Service Desk topics.", + "tool_choice": None, + }, } class ServicedeskBot(AdminBot): - name = 'servicedesk' - index = 'servicedesk' - groups = ['graph-chatbot-admins', 'graph-rag-vip', 'graph-rag-servicedesk', 'SI-ServiceDesk-Niv1'] + name = "servicedesk" + index = "servicedesk" + groups = ["graph-chatbot-admins", "graph-rag-vip", "graph-rag-servicedesk", "SI-ServiceDesk-Niv1"] - tool_name = 'search_servicedesk' + tool_name = "search_servicedesk" CATEGORIES = CATEGORIES diff --git a/app/bots/base.py b/app/bots/base.py index d033713..a8fde75 100644 --- a/app/bots/base.py +++ b/app/bots/base.py @@ -8,8 +8,8 @@ from langgraph.graph import MessagesState from langgraph.graph.state import CompiledStateGraph -from app.config import config from app.bots.prompts import resolve +from app.config import config BOTS_ROOT = Path(__file__).parent @@ -42,14 +42,14 @@ class Bot(ABC): light_model: ChatOpenAI = model - model_nodes: tuple[str, ...] = ('model',) + model_nodes: tuple[str, ...] = ("model",) def prompt_context(self) -> dict: - return {'today': datetime.now().strftime("%Y-%m-%d")} + return {"today": datetime.now().strftime("%Y-%m-%d")} def prompt(self, name: str | None = None) -> str: cls_dir = Path(inspect.getfile(type(self))).parent - template = resolve(name or 'prompt', cls_dir, BOTS_ROOT) + template = resolve(name or "prompt", cls_dir, BOTS_ROOT) return template.format(**self.prompt_context()) @abstractmethod diff --git a/app/bots/course/course_bot.py b/app/bots/course/course_bot.py index b8395a8..7bcb3ce 100644 --- a/app/bots/course/course_bot.py +++ b/app/bots/course/course_bot.py @@ -7,7 +7,7 @@ from langgraph.graph.state import CompiledStateGraph from pydantic import BaseModel -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 @@ -18,25 +18,25 @@ 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, }, - 'theory': { - 'description': "The user's request is about a theoretical aspect of the course.", - 'tool_choice': 'any', + "theory": { + "description": "The user's request is about a theoretical aspect of the course.", + "tool_choice": "any", }, - 'practice': { - 'description': "The user's request is about an exercise, lab session, practice exam or similar.", - 'tool_choice': 'any', + "practice": { + "description": "The user's request is about an exercise, lab session, practice exam or similar.", + "tool_choice": "any", }, - 'admin': { - 'description': "The user's request is about an administrative aspect of the course, like schedule, rooms, grading, or logistics.", - 'tool_choice': None, + "admin": { + "description": "The user's request is about an administrative aspect of the course, like schedule, rooms, grading, or logistics.", + "tool_choice": None, }, - 'unrelated': { - 'description': "The user's request is completely unrelated to the course.", - 'tool_choice': None, + "unrelated": { + "description": "The user's request is completely unrelated to the course.", + "tool_choice": None, }, } @@ -68,22 +68,21 @@ def _format_results(results: list) -> list: formatted = [] for r in results: item = { - 'type': f"{r.get('type')}: {r.get('subtype')}", - 'title': r.get('title'), - 'week': r.get('week'), - 'number': r.get('number'), - 'url': r.get('original_link'), - 'page': r.get('page'), - 'position': r.get('position'), - 'content.fr': r.get('content.fr'), - 'content.en': r.get('content.en'), + "type": f"{r.get('type')}: {r.get('subtype')}", + "title": r.get("title"), + "week": r.get("week"), + "number": r.get("number"), + "url": r.get("original_link"), + "page": r.get("page"), + "position": r.get("position"), + "content.fr": r.get("content.fr"), + "content.en": r.get("content.en"), } - video_lectures = r.get('associated_video_lectures') or [] + video_lectures = r.get("associated_video_lectures") or [] if video_lectures: - item['associated_video_lectures'] = [ - {'title': v.get('title'), 'url': v.get('original_link')} - for v in video_lectures + item["associated_video_lectures"] = [ + {"title": v.get("title"), "url": v.get("original_link")} for v in video_lectures ] formatted.append({k: v for k, v in item.items() if v is not None}) @@ -107,17 +106,21 @@ async def search_course_material(self, query: str, filters) -> list: def build_tools(self) -> list: subclass_dir = Path(inspect.getfile(type(self))).parent - description = resolve('tool_description', subclass_dir, BOTS_ROOT) - return [tool('search_course_material', args_schema=self.tool_input_schema, description=description)(self.search_course_material)] + description = resolve("tool_description", subclass_dir, BOTS_ROOT) + return [ + tool("search_course_material", args_schema=self.tool_input_schema, description=description)( + self.search_course_material + ) + ] 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() diff --git a/app/bots/course/debate/debate_bot.py b/app/bots/course/debate/debate_bot.py index dae4719..1185e49 100644 --- a/app/bots/course/debate/debate_bot.py +++ b/app/bots/course/debate/debate_bot.py @@ -13,29 +13,29 @@ logger = logging.getLogger(__name__) CATEGORIES = { - 'no-case-study': { - 'description': "The student has not yet indicated which case study they want to discuss.", - 'tool_choice': 'any', + "no-case-study": { + "description": "The student has not yet indicated which case study they want to discuss.", + "tool_choice": "any", }, - 'no-position': { - 'description': "A case study has been chosen but the student has not yet stated which answer options they think are correct or incorrect, nor started giving arguments.", - 'tool_choice': 'any', + "no-position": { + "description": "A case study has been chosen but the student has not yet stated which answer options they think are correct or incorrect, nor started giving arguments.", + "tool_choice": "any", }, - 'early-stage-debate': { - 'description': "The debate is in an early stage: most ideas have not yet been exchanged or developed.", - 'tool_choice': 'any', + "early-stage-debate": { + "description": "The debate is in an early stage: most ideas have not yet been exchanged or developed.", + "tool_choice": "any", }, - 'mid-stage-debate': { - 'description': "The debate is in an intermediate stage: some ideas have been developed, but there is more to discuss.", - 'tool_choice': 'any', + "mid-stage-debate": { + "description": "The debate is in an intermediate stage: some ideas have been developed, but there is more to discuss.", + "tool_choice": "any", }, - 'late-stage-debate': { - 'description': "The debate is in a late stage: most ideas have been discussed and there is little left to explore.", - 'tool_choice': 'any', + "late-stage-debate": { + "description": "The debate is in a late stage: most ideas have been discussed and there is little left to explore.", + "tool_choice": "any", }, - 'debate-ended': { - 'description': "The complete solution to the case study has already been explicitly revealed in this conversation.", - 'tool_choice': 'any', + "debate-ended": { + "description": "The complete solution to the case study has already been explicitly revealed in this conversation.", + "tool_choice": "any", }, } @@ -47,31 +47,34 @@ class DebateCourseBotState(BotState): class DebateCourseBot(CourseBot): """CourseBot variant that uses a peer-debate pedagogical style.""" - model_nodes: tuple[str, ...] = tuple(f'model-{c}' for c in CATEGORIES) + model_nodes: tuple[str, ...] = tuple(f"model-{c}" for c in CATEGORIES) CATEGORIES: dict = CATEGORIES def prompt(self, name: str | None = None) -> str: - return super().prompt(name or 'prompt-no-case-study') + return super().prompt(name or "prompt-no-case-study") def build_graph(self) -> CompiledStateGraph: tools = self.build_tools() workflow = StateGraph(DebateCourseBotState, context_schema=Bot) - workflow.add_node('classify', make_classify_node(self.CATEGORIES)) - workflow.add_conditional_edges('classify', lambda s: f"model-{s['category']}") + workflow.add_node("classify", make_classify_node(self.CATEGORIES)) + workflow.add_conditional_edges("classify", lambda s: f"model-{s['category']}") for category in self.CATEGORIES: - node_name = f'model-{category}' - workflow.add_node(node_name, make_model_node( - tools, - prompt_name=f'prompt-{category}', - state_update={'active_node': node_name}, - )) - - workflow.add_node('tools', make_tools_node(tools, back_to=None)) - - workflow.set_entry_point('classify') + node_name = f"model-{category}" + workflow.add_node( + node_name, + make_model_node( + tools, + prompt_name=f"prompt-{category}", + state_update={"active_node": node_name}, + ), + ) + + workflow.add_node("tools", make_tools_node(tools, back_to=None)) + + workflow.set_entry_point("classify") return workflow.compile() diff --git a/app/bots/course/debate/micro452/micro452_bot.py b/app/bots/course/debate/micro452/micro452_bot.py index c2b6f8d..2e95347 100644 --- a/app/bots/course/debate/micro452/micro452_bot.py +++ b/app/bots/course/debate/micro452/micro452_bot.py @@ -14,6 +14,7 @@ class ToolInput(BaseModel): """ Search schema for MICRO-452 case study material. """ + keywords: Optional[list[str]] = Field( default=None, description="Keywords to search for in the theory material. Ignored when case_study_number is not provided.", @@ -25,9 +26,9 @@ class ToolInput(BaseModel): class MICRO452DebateBot(DebateCourseBot): - name = 'MICRO-452-case-studies' - index = 'course_micro_452_case_studies' - groups = ['graph-chatbot-admins', 'graph-rag-vip', 'MICRO-452-admin', 'MICRO-452-case-studies'] + name = "MICRO-452-case-studies" + index = "course_micro_452_case_studies" + groups = ["graph-chatbot-admins", "graph-rag-vip", "MICRO-452-admin", "MICRO-452-case-studies"] tool_input_schema = ToolInput async def search_course_material( @@ -44,13 +45,13 @@ async def search_course_material( index=self.index, texts=keywords, limit=9999, - filters={'type': 'case_study', 'week': 1, 'number': str(case_study_number)}, + filters={"type": "case_study", "week": 1, "number": str(case_study_number)}, ), graphai.rag_retrieve( index=self.index, texts=keywords, limit=5, - filters={'type': 'theory', 'subtype': 'lecture_slides'}, + filters={"type": "theory", "subtype": "lecture_slides"}, ), ) results = case_study_results + theory_results @@ -59,7 +60,7 @@ async def search_course_material( index=self.index, texts=keywords, limit=9999, - filters={'type': 'case_study', 'week': 1, 'subtype': 'question'}, + filters={"type": "case_study", "week": 1, "subtype": "question"}, ) logger.info(f"Retrieved {len(results)} chunks.") diff --git a/app/bots/course/direct/env342/env342_bot.py b/app/bots/course/direct/env342/env342_bot.py index d7c31d5..184d3f1 100644 --- a/app/bots/course/direct/env342/env342_bot.py +++ b/app/bots/course/direct/env342/env342_bot.py @@ -48,6 +48,7 @@ class ToolInput(BaseModel): Search schema for ENV-342 course material. Keep queries concise (≤ 15 words). For exercises leave query="" and rely on filters. """ + query: str = Field("", description="Concise keywords (≤15 words).") filters: Annotated[Union[TheoryFilters, PracticeFilters, ExamFilters], Field(discriminator="type")] = Field( default_factory=lambda: TheoryFilters(type="theory"), @@ -56,7 +57,7 @@ class ToolInput(BaseModel): class ENV342Bot(DirectCourseBot): - name = 'ENV-342' - index = 'course_env342' - groups = ['graph-chatbot-admins', 'graph-rag-vip', 'chatbot_env_342'] + name = "ENV-342" + index = "course_env342" + groups = ["graph-chatbot-admins", "graph-rag-vip", "chatbot_env_342"] tool_input_schema = ToolInput diff --git a/app/bots/course/direct/math106e/math106e_bot.py b/app/bots/course/direct/math106e/math106e_bot.py index c156bc8..9f1343e 100644 --- a/app/bots/course/direct/math106e/math106e_bot.py +++ b/app/bots/course/direct/math106e/math106e_bot.py @@ -50,6 +50,7 @@ class ToolInput(BaseModel): Search schema for MATH-106(e) course material. Keep queries concise (≤ 15 words). For exercises leave query="" and rely on filters. """ + query: str = Field("", description="Concise keywords (≤15 words).") filters: Annotated[Union[TheoryFilters, PracticeFilters, ExamFilters], Field(discriminator="type")] = Field( default_factory=lambda: TheoryFilters(type="theory"), @@ -58,7 +59,7 @@ class ToolInput(BaseModel): class MATH106eBot(DirectCourseBot): - name = 'MATH-106e' - index = 'course_math106e' - groups = ['graph-chatbot-admins', 'graph-rag-vip', 'chatbot_math_106_e'] + name = "MATH-106e" + index = "course_math106e" + groups = ["graph-chatbot-admins", "graph-rag-vip", "chatbot_math_106_e"] tool_input_schema = ToolInput diff --git a/app/bots/course/direct/statistics/statistics_bot.py b/app/bots/course/direct/statistics/statistics_bot.py index 0446c32..59b6dbb 100644 --- a/app/bots/course/direct/statistics/statistics_bot.py +++ b/app/bots/course/direct/statistics/statistics_bot.py @@ -34,6 +34,7 @@ class ToolInput(BaseModel): Search schema for Statistics course material. Keep queries concise (≤ 15 words). For exercises leave query="" and rely on filters. """ + query: str = Field("", description="Concise keywords (≤15 words).") filters: Annotated[Union[TheoryFilters, PracticeFilters], Field(discriminator="type")] = Field( default_factory=lambda: TheoryFilters(type="theory"), @@ -42,7 +43,7 @@ class ToolInput(BaseModel): class StatisticsBot(DirectCourseBot): - name = 'statistics' - index = 'course_swissunidemo' + name = "statistics" + index = "course_swissunidemo" groups = [] tool_input_schema = ToolInput diff --git a/app/bots/course/hinting/bioeng310/bioeng310_bot.py b/app/bots/course/hinting/bioeng310/bioeng310_bot.py index 3f619c4..8e303d5 100644 --- a/app/bots/course/hinting/bioeng310/bioeng310_bot.py +++ b/app/bots/course/hinting/bioeng310/bioeng310_bot.py @@ -50,6 +50,7 @@ class ToolInput(BaseModel): Search schema for BIOENG-310 course material. Keep queries concise (≤ 15 words). For exercises leave query="" and rely on filters. """ + query: str = Field("", description="Concise keywords (≤15 words).") filters: Annotated[Union[TheoryFilters, PracticeFilters, ExamFilters], Field(discriminator="type")] = Field( default_factory=lambda: TheoryFilters(type="theory"), @@ -58,7 +59,7 @@ class ToolInput(BaseModel): class BIOENG310Bot(HintingCourseBot): - name = 'BIOENG-310' - index = 'course_bioeng310' - groups = ['graph-chatbot-admins', 'graph-rag-vip', 'chatbot_bioeng_310'] + name = "BIOENG-310" + index = "course_bioeng310" + groups = ["graph-chatbot-admins", "graph-rag-vip", "chatbot_bioeng_310"] tool_input_schema = ToolInput diff --git a/app/bots/course/hinting/math240/math240_bot.py b/app/bots/course/hinting/math240/math240_bot.py index e46d5d1..bcc710f 100644 --- a/app/bots/course/hinting/math240/math240_bot.py +++ b/app/bots/course/hinting/math240/math240_bot.py @@ -30,6 +30,7 @@ class ToolInput(BaseModel): Search schema for MATH-240 course material. Keep queries concise (≤ 15 words). For exercises leave query="" and rely on filters. """ + query: str = Field("", description="Concise keywords (≤15 words).") filters: Annotated[Union[TheoryFilters, PracticeFilters], Field(discriminator="type")] = Field( default_factory=lambda: TheoryFilters(type="theory"), @@ -38,7 +39,7 @@ class ToolInput(BaseModel): class MATH240Bot(HintingCourseBot): - name = 'MATH-240' - index = 'course_math240' - groups = ['graph-chatbot-admins', 'graph-rag-vip', 'chatbot_math_240'] + name = "MATH-240" + index = "course_math240" + groups = ["graph-chatbot-admins", "graph-rag-vip", "chatbot_math_240"] tool_input_schema = ToolInput diff --git a/app/bots/course/hinting/math261/math261_bot.py b/app/bots/course/hinting/math261/math261_bot.py index 4eac539..7f1faa4 100644 --- a/app/bots/course/hinting/math261/math261_bot.py +++ b/app/bots/course/hinting/math261/math261_bot.py @@ -34,6 +34,7 @@ class ToolInput(BaseModel): Search schema for MATH-261 course material. Keep queries concise (≤ 15 words). For exercises leave query="" and rely on filters. """ + query: str = Field("", description="Concise keywords (≤15 words).") filters: Annotated[Union[TheoryFilters, PracticeFilters], Field(discriminator="type")] = Field( default_factory=lambda: TheoryFilters(type="theory"), @@ -42,7 +43,7 @@ class ToolInput(BaseModel): class MATH261Bot(HintingCourseBot): - name = 'MATH-261' - index = 'course_math261' - groups = ['graph-chatbot-admins', 'graph-rag-vip', 'chatbot_math_261'] + name = "MATH-261" + index = "course_math261" + groups = ["graph-chatbot-admins", "graph-rag-vip", "chatbot_math_261"] tool_input_schema = ToolInput diff --git a/app/bots/course/hinting/math535/math535_bot.py b/app/bots/course/hinting/math535/math535_bot.py index 06c591c..cab5025 100644 --- a/app/bots/course/hinting/math535/math535_bot.py +++ b/app/bots/course/hinting/math535/math535_bot.py @@ -34,6 +34,7 @@ class ToolInput(BaseModel): Search schema for MATH-535 course material. Keep queries concise (≤ 15 words). For exercises leave query="" and rely on filters. """ + query: str = Field("", description="Concise keywords (≤15 words).") filters: Annotated[Union[TheoryFilters, PracticeFilters], Field(discriminator="type")] = Field( default_factory=lambda: TheoryFilters(type="theory"), @@ -42,7 +43,7 @@ class ToolInput(BaseModel): class MATH535Bot(HintingCourseBot): - name = 'MATH-535' - index = 'course_math535' - groups = ['graph-chatbot-admins', 'graph-rag-vip', 'chatbot_math_535'] + name = "MATH-535" + index = "course_math535" + groups = ["graph-chatbot-admins", "graph-rag-vip", "chatbot_math_535"] tool_input_schema = ToolInput diff --git a/app/bots/course/hinting/me331/me331_bot.py b/app/bots/course/hinting/me331/me331_bot.py index 2fb71c8..75a9f11 100644 --- a/app/bots/course/hinting/me331/me331_bot.py +++ b/app/bots/course/hinting/me331/me331_bot.py @@ -46,6 +46,7 @@ class ToolInput(BaseModel): Search schema for ME-331 course material. Keep queries concise (≤ 15 words). For exercises leave query="" and rely on filters. """ + query: str = Field("", description="Concise keywords (≤15 words).") filters: Annotated[Union[TheoryFilters, PracticeFilters, ExamFilters], Field(discriminator="type")] = Field( default_factory=lambda: TheoryFilters(type="theory"), @@ -54,7 +55,7 @@ class ToolInput(BaseModel): class ME331Bot(HintingCourseBot): - name = 'ME-331' - index = 'course_me331' - groups = ['graph-chatbot-admins', 'graph-rag-vip', 'chatbot_me_331'] + name = "ME-331" + index = "course_me331" + groups = ["graph-chatbot-admins", "graph-rag-vip", "chatbot_me_331"] tool_input_schema = ToolInput diff --git a/app/bots/course/hinting/micro315/micro315_bot.py b/app/bots/course/hinting/micro315/micro315_bot.py index 94a775f..55c3899 100644 --- a/app/bots/course/hinting/micro315/micro315_bot.py +++ b/app/bots/course/hinting/micro315/micro315_bot.py @@ -30,6 +30,7 @@ class ToolInput(BaseModel): Search schema for MICRO-315 course material. Keep queries concise (≤ 15 words). For exercises leave query="" and rely on filters. """ + query: str = Field("", description="Concise keywords (≤15 words).") filters: Annotated[Union[TheoryFilters, PracticeFilters], Field(discriminator="type")] = Field( default_factory=lambda: TheoryFilters(type="theory"), @@ -38,17 +39,32 @@ class ToolInput(BaseModel): class MICRO315Bot(HintingCourseBot): - name = 'MICRO-315' - index = 'course_micro315' - groups = ['graph-chatbot-admins', 'graph-rag-vip', 'chatbot_micro_315'] + name = "MICRO-315" + index = "course_micro315" + groups = ["graph-chatbot-admins", "graph-rag-vip", "chatbot_micro_315"] tool_input_schema = ToolInput CATEGORIES = { - 'greeting': {'description': "The user is just greeting the assistant or similar.", 'tool_choice': None}, - 'theory': {'description': "The user is asking a question about a certain concept, a course lecture or the course slides.", 'tool_choice': 'any'}, - 'exercise': {'description': "The user is asking a question about a lab session or a course exercise or assignment, but not related to code.", 'tool_choice': 'any'}, - 'exercise-coding': {'description': "The user is asking a question about a lab session or a course exercise or assignment, and the question is related to code or coding environments, or the user is pasting some piece of the assignment.", 'tool_choice': 'any'}, - 'debugging': {'description': "The user is asking help to debug code that has bugs at the execution level and not compilation. Typically, the user asks about resolving kernel panic states (robot in panic handler), in which the robot blinks four red LEDs.", 'tool_choice': 'any'}, - 'admin': {'description': "The user's request is about an administrative aspect of the course, like schedule, rooms, grading, logistics or similar.", 'tool_choice': None}, - 'unrelated': {'description': "The user's request is completely unrelated to the course.", 'tool_choice': None}, + "greeting": {"description": "The user is just greeting the assistant or similar.", "tool_choice": None}, + "theory": { + "description": "The user is asking a question about a certain concept, a course lecture or the course slides.", + "tool_choice": "any", + }, + "exercise": { + "description": "The user is asking a question about a lab session or a course exercise or assignment, but not related to code.", + "tool_choice": "any", + }, + "exercise-coding": { + "description": "The user is asking a question about a lab session or a course exercise or assignment, and the question is related to code or coding environments, or the user is pasting some piece of the assignment.", + "tool_choice": "any", + }, + "debugging": { + "description": "The user is asking help to debug code that has bugs at the execution level and not compilation. Typically, the user asks about resolving kernel panic states (robot in panic handler), in which the robot blinks four red LEDs.", + "tool_choice": "any", + }, + "admin": { + "description": "The user's request is about an administrative aspect of the course, like schedule, rooms, grading, logistics or similar.", + "tool_choice": None, + }, + "unrelated": {"description": "The user's request is completely unrelated to the course.", "tool_choice": None}, } diff --git a/app/bots/graph_chat/graph_chat_bot.py b/app/bots/graph_chat/graph_chat_bot.py index d3b594e..873853e 100644 --- a/app/bots/graph_chat/graph_chat_bot.py +++ b/app/bots/graph_chat/graph_chat_bot.py @@ -4,8 +4,8 @@ from langgraph.graph import StateGraph from langgraph.graph.state import CompiledStateGraph -from app.bots.graph_chat.graph_chat_tools import search_graph, search_news, search_exoset from app.bots.base import Bot, BotState +from app.bots.graph_chat.graph_chat_tools import search_exoset, search_graph, search_news 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 @@ -13,86 +13,86 @@ logger = logging.getLogger(__name__) CATEGORIES = { - 'greeting': { - 'description': "Requests that are just a greeting or similar.", - 'tool_choice': None, + "greeting": { + "description": "Requests that are just a greeting or similar.", + "tool_choice": None, }, - 'help-with-assignment': { - 'description': "Requests that present an exercise or question and want help with its solution.", - 'tool_choice': 'search_graph', + "help-with-assignment": { + "description": "Requests that present an exercise or question and want help with its solution.", + "tool_choice": "search_graph", }, - 'explain-concept': { - 'description': "Requests that ask a question about some specific concept or domain.", - 'tool_choice': 'search_graph', + "explain-concept": { + "description": "Requests that ask a question about some specific concept or domain.", + "tool_choice": "search_graph", }, - 'people': { - 'description': "Requests about researchers or instructors at EPFL.", - 'tool_choice': 'search_graph', + "people": { + "description": "Requests about researchers or instructors at EPFL.", + "tool_choice": "search_graph", }, - 'lectures': { - 'description': "Requests about EPFL video lectures.", - 'tool_choice': 'search_graph', + "lectures": { + "description": "Requests about EPFL video lectures.", + "tool_choice": "search_graph", }, - 'exercises': { - 'description': "Requests that want to find exercises about some topic.", - 'tool_choice': 'search_exoset', + "exercises": { + "description": "Requests that want to find exercises about some topic.", + "tool_choice": "search_exoset", }, - 'courses': { - 'description': "Requests about EPFL courses.", - 'tool_choice': 'search_graph', + "courses": { + "description": "Requests about EPFL courses.", + "tool_choice": "search_graph", }, - 'study-plan': { - 'description': "Requests about the EPFL study plan, for example about credits, pre-requisites or the availability of courses in a given plan.", - 'tool_choice': 'search_graph', + "study-plan": { + "description": "Requests about the EPFL study plan, for example about credits, pre-requisites or the availability of courses in a given plan.", + "tool_choice": "search_graph", }, - 'schedule': { - 'description': "Student requests about the time schedule of the classes.", - 'tool_choice': 'search_graph', + "schedule": { + "description": "Student requests about the time schedule of the classes.", + "tool_choice": "search_graph", }, - 'student-projects': { - 'description': "Explicit requests about student projects.", - 'tool_choice': 'search_graph', + "student-projects": { + "description": "Explicit requests about student projects.", + "tool_choice": "search_graph", }, - 'internships': { - 'description': "Explicit requests about student internships.", - 'tool_choice': 'search_graph', + "internships": { + "description": "Explicit requests about student internships.", + "tool_choice": "search_graph", }, - 'labs-or-units': { - 'description': "Requests about EPFL units (labs, centers, institutes, chairs, etc.).", - 'tool_choice': 'search_graph', + "labs-or-units": { + "description": "Requests about EPFL units (labs, centers, institutes, chairs, etc.).", + "tool_choice": "search_graph", }, - 'startups': { - 'description': "Explicit requests about EPFL startups or spin-off companies.", - 'tool_choice': 'search_graph', + "startups": { + "description": "Explicit requests about EPFL startups or spin-off companies.", + "tool_choice": "search_graph", }, - 'news': { - 'description': "Explicit requests for news articles from EPFL.", - 'tool_choice': 'search_news', + "news": { + "description": "Explicit requests for news articles from EPFL.", + "tool_choice": "search_news", }, } class GraphChatBot(Bot): - name = 'graph-chat' + name = "graph-chat" groups = [] CATEGORIES = CATEGORIES def build_tools(self) -> list: return [ - tool('search_graph')(search_graph), - tool('search_news')(search_news), - tool('search_exoset')(search_exoset), + tool("search_graph")(search_graph), + tool("search_news")(search_news), + tool("search_exoset")(search_exoset), ] 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() diff --git a/app/bots/graph_chat/graph_chat_tools/__init__.py b/app/bots/graph_chat/graph_chat_tools/__init__.py index a3a44eb..551a8f1 100644 --- a/app/bots/graph_chat/graph_chat_tools/__init__.py +++ b/app/bots/graph_chat/graph_chat_tools/__init__.py @@ -1,5 +1,5 @@ +from app.bots.graph_chat.graph_chat_tools.exoset import search_exoset from app.bots.graph_chat.graph_chat_tools.graph import search_graph from app.bots.graph_chat.graph_chat_tools.news import search_news -from app.bots.graph_chat.graph_chat_tools.exoset import search_exoset __all__ = ["search_graph", "search_news", "search_exoset"] diff --git a/app/bots/graph_chat/graph_chat_tools/exoset.py b/app/bots/graph_chat/graph_chat_tools/exoset.py index f13a596..912fdff 100644 --- a/app/bots/graph_chat/graph_chat_tools/exoset.py +++ b/app/bots/graph_chat/graph_chat_tools/exoset.py @@ -3,7 +3,6 @@ import httpx import pandas as pd - from graphes.core.graphes import GraphES from app.config import config @@ -18,19 +17,19 @@ async def _fetch_concept_exercises(client: httpx.AsyncClient, node: dict) -> pd.DataFrame: try: - response = await client.post(API_URL, params={'concept': node['name']['en']}) + response = await client.post(API_URL, params={"concept": node["name"]["en"]}) exercises = pd.DataFrame(response.json()) - exercises['concept_id'] = node['doc_id'] - exercises['coef'] = node['score'] + exercises["concept_id"] = node["doc_id"] + exercises["coef"] = node["score"] return exercises except Exception: logger.info("[EXOSET TOOL] The request to EXOSET PROD API failed. Trying EXOSET TEST API...") try: - response = await client.post(TEST_API_URL, params={'concept': node['name']['en']}) + response = await client.post(TEST_API_URL, params={"concept": node["name"]["en"]}) exercises = pd.DataFrame(response.json()) - exercises['concept_id'] = node['doc_id'] - exercises['coef'] = node['score'] + exercises["concept_id"] = node["doc_id"] + exercises["coef"] = node["score"] return exercises except Exception: logger.info("[EXOSET TOOL] The request to EXOSET TEST API failed. Returning no exercises.") @@ -38,7 +37,7 @@ async def _fetch_concept_exercises(client: httpx.AsyncClient, node: dict) -> pd. return pd.DataFrame([]) -async def search_exoset(query: str, language: str = 'EN') -> list: +async def search_exoset(query: str, language: str = "EN") -> list: """ Search exercises from EPFL's EXOSET database that best match the given `query`, which should be in English. The parameter `language` will prioritise exercises in that language, if available. @@ -48,25 +47,32 @@ async def search_exoset(query: str, language: str = 'EN') -> list: logger.info("[EXOSET TOOL] Called the `search_exercises` tool with input `%s` and language `%s`", query, language) - if language.lower() in ['fr', 'french', 'français']: - language = 'FR' + if language.lower() in ["fr", "french", "français"]: + language = "FR" else: - language = 'EN' + language = "EN" if query in _exoset_cache: - logger.info("[EXOSET TOOL] Found %d cached exercises for query `%s` and language `%s`, returning those", len(_exoset_cache[query]), query, language) + logger.info( + "[EXOSET TOOL] Found %d cached exercises for query `%s` and language `%s`, returning those", + len(_exoset_cache[query]), + query, + language, + ) return _exoset_cache[query] client = GraphES() nodes = await asyncio.to_thread( client.search, query=query, - node_types=['Concept'], - index_name=config['elasticsearch']['index'], + node_types=["Concept"], + index_name=config["elasticsearch"]["index"], limit=50, ) - logger.info("[EXOSET TOOL] Got %d concepts to query for exercises: %s", len(nodes), [node['name']['en'] for node in nodes]) + logger.info( + "[EXOSET TOOL] Got %d concepts to query for exercises: %s", len(nodes), [node["name"]["en"] for node in nodes] + ) if len(nodes) == 0: logger.info("[EXOSET TOOL] No concepts found, returning empty list") @@ -81,32 +87,37 @@ async def search_exoset(query: str, language: str = 'EN') -> list: all_exercises = pd.merge( all_exercises, - all_exercises.groupby(by=['concept_id', 'author', 'series', 'exercise']).aggregate(count=('langue_file', 'count')).reset_index(), - how='inner', - on=['concept_id', 'author', 'series', 'exercise'] + all_exercises.groupby(by=["concept_id", "author", "series", "exercise"]) + .aggregate(count=("langue_file", "count")) + .reset_index(), + how="inner", + on=["concept_id", "author", "series", "exercise"], ) - all_exercises = all_exercises[ - (all_exercises['count'] == 1) - | (all_exercises['langue_file'] == language) - ] + all_exercises = all_exercises[(all_exercises["count"] == 1) | (all_exercises["langue_file"] == language)] - all_exercises['coef'] = all_exercises['coef'] / all_exercises['coef'].max() - all_exercises['score'] = (all_exercises['score'] + 2 * all_exercises['ontology_score']) * all_exercises['coef'] + all_exercises["coef"] = all_exercises["coef"] / all_exercises["coef"].max() + all_exercises["score"] = (all_exercises["score"] + 2 * all_exercises["ontology_score"]) * all_exercises["coef"] - all_exercises = all_exercises.groupby(by=['title', 'url']).aggregate(score=('score', 'sum')).reset_index() - all_exercises = all_exercises.sort_values(by='score', ascending=False).reset_index(drop=True) + all_exercises = all_exercises.groupby(by=["title", "url"]).aggregate(score=("score", "sum")).reset_index() + all_exercises = all_exercises.sort_values(by="score", ascending=False).reset_index(drop=True) logger.info("[EXOSET TOOL] Found %d exercises among all concepts", len(all_exercises)) all_exercises = all_exercises[:20] - all_exercises = all_exercises.to_dict(orient='records') + all_exercises = all_exercises.to_dict(orient="records") - logger.info("[EXOSET TOOL] Storing %d exercises for query `%s` and language `%s` in cache", len(all_exercises), query, language) + logger.info( + "[EXOSET TOOL] Storing %d exercises for query `%s` and language `%s` in cache", + len(all_exercises), + query, + language, + ) _exoset_cache[query] = all_exercises return all_exercises -if __name__ == '__main__': - exos = asyncio.run(search_exoset('double pendulum')) + +if __name__ == "__main__": + exos = asyncio.run(search_exoset("double pendulum")) print(exos) diff --git a/app/bots/graph_chat/graph_chat_tools/graph/clean.py b/app/bots/graph_chat/graph_chat_tools/graph/clean.py index 8237de5..c2a49eb 100644 --- a/app/bots/graph_chat/graph_chat_tools/graph/clean.py +++ b/app/bots/graph_chat/graph_chat_tools/graph/clean.py @@ -2,20 +2,19 @@ This module contains cleaner functions that rearrange and filter node lists from elasticsearch to make them suitable for the LLM """ -from app.config import config - from app.bots.graph_chat.graph_chat_tools.graph.organisational_links import get_organisational_field_details +from app.config import config def clean_link(link): link = { - 'type': link.get('link_type', ''), - 'id': link.get('link_id', ''), - 'name_en': link.get('link_name', {}).get('en', ''), - 'name_fr': link.get('link_name', {}).get('fr', ''), - 'short_description_en': link.get('link_short_description', {}).get('en', ''), - 'short_description_fr': link.get('link_short_description', {}).get('fr', ''), - 'url': f"{config['graphsearch']['base_url']}/{link.get('link_type', '').lower()}/{link.get('link_id', '')}" + "type": link.get("link_type", ""), + "id": link.get("link_id", ""), + "name_en": link.get("link_name", {}).get("en", ""), + "name_fr": link.get("link_name", {}).get("fr", ""), + "short_description_en": link.get("link_short_description", {}).get("en", ""), + "short_description_fr": link.get("link_short_description", {}).get("fr", ""), + "url": f"{config['graphsearch']['base_url']}/{link.get('link_type', '').lower()}/{link.get('link_id', '')}", } return link @@ -23,16 +22,18 @@ def clean_link(link): def clean_links(links, node_types): # Split node links between semantic and organisational - semantic_links = [link for link in links if link.get('link_subtype', '') == 'Semantic'] - organisational_links = [link for link in links if link.get('link_subtype', '') != 'Semantic'] + semantic_links = [link for link in links if link.get("link_subtype", "") == "Semantic"] + organisational_links = [link for link in links if link.get("link_subtype", "") != "Semantic"] # Clean all the organisational ones organisational_links = [clean_link(link) for link in organisational_links] # Define node_types properly and how many links of a given node_type we may have if node_types is None: - node_types = [link.get('link_type', '') for link in semantic_links] - node_types = list(dict.fromkeys(node_types)) # Remove duplicates. This is like list(set(x)) but preserving the order. + node_types = [link.get("link_type", "") for link in semantic_links] + node_types = list( + dict.fromkeys(node_types) + ) # Remove duplicates. This is like list(set(x)) but preserving the order. limit = 5 else: if isinstance(node_types, str): @@ -42,7 +43,7 @@ def clean_links(links, node_types): # Clean up semantic links and keep only links of the given node types, and up to the defined limit clean_semantic_links = [] for node_type in node_types: - node_type_semantic_links = [link for link in semantic_links if link.get('link_type', '') == node_type] + node_type_semantic_links = [link for link in semantic_links if link.get("link_type", "") == node_type] clean_semantic_links += [clean_link(link) for link in node_type_semantic_links[:limit]] return organisational_links, clean_semantic_links @@ -50,17 +51,17 @@ def clean_links(links, node_types): def clean_node(node, node_types): # Clean node links and separate into organisational vs. semantic - organisational_links, semantic_links = clean_links(node.get('links', []), node_types) + organisational_links, semantic_links = clean_links(node.get("links", []), node_types) organisational_fields = {} for link in organisational_links: - organisational_field_details = get_organisational_field_details(node.get('doc_type', ''), link.get('type', '')) + organisational_field_details = get_organisational_field_details(node.get("doc_type", ""), link.get("type", "")) if not organisational_field_details: continue - field = organisational_field_details['field'] - limit = organisational_field_details['limit'] + field = organisational_field_details["field"] + limit = organisational_field_details["limit"] if field in organisational_fields: if limit is None or len(organisational_fields[field]) < limit: @@ -72,15 +73,15 @@ def clean_node(node, node_types): organisational_fields[field] = [link] node = { - 'type': node.get('doc_type', ''), - 'id': node.get('doc_id', ''), - 'name_en': node.get('name', {}).get('en', ''), - 'name_fr': node.get('name', {}).get('fr', ''), - 'short_description_en': node.get('short_description', {}).get('en', ''), - 'short_description_fr': node.get('short_description', {}).get('fr', ''), - 'url': f"{config['graphsearch']['base_url']}/{node.get('doc_type', '').lower()}/{node.get('doc_id', '')}", + "type": node.get("doc_type", ""), + "id": node.get("doc_id", ""), + "name_en": node.get("name", {}).get("en", ""), + "name_fr": node.get("name", {}).get("fr", ""), + "short_description_en": node.get("short_description", {}).get("en", ""), + "short_description_fr": node.get("short_description", {}).get("fr", ""), + "url": f"{config['graphsearch']['base_url']}/{node.get('doc_type', '').lower()}/{node.get('doc_id', '')}", **organisational_fields, - 'nearest_nodes': semantic_links, + "nearest_nodes": semantic_links, } return node diff --git a/app/bots/graph_chat/graph_chat_tools/graph/organisational_links.py b/app/bots/graph_chat/graph_chat_tools/graph/organisational_links.py index 4300b40..049d92d 100644 --- a/app/bots/graph_chat/graph_chat_tools/graph/organisational_links.py +++ b/app/bots/graph_chat/graph_chat_tools/graph/organisational_links.py @@ -5,39 +5,35 @@ # Dictionary that maps node and link types to the field name and the allowed limit of such links organisational_fields_mapping = { - 'Unit': { - 'Unit': {'field': 'parent_unit', 'limit': 1}, - 'Person': {'field': 'members', 'limit': None}, + "Unit": { + "Unit": {"field": "parent_unit", "limit": 1}, + "Person": {"field": "members", "limit": None}, }, - 'Person': { - 'Unit': {'field': 'unit', 'limit': 1}, - 'Publication': {'field': 'publications', 'limit': None}, - 'Course': {'field': 'teaching_courses', 'limit': None}, - 'MOOC': {'field': 'teaching_moocs', 'limit': None}, - 'Lecture': {'field': 'lectures_in_teaching_courses', 'limit': None}, + "Person": { + "Unit": {"field": "unit", "limit": 1}, + "Publication": {"field": "publications", "limit": None}, + "Course": {"field": "teaching_courses", "limit": None}, + "MOOC": {"field": "teaching_moocs", "limit": None}, + "Lecture": {"field": "lectures_in_teaching_courses", "limit": None}, }, - 'Publication': { - 'Person': {'field': 'authors', 'limit': None}, + "Publication": { + "Person": {"field": "authors", "limit": None}, }, - 'Course': { - 'Person': {'field': 'instructors', 'limit': None}, - 'Lecture': {'field': 'lectures', 'limit': None}, + "Course": { + "Person": {"field": "instructors", "limit": None}, + "Lecture": {"field": "lectures", "limit": None}, }, - 'MOOC': { - 'Person': {'field': 'instructors', 'limit': None}, - 'Lecture': {'field': 'lectures', 'limit': None}, + "MOOC": { + "Person": {"field": "instructors", "limit": None}, + "Lecture": {"field": "lectures", "limit": None}, }, - 'Category': { - 'Concept': {'field': 'concepts', 'limit': None} + "Category": {"Concept": {"field": "concepts", "limit": None}}, + "Concept": {"Category": {"field": "category", "limit": 1}}, + "Lecture": { + "Course": {"field": "courses", "limit": None}, + "MOOC": {"field": "MOOCs", "limit": None}, + "Person": {"field": "instructors_in_lecture_courses", "limit": None}, }, - 'Concept': { - 'Category': {'field': 'category', 'limit': 1} - }, - 'Lecture': { - 'Course': {'field': 'courses', 'limit': None}, - 'MOOC': {'field': 'MOOCs', 'limit': None}, - 'Person': {'field': 'instructors_in_lecture_courses', 'limit': None}, - } } @@ -45,12 +41,12 @@ def get_organisational_field_details(source_node_type, target_node_type): try: return organisational_fields_mapping[source_node_type][target_node_type] except Exception: - print('[WARNING]', f"No organisational fields defined for node types {source_node_type} and {target_node_type}") + print("[WARNING]", f"No organisational fields defined for node types {source_node_type} and {target_node_type}") return {} def get_organisational_field_names(node_type): if node_type in organisational_fields_mapping: - return [row['field'] for row in organisational_fields_mapping[node_type].values()] + return [row["field"] for row in organisational_fields_mapping[node_type].values()] return [] diff --git a/app/bots/graph_chat/graph_chat_tools/graph/search.py b/app/bots/graph_chat/graph_chat_tools/graph/search.py index 0b33b6f..c837b23 100644 --- a/app/bots/graph_chat/graph_chat_tools/graph/search.py +++ b/app/bots/graph_chat/graph_chat_tools/graph/search.py @@ -10,13 +10,13 @@ async def search_graph(query: str) -> list: """ client = GraphES() - nodes = client.search(query=query, index_name=config['elasticsearch']['index'], limit=5) + nodes = client.search(query=query, index_name=config["elasticsearch"]["index"], limit=5) nodes = clean_nodes(nodes) return nodes -if __name__ == '__main__': +if __name__ == "__main__": import asyncio nodes = asyncio.run(search_graph(query="Anna Fontcuberta")) diff --git a/app/bots/graph_chat/graph_chat_tools/news.py b/app/bots/graph_chat/graph_chat_tools/news.py index ff37cb0..0102019 100644 --- a/app/bots/graph_chat/graph_chat_tools/news.py +++ b/app/bots/graph_chat/graph_chat_tools/news.py @@ -11,32 +11,37 @@ async def search_news(query: str) -> list: print("[NEWS TOOL]", f"Called the `search_news` tool with input `{query}`") async with httpx.AsyncClient() as client: - response = await client.get("https://search-backend.epfl.ch/api/cse", params={ - 'hl': 'en', - 'siteSearch': 'actu.epfl.ch/news', - 'siteSearchFilter': 'i', - 'q': query, - }) - items = response.json().get('items', []) + response = await client.get( + "https://search-backend.epfl.ch/api/cse", + params={ + "hl": "en", + "siteSearch": "actu.epfl.ch/news", + "siteSearchFilter": "i", + "q": query, + }, + ) + items = response.json().get("items", []) print("[NEWS TOOL]", f"Got {len(items)} news articles") cutoff_date = (datetime.datetime.now() - datetime.timedelta(days=3 * 365)).strftime("%Y-%m-%d") news = [] for item in items: - og_list = item.get('pagemap', {}).get('metatags', []) + og_list = item.get("pagemap", {}).get("metatags", []) if not og_list: continue og_item = og_list[0] - date = og_item.get('article:published_time', '') + date = og_item.get("article:published_time", "") if date and date < cutoff_date: continue - news.append({ - 'title': og_item.get('og:title', ''), - 'description': og_item.get('og:description', ''), - 'url': og_item.get('og:url', ''), - 'date': og_item.get('article:published_time', ''), - }) + news.append( + { + "title": og_item.get("og:title", ""), + "description": og_item.get("og:description", ""), + "url": og_item.get("og:url", ""), + "date": og_item.get("article:published_time", ""), + } + ) return news diff --git a/app/bots/main.py b/app/bots/main.py index 161de1f..c3953d9 100644 --- a/app/bots/main.py +++ b/app/bots/main.py @@ -14,58 +14,61 @@ logger = logging.getLogger(__name__) langfuse = Langfuse( - host=config.get('langfuse', {}).get('host'), - secret_key=config.get('langfuse', {}).get('secret_key'), - public_key=config.get('langfuse', {}).get('public_key'), - environment=config.get('langfuse', {}).get('environment'), + host=config.get("langfuse", {}).get("host"), + secret_key=config.get("langfuse", {}).get("secret_key"), + public_key=config.get("langfuse", {}).get("public_key"), + environment=config.get("langfuse", {}).get("environment"), ) + async def generate_completion(chat_request: CompletionCreateParams, bot: Bot) -> dict: - messages = list(chat_request['messages']) + messages = list(chat_request["messages"]) logger.info(f"Received non-streaming request for bot `{bot.name}` with {len(messages)} message(s)") - agent_input = {'messages': messages} + agent_input = {"messages": messages} agent_config = { - 'callbacks': [CallbackHandler()], - 'metadata': {'langfuse_tags': [bot.name]}, + "callbacks": [CallbackHandler()], + "metadata": {"langfuse_tags": [bot.name]}, } agent_state = await bot.graph.ainvoke(input=agent_input, config=agent_config, context=bot) - content = agent_state['messages'][-1].content + content = agent_state["messages"][-1].content return { - 'id': '1', - 'object': 'chat.completion', - 'created': time.time(), - 'model': chat_request['model'], - 'choices': [{'message': {'role': 'assistant', 'content': content}}], + "id": "1", + "object": "chat.completion", + "created": time.time(), + "model": chat_request["model"], + "choices": [{"message": {"role": "assistant", "content": content}}], } async def agenerate_completion(chat_request: CompletionCreateParams, bot: Bot) -> AsyncGenerator: - messages = list(chat_request['messages']) + messages = list(chat_request["messages"]) logger.info(f"Received streaming request for bot `{bot.name}` with {len(messages)} message(s)") - agent_input = {'messages': messages} + agent_input = {"messages": messages} agent_config = { - 'callbacks': [CallbackHandler()], - 'metadata': {'langfuse_tags': [bot.name]}, + "callbacks": [CallbackHandler()], + "metadata": {"langfuse_tags": [bot.name]}, } try: - async for chunk, metadata in bot.graph.astream(input=agent_input, config=agent_config, context=bot, stream_mode="messages"): - if metadata.get('langgraph_node') not in bot.model_nodes: + async for chunk, metadata in bot.graph.astream( + input=agent_input, config=agent_config, context=bot, stream_mode="messages" + ): + if metadata.get("langgraph_node") not in bot.model_nodes: continue - chunk_text = chunk.content if isinstance(chunk.content, str) else '' + chunk_text = chunk.content if isinstance(chunk.content, str) else "" if not chunk_text: continue sse_chunk = { - 'id': '1', - 'object': 'chat.completion.chunk', - 'created': time.time(), - 'model': chat_request['model'], - 'choices': [{'delta': {'content': chunk_text}}], + "id": "1", + "object": "chat.completion.chunk", + "created": time.time(), + "model": chat_request["model"], + "choices": [{"delta": {"content": chunk_text}}], } yield f"data: {json.dumps(sse_chunk)}\n\n" diff --git a/app/bots/nodes/classify.py b/app/bots/nodes/classify.py index c47579c..f9d9538 100644 --- a/app/bots/nodes/classify.py +++ b/app/bots/nodes/classify.py @@ -1,9 +1,8 @@ import logging from typing import Callable, Literal -from pydantic import BaseModel - from langgraph.runtime import Runtime +from pydantic import BaseModel from app.bots.base import Bot from app.llms import build_prompt_from_message_list, generate_structured_response @@ -30,13 +29,13 @@ async def classify_node(state, runtime: Runtime[Bot]) -> dict: categories_dict = categories(state) if callable(categories) else categories - categories_prompt = '\n'.join([f'* {name}: {cat["description"]}' for name, cat in categories_dict.items()]) + categories_prompt = "\n".join([f"* {name}: {cat['description']}" for name, cat in categories_dict.items()]) system_prompt = f"""You will be given a conversation between a Human and an AI system. Your task is to classify the conversation based on the last request. The possible categories are the following: {categories_prompt}""" - human_prompt = build_prompt_from_message_list(state['messages']) + human_prompt = build_prompt_from_message_list(state["messages"]) class Category(BaseModel): category: Literal[*list(categories_dict.keys())] @@ -50,8 +49,8 @@ class Category(BaseModel): logger.info(f"Classified as `{category}`") return { - 'category': category, - 'tool_choice': categories_dict[category].get('tool_choice'), + "category": category, + "tool_choice": categories_dict[category].get("tool_choice"), } return classify_node diff --git a/app/bots/nodes/model.py b/app/bots/nodes/model.py index e20c875..377e9f2 100644 --- a/app/bots/nodes/model.py +++ b/app/bots/nodes/model.py @@ -23,7 +23,7 @@ def make_model_node(tools: list, prompt_name: str | None = None, state_update: d async def model_node(state, runtime: Runtime[Bot]) -> Command: bot = runtime.context - tool_choice = state.get('tool_choice') + tool_choice = state.get("tool_choice") if tool_choice: model = bot.model.bind_tools(tools, tool_choice=tool_choice) elif tools: @@ -31,14 +31,14 @@ async def model_node(state, runtime: Runtime[Bot]) -> Command: else: model = bot.model - messages = [SystemMessage(content=bot.prompt(prompt_name))] + state['messages'] + messages = [SystemMessage(content=bot.prompt(prompt_name))] + state["messages"] logger.info(f"Calling LLM with {len(tools)} tool(s), tool_choice={tool_choice}") ai_message = await model.ainvoke(messages) if ai_message.tool_calls: - return Command(goto='tools', update={'messages': [ai_message], **(state_update or {})}) + return Command(goto="tools", update={"messages": [ai_message], **(state_update or {})}) else: - return Command(goto=END, update={'messages': [ai_message]}) + return Command(goto=END, update={"messages": [ai_message]}) return model_node diff --git a/app/bots/nodes/tools.py b/app/bots/nodes/tools.py index bb8e87b..52728fc 100644 --- a/app/bots/nodes/tools.py +++ b/app/bots/nodes/tools.py @@ -8,7 +8,7 @@ logger = logging.getLogger(__name__) -def make_tools_node(tools: list, back_to: str | None = 'model'): +def make_tools_node(tools: list, back_to: str | None = "model"): """ Returns a tools node that executes all tool calls in the last message. @@ -22,28 +22,28 @@ def make_tools_node(tools: list, back_to: str | None = 'model'): _tool_node = ToolNode(tools) async def tools_node(state, runtime: Runtime) -> Command: - tool_calls = state['messages'][-1].tool_calls + tool_calls = state["messages"][-1].tool_calls for i, tc in enumerate(tool_calls): # Fix missing tool call ids (https://github.com/langchain-ai/langgraph/issues/4717) - if not tc['id']: + if not tc["id"]: logger.warning("Missing tool call id, fixing with random string.") - state['messages'][-1].tool_calls[i]['id'] = f"chatcmpl-tool-{secrets.token_hex(16)}" + state["messages"][-1].tool_calls[i]["id"] = f"chatcmpl-tool-{secrets.token_hex(16)}" # Fix tool name being repeated (e.g. 'search_lexsearch_lex' → 'search_lex') - if tc['name'] not in tool_names: + if tc["name"] not in tool_names: for name in tool_names: - if name in tc['name']: + if name in tc["name"]: logger.warning(f"Fixing repeated tool name `{tc['name']}` → `{name}`.") - state['messages'][-1].tool_calls[i]['name'] = name + state["messages"][-1].tool_calls[i]["name"] = name break logger.info(f"Executing {len(tool_calls)} tool call(s) in parallel") result = await _tool_node.ainvoke(state) - update = {'messages': result['messages'], 'tool_choice': None} + update = {"messages": result["messages"], "tool_choice": None} - destination = back_to if back_to else state.get('active_node') or 'model' + destination = back_to if back_to else state.get("active_node") or "model" return Command(goto=destination, update=update) return tools_node diff --git a/app/bots/prompts.py b/app/bots/prompts.py index f09c744..8cbc9f2 100644 --- a/app/bots/prompts.py +++ b/app/bots/prompts.py @@ -15,23 +15,21 @@ def resolve(name: str, path: Path, root: Path) -> str: """ file = _find(name, start=path, root=root) if file is None: - raise FileNotFoundError( - f"No '{name}.md' found (searched from '{path}' up to '{root}')" - ) + raise FileNotFoundError(f"No '{name}.md' found (searched from '{path}' up to '{root}')") template = file.read_text() def replacer(match: re.Match) -> str: placeholder = match.group(1) return resolve(placeholder, path, root) - result = re.sub(r'(? Path | None: for directory in [start, *start.parents]: - candidate = directory / f'{name}.md' + candidate = directory / f"{name}.md" if candidate.exists(): return candidate if directory == root: diff --git a/app/bots/registry.py b/app/bots/registry.py index 90456ce..0551e6a 100644 --- a/app/bots/registry.py +++ b/app/bots/registry.py @@ -13,19 +13,19 @@ _registry: dict[str, Bot] = {} -_BOTS_PACKAGE = 'app.bots' +_BOTS_PACKAGE = "app.bots" _BOTS_DIR = Path(__file__).parent def init_bots() -> None: - for bot_file in sorted(_BOTS_DIR.rglob('*_bot.py')): + for bot_file in sorted(_BOTS_DIR.rglob("*_bot.py")): relative = bot_file.relative_to(_BOTS_DIR.parent.parent) # relative to app/ - module_path = '.'.join(relative.with_suffix('').parts) # e.g. app.bots.admin.lex.bot + module_path = ".".join(relative.with_suffix("").parts) # e.g. app.bots.admin.lex.bot try: module = importlib.import_module(module_path) except Exception: - logger.exception(f'Failed to import {module_path}') + logger.exception(f"Failed to import {module_path}") continue for attr_name in dir(module): @@ -34,13 +34,15 @@ def init_bots() -> None: isinstance(attr, type) and issubclass(attr, Bot) and attr is not Bot - and isinstance(getattr(attr, 'name', None), str) + and isinstance(getattr(attr, "name", None), str) ): instance = attr() if instance.name in _registry: - logger.warning(f'Duplicate bot name `{instance.name}` from {module_path}, overwriting previous registration') + logger.warning( + f"Duplicate bot name `{instance.name}` from {module_path}, overwriting previous registration" + ) _registry[instance.name] = instance - logger.info(f'Registered bot: {instance.name}') + logger.info(f"Registered bot: {instance.name}") def get_bot(name: str) -> Bot | None: diff --git a/app/config.py b/app/config.py index f9e6afe..d9bb0cf 100644 --- a/app/config.py +++ b/app/config.py @@ -12,6 +12,6 @@ # Load the configuration file path = os.path.dirname(__file__) parser = ConfigParser() -parser.read(f'{path}/../config.ini') +parser.read(f"{path}/../config.ini") config = {section: dict(parser[section]) for section in parser.sections()} diff --git a/app/interfaces/graphai.py b/app/interfaces/graphai.py index 5c6b329..a19f246 100644 --- a/app/interfaces/graphai.py +++ b/app/interfaces/graphai.py @@ -10,26 +10,25 @@ class GraphAIClient: - def __init__(self): self.url = f"{config['graphai']['host']}:{config['graphai']['port']}" - self.username = config['graphai']['username'] - self.password = config['graphai']['password'] + self.username = config["graphai"]["username"] + self.password = config["graphai"]["password"] self.bearer_token = None async def authenticate(self): headers = { - 'accept': 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', + "accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", } data = { - 'grant_type': 'password', - 'username': self.username, - 'password': self.password, - 'scope': '', - 'client_id': '', - 'client_secret': '', + "grant_type": "password", + "username": self.username, + "password": self.password, + "scope": "", + "client_id": "", + "client_secret": "", } try: @@ -48,7 +47,7 @@ async def call_async_endpoint(self, endpoint, payload, timeout=10, verbose=False # Make sure we are authenticated await self.authenticate() - headers = {'Authorization': f'Bearer {self.bearer_token}'} + headers = {"Authorization": f"Bearer {self.bearer_token}"} async with httpx.AsyncClient(timeout=timeout) as client: # Make first request, which will return a task_id @@ -93,7 +92,7 @@ async def call_sync_endpoint(self, endpoint, payload, timeout=30, verbose=False) # Make sure we are authenticated await self.authenticate() - headers = {'Authorization': f'Bearer {self.bearer_token}'} + headers = {"Authorization": f"Bearer {self.bearer_token}"} try: async with httpx.AsyncClient(timeout=timeout) as client: @@ -113,31 +112,31 @@ async def rag_retrieve(self, index: str, texts: list[str], limit: int = 10, filt texts = [text.strip() for text in texts if text.strip()] # Join texts into one string - texts = ' '.join(texts) + texts = " ".join(texts) # Prepare payload payload = { - 'index': index, - 'text': texts, - 'limit': limit, + "index": index, + "text": texts, + "limit": limit, } if filters: - payload['filters'] = filters + payload["filters"] = filters # Send request and return empty if it fails try: - response = await self.call_sync_endpoint(endpoint='/rag/retrieve', payload=payload) + response = await self.call_sync_endpoint(endpoint="/rag/retrieve", payload=payload) except Exception as e: logger.exception(f"Error retrieving document chunks: {e}") return [] # Return empty if response is not marked as successful - if not response.get('successful'): + if not response.get("successful"): logger.warning(f"Unsuccessful retrieval of chunks: {response.get('result', [])}") return [] - return response.get('result', []) + return response.get("result", []) # Shared singleton — re-authenticates before each request so it never goes stale diff --git a/app/llms/utils.py b/app/llms/utils.py index 69ca722..6292945 100644 --- a/app/llms/utils.py +++ b/app/llms/utils.py @@ -1,8 +1,8 @@ import logging from langchain_core.messages import ( - SystemMessage, HumanMessage, + SystemMessage, ) logger = logging.getLogger(__name__) @@ -13,17 +13,19 @@ def build_prompt_from_message_list(messages): human_prompt = [] for message in messages: # Keep only human and ai messages - if message.type not in ('human', 'ai'): + if message.type not in ("human", "ai"): continue # Extract only text from messages to send (otherwise images or other media types can fill the context window) if isinstance(message.content, str): message_content = message.content else: - message_content = '\n'.join([content_piece['text'] for content_piece in message.content if content_piece['type'] == 'text']) + message_content = "\n".join( + [content_piece["text"] for content_piece in message.content if content_piece["type"] == "text"] + ) - human_prompt.append(f'----{message.type.upper()}----\n{message_content}') - human_prompt = '\n\n'.join(human_prompt) + human_prompt.append(f"----{message.type.upper()}----\n{message_content}") + human_prompt = "\n\n".join(human_prompt) return human_prompt diff --git a/app/logging_config.py b/app/logging_config.py index 0b0f583..a0fffce 100644 --- a/app/logging_config.py +++ b/app/logging_config.py @@ -4,5 +4,5 @@ def setup_logging(): logging.basicConfig( level=logging.INFO, - format='[%(levelname)s] %(filename)s:%(lineno)d - %(message)s', + format="[%(levelname)s] %(filename)s:%(lineno)d - %(message)s", ) diff --git a/app/main.py b/app/main.py index 0c2d1be..e953d40 100644 --- a/app/main.py +++ b/app/main.py @@ -12,7 +12,6 @@ from app.logging_config import setup_logging from app.routers import public - setup_logging() @@ -45,7 +44,7 @@ async def lifespan(app: FastAPI): title="EPFL Graph and CEDE Chatbots", description="FastAPI backend for the EPFL Graph and CEDE chatbots: a modular framework to build and serve educational tutors, the EPFL Graph chatbot and other administrative RAG assistants.", version="2.0.0", - lifespan=lifespan + lifespan=lifespan, ) app.include_router( diff --git a/app/routers/public.py b/app/routers/public.py index 3283875..1aaf04a 100644 --- a/app/routers/public.py +++ b/app/routers/public.py @@ -2,12 +2,13 @@ from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse - from openai.types.chat.completion_create_params import CompletionCreateParams from app.bots import registry as bot_registry from app.bots.main import ( agenerate_completion as bot_agenerate_completion, +) +from app.bots.main import ( generate_completion as bot_generate_completion, ) @@ -16,22 +17,19 @@ router = APIRouter() -@router.post('/chat/completions') +@router.post("/chat/completions") async def chat(chat_request: CompletionCreateParams): - bot = bot_registry.get_bot(chat_request['model']) + bot = bot_registry.get_bot(chat_request["model"]) if bot is None: raise HTTPException(status_code=404, detail=f"Bot '{chat_request['model']}' not found") - if chat_request.get('stream'): - return StreamingResponse( - bot_agenerate_completion(chat_request, bot), - media_type="text/event-stream" - ) + if chat_request.get("stream"): + return StreamingResponse(bot_agenerate_completion(chat_request, bot), media_type="text/event-stream") else: return await bot_generate_completion(chat_request, bot) -@router.get('/models') +@router.get("/models") async def models(): return { "object": "list", @@ -44,4 +42,4 @@ async def models(): } for bot in bot_registry.list_bots() ], - } \ No newline at end of file + } From 3e943272b188ea780bd4f22c1afc7bbf0d80c31b Mon Sep 17 00:00:00 2001 From: Spiros Chalkias Date: Tue, 21 Jul 2026 14:01:43 +0200 Subject: [PATCH 03/10] ci: fail lint on unformatted code --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index d1ca179..b4fae63 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ help: @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 diff" + @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" @@ -23,7 +23,7 @@ install: install-uv lint: uv run ruff check app tests - uv run ruff format app tests --diff + uv run ruff format app tests --check lint-fix: uv run ruff check app tests --fix From af2dac2e135ff5fd5a1d0cdc7e0152b5685bd89e Mon Sep 17 00:00:00 2001 From: Spiros Chalkias Date: Tue, 21 Jul 2026 14:01:45 +0200 Subject: [PATCH 04/10] build: restrict package to Python 3.11 --- pyproject.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c806e4d..761da36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ authors = [ description = "FastAPI backend for the EPFL Graph and CEDE chatbots: a modular framework to build and serve educational tutors, the EPFL Graph chatbot and other administrative RAG assistants." readme = "README.md" license = "MIT" -requires-python = ">=3.11" +requires-python = ">=3.11,<3.12" classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: FastAPI", @@ -27,8 +27,6 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: Artificial Intelligence", ] dependencies = [ From 98bd3791a1b8ee8d8d9770814c9c9111e1daaa2c Mon Sep 17 00:00:00 2001 From: Spiros Chalkias Date: Tue, 21 Jul 2026 14:01:49 +0200 Subject: [PATCH 05/10] ci: add GitHub Actions workflow --- .github/workflows/ci.yaml | 95 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..c620cef --- /dev/null +++ b/.github/workflows/ci.yaml @@ -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.11 + uses: actions/setup-python@v7 + with: + python-version: '3.11' + + - 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.11 + uses: actions/setup-python@v7 + with: + python-version: '3.11' + + - 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 }} From 300e39186958567d5992167bca764dd7bc7f25f1 Mon Sep 17 00:00:00 2001 From: Spiros Chalkias Date: Tue, 21 Jul 2026 14:02:58 +0200 Subject: [PATCH 06/10] docs: reflect Python 3.11-only support and add CI badge --- AGENTS.md | 2 +- README.md | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a3958b3..88c5502 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.11) - **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"]` diff --git a/README.md b/README.md index 90c4d65..69ee1e0 100644 --- a/README.md +++ b/README.md @@ -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.11](https://img.shields.io/badge/python-3.11-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/) @@ -56,7 +57,7 @@ app/ ### Prerequisites -- **Python** >= 3.11 +- **Python** 3.11 - A running RAG backend (GraphAI / Elasticsearch) if using RAG-enabled bots - An [RCP API key](https://portal.rcp.epfl.ch) @@ -166,6 +167,6 @@ 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.11 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` From 1ad17b28e931f30ae542c90bf84083abfb55eee8 Mon Sep 17 00:00:00 2001 From: Spiros Chalkias Date: Tue, 21 Jul 2026 14:29:09 +0200 Subject: [PATCH 07/10] ci: upload coverage via GitHub's code coverage API --- .github/workflows/ci.yaml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c620cef..49622c5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -57,7 +57,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: write + code-quality: write + pull-requests: read steps: - name: Checkout uses: actions/checkout@v7 @@ -87,9 +88,9 @@ jobs: coverage/ if-no-files-found: error - - name: Coverage - if: github.event_name == 'pull_request' - uses: orgoro/coverage@v3.3.1 + - name: Upload coverage to GitHub + uses: actions/upload-code-coverage@v1 with: - coverageFile: coverage.xml - token: ${{ secrets.GITHUB_TOKEN }} + file: coverage.xml + language: Python + label: code-coverage/unittest From a294bedb26e0f6b5377db3777025700a58a3fadc Mon Sep 17 00:00:00 2001 From: Spiros Chalkias Date: Tue, 21 Jul 2026 14:34:34 +0200 Subject: [PATCH 08/10] ci: revert to orgoro/coverage (GitHub Code Quality not enabled) --- .github/workflows/ci.yaml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 49622c5..c620cef 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -57,8 +57,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - code-quality: write - pull-requests: read + pull-requests: write steps: - name: Checkout uses: actions/checkout@v7 @@ -88,9 +87,9 @@ jobs: coverage/ if-no-files-found: error - - name: Upload coverage to GitHub - uses: actions/upload-code-coverage@v1 + - name: Coverage + if: github.event_name == 'pull_request' + uses: orgoro/coverage@v3.3.1 with: - file: coverage.xml - language: Python - label: code-coverage/unittest + coverageFile: coverage.xml + token: ${{ secrets.GITHUB_TOKEN }} From 4aa1296b58489fb71b27c5ae641209672f7c3eae Mon Sep 17 00:00:00 2001 From: Spiros Chalkias Date: Tue, 21 Jul 2026 15:31:20 +0200 Subject: [PATCH 09/10] chore: require Python 3.12 and update CI/docs --- .github/workflows/ci.yaml | 8 ++++---- AGENTS.md | 2 +- README.md | 6 +++--- pyproject.toml | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c620cef..5834611 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,10 +35,10 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Set up Python 3.11 + - name: Set up Python 3.12 uses: actions/setup-python@v7 with: - python-version: '3.11' + python-version: '3.12' - name: Install uv uses: astral-sh/setup-uv@v8.3.2 @@ -62,10 +62,10 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Set up Python 3.11 + - name: Set up Python 3.12 uses: actions/setup-python@v7 with: - python-version: '3.11' + python-version: '3.12' - name: Install uv uses: astral-sh/setup-uv@v8.3.2 diff --git a/AGENTS.md b/AGENTS.md index 88c5502..22e5cb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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"]` diff --git a/README.md b/README.md index 69ee1e0..c3cc775 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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/) @@ -57,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) @@ -167,6 +167,6 @@ 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` diff --git a/pyproject.toml b/pyproject.toml index 761da36..9cafb58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ authors = [ description = "FastAPI backend for the EPFL Graph and CEDE chatbots: a modular framework to build and serve educational tutors, the EPFL Graph chatbot and other administrative RAG assistants." readme = "README.md" license = "MIT" -requires-python = ">=3.11,<3.12" +requires-python = ">=3.12,<3.13" classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: FastAPI", @@ -26,7 +26,7 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Scientific/Engineering :: Artificial Intelligence", ] dependencies = [ From 00d469e903ae1cbdfdad9adc255a2bcd35012749 Mon Sep 17 00:00:00 2001 From: Spiros Chalkias Date: Tue, 21 Jul 2026 15:36:42 +0200 Subject: [PATCH 10/10] test: add minimal placeholder smoke test --- tests/test_smoke.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 tests/test_smoke.py diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..35be3d4 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,8 @@ +import unittest + + +# Placeholder test so that CI does not fail with "no tests ran". +# Replace with real tests as the codebase grows. +class SmokeTestCase(unittest.TestCase): + def test_dummy(self): + self.assertTrue(True)