diff --git a/.claude/settings.json b/.claude/settings.json index 1eb0f68..93ec7b2 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -3,22 +3,6 @@ "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" }, "teammateMode": "in-process", - "permissions": { - "allow": [ - "Bash(pytest:*)", - "Bash(python -m pytest:*)", - "Bash(.venv/Scripts/python -m pytest:*)", - "Bash(.venv\\Scripts\\python -m pytest:*)", - "Bash(backend/.venv/Scripts/python -m pytest:*)", - "Bash(backend\\.venv\\Scripts\\python -m pytest:*)", - "Bash(ruff:*)", - "Bash(python -m ruff:*)", - "Bash(.venv/Scripts/python -m ruff:*)", - "Bash(.venv\\Scripts\\python -m ruff:*)", - "Bash(backend/.venv/Scripts/python -m ruff:*)", - "Bash(backend\\.venv\\Scripts\\python -m ruff:*)" - ] - }, "hooks": { "PreToolUse": [ { diff --git a/CLAUDE.md b/CLAUDE.md index c9bd6c5..f0f9db7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Frontend**: React 18 + TypeScript + Vite + Tailwind CSS, runs on port 5173 - **Database**: MongoDB 7 (`football_analytics` db) - **Scraping**: botasaurus + Chrome (inside Docker) for Sofascore; Tor is present but Sofascore's Cloudflare 403s it, so Chrome scrapes run without the Tor proxy +- **Chatbot agent**: LangChain + LangGraph (`create_agent`), configurable LLM provider (`app/agent/providers.py`); Gemini free tier by default ## Running the project @@ -22,10 +23,12 @@ uvicorn app.main:app --reload npm run dev ``` -Required env file: `secrets.env` in project root (loaded by Docker). Backend also reads `.env` for local dev. Key variables: `MONGO_URI`, `CORS_ORIGINS`. +Required env file: `secrets.env` in project root (loaded by Docker). Backend also reads `.env` for local dev. Key variables: `MONGO_URI`, `CORS_ORIGINS`, `GEMINI_API_KEY` (chat agent; missing key disables the agent but not the rest of the API). Chat agent tuning: `LLM_PROVIDER`, `LLM_MODEL`, `LLM_API_KEY`, `LLM_FALLBACK_MODEL`, `AGENT_MAX_TOOL_ITERATIONS`, `AGENT_MAX_ROWS`, `CHECKPOINT_COLLECTION`, `CHAT_SESSION_TTL_SECONDS` — see `backend/app/config.py`. Data loading is done via the `tools/fetch_cli` developer CLI, not the UI — see `tools/fetch_cli/README.md`. +After pulling changes touching backend or frontend dependencies: `docker compose build backend`, and `docker compose up -d --force-recreate --renew-anon-volumes frontend` (frontend `node_modules` lives in an anonymous volume). + ## Tests ```bash @@ -48,6 +51,8 @@ No frontend tests currently. .venv/Scripts/python -m pytest fetch_cli/tests ``` +Agent tests: `backend/tests/agent/`. The offline eval in `backend/tests/agent/eval/` is skipped by default (needs a live model + populated DB) — run with `AGENT_EVAL=1 pytest tests/agent/eval -v -s`. + ## DB snapshots Run inside the backend container (or locally with the stack up): @@ -70,6 +75,9 @@ app/ # GET /v1/fetch/competitions, /seasons, /fetched — catalog + fetched-state reads for tools/fetch_cli players.py # GET /v1/players, GET /v1/players/{id} analysis.py # GET /v1/analysis/scatter + chat.py # POST /v1/chat, GET/DELETE /v1/chat/sessions/{session_id} + modals/ + chat_modals.py # ChatRequest, ChatResponse, ChatTurn, ChatHistory modes/ # Strategy pattern base.py # AnalysisMode ABC: fetch_data(), process() factory.py # ModeFactory.create("fantasy") @@ -79,14 +87,29 @@ app/ models.py # PlayerDTO, Stats, Score, CompetitionEntry, AggregatedScores scoring_engine.py sleeper_detector.py - player_assembler.py # build_player(), merge(), aggregate_stats() - competitions.py # canonical_competition() — normalizes competition names + defensive_stats.py # Defensive metric computation, feeds Stats + player_assembler.py # build_player(), merge(), aggregate_stats() + competitions.py # canonical_competition() — normalizes competition names + metric_fields.py # METRIC_FIELDS — field registry consumed by agent tools infrastructure/ mongo_repository.py # All MongoDB I/O; serializes/deserializes domain models sofascore_client.py # Fetches from Sofascore via ScraperFC (Chrome/botasaurus) text_utils.py # normalize_text() for name/team fuzzy matching + agent/ # Chatbot: LangGraph tool-calling loop over the DB + agent.py # ChatAgent, build_agent() — create_agent loop, checkpointed per session + providers.py # ModelProvider strategy + PROVIDERS registry (gemini/openai/anthropic) + llm.py # build_chat_model(), build_fallback_model() + system_prompt.py # SYSTEM_PROMPT + answer_check.py # uncited_numbers() — flags figures not backed by a tool row + checkpoints.py # MongoDBSaver wiring; keep_latest_checkpoint() prunes old checkpoints + constants.py # MAX_TOOL_ITERATIONS, MAX_ROWS, error strings + tools/ + base.py # MetricQuery schema, run_metric_query(), build_metric_tool() + identity/ # find_player, compare_players, data_coverage — non-metric lookups + attacking/, shots/, defending/, goalkeeping/, discipline/, playing_time/, composite_scores/ + # one metric-family tool each, built over METRIC_FIELDS config.py # Pydantic Settings (env vars) - dependencies.py # FastAPI DI: get_repo(), get_mode_factory() + dependencies.py # FastAPI DI: get_repo(), get_mode_factory(), get_agent() logging_config.py main.py # App wiring: lifespan, CORS, router registration ``` @@ -97,12 +120,15 @@ app/ **Read path**: `GET /v1/players` → `MongoRepository.get_players()` → paginates + filters by position/team/nationality/sleeper_flag. +**Chat path**: `POST /v1/chat` → `ChatAgent.answer()` → LangGraph `create_agent` loop calls metric-family tools (each wraps `MongoRepository.get_players()`) → `answer_check.uncited_numbers()` flags any figure in the reply not present in a tool row (sets `degraded=True`, does not block the reply) → session state checkpointed to MongoDB by `session_id` (thread id). If the agent could not be built at startup (e.g. no `GEMINI_API_KEY`), `get_agent()` returns `None` and `/v1/chat` responds with a degraded generic answer instead of failing. + ### MongoDB collections - `player_bios` — one doc per player (identity/bio: `name`, `norm_name`, `sofascore_player_id`, `position`, `nationality`, `photo_url`). Indexed on `sofascore_player_id` (sparse unique) and `norm_name`. - `player_stats` — one doc per `(player_bio_id, season)`. Contains `competitions[]`, `aggregated_stats`, `aggregated_scores`, `team`, `low_sample_size`. Each competition entry includes `stats`, `scores`, `raw_stats` (untyped ScraperFC columns), and `total_matches`. - `fetch_log` — audit trail for each `fetch_data()` call. - `league_meta` — one doc per `(competition, season)` tracking `total_matches` played so far. Populated at fetch time; stored per competition entry and exposed by the API. No longer feeds `s_final`. +- `chat_checkpoints` / `checkpoint_writes` — one document per chat session (thread), written by LangGraph's `MongoDBSaver`; TTL-expired `chat_session_ttl_seconds` (default 7 days) after the last write. `keep_latest_checkpoint()` also prunes all but the newest checkpoint after each turn. ### Key domain concepts @@ -123,6 +149,8 @@ app/ Data loading is developer-driven via `tools/fetch_cli` (see its README) — the frontend has no fetch-triggering UI; when the DB is empty it just points to the CLI. +`ChatWidget` — floating chat bubble rendered on every tab (`src/components/ChatWidget.tsx`); opens `ChatPanel`. `?chat=1` renders `ChatFullScreen` instead of the tabbed `Dashboard` (`src/App.tsx`), reusing the same `ChatPanel`. Deliberately no navbar tab. Session id is generated client-side (`src/api/chat.ts`) and persisted for `GET/DELETE /v1/chat/sessions/{session_id}`. + ### tools/fetch_cli A standalone developer CLI, deliberately outside `backend/` — not part of the shipped @@ -155,6 +183,7 @@ running backend's HTTP API (`http://localhost:8000` by default). Three commands: - Always run python backend modules (like Pytest) via `.venv\Scripts\python`. - Never push from `dev/*` to `master` without PR, ask user to approve merge only if CI passed. - Delete the feature branch right after the changes were merge to the master branch. +- Advise only on free-tier technologies, this project should not cost any money. ## Never do these diff --git a/README.md b/README.md index 420c7c8..7d68e25 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,9 @@ This project is a **free, open-source, educational tool** built for football ent | Layer | Technology | |---|---| -| Frontend | React 18 · TypeScript · Vite · Tailwind CSS · Recharts | +| Frontend | React 18 · TypeScript · Vite · Tailwind CSS · Recharts · react-markdown | | Backend | FastAPI · Python 3.12 · PyMongo · Pydantic Settings | +| Chatbot Agent | LangChain · LangGraph (`create_agent`), configurable LLM provider (Gemini free tier by default) | | Database | MongoDB 7 | | Data Fetching | ScraperFC · botasaurus · Chromium | | Infrastructure | Docker Compose | @@ -41,18 +42,23 @@ flowchart LR PA["Player Assembler\nbuild · merge · aggregate"] SC["Stats Client\nScraperFC + Chrome"] MR["Mongo Repository"] + CA["Chat Agent\nLangGraph tool loop"] end DB[("MongoDB 7\n:27017")] end EXT["Live Football\nData Source"] + LLM["LLM Provider"] User --> FE FE -- REST --> API API --> SE API --> PA + API --> CA PA --> SC PA --> MR + CA --> MR + CA -- "chat tools" --> LLM MR --> DB SC -- "ScraperFC / botasaurus" --> EXT ``` @@ -61,6 +67,8 @@ flowchart LR **Read path:** React SPA → API routers → `MongoRepository.get_players()` → paginated and filterable by position, team, nationality, or sleeper flag. +**Chat path:** floating chat widget (or `?chat=1` full-screen view) → `POST /v1/chat` → `ChatAgent` (LangGraph `create_agent` loop over per-metric-family DB query tools) → answer built from the rows those tools returned. Session history is a MongoDB checkpoint per thread, expiring 7 days after the last message. + --- ## Core Features @@ -72,6 +80,7 @@ flowchart LR - **Player Detail** — per-competition stat breakdown and aggregated scores for any player, including those without a linked external ID - **Head-to-Head Compare** — side-by-side comparison of exactly two players across all stat dimensions - **Scatter Plot** — interactive xG+xA vs G+A chart (Recharts) across the full dataset +- **Chat Agent** — floating widget on every tab (also a full-screen view at `?chat=1`) answers natural-language questions about players and metrics from live DB tool calls; figures with no supporting row are flagged, and questions the database cannot answer are answered from the model's own knowledge and labelled as such; no navbar tab by design - **Developer Data Loading** — `tools/fetch_cli`, a standalone CLI for browsing available competitions/seasons and loading data into MongoDB, with live per-task fetch progress - **DB Snapshots** — JSON dump/restore scripts (`backend/scripts/DB/`) for safe local dev iteration @@ -134,14 +143,24 @@ Player data is split across two MongoDB collections: ```env MONGO_URI=mongodb://mongodb:27017/football_analytics CORS_ORIGINS=["http://localhost:5173"] +GEMINI_API_KEY=your-key-here ``` +`GEMINI_API_KEY` powers the chat agent (default provider, free tier). Without it the rest of the API still starts — the agent is just disabled and `/v1/chat` returns a degraded response. See [Chat Agent Configuration](#chat-agent-configuration) below for other providers. + ### Full stack ```bash docker compose up ``` +After pulling changes to the backend or frontend dependencies, rebuild: + +```bash +docker compose build backend +docker compose up -d --force-recreate --renew-anon-volumes frontend # node_modules lives in an anonymous volume +``` + | Service | URL | |---|---| | Frontend | http://localhost:5173 | @@ -173,7 +192,28 @@ pytest tests/domain/test_scoring_engine.py pytest tests/domain/test_scoring_engine.py::test_name ``` -Tests use `mongomock` — no running MongoDB required. +Tests use `mongomock` — no running MongoDB required. Agent tests live in `backend/tests/agent/`; the offline eval under `backend/tests/agent/eval/` is skipped by default and needs `AGENT_EVAL=1`, a live model, and a populated DB: + +```bash +AGENT_EVAL=1 pytest tests/agent/eval -v -s +``` + +### Chat Agent Configuration + +Set in `secrets.env` / `.env`: + +| Variable | Default | Notes | +|---|---|---| +| `LLM_PROVIDER` | `gemini` | `gemini`, `openai`, or `anthropic` | +| `LLM_MODEL` | provider default | `openai`/`anthropic` have no default — must be set explicitly | +| `LLM_API_KEY` | unset | falls back to the provider SDK's own env var (e.g. `GEMINI_API_KEY`) | +| `LLM_FALLBACK_MODEL` | unset | model to retry with on failure; empty means no fallback | +| `AGENT_MAX_TOOL_ITERATIONS` | `8` | tool-call loop limit per turn | +| `AGENT_MAX_ROWS` | `25` | max rows a DB query tool can return | +| `CHECKPOINT_COLLECTION` | `chat_checkpoints` | MongoDB collection for session state | +| `CHAT_SESSION_TTL_SECONDS` | `604800` (7 days) | session expiry after the last message | + +`openai` and `anthropic` require installing their LangChain package (`langchain-openai` / `langchain-anthropic`) and setting `LLM_MODEL` explicitly. ### Loading Data diff --git a/backend/app/agent/__init__.py b/backend/app/agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/agent/agent.py b/backend/app/agent/agent.py new file mode 100644 index 0000000..5145aa0 --- /dev/null +++ b/backend/app/agent/agent.py @@ -0,0 +1,161 @@ +"""The chatbot agent: a LangGraph tool-calling loop over the database tools.""" + +import asyncio +import logging +from collections.abc import Callable +from dataclasses import dataclass +from functools import partial + +from langchain.agents import create_agent +from langchain.agents.middleware import ModelFallbackMiddleware +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage +from pydantic import TypeAdapter, ValidationError + +from app.agent.answer_check import uncited_numbers +from app.agent.checkpoints import build_checkpointer, keep_latest_checkpoint +from app.agent.constants import GENERIC_ERROR, MAX_TOOL_ITERATIONS +from app.agent.llm import build_chat_model, build_fallback_model +from app.agent.system_prompt import SYSTEM_PROMPT +from app.agent.tools import build_tools + +logger = logging.getLogger(__name__) + + +@dataclass +class ChatResult: + answer: str + used_tools: bool + degraded: bool = False + + +class ChatAgent: + def __init__( + self, + model: BaseChatModel, + repo, + checkpointer, + fallback_models: list[BaseChatModel] | None = None, + prune: Callable[[str], None] | None = None, + ) -> None: + self._prune = prune + self._graph = create_agent( + model=model, + tools=build_tools(repo), + system_prompt=SYSTEM_PROMPT, + checkpointer=checkpointer, + middleware=[ModelFallbackMiddleware(*fallback_models)] if fallback_models else [], + ) + + def _config(self, session_id: str) -> dict: + return { + "configurable": {"thread_id": session_id}, + "recursion_limit": MAX_TOOL_ITERATIONS * 2, + } + + async def answer(self, message: str, session_id: str) -> ChatResult: + try: + # "exit" saves one checkpoint when the turn ends, not one per graph step. + state = await self._graph.ainvoke( + {"messages": [HumanMessage(content=message)]}, + config=self._config(session_id), + durability="exit", + ) + except Exception: + logger.exception("Agent failed for session %s", session_id) + return ChatResult(answer=GENERIC_ERROR, used_tools=False, degraded=True) + await self._prune_session(session_id) + + messages = _current_turn(state["messages"]) + used_tools = any(isinstance(m, ToolMessage) for m in messages) + answer = messages[-1].text if messages else "" + + if used_tools and answer: + rows = _tool_rows(messages) + uncited = uncited_numbers(answer, rows) if rows is not None else [] + if uncited: + # A signal, not a gate: withholding the answer would be worse than flagging it. + logger.warning( + "Ungrounded figures %s in answer for session %s; rows=%s", + uncited, + session_id, + rows, + ) + return ChatResult(answer=answer, used_tools=True, degraded=True) + + # No tool behind the answer means it is the model's own knowledge, not this app's data. + return ChatResult( + answer=answer or GENERIC_ERROR, + used_tools=used_tools, + degraded=not (answer and used_tools), + ) + + async def _prune_session(self, session_id: str) -> None: + if self._prune is None: + return + try: + await asyncio.to_thread(self._prune, session_id) + except Exception: + # Old checkpoints only cost storage, and the TTL removes them anyway. + logger.exception("Could not prune checkpoints for session %s", session_id) + + def history(self, session_id: str) -> list[dict]: + """Replay the thread as {role, content} turns. Tool messages are never exposed.""" + try: + state = self._graph.get_state(self._config(session_id)) + except Exception: + logger.exception("Could not read history for session %s", session_id) + return [] + turns = [] + for m in (state.values or {}).get("messages", []): + if isinstance(m, HumanMessage): + turns.append({"role": "user", "content": m.text}) + # Text sent alongside a tool call is the model's preamble; it was never shown. + elif isinstance(m, AIMessage) and m.text and not m.tool_calls: + turns.append({"role": "assistant", "content": m.text}) + return turns + + def clear(self, session_id: str) -> None: + try: + self._graph.checkpointer.delete_thread(session_id) + except Exception: + # The caller gets 204 either way; the TTL removes the thread later. + logger.exception("Could not clear session %s", session_id) + + +def _current_turn(messages: list) -> list: + """Messages after the latest user message; the state holds the whole thread.""" + for i in range(len(messages) - 1, -1, -1): + if isinstance(messages[i], HumanMessage): + return messages[i + 1 :] + return messages + + +# Rows are heterogeneous by design: a metric row, an identity profile, a coverage object or +# an error row. Validate the shape, not a schema. +_ToolPayload = TypeAdapter(list[dict] | dict) + + +def _tool_rows(messages) -> list[dict] | None: + """Rows every tool returned this turn, or None if any output could not be read.""" + rows: list[dict] = [] + for m in messages: + if not isinstance(m, ToolMessage): + continue + try: + payload = _ToolPayload.validate_json(m.content) + except ValidationError: + return None # unreadable rows would look like missing citations + rows.extend(payload if isinstance(payload, list) else [payload]) + return rows + + +def build_agent(repo, mongo_client) -> ChatAgent: + checkpointer = build_checkpointer(mongo_client) + return ChatAgent( + model=build_chat_model(), + repo=repo, + checkpointer=checkpointer, + fallback_models=[m for m in [build_fallback_model()] if m], + prune=partial(keep_latest_checkpoint, checkpointer), + ) diff --git a/backend/app/agent/answer_check.py b/backend/app/agent/answer_check.py new file mode 100644 index 0000000..f823712 --- /dev/null +++ b/backend/app/agent/answer_check.py @@ -0,0 +1,62 @@ +"""Flag figures in an answer that no tool row supports. No extra model call.""" + +import re + +# Thousand separators included: "3,380" is one number, not 3 and 380. +_NUMBER = re.compile(r"\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?") + +# Integers this small describe the query ("top 5", "the 3 players"), not a statistic. +_SMALL_COUNT_MAX = 10 + + +# "1. ", "12. " at the start of a line are markdown list markers, not figures. +_LIST_MARKER = re.compile(r"^\s*\d+[.)]\s", re.MULTILINE) + + +def uncited_numbers(answer: str, tool_rows: list[dict]) -> list[str]: + """Return numeric tokens in the answer that appear in no row. Empty means grounded.""" + values, texts = _row_contents(tool_rows) + answer = _LIST_MARKER.sub("", answer or "") + + uncited: list[str] = [] + for token in _NUMBER.findall(answer or ""): + plain = token.replace(",", "") + if any(plain in text or token in text for text in texts): + continue + number = float(plain) + if number.is_integer() and number <= _SMALL_COUNT_MAX: + continue + if any(_is_rendering_of(plain, number, value) for value in values): + continue + uncited.append(token) + return uncited + + +def _row_contents(tool_rows: list[dict]) -> tuple[list[float], list[str]]: + """Every number and string in the rows, however deeply nested.""" + values: list[float] = [] + texts: list[str] = [] + + def walk(value) -> None: + if isinstance(value, bool): + return + if isinstance(value, int | float): + values.append(float(value)) + elif isinstance(value, str): + texts.append(value) + elif isinstance(value, dict): + for item in value.values(): + walk(item) + elif isinstance(value, list | tuple): + for item in value: + walk(item) + + walk(list(tool_rows or [])) + return values, texts + + +def _is_rendering_of(token: str, number: float, value: float) -> bool: + """True when the token could be `value` written to the token's own precision.""" + decimals = len(token.split(".")[1]) if "." in token else 0 + tolerance = 0.5 * (10**-decimals) + return abs(value - number) <= tolerance diff --git a/backend/app/agent/checkpoints.py b/backend/app/agent/checkpoints.py new file mode 100644 index 0000000..de22ae2 --- /dev/null +++ b/backend/app/agent/checkpoints.py @@ -0,0 +1,26 @@ +"""Chat session storage: one checkpoint document per session, expired by TTL.""" + +from langgraph.checkpoint.mongodb import MongoDBSaver +from pymongo import MongoClient + +from app.config import settings + + +def build_checkpointer(mongo_client: MongoClient) -> MongoDBSaver: + """Mongo saver whose documents expire `chat_session_ttl_seconds` after their last write.""" + return MongoDBSaver( + mongo_client, + db_name="football_analytics", + checkpoint_collection_name=settings.checkpoint_collection, + ttl=settings.chat_session_ttl_seconds, + ) + + +def keep_latest_checkpoint(saver: MongoDBSaver, session_id: str) -> None: + """Delete every checkpoint of the session except the newest. The library's prune() is a stub.""" + latest = saver.get_tuple({"configurable": {"thread_id": session_id}}) + if latest is None: + return + older = {"thread_id": session_id, "checkpoint_id": {"$ne": latest.checkpoint["id"]}} + saver.checkpoint_collection.delete_many(older) + saver.writes_collection.delete_many(older) diff --git a/backend/app/agent/constants.py b/backend/app/agent/constants.py new file mode 100644 index 0000000..3aa594c --- /dev/null +++ b/backend/app/agent/constants.py @@ -0,0 +1,7 @@ +from app.config import settings + +MAX_TOOL_ITERATIONS = settings.agent_max_tool_iterations +MAX_ROWS = settings.agent_max_rows + +GENERIC_ERROR = "Sorry — I could not answer that right now. Please try again in a moment." +TOOL_ERROR = "Could not read that data." diff --git a/backend/app/agent/llm.py b/backend/app/agent/llm.py new file mode 100644 index 0000000..3e43ed9 --- /dev/null +++ b/backend/app/agent/llm.py @@ -0,0 +1,27 @@ +"""Chat model construction. The provider and model come from configuration.""" + +from langchain_core.language_models import BaseChatModel + +from app.agent.providers import ModelProvider, get_provider +from app.config import settings + + +def build_chat_model(model: str | None = None) -> BaseChatModel: + provider = get_provider(settings.llm_provider) + name = model or settings.llm_model or provider.default_model + if not name: + raise ValueError(f"Provider {provider.name!r} has no default model: set LLM_MODEL.") + return provider.create(name, _api_key(provider)) + + +def build_fallback_model() -> BaseChatModel | None: + """The model used when the primary one fails. None unless LLM_FALLBACK_MODEL is set.""" + return build_chat_model(settings.llm_fallback_model) if settings.llm_fallback_model else None + + +def _api_key(provider: ModelProvider) -> str | None: + if settings.llm_api_key: + return settings.llm_api_key + if provider.name == "gemini" and settings.gemini_api_key: + return settings.gemini_api_key # the key name this project already ships with + return None # let the provider SDK read its own environment variable diff --git a/backend/app/agent/providers.py b/backend/app/agent/providers.py new file mode 100644 index 0000000..82323c3 --- /dev/null +++ b/backend/app/agent/providers.py @@ -0,0 +1,73 @@ +"""Chat model providers (strategy pattern). Gemini's free tier is the default. + +Adding a provider means adding one subclass and listing it in PROVIDERS. Everything +downstream depends on BaseChatModel, never on a provider. +""" + +import importlib +from abc import ABC, abstractmethod + +from langchain_core.language_models import BaseChatModel +from langchain_google_genai import ChatGoogleGenerativeAI + +MAX_RETRIES = 3 + + +class ModelProvider(ABC): + name: str + package: str + default_model: str = "" # empty means the user must set LLM_MODEL + + @abstractmethod + def create(self, model: str, api_key: str | None) -> BaseChatModel: + """Build the chat model. Must not make a network call.""" + + def _class_from(self, module_name: str, class_name: str): + try: + return getattr(importlib.import_module(module_name), class_name) + except ImportError as exc: + raise RuntimeError( + f"Provider {self.name!r} needs a package: pip install {self.package}" + ) from exc + + +class GeminiProvider(ModelProvider): + name = "gemini" + package = "langchain-google-genai" + default_model = "gemini-3.5-flash" # free tier; 3.6-flash allows only 20 requests/day + + def create(self, model: str, api_key: str | None) -> BaseChatModel: + # No temperature: Gemini 3.x uses fixed sampling and ignores it. + return ChatGoogleGenerativeAI(model=model, api_key=api_key, max_retries=MAX_RETRIES) + + +class OpenAIProvider(ModelProvider): + name = "openai" + package = "langchain-openai" + + def create(self, model: str, api_key: str | None) -> BaseChatModel: + chat_class = self._class_from("langchain_openai", "ChatOpenAI") + return chat_class(model=model, api_key=api_key, max_retries=MAX_RETRIES) + + +class AnthropicProvider(ModelProvider): + name = "anthropic" + package = "langchain-anthropic" + + def create(self, model: str, api_key: str | None) -> BaseChatModel: + chat_class = self._class_from("langchain_anthropic", "ChatAnthropic") + return chat_class(model=model, api_key=api_key, max_retries=MAX_RETRIES) + + +PROVIDERS: dict[str, ModelProvider] = { + p.name: p for p in (GeminiProvider(), OpenAIProvider(), AnthropicProvider()) +} + + +def get_provider(name: str) -> ModelProvider: + try: + return PROVIDERS[name] + except KeyError: + raise ValueError( + f"Unknown LLM_PROVIDER {name!r}. Available: {', '.join(sorted(PROVIDERS))}." + ) from None diff --git a/backend/app/agent/system_prompt.py b/backend/app/agent/system_prompt.py new file mode 100644 index 0000000..0fa9358 --- /dev/null +++ b/backend/app/agent/system_prompt.py @@ -0,0 +1,42 @@ +"""System prompt: role and answering policy only. + +What each tool does, and when to prefer it, lives in that tool's own description, which the +API sends with every request. Repeating it here would send the same text twice per turn. +""" + +_ROLE = """You are the football analytics assistant for this application. You answer \ +questions about players using your tools, which read this application's own database. + +Scope: men's football only. The database holds season aggregates per player per \ +competition. Use the data_coverage tool when you are asked what the database contains.""" + +_POLICY = """How many tools to call: +- Use the FEWEST calls that answer the question. Usually that is exactly ONE. +- A metric tool takes player_name directly, so do NOT call identity first just to look \ +someone up. Call identity only when a name is ambiguous or you need a full profile. +- Call more than once only when: the question names two players or teams (one call each); \ +it spans two metric families, such as creating versus finishing (one call each); a call \ +came back empty and a broader filter is worth one retry; or a name matched several players. +- Otherwise stop at one call and answer. + +How to answer: +- Answer only from what the tools returned. Do not invent numbers, players or competitions. +- If the database does not cover the question, answer it from your own knowledge and \ +begin that part with "Not from the app's data:". Never reply with nothing, never refuse, \ +and never substitute a different metric for the one that was asked. +- s_final is the composite score and the default ranking metric. +- Mention low_sample_size when it is true, because those numbers are unreliable. +- A rate such as tackles_won_pct has no minimum-volume guard, so say how many attempts \ +it is based on rather than presenting it alone. + +How to write the answer: +- Give the answer directly. Be concise and specific. +- Never reveal your reasoning, the tools you called, their arguments, or raw rows. \ +The user sees your answer only.""" + + +def build_system_prompt() -> str: + return f"{_ROLE}\n\n{_POLICY}" + + +SYSTEM_PROMPT = build_system_prompt() diff --git a/backend/app/agent/tools/__init__.py b/backend/app/agent/tools/__init__.py new file mode 100644 index 0000000..a4d3d4d --- /dev/null +++ b/backend/app/agent/tools/__init__.py @@ -0,0 +1,27 @@ +from langchain_core.tools import BaseTool + +from app.agent.tools import ( + attacking, + composite_scores, + defending, + discipline, + goalkeeping, + identity, + playing_time, + shots, +) + +FAMILIES = [ + attacking, + shots, + defending, + goalkeeping, + discipline, + playing_time, + composite_scores, + identity, +] + + +def build_tools(repo) -> list[BaseTool]: + return [tool for family in FAMILIES for tool in family.functions.build(repo)] diff --git a/backend/app/agent/tools/attacking/__init__.py b/backend/app/agent/tools/attacking/__init__.py new file mode 100644 index 0000000..ecab877 --- /dev/null +++ b/backend/app/agent/tools/attacking/__init__.py @@ -0,0 +1 @@ +from app.agent.tools.attacking import functions, prompts # noqa: F401 diff --git a/backend/app/agent/tools/attacking/functions.py b/backend/app/agent/tools/attacking/functions.py new file mode 100644 index 0000000..18ccd84 --- /dev/null +++ b/backend/app/agent/tools/attacking/functions.py @@ -0,0 +1,21 @@ +from langchain_core.tools import BaseTool + +from app.agent.tools.attacking import prompts +from app.agent.tools.base import build_metric_tool + +METRICS = [ + "goals", + "assists", + "xg", + "xa", + "key_passes", + "big_chances_created", + "pk_won", + "pk_scored", +] + + +def build(repo) -> list[BaseTool]: + return [ + build_metric_tool(repo, name="attacking", description=prompts.DESCRIPTION, metrics=METRICS) + ] diff --git a/backend/app/agent/tools/attacking/prompts.py b/backend/app/agent/tools/attacking/prompts.py new file mode 100644 index 0000000..6600d57 --- /dev/null +++ b/backend/app/agent/tools/attacking/prompts.py @@ -0,0 +1,6 @@ +DESCRIPTION = ( + "Goal contribution and chance creation: goals, assists, xg, xa, key_passes, " + "big_chances_created, pk_won, pk_scored. Rank players, filter by a range, or read " + "one player's value. Use composite_scores instead when the question is about " + "overall quality or value." +) diff --git a/backend/app/agent/tools/base.py b/backend/app/agent/tools/base.py new file mode 100644 index 0000000..9ffd141 --- /dev/null +++ b/backend/app/agent/tools/base.py @@ -0,0 +1,89 @@ +"""Shared args schema and query executor for every metric-family tool.""" + +import logging +from typing import Literal + +from langchain_core.tools import BaseTool, StructuredTool +from pydantic import BaseModel, Field, create_model + +from app.agent.constants import MAX_ROWS, TOOL_ERROR +from app.config import settings +from app.domain.metric_fields import METRIC_FIELDS, python_value + +logger = logging.getLogger(__name__) + + +class MetricQuery(BaseModel): + metric: str + position: Literal["GK", "DF", "MF", "FW"] | None = None + team: str | None = None + nationality: str | None = None + competition: str | None = Field(None, description="Limit to one competition by name.") + player_name: str | None = None + min_value: float | None = None + max_value: float | None = None + order: Literal["desc", "asc"] = Field( + "desc", description="desc ranks highest first; asc for 'fewest' or 'least' questions." + ) + limit: int = Field(10, ge=1, description="How many rows to return.") + + +def _row(player, metric: str) -> dict: + return { + "name": player.name, + "team": player.team, + "position": player.position, + metric: python_value(player, metric), + "s_final": round(player.aggregated_scores.s_final, 2), + "minutes": player.aggregated_stats.minutes, + "sleeper_flag": player.aggregated_scores.underpredicted_flag, + "low_sample_size": player.low_sample_size, + } + + +def run_metric_query(repo, family: set[str], q: MetricQuery) -> list[dict]: + if q.metric not in family or q.metric not in METRIC_FIELDS: + return [{"error": f"{q.metric!r} is not available in this tool."}] + + filters: list[dict] = [] + if q.min_value is not None: + filters.append({"field": q.metric, "op": "gte", "value": float(q.min_value)}) + if q.max_value is not None: + filters.append({"field": q.metric, "op": "lte", "value": float(q.max_value)}) + + try: + players, _ = repo.get_players( + season=settings.season, + position=q.position, + team=q.team, + nationality=q.nationality, + name=q.player_name, + stats_view=q.competition, + sort_by=q.metric, + order=q.order, + filters=filters or None, + page=1, + page_size=min(q.limit, MAX_ROWS), + ) + except Exception: + logger.exception("Tool query failed for metric %s", q.metric) + return [{"error": TOOL_ERROR}] + + return [_row(p, q.metric) for p in players] + + +def build_metric_tool(repo, *, name: str, description: str, metrics: list[str]) -> BaseTool: + """Build one family tool whose `metric` argument is restricted to `metrics`.""" + family = set(metrics) + args_schema = create_model( + f"{name.title().replace('_', '')}Args", + __base__=MetricQuery, + metric=(Literal[tuple(metrics)], ...), # type: ignore[valid-type] + ) + + def _run(**kwargs) -> list[dict]: + return run_metric_query(repo, family, MetricQuery(**kwargs)) + + return StructuredTool.from_function( + func=_run, name=name, description=description, args_schema=args_schema + ) diff --git a/backend/app/agent/tools/composite_scores/__init__.py b/backend/app/agent/tools/composite_scores/__init__.py new file mode 100644 index 0000000..58d7d95 --- /dev/null +++ b/backend/app/agent/tools/composite_scores/__init__.py @@ -0,0 +1 @@ +from app.agent.tools.composite_scores import functions, prompts # noqa: F401 diff --git a/backend/app/agent/tools/composite_scores/functions.py b/backend/app/agent/tools/composite_scores/functions.py new file mode 100644 index 0000000..e316648 --- /dev/null +++ b/backend/app/agent/tools/composite_scores/functions.py @@ -0,0 +1,19 @@ +from langchain_core.tools import BaseTool + +from app.agent.tools.base import build_metric_tool +from app.agent.tools.composite_scores import prompts + +METRICS = [ + "s_final", + "offensive", + "defensive", + "tactical", +] + + +def build(repo) -> list[BaseTool]: + return [ + build_metric_tool( + repo, name="composite_scores", description=prompts.DESCRIPTION, metrics=METRICS + ) + ] diff --git a/backend/app/agent/tools/composite_scores/prompts.py b/backend/app/agent/tools/composite_scores/prompts.py new file mode 100644 index 0000000..4bcdd64 --- /dev/null +++ b/backend/app/agent/tools/composite_scores/prompts.py @@ -0,0 +1,7 @@ +DESCRIPTION = ( + "Overall quality, value and 'who is best' questions: s_final, offensive, defensive, " + "tactical. s_final is this application's own per-90 composite, adjusted for how " + "often a player starts and how many appearances back the number up; it is the " + "default ranking metric. Rows also carry sleeper_flag, so use this for undervalued " + "or underrated player questions." +) diff --git a/backend/app/agent/tools/defending/__init__.py b/backend/app/agent/tools/defending/__init__.py new file mode 100644 index 0000000..0625541 --- /dev/null +++ b/backend/app/agent/tools/defending/__init__.py @@ -0,0 +1 @@ +from app.agent.tools.defending import functions, prompts # noqa: F401 diff --git a/backend/app/agent/tools/defending/functions.py b/backend/app/agent/tools/defending/functions.py new file mode 100644 index 0000000..7801089 --- /dev/null +++ b/backend/app/agent/tools/defending/functions.py @@ -0,0 +1,29 @@ +from langchain_core.tools import BaseTool + +from app.agent.tools.base import build_metric_tool +from app.agent.tools.defending import prompts + +METRICS = [ + "clean_sheets", + "goals_conceded", + "penalty_conceded", + "tackles", + "tackles_won", + "tackles_won_pct", + "interceptions", + "clearances", + "blocks", + "aerial_duels_won", + "aerial_lost", + "aerial_duels_won_pct", + "ball_recoveries", + "dribbled_past", + "errors_lead_to_goal", + "errors_lead_to_shot", +] + + +def build(repo) -> list[BaseTool]: + return [ + build_metric_tool(repo, name="defending", description=prompts.DESCRIPTION, metrics=METRICS) + ] diff --git a/backend/app/agent/tools/defending/prompts.py b/backend/app/agent/tools/defending/prompts.py new file mode 100644 index 0000000..8d130ea --- /dev/null +++ b/backend/app/agent/tools/defending/prompts.py @@ -0,0 +1,9 @@ +DESCRIPTION = ( + "Defensive work and team defensive outcomes: tackles, tackles_won, tackles_won_pct, " + "interceptions, clearances, blocks, aerial_duels_won, aerial_lost, " + "aerial_duels_won_pct, ball_recoveries, dribbled_past, errors_lead_to_goal, " + "errors_lead_to_shot, clean_sheets, goals_conceded, penalty_conceded. dribbled_past " + "and the error counts are bad for the player, so lower is better. The _pct rates " + "have no minimum-volume guard, so prefer counts for 'best defender' questions and " + "say how many attempts a rate is based on." +) diff --git a/backend/app/agent/tools/discipline/__init__.py b/backend/app/agent/tools/discipline/__init__.py new file mode 100644 index 0000000..232dca1 --- /dev/null +++ b/backend/app/agent/tools/discipline/__init__.py @@ -0,0 +1 @@ +from app.agent.tools.discipline import functions, prompts # noqa: F401 diff --git a/backend/app/agent/tools/discipline/functions.py b/backend/app/agent/tools/discipline/functions.py new file mode 100644 index 0000000..f4cedbf --- /dev/null +++ b/backend/app/agent/tools/discipline/functions.py @@ -0,0 +1,18 @@ +from langchain_core.tools import BaseTool + +from app.agent.tools.base import build_metric_tool +from app.agent.tools.discipline import prompts + +METRICS = [ + "yellow_cards", + "red_cards", + "yellow_red_cards", + "direct_red_cards", + "fouls_committed", +] + + +def build(repo) -> list[BaseTool]: + return [ + build_metric_tool(repo, name="discipline", description=prompts.DESCRIPTION, metrics=METRICS) + ] diff --git a/backend/app/agent/tools/discipline/prompts.py b/backend/app/agent/tools/discipline/prompts.py new file mode 100644 index 0000000..ee62cf3 --- /dev/null +++ b/backend/app/agent/tools/discipline/prompts.py @@ -0,0 +1,5 @@ +DESCRIPTION = ( + "Cards and fouls: yellow_cards, red_cards, yellow_red_cards, direct_red_cards, " + "fouls_committed. red_cards is the display total; yellow_red_cards and " + "direct_red_cards are the two kinds that make it up, so do not add them to it." +) diff --git a/backend/app/agent/tools/goalkeeping/__init__.py b/backend/app/agent/tools/goalkeeping/__init__.py new file mode 100644 index 0000000..2ecb0ea --- /dev/null +++ b/backend/app/agent/tools/goalkeeping/__init__.py @@ -0,0 +1 @@ +from app.agent.tools.goalkeeping import functions, prompts # noqa: F401 diff --git a/backend/app/agent/tools/goalkeeping/functions.py b/backend/app/agent/tools/goalkeeping/functions.py new file mode 100644 index 0000000..95c52b9 --- /dev/null +++ b/backend/app/agent/tools/goalkeeping/functions.py @@ -0,0 +1,21 @@ +from langchain_core.tools import BaseTool + +from app.agent.tools.base import build_metric_tool +from app.agent.tools.goalkeeping import prompts + +METRICS = [ + "saves", + "saves_outside_box", + "goals_prevented", + "high_claims", + "pk_saved", + "penalty_faced", +] + + +def build(repo) -> list[BaseTool]: + return [ + build_metric_tool( + repo, name="goalkeeping", description=prompts.DESCRIPTION, metrics=METRICS + ) + ] diff --git a/backend/app/agent/tools/goalkeeping/prompts.py b/backend/app/agent/tools/goalkeeping/prompts.py new file mode 100644 index 0000000..8ef8634 --- /dev/null +++ b/backend/app/agent/tools/goalkeeping/prompts.py @@ -0,0 +1,5 @@ +DESCRIPTION = ( + "Goalkeeper shot-stopping and area control: saves, saves_outside_box, " + "goals_prevented, high_claims, pk_saved, penalty_faced. goals_prevented is versus " + "expected, so it can be negative. Clean sheets and goals conceded are in defending." +) diff --git a/backend/app/agent/tools/identity/__init__.py b/backend/app/agent/tools/identity/__init__.py new file mode 100644 index 0000000..5243be4 --- /dev/null +++ b/backend/app/agent/tools/identity/__init__.py @@ -0,0 +1 @@ +from app.agent.tools.identity import functions, prompts # noqa: F401 diff --git a/backend/app/agent/tools/identity/functions.py b/backend/app/agent/tools/identity/functions.py new file mode 100644 index 0000000..c7a2f64 --- /dev/null +++ b/backend/app/agent/tools/identity/functions.py @@ -0,0 +1,84 @@ +"""Non-metric tools: resolve a player by name, compare two, or report data coverage.""" + +import logging + +from langchain_core.tools import BaseTool, StructuredTool + +from app.agent.constants import MAX_ROWS, TOOL_ERROR +from app.agent.tools.identity import prompts +from app.config import settings + +logger = logging.getLogger(__name__) + +_NAME_MATCH_LIMIT = 5 + + +def _profile(player) -> dict: + stats, scores = player.aggregated_stats, player.aggregated_scores + return { + "name": player.name, + "team": player.team, + "position": player.position, + "position_exact": player.position_exact, + "nationality": player.nationality, + "minutes": stats.minutes, + "appearances": stats.appearances, + "goals": stats.goals, + "assists": stats.assists, + "s_final": round(scores.s_final, 2), + "sleeper_flag": scores.underpredicted_flag, + "low_sample_size": player.low_sample_size, + } + + +def _lookup(repo, name: str, limit: int = _NAME_MATCH_LIMIT) -> list[dict]: + players, _ = repo.get_players( + season=settings.season, name=name, page=1, page_size=min(limit, MAX_ROWS) + ) + return [_profile(p) for p in players] + + +def build(repo) -> list[BaseTool]: + def find_player(name: str) -> list[dict]: + try: + return _lookup(repo, name) + except Exception: + logger.exception("find_player failed for %s", name) + return [{"error": TOOL_ERROR}] + + def compare_players(first_name: str, second_name: str) -> list[dict]: + rows: list[dict] = [] + for who in (first_name, second_name): + try: + rows.extend(_lookup(repo, who, limit=1)) + except Exception: + logger.exception("compare_players failed for %s", who) + return [{"error": TOOL_ERROR}] + return rows + + def data_coverage() -> dict: + try: + competitions = repo.get_competition_list(settings.season) + fetched = repo.list_fetched_leagues() + except Exception: + logger.exception("data_coverage failed") + return {"error": TOOL_ERROR} + return { + "club_competitions": competitions.get("club", []), + "national_competitions": competitions.get("national", []), + "seasons": sorted({row["season"] for row in fetched if row.get("season")}), + } + + return [ + StructuredTool.from_function( + func=find_player, name="find_player", description=prompts.DESCRIPTION_FIND + ), + StructuredTool.from_function( + func=compare_players, + name="compare_players", + description=prompts.DESCRIPTION_COMPARE, + ), + StructuredTool.from_function( + func=data_coverage, name="data_coverage", description=prompts.DESCRIPTION_COVERAGE + ), + ] diff --git a/backend/app/agent/tools/identity/prompts.py b/backend/app/agent/tools/identity/prompts.py new file mode 100644 index 0000000..5227cc6 --- /dev/null +++ b/backend/app/agent/tools/identity/prompts.py @@ -0,0 +1,21 @@ +DESCRIPTION = ( + "Who a player is and what the database holds: look one player up by name, compare " + "two, or report which competitions and seasons have been fetched." +) + +DESCRIPTION_FIND = ( + "Look up one player by name: team, position, nationality, minutes, appearances, " + "goals, assists, s_final and sleeper flag. The metric tools already accept " + "player_name, so do NOT call this just to resolve a name — call it only when a name " + "is ambiguous, you need a full profile, or the user asks who someone is. An empty " + "result means no player matched; never invent one." +) + +DESCRIPTION_COMPARE = ( + "Return one profile row for each of two named players, for side-by-side comparison." +) + +DESCRIPTION_COVERAGE = ( + "Report what this database contains: which club and national competitions have been " + "fetched, and for which seasons. Use it when asked what data is available." +) diff --git a/backend/app/agent/tools/playing_time/__init__.py b/backend/app/agent/tools/playing_time/__init__.py new file mode 100644 index 0000000..36efd43 --- /dev/null +++ b/backend/app/agent/tools/playing_time/__init__.py @@ -0,0 +1 @@ +from app.agent.tools.playing_time import functions, prompts # noqa: F401 diff --git a/backend/app/agent/tools/playing_time/functions.py b/backend/app/agent/tools/playing_time/functions.py new file mode 100644 index 0000000..87b052d --- /dev/null +++ b/backend/app/agent/tools/playing_time/functions.py @@ -0,0 +1,19 @@ +from langchain_core.tools import BaseTool + +from app.agent.tools.base import build_metric_tool +from app.agent.tools.playing_time import prompts + +METRICS = [ + "minutes", + "appearances", + "matches_started", + "rating", +] + + +def build(repo) -> list[BaseTool]: + return [ + build_metric_tool( + repo, name="playing_time", description=prompts.DESCRIPTION, metrics=METRICS + ) + ] diff --git a/backend/app/agent/tools/playing_time/prompts.py b/backend/app/agent/tools/playing_time/prompts.py new file mode 100644 index 0000000..5dce392 --- /dev/null +++ b/backend/app/agent/tools/playing_time/prompts.py @@ -0,0 +1,4 @@ +DESCRIPTION = ( + "Availability and workload: minutes, appearances, matches_started, rating. Use for " + "questions about regular starters, squad players or rotation." +) diff --git a/backend/app/agent/tools/shots/__init__.py b/backend/app/agent/tools/shots/__init__.py new file mode 100644 index 0000000..82a1858 --- /dev/null +++ b/backend/app/agent/tools/shots/__init__.py @@ -0,0 +1 @@ +from app.agent.tools.shots import functions, prompts # noqa: F401 diff --git a/backend/app/agent/tools/shots/functions.py b/backend/app/agent/tools/shots/functions.py new file mode 100644 index 0000000..bed2231 --- /dev/null +++ b/backend/app/agent/tools/shots/functions.py @@ -0,0 +1,20 @@ +from langchain_core.tools import BaseTool + +from app.agent.tools.base import build_metric_tool +from app.agent.tools.shots import prompts + +METRICS = [ + "total_shots", + "shots_on_target", + "shots_off_target", + "scoring_frequency", + "headed_goals", + "left_foot_goals", + "right_foot_goals", + "pk_taken", + "penalty_miss", +] + + +def build(repo) -> list[BaseTool]: + return [build_metric_tool(repo, name="shots", description=prompts.DESCRIPTION, metrics=METRICS)] diff --git a/backend/app/agent/tools/shots/prompts.py b/backend/app/agent/tools/shots/prompts.py new file mode 100644 index 0000000..9eb1a23 --- /dev/null +++ b/backend/app/agent/tools/shots/prompts.py @@ -0,0 +1,6 @@ +DESCRIPTION = ( + "How a player shoots rather than what they produce: total_shots, shots_on_target, " + "shots_off_target, scoring_frequency, headed_goals, left_foot_goals, " + "right_foot_goals, pk_taken, penalty_miss. Use attacking for goals and assists " + "themselves." +) diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py new file mode 100644 index 0000000..970c443 --- /dev/null +++ b/backend/app/api/chat.py @@ -0,0 +1,38 @@ +import logging + +from fastapi import APIRouter, Depends, Response + +from app.agent.agent import ChatAgent +from app.agent.constants import GENERIC_ERROR +from app.api.modals.chat_modals import ChatHistory, ChatRequest, ChatResponse +from app.dependencies import get_agent + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.post("", response_model=ChatResponse) +async def chat(body: ChatRequest, agent: ChatAgent | None = Depends(get_agent)) -> ChatResponse: + """Answer one question. Never surfaces internal errors to the caller.""" + if agent is None: + return ChatResponse(answer=GENERIC_ERROR, session_id=body.session_id, degraded=True) + try: + result = await agent.answer(body.message, session_id=body.session_id) + except Exception: + logger.exception("Chat request failed for session %s", body.session_id) + return ChatResponse(answer=GENERIC_ERROR, session_id=body.session_id, degraded=True) + return ChatResponse(answer=result.answer, session_id=body.session_id, degraded=result.degraded) + + +@router.get("/sessions/{session_id}", response_model=ChatHistory) +def get_session(session_id: str, agent: ChatAgent | None = Depends(get_agent)) -> ChatHistory: + if agent is None: + return ChatHistory() + return ChatHistory(turns=agent.history(session_id)) + + +@router.delete("/sessions/{session_id}", status_code=204) +def clear_session(session_id: str, agent: ChatAgent | None = Depends(get_agent)) -> Response: + if agent is not None: + agent.clear(session_id) + return Response(status_code=204) diff --git a/backend/app/api/modals/chat_modals.py b/backend/app/api/modals/chat_modals.py new file mode 100644 index 0000000..b32032c --- /dev/null +++ b/backend/app/api/modals/chat_modals.py @@ -0,0 +1,24 @@ +from typing import Literal + +from pydantic import BaseModel, Field + + +class ChatRequest(BaseModel): + session_id: str = Field(min_length=1, max_length=128) + message: str = Field(min_length=1, max_length=2000) + + +# No field can carry a tool trace: tool names, arguments and rows never leave the server. +class ChatResponse(BaseModel): + answer: str + session_id: str + degraded: bool = False + + +class ChatTurn(BaseModel): + role: Literal["user", "assistant"] + content: str + + +class ChatHistory(BaseModel): + turns: list[ChatTurn] = [] diff --git a/backend/app/config.py b/backend/app/config.py index 3439cd0..78548de 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -17,6 +17,18 @@ class Settings(BaseSettings): cors_origins: list[str] = ["http://localhost:5173"] + # --- chatbot agent --- + # Provider and model are configurable; the defaults are Gemini's free tier. + llm_provider: str = "gemini" + llm_model: str = "" # empty means the provider's default model + llm_api_key: str = "" # empty means the provider SDK reads its own env var + llm_fallback_model: str = "" # empty means no fallback + gemini_api_key: str = "" + agent_max_tool_iterations: int = 8 + agent_max_rows: int = 25 + checkpoint_collection: str = "chat_checkpoints" + chat_session_ttl_seconds: int = 7 * 24 * 60 * 60 + model_config = {"env_file": ".env"} diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 168d41b..c7d766a 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -1,8 +1,10 @@ +from app.agent.agent import ChatAgent from app.infrastructure.mongo_repository import MongoRepository from app.modes.factory import ModeFactory _repo: MongoRepository | None = None _mode_factory: ModeFactory | None = None +_agent: ChatAgent | None = None def get_repo() -> MongoRepository: @@ -13,3 +15,8 @@ def get_repo() -> MongoRepository: def get_mode_factory() -> ModeFactory: assert _mode_factory is not None, "App not started" return _mode_factory + + +def get_agent() -> ChatAgent | None: + """The chat agent, or None when it could not be built (e.g. no GEMINI_API_KEY).""" + return _agent diff --git a/backend/app/infrastructure/mongo_repository.py b/backend/app/infrastructure/mongo_repository.py index 3ce17d0..0d954af 100644 --- a/backend/app/infrastructure/mongo_repository.py +++ b/backend/app/infrastructure/mongo_repository.py @@ -1,3 +1,4 @@ +import re from dataclasses import asdict, fields from datetime import UTC, datetime @@ -327,7 +328,8 @@ def get_players( if nationality: bio_query["nationality"] = nationality if name: - bio_query["name"] = {"$regex": name, "$options": "i"} + # The name is user text: escape it so "." cannot match every player. + bio_query["name"] = {"$regex": re.escape(name), "$options": "i"} stats_query: dict = {"season": season} if bio_query: diff --git a/backend/app/main.py b/backend/app/main.py index e364abc..87a551f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,4 @@ +import logging from collections.abc import AsyncGenerator from contextlib import asynccontextmanager @@ -6,13 +7,15 @@ from pymongo import MongoClient from app import dependencies -from app.api import analysis, fetch, players +from app.agent.agent import build_agent +from app.api import analysis, chat, fetch, players from app.config import settings from app.infrastructure.mongo_repository import MongoRepository from app.logging_config import configure_logging from app.modes.factory import ModeFactory configure_logging() +logger = logging.getLogger(__name__) @asynccontextmanager @@ -20,6 +23,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: mongo_client = MongoClient(settings.mongo_uri) dependencies._repo = MongoRepository(mongo_client) dependencies._mode_factory = ModeFactory(mongo_client) + try: + dependencies._agent = build_agent(dependencies._repo, mongo_client) + except Exception: + # The chatbot is optional: without it the rest of the API must still start. + logger.warning("Chat agent disabled: could not be built", exc_info=True) + dependencies._agent = None yield mongo_client.close() @@ -34,6 +43,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.include_router(fetch.router, prefix="/v1/fetch") app.include_router(players.router, prefix="/v1/players") app.include_router(analysis.router, prefix="/v1/analysis") +app.include_router(chat.router, prefix="/v1/chat") # Re-export for backward compatibility with tests that import from app.main diff --git a/backend/requirements.txt b/backend/requirements.txt index 4e140c7..063f2dd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,6 @@ fastapi==0.115.5 uvicorn[standard]==0.32.1 -pymongo==4.10.1 +pymongo>=4.12,<4.17 ScraperFC @ git+https://github.com/YonatanHen/ScraperFC.git@main pandas>=2.2.0 pyyaml==6.0.2 @@ -8,4 +8,9 @@ pydantic-settings==2.6.1 mongomock==4.2.0.post1 pytest==8.3.4 pytest-asyncio==0.24.0 -httpx[socks]==0.28.0 +httpx[socks]>=0.28.1,<0.29 +langchain[google-genai]>=1.3.0 +# floor pinned: <4 uses the legacy google-ai-generativelanguage SDK, 4.x uses google-genai +langchain-google-genai>=4.4,<5 +langgraph-checkpoint-mongodb>=0.4.0 +tenacity>=9.0.0 diff --git a/backend/tests/agent/__init__.py b/backend/tests/agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/agent/conftest.py b/backend/tests/agent/conftest.py new file mode 100644 index 0000000..dfbb885 --- /dev/null +++ b/backend/tests/agent/conftest.py @@ -0,0 +1,66 @@ +from collections.abc import Sequence +from typing import Any +from unittest.mock import MagicMock + +from langchain_core.callbacks import CallbackManagerForLLMRun +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage +from langchain_core.outputs import ChatGeneration, ChatResult + +from app.domain.models import AggregatedScores, PlayerDTO, Stats + + +class FakeToolCallingModel(BaseChatModel): + """Replays scripted AIMessages and supports bind_tools, which the built-in fakes do not.""" + + responses: list[AIMessage] = [] + index: int = 0 + + @property + def _llm_type(self) -> str: + return "fake-tool-calling" + + def bind_tools(self, tools: Sequence[Any], **kwargs: Any) -> "FakeToolCallingModel": + return self + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + message = self.responses[min(self.index, len(self.responses) - 1)] + self.index += 1 + return ChatResult(generations=[ChatGeneration(message=message)]) + + +def fake_player(name: str = "Player A", goals: int = 10) -> PlayerDTO: + return PlayerDTO( + sofascore_player_id="1", + name=name, + season="2025-2026", + position="FW", + position_exact="ST", + team="Team A", + nationality="Portugal", + photo_url="", + competitions=[], + aggregated_stats=Stats(goals=goals, assists=3, minutes=900), + aggregated_scores=AggregatedScores( + offensive=1, + defensive=0, + tactical=0, + s_final=5.5, + underpredicted_ratio=None, + underpredicted_flag=None, + ), + low_sample_size=False, + last_updated="2026-09-02T00:00:00+00:00", + ) + + +def fake_repo(rows: list[PlayerDTO] | None = None) -> MagicMock: + repo = MagicMock() + repo.get_players.return_value = (rows or [], len(rows or [])) + return repo diff --git a/backend/tests/agent/eval/__init__.py b/backend/tests/agent/eval/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/agent/eval/cases.py b/backend/tests/agent/eval/cases.py new file mode 100644 index 0000000..c26c4b3 --- /dev/null +++ b/backend/tests/agent/eval/cases.py @@ -0,0 +1,65 @@ +"""Eval questions paired with the repository call that computes their true answer.""" + +from collections.abc import Callable +from dataclasses import dataclass, field + +from app.config import settings + + +@dataclass +class EvalCase: + id: str + question: str + expected: Callable[..., list[str]] = field(repr=False) # repo -> names the answer must name + + +def _top(repo, metric: str, n: int, **filters) -> list[str]: + players, _ = repo.get_players( + season=settings.season, sort_by=metric, order="desc", page=1, page_size=n, **filters + ) + return [p.name for p in players] + + +def _first_match(repo, name: str) -> list[str]: + players, _ = repo.get_players(season=settings.season, name=name, page=1, page_size=1) + return [p.name for p in players] + + +CASES = [ + EvalCase( + id="top-forwards-goals", + question="Who are the top 5 forwards by goals?", + expected=lambda repo: _top(repo, "goals", 5, position="FW"), + ), + EvalCase( + id="gk-clean-sheets", + question="Which goalkeeper has the most clean sheets?", + expected=lambda repo: _top(repo, "clean_sheets", 1, position="GK"), + ), + EvalCase( + id="df-tackles", + question="Which defender has made the most tackles?", + expected=lambda repo: _top(repo, "tackles", 1, position="DF"), + ), + EvalCase( + id="team-assists", + question="Who has the most assists at Arsenal?", + expected=lambda repo: _top(repo, "assists", 1, team="Arsenal"), + ), + EvalCase( + id="nationality-goals", + question="Which Brazilian player has scored the most goals?", + expected=lambda repo: _top(repo, "goals", 1, nationality="Brazil"), + ), + EvalCase( + id="compare-two", + question="Compare Mohamed Salah and Bukayo Saka.", + expected=lambda repo: _first_match(repo, "Salah") + _first_match(repo, "Saka"), + ), + EvalCase( + # Outside the database: the model must answer from its own knowledge, not refuse. + id="outside-data", + question="Which country won the 2018 FIFA World Cup?", + expected=lambda repo: ["France"], + ), +] diff --git a/backend/tests/agent/eval/conftest.py b/backend/tests/agent/eval/conftest.py new file mode 100644 index 0000000..06a12c5 --- /dev/null +++ b/backend/tests/agent/eval/conftest.py @@ -0,0 +1,18 @@ +import pytest +from pymongo import MongoClient +from pymongo.errors import PyMongoError + +from app.config import settings +from app.infrastructure.mongo_repository import MongoRepository + + +@pytest.fixture(scope="module") +def live_repo() -> MongoRepository: + if not (settings.gemini_api_key or settings.llm_api_key): + pytest.fail("No API key is set; the eval needs a live model.") + client = MongoClient(settings.mongo_uri, serverSelectionTimeoutMS=3000) + try: + client.admin.command("ping") + except PyMongoError: + pytest.fail(f"MongoDB is not reachable at {settings.mongo_uri}; start the stack first.") + return MongoRepository(client) diff --git a/backend/tests/agent/eval/test_eval.py b/backend/tests/agent/eval/test_eval.py new file mode 100644 index 0000000..a9fb128 --- /dev/null +++ b/backend/tests/agent/eval/test_eval.py @@ -0,0 +1,60 @@ +"""Offline eval against the live model and DB. Run: AGENT_EVAL=1 pytest tests/agent/eval -v -s""" + +import os + +import pytest +from langgraph.checkpoint.memory import InMemorySaver + +from app.agent.agent import ChatAgent +from app.agent.llm import build_chat_model, build_fallback_model +from app.infrastructure.text_utils import normalize_text + +from .cases import CASES, EvalCase + +live = pytest.mark.skipif(not os.getenv("AGENT_EVAL"), reason="set AGENT_EVAL=1 to run the eval") + + +def mentions(answer: str, name: str) -> bool: + """True when the answer names the player in full or by surname.""" + text, full = normalize_text(answer), normalize_text(name) + # Surname-only is how people write answers; a shared surname can give a false pass. + return full in text or full.split()[-1] in text + + +def grade(case: EvalCase, answer: str, repo) -> tuple[str, str]: + """Return (verdict, detail); verdict is pass, fail or no-truth.""" + names = case.expected(repo) + if not names: + return "no-truth", "the DB has no rows for this question" + missing = [n for n in names if not mentions(answer, n)] + return ("fail", f"missing {missing}") if missing else ("pass", "") + + +@pytest.fixture(scope="module") +def results(): + collected: list[tuple[str, str, str]] = [] + yield collected + graded = [r for r in collected if r[1] != "no-truth"] + passed = sum(r[1] == "pass" for r in graded) + print(f"\n=== eval: {passed}/{len(graded)} passed ({len(collected) - len(graded)} no-truth)") + for case_id, verdict, detail in collected: + print(f" {verdict:8} {case_id} {detail}") + + +@live +@pytest.mark.asyncio +@pytest.mark.parametrize("case", CASES, ids=lambda c: c.id) +async def test_eval_case(case: EvalCase, live_repo, results): + agent = ChatAgent( + model=build_chat_model(), + repo=live_repo, + checkpointer=InMemorySaver(), + fallback_models=[m for m in [build_fallback_model()] if m], + ) + res = await agent.answer(case.question, session_id=f"eval-{case.id}") + verdict, detail = grade(case, res.answer, live_repo) + results.append((case.id, verdict, detail)) + print(f"\n[{case.id}] {res.answer}") + if verdict == "no-truth": + pytest.skip(detail) + assert verdict == "pass", detail diff --git a/backend/tests/agent/eval/test_grading.py b/backend/tests/agent/eval/test_grading.py new file mode 100644 index 0000000..cb9e1f7 --- /dev/null +++ b/backend/tests/agent/eval/test_grading.py @@ -0,0 +1,35 @@ +from unittest.mock import MagicMock + +from .cases import CASES, EvalCase +from .test_eval import grade, mentions + + +def test_mentions_accepts_accents_and_surnames(): + assert mentions("Kylian Mbappe leads with 30.", "Kylian Mbappé") + assert mentions("Saka has 12 assists.", "Bukayo Saka") + assert not mentions("Saka has 12 assists.", "Martin Odegaard") + + +def test_grade_fails_when_an_expected_name_is_missing(): + case = EvalCase(id="x", question="q", expected=lambda repo: ["Player A", "Player B"]) + assert grade(case, "Player A is first.", MagicMock()) == ("fail", "missing ['Player B']") + assert grade(case, "Player A, then Player B.", MagicMock()) == ("pass", "") + + +def test_grade_reports_no_truth_instead_of_failing(): + case = EvalCase(id="x", question="q", expected=lambda repo: []) + assert grade(case, "anything", MagicMock())[0] == "no-truth" + + +def test_case_ids_are_unique(): + ids = [c.id for c in CASES] + assert len(ids) == len(set(ids)) + + +def test_truth_is_derived_from_the_repository(): + repo = MagicMock() + repo.get_players.return_value = ([MagicMock(name="p")], 1) + repo.get_players.return_value[0][0].name = "Player A" + for case in CASES: + if case.id != "outside-data": # the only case with a fixed, non-repository truth + assert "Player A" in case.expected(repo) diff --git a/backend/tests/agent/test_agent.py b/backend/tests/agent/test_agent.py new file mode 100644 index 0000000..cb848b0 --- /dev/null +++ b/backend/tests/agent/test_agent.py @@ -0,0 +1,260 @@ +import pytest +from langchain_core.messages import AIMessage +from langgraph.checkpoint.memory import InMemorySaver + +from app.agent.agent import ChatAgent + +from .conftest import FakeToolCallingModel, fake_player, fake_repo + + +def _agent(responses, repo=None): + return ChatAgent( + model=FakeToolCallingModel(responses=responses), + repo=repo or fake_repo(), + checkpointer=InMemorySaver(), + ) + + +@pytest.mark.asyncio +async def test_plain_answer_is_returned_without_tools(): + res = await _agent([AIMessage(content="Ronaldo plays as a forward.")]).answer( + "what position does ronaldo play?", session_id="s1" + ) + assert res.answer == "Ronaldo plays as a forward." + assert res.used_tools is False + assert res.degraded is True # no tool backed it, so it is not from the app's data + + +@pytest.mark.asyncio +async def test_tool_call_is_dispatched_then_answered(): + scripted = [ + AIMessage( + content="", + tool_calls=[{"name": "attacking", "args": {"metric": "goals"}, "id": "call-1"}], + ), + AIMessage(content="Player A leads with 10 goals."), + ] + repo = fake_repo(rows=[fake_player()]) + res = await _agent(scripted, repo).answer("top scorer?", session_id="s2") + assert res.answer == "Player A leads with 10 goals." + assert res.used_tools is True + repo.get_players.assert_called_once() + + +@pytest.mark.asyncio +async def test_history_is_kept_per_session(): + agent = _agent([AIMessage(content="ok")]) + await agent.answer("first question", session_id="s3") + turns = agent.history("s3") + assert [t["role"] for t in turns] == ["user", "assistant"] + assert turns[0]["content"] == "first question" + + +@pytest.mark.asyncio +async def test_sessions_do_not_leak_into_each_other(): + agent = _agent([AIMessage(content="ok")]) + await agent.answer("question in one", session_id="s4") + assert agent.history("s5") == [] + + +@pytest.mark.asyncio +async def test_history_never_exposes_tool_messages_or_arguments(): + scripted = [ + AIMessage( + content="", + tool_calls=[{"name": "attacking", "args": {"metric": "goals"}, "id": "c1"}], + ), + AIMessage(content="Player A leads."), + ] + agent = _agent(scripted, fake_repo(rows=[fake_player()])) + await agent.answer("top scorer?", session_id="s7") + turns = agent.history("s7") + assert [t["role"] for t in turns] == ["user", "assistant"] + assert not any("attacking" in t["content"] for t in turns) + + +@pytest.mark.asyncio +async def test_model_failure_returns_the_generic_message(): + from app.agent.constants import GENERIC_ERROR + + model = FakeToolCallingModel(responses=[]) # index error on first call + agent = ChatAgent(model=model, repo=fake_repo(), checkpointer=InMemorySaver()) + res = await agent.answer("anything", session_id="s6") + assert res.answer == GENERIC_ERROR + assert res.degraded is True + + +@pytest.mark.asyncio +async def test_list_of_content_parts_is_returned_as_plain_text(): + # Gemini 3.x returns content as parts, not a string. + parts = [{"type": "text", "text": "Ronaldo plays "}, {"type": "text", "text": "as a forward."}] + agent = _agent([AIMessage(content=parts)]) + res = await agent.answer("position?", session_id="p1") + assert res.answer == "Ronaldo plays as a forward." + assert agent.history("p1")[1]["content"] == "Ronaldo plays as a forward." + + +@pytest.mark.asyncio +async def test_citation_check_reads_content_parts(): + scripted = [ + AIMessage( + content="", + tool_calls=[{"name": "attacking", "args": {"metric": "goals"}, "id": "c1"}], + ), + AIMessage(content=[{"type": "text", "text": "Player A scored 41 goals."}]), + ] + res = await _agent(scripted, fake_repo(rows=[fake_player(goals=10)])).answer( + "top scorer?", session_id="p2" + ) + assert res.answer == "Player A scored 41 goals." + assert res.degraded is True + + +@pytest.mark.asyncio +async def test_fallback_model_answers_when_the_primary_fails(): + agent = ChatAgent( + model=FakeToolCallingModel(responses=[]), # raises on first call + repo=fake_repo(), + checkpointer=InMemorySaver(), + fallback_models=[FakeToolCallingModel(responses=[AIMessage(content="from fallback")])], + ) + res = await agent.answer("anything", session_id="f1") + assert res.answer == "from fallback" + + +@pytest.mark.asyncio +async def test_a_turn_is_saved_once_not_after_every_step(): + scripted = [ + AIMessage( + content="", + tool_calls=[{"name": "attacking", "args": {"metric": "goals"}, "id": "c1"}], + ), + AIMessage(content="Player A leads."), + ] + saver = InMemorySaver() + agent = ChatAgent( + model=FakeToolCallingModel(responses=scripted), + repo=fake_repo(rows=[fake_player()]), + checkpointer=saver, + ) + await agent.answer("top scorer?", session_id="d1") + assert len(list(saver.list({"configurable": {"thread_id": "d1"}}))) == 1 + + +@pytest.mark.asyncio +async def test_prune_runs_once_after_a_successful_turn(): + pruned = [] + agent = ChatAgent( + model=FakeToolCallingModel(responses=[AIMessage(content="ok")]), + repo=fake_repo(), + checkpointer=InMemorySaver(), + prune=pruned.append, + ) + await agent.answer("hi", session_id="pr1") + assert pruned == ["pr1"] + + +@pytest.mark.asyncio +async def test_prune_is_skipped_when_the_turn_fails(): + pruned = [] + agent = ChatAgent( + model=FakeToolCallingModel(responses=[]), + repo=fake_repo(), + checkpointer=InMemorySaver(), + prune=pruned.append, + ) + await agent.answer("hi", session_id="pr2") + assert pruned == [] + + +@pytest.mark.asyncio +async def test_a_prune_failure_does_not_lose_the_answer(): + def broken(session_id): + raise RuntimeError("mongo down") + + agent = ChatAgent( + model=FakeToolCallingModel(responses=[AIMessage(content="ok")]), + repo=fake_repo(), + checkpointer=InMemorySaver(), + prune=broken, + ) + res = await agent.answer("hi", session_id="pr3") + assert res.answer == "ok" + + +@pytest.mark.asyncio +async def test_a_tool_from_an_earlier_turn_does_not_count_for_this_turn(): + scripted = [ + AIMessage( + content="", + tool_calls=[{"name": "attacking", "args": {"metric": "goals"}, "id": "c1"}], + ), + AIMessage(content="Player A leads."), + AIMessage(content="France won it."), # second turn: no tool + ] + agent = _agent(scripted, fake_repo(rows=[fake_player()])) + await agent.answer("top scorer?", session_id="x1") + res = await agent.answer("who won the 2018 world cup?", session_id="x1") + assert res.used_tools is False + assert res.answer == "France won it." + + +@pytest.mark.asyncio +async def test_rows_from_an_earlier_turn_do_not_support_this_answer(): + call = {"name": "attacking", "args": {"metric": "goals"}, "id": "c1"} + scripted = [ + AIMessage(content="", tool_calls=[call]), + AIMessage(content="Player A has 41 goals."), + AIMessage(content="", tool_calls=[{**call, "id": "c2"}]), + AIMessage(content="Player A has 41 goals."), + ] + repo = fake_repo(rows=[fake_player(goals=41)]) + agent = _agent(scripted, repo) + first = await agent.answer("top scorer?", session_id="x2") + assert first.degraded is False + repo.get_players.return_value = ([fake_player(goals=10)], 1) + second = await agent.answer("and now?", session_id="x2") + assert second.degraded is True # 41 is only in the first turn's rows + + +@pytest.mark.asyncio +async def test_clear_drops_the_thread(): + agent = _agent([AIMessage(content="ok")]) + await agent.answer("remember this", session_id="s8") + assert agent.history("s8") + agent.clear("s8") + assert agent.history("s8") == [] + + +@pytest.mark.asyncio +async def test_an_answer_with_no_tool_behind_it_is_marked_unverified(): + # The prompt lets the model answer outside questions from its own knowledge. + res = await _agent([AIMessage(content="Not from the app's data: France won in 2018.")]).answer( + "who won the 2018 world cup?", session_id="u1" + ) + assert res.used_tools is False + assert res.degraded is True + + +@pytest.mark.asyncio +async def test_history_hides_text_that_came_with_a_tool_call(): + # Such text is the model's preamble; the live view never showed it. + scripted = [ + AIMessage( + content="Let me check the attacking tool.", + tool_calls=[{"name": "attacking", "args": {"metric": "goals"}, "id": "c1"}], + ), + AIMessage(content="Player A leads."), + ] + agent = _agent(scripted, fake_repo(rows=[fake_player()])) + await agent.answer("top scorer?", session_id="h1") + assert [t["content"] for t in agent.history("h1")] == ["top scorer?", "Player A leads."] + + +def test_clearing_a_session_survives_a_storage_failure(): + checkpointer = InMemorySaver() + checkpointer.delete_thread = lambda session_id: (_ for _ in ()).throw(RuntimeError("mongo")) + agent = ChatAgent( + model=FakeToolCallingModel(responses=[]), repo=fake_repo(), checkpointer=checkpointer + ) + agent.clear("gone") # must not raise: the API returns 204 either way diff --git a/backend/tests/agent/test_answer_check.py b/backend/tests/agent/test_answer_check.py new file mode 100644 index 0000000..e19542f --- /dev/null +++ b/backend/tests/agent/test_answer_check.py @@ -0,0 +1,132 @@ +import pytest +from langchain_core.messages import AIMessage, ToolMessage +from langgraph.checkpoint.memory import InMemorySaver + +from app.agent.agent import ChatAgent +from app.agent.answer_check import uncited_numbers + +from .conftest import FakeToolCallingModel, fake_player, fake_repo + +ROWS = [{"name": "Player A", "goals": 12, "s_final": 7.25, "minutes": 1200}] + + +def test_grounded_answer_has_no_uncited_numbers(): + assert uncited_numbers("Player A scored 12 goals in 1200 minutes.", ROWS) == [] + + +def test_invented_number_is_flagged(): + assert uncited_numbers("Player A scored 19 goals.", ROWS) == ["19"] + + +def test_rounded_citation_is_accepted(): + # 7.25 rendered as 7.3 or 7 is a formatting choice, not a fabrication. + assert uncited_numbers("His score is 7.3.", ROWS) == [] + assert uncited_numbers("His score is 7.2.", ROWS) == [] + assert uncited_numbers("His score is about 7.", ROWS) == [] + + +def test_a_number_close_to_a_row_value_but_outside_rounding_is_flagged(): + # 7.5 cannot be a rendering of 7.25; only genuine rounding is tolerated. + assert uncited_numbers("His score is 7.5.", ROWS) == ["7.5"] + + +def test_ordinals_and_small_counts_are_ignored(): + # "top 5", "the 3 players" describe the query, not a stat. + assert uncited_numbers("The top 5 are led by Player A.", ROWS) == [] + + +def test_number_inside_a_name_is_not_a_statistic(): + rows = [{"name": "Player 750", "goals": 12}] + assert uncited_numbers("Player 750 scored 12.", rows) == [] + + +def test_every_invented_number_is_reported_not_just_the_first(): + out = uncited_numbers("He scored 19 goals and 23 assists.", ROWS) + assert out == ["19", "23"] + + +def test_an_answer_with_no_rows_flags_every_real_figure(): + # The web fallback path has no rows; a figure there is ungrounded by definition. + assert uncited_numbers("He scored 19 goals.", []) == ["19"] + + +def test_an_answer_with_no_numbers_is_clean(): + assert uncited_numbers("Player A leads the table.", ROWS) == [] + + +def test_booleans_are_not_treated_as_citable_numbers(): + # low_sample_size=True must not make "1" a cited value. + rows = [{"name": "Player A", "goals": 12, "low_sample_size": True}] + assert uncited_numbers("He scored 41 goals.", rows) == ["41"] + + +@pytest.mark.asyncio +async def test_agent_flags_an_invented_figure_but_still_returns_the_answer(): + scripted = [ + AIMessage( + content="", + tool_calls=[{"name": "attacking", "args": {"metric": "goals"}, "id": "c1"}], + ), + AIMessage(content="Player A scored 41 goals."), # the row says 10 + ] + agent = ChatAgent( + model=FakeToolCallingModel(responses=scripted), + repo=fake_repo(rows=[fake_player(goals=10)]), + checkpointer=InMemorySaver(), + ) + res = await agent.answer("top scorer?", session_id="ac1") + assert res.answer == "Player A scored 41 goals." + assert res.degraded is True + + +@pytest.mark.asyncio +async def test_agent_does_not_flag_a_grounded_answer(): + scripted = [ + AIMessage( + content="", + tool_calls=[{"name": "attacking", "args": {"metric": "goals"}, "id": "c1"}], + ), + AIMessage(content="Player A scored 10 goals in 900 minutes."), + ] + agent = ChatAgent( + model=FakeToolCallingModel(responses=scripted), + repo=fake_repo(rows=[fake_player(goals=10)]), + checkpointer=InMemorySaver(), + ) + res = await agent.answer("top scorer?", session_id="ac2") + assert res.degraded is False + + +def test_unreadable_tool_output_skips_the_check_rather_than_guessing(): + from app.agent.agent import _tool_rows + + assert _tool_rows([ToolMessage(content="not json", tool_call_id="c1")]) is None + + +def test_thousand_separators_are_read_as_one_number(): + # The model writes "3,380 minutes"; naive splitting reads 3 and 380 and flags the answer. + rows = [{"name": "Player A", "minutes": 3380, "tackles": 103}] + assert uncited_numbers("He played 3,380 minutes and made 103 tackles.", rows) == [] + + +def test_a_separated_number_that_no_row_supports_is_still_flagged(): + rows = [{"name": "Player A", "minutes": 3380}] + assert uncited_numbers("He played 9,999 minutes.", rows) == ["9,999"] + + +def test_values_inside_lists_count_as_cited(): + # data_coverage returns lists only; ignoring them flagged every coverage answer. + rows = [{"seasons": ["2025-2026"], "club_competitions": ["England Premier League"]}] + assert uncited_numbers("I have 2025-2026 data for the England Premier League.", rows) == [] + + +def test_values_inside_nested_dicts_count_as_cited(): + rows = [{"totals": {"minutes": 3380}}] + assert uncited_numbers("He played 3380 minutes.", rows) == [] + + +def test_ordered_list_markers_are_not_statistics(): + # "11. Player Name" is markdown, not a figure the tools had to return. + rows = [{"name": "Player A", "goals": 12}] + answer = "1. Player A - 12 goals\n11. Player B\n12. Player C" + assert uncited_numbers(answer, rows) == [] diff --git a/backend/tests/agent/test_checkpoints.py b/backend/tests/agent/test_checkpoints.py new file mode 100644 index 0000000..aa2f959 --- /dev/null +++ b/backend/tests/agent/test_checkpoints.py @@ -0,0 +1,97 @@ +import mongomock +import pytest +from langchain_core.messages import AIMessage + +from app.agent.agent import ChatAgent +from app.agent.checkpoints import build_checkpointer, keep_latest_checkpoint +from app.config import settings + +from .conftest import FakeToolCallingModel, fake_player, fake_repo + +WEEK = 7 * 24 * 60 * 60 + + +def _tool_turn_then_plain_turn(): + return [ + AIMessage( + content="", + tool_calls=[{"name": "attacking", "args": {"metric": "goals"}, "id": "c1"}], + ), + AIMessage(content="Player A leads with 10 goals."), + AIMessage(content="You asked about top scorers."), + ] + + +@pytest.fixture +def mongo(): + return mongomock.MongoClient() + + +@pytest.fixture +def saver(mongo): + return build_checkpointer(mongo) + + +def _agent(saver, responses): + return ChatAgent( + model=FakeToolCallingModel(responses=responses), + repo=fake_repo(rows=[fake_player()]), + checkpointer=saver, + prune=lambda session_id: keep_latest_checkpoint(saver, session_id), + ) + + +def test_session_ttl_defaults_to_one_week(): + assert settings.chat_session_ttl_seconds == WEEK + + +def test_checkpoints_expire_after_the_configured_ttl(saver): + indexes = saver.checkpoint_collection.index_information().values() + assert any(ix.get("expireAfterSeconds") == WEEK for ix in indexes) + + +@pytest.mark.asyncio +async def test_a_session_is_stored_as_one_document(saver): + agent = _agent(saver, _tool_turn_then_plain_turn()) + await agent.answer("top scorer?", session_id="t1") + await agent.answer("what did I ask?", session_id="t1") + assert saver.checkpoint_collection.count_documents({"thread_id": "t1"}) == 1 + assert saver.writes_collection.count_documents({"thread_id": "t1"}) == 0 + + +@pytest.mark.asyncio +async def test_history_survives_pruning(saver): + # Guard: if an upgrade stores deltas instead of full state, pruning would empty history. + agent = _agent(saver, _tool_turn_then_plain_turn()) + await agent.answer("top scorer?", session_id="t2") + await agent.answer("what did I ask?", session_id="t2") + assert agent.history("t2") == [ + {"role": "user", "content": "top scorer?"}, + {"role": "assistant", "content": "Player A leads with 10 goals."}, + {"role": "user", "content": "what did I ask?"}, + {"role": "assistant", "content": "You asked about top scorers."}, + ] + + +@pytest.mark.asyncio +async def test_the_kept_document_carries_the_ttl_timestamp(saver): + agent = _agent(saver, [AIMessage(content="ok")]) + await agent.answer("hi", session_id="t3") + assert saver.checkpoint_collection.find_one({"thread_id": "t3"})["created_at"] + + +@pytest.mark.asyncio +async def test_pruning_one_session_leaves_other_sessions_alone(saver): + agent = _agent(saver, [AIMessage(content="ok")]) + await agent.answer("one", session_id="a") + await agent.answer("two", session_id="b") + keep_latest_checkpoint(saver, "a") + assert agent.history("b") == [ + {"role": "user", "content": "two"}, + {"role": "assistant", "content": "ok"}, + ] + + +def test_pruning_an_unknown_session_is_a_no_op(saver): + keep_latest_checkpoint(saver, "never-used") + assert saver.checkpoint_collection.count_documents({}) == 0 diff --git a/backend/tests/agent/test_identity_tools.py b/backend/tests/agent/test_identity_tools.py new file mode 100644 index 0000000..a4de46b --- /dev/null +++ b/backend/tests/agent/test_identity_tools.py @@ -0,0 +1,94 @@ +from unittest.mock import MagicMock + +from app.agent.tools.identity import functions +from app.domain.models import AggregatedScores, PlayerDTO, Stats + + +def _player(name): + return PlayerDTO( + sofascore_player_id="1", + name=name, + season="2025-2026", + position="FW", + position_exact="ST", + team="Team A", + nationality="Portugal", + photo_url="", + competitions=[], + aggregated_stats=Stats( + goals=12, assists=4, minutes=1200, appearances=15, matches_started=14 + ), + aggregated_scores=AggregatedScores( + offensive=8, + defensive=0, + tactical=1, + s_final=7.25, + underpredicted_ratio=1.4, + underpredicted_flag="HIGH_VALUE", + ), + low_sample_size=False, + last_updated="2026-09-02T00:00:00+00:00", + ) + + +def _tools(repo): + return {t.name: t for t in functions.build(repo)} + + +def test_find_player_returns_a_profile(): + repo = MagicMock() + repo.get_players.return_value = ([_player("Player A")], 1) + out = _tools(repo)["find_player"].invoke({"name": "Player A"}) + assert out[0]["name"] == "Player A" + assert out[0]["position"] == "FW" + assert out[0]["sleeper_flag"] == "HIGH_VALUE" + + +def test_find_player_reports_no_match_without_inventing_one(): + repo = MagicMock() + repo.get_players.return_value = ([], 0) + out = _tools(repo)["find_player"].invoke({"name": "Nobody"}) + assert out == [] + + +def test_compare_players_returns_one_row_each(): + repo = MagicMock() + repo.get_players.side_effect = [([_player("A")], 1), ([_player("B")], 1)] + out = _tools(repo)["compare_players"].invoke({"first_name": "A", "second_name": "B"}) + assert [r["name"] for r in out] == ["A", "B"] + + +def test_compare_players_keeps_the_half_it_found(): + # One unknown name must not discard the player that did match. + repo = MagicMock() + repo.get_players.side_effect = [([_player("A")], 1), ([], 0)] + out = _tools(repo)["compare_players"].invoke({"first_name": "A", "second_name": "Nobody"}) + assert [r["name"] for r in out] == ["A"] + + +def test_data_coverage_reports_competitions_and_seasons(): + repo = MagicMock() + repo.get_competition_list.return_value = {"club": ["England Premier League"], "national": []} + repo.list_fetched_leagues.return_value = [ + {"competition": "England Premier League", "season": "2025-2026", "updated_at": "x"} + ] + out = _tools(repo)["data_coverage"].invoke({}) + assert "England Premier League" in out["club_competitions"] + assert out["seasons"] == ["2025-2026"] + + +def test_a_repository_failure_returns_a_tool_error_not_an_exception(): + from app.agent.constants import TOOL_ERROR + + repo = MagicMock() + repo.get_players.side_effect = RuntimeError("mongo is down") + out = _tools(repo)["find_player"].invoke({"name": "Player A"}) + assert out == [{"error": TOOL_ERROR}] + + +def test_identity_tools_are_registered_in_the_family_list(): + from app.agent.tools import FAMILIES, build_tools + + assert any(f.__name__.endswith("identity") for f in FAMILIES) + names = [t.name for t in build_tools(MagicMock())] + assert {"find_player", "compare_players", "data_coverage"} <= set(names) diff --git a/backend/tests/agent/test_providers.py b/backend/tests/agent/test_providers.py new file mode 100644 index 0000000..e7ab61e --- /dev/null +++ b/backend/tests/agent/test_providers.py @@ -0,0 +1,134 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from app.agent.llm import build_chat_model, build_fallback_model +from app.agent.providers import PROVIDERS, get_provider +from app.config import settings + + +def test_gemini_is_the_default_provider_and_needs_no_configuration(): + assert settings.llm_provider == "gemini" + # 3.6-flash is capped at 20 free requests a day, which the chat exhausts in minutes. + assert get_provider(settings.llm_provider).default_model == "gemini-3.5-flash" + + +def test_every_provider_is_registered_under_its_own_name(): + assert {"gemini", "openai", "anthropic"} <= set(PROVIDERS) + assert all(name == provider.name for name, provider in PROVIDERS.items()) + + +def test_an_unknown_provider_names_the_valid_ones(): + with pytest.raises(ValueError, match="gemini"): + get_provider("llama-at-home") + + +def test_the_configured_model_wins_over_the_provider_default(): + with patch.object(settings, "llm_model", "gemini-3.5-flash-lite"): + with patch("app.agent.providers.ChatGoogleGenerativeAI") as ctor: + build_chat_model() + assert ctor.call_args.kwargs["model"] == "gemini-3.5-flash-lite" + + +def test_an_explicit_model_argument_wins_over_the_configuration(): + with patch("app.agent.providers.ChatGoogleGenerativeAI") as ctor: + build_chat_model("some-other-model") + assert ctor.call_args.kwargs["model"] == "some-other-model" + + +def test_a_paid_provider_without_a_model_says_which_setting_is_missing(): + with patch.object(settings, "llm_provider", "openai"), patch.object(settings, "llm_model", ""): + with pytest.raises(ValueError, match="LLM_MODEL"): + build_chat_model() + + +def test_a_paid_provider_builds_its_own_chat_class(): + module = MagicMock() + with ( + patch.object(settings, "llm_provider", "anthropic"), + patch.object(settings, "llm_model", "some-anthropic-model"), + patch.object(settings, "llm_api_key", "k-1"), + ): + with patch("importlib.import_module", return_value=module): + model = build_chat_model() + assert model is module.ChatAnthropic.return_value + assert module.ChatAnthropic.call_args.kwargs["model"] == "some-anthropic-model" + + +def test_a_missing_provider_package_explains_what_to_install(): + with ( + patch.object(settings, "llm_provider", "openai"), + patch.object(settings, "llm_model", "some-openai-model"), + ): + with patch("importlib.import_module", side_effect=ImportError("no module")): + with pytest.raises(RuntimeError, match="langchain-openai"): + build_chat_model() + + +def test_the_generic_key_is_used_when_set(): + with patch.object(settings, "llm_api_key", "generic-key"): + with patch("app.agent.providers.ChatGoogleGenerativeAI") as ctor: + build_chat_model() + assert ctor.call_args.kwargs["api_key"] == "generic-key" + + +def test_gemini_still_reads_the_key_this_project_already_ships(): + with ( + patch.object(settings, "llm_api_key", ""), + patch.object(settings, "gemini_api_key", "legacy-key"), + ): + with patch("app.agent.providers.ChatGoogleGenerativeAI") as ctor: + build_chat_model() + assert ctor.call_args.kwargs["api_key"] == "legacy-key" + + +def test_an_unset_key_is_passed_as_none_so_the_sdk_reads_its_own_env_var(): + with patch.object(settings, "llm_api_key", ""), patch.object(settings, "gemini_api_key", ""): + with patch("app.agent.providers.ChatGoogleGenerativeAI") as ctor: + build_chat_model() + assert ctor.call_args.kwargs["api_key"] is None + + +def test_a_gemini_key_is_never_sent_to_another_provider(): + module = MagicMock() + with ( + patch.object(settings, "llm_provider", "openai"), + patch.object(settings, "llm_model", "m"), + patch.object(settings, "llm_api_key", ""), + patch.object(settings, "gemini_api_key", "gemini-only"), + ): + with patch("importlib.import_module", return_value=module): + build_chat_model() + assert module.ChatOpenAI.call_args.kwargs["api_key"] is None + + +def test_there_is_no_fallback_model_unless_one_is_configured(): + assert settings.llm_fallback_model == "" + assert build_fallback_model() is None + + +def test_a_configured_fallback_model_is_built_with_the_same_provider(): + with patch.object(settings, "llm_fallback_model", "gemini-3.5-flash-lite"): + with patch("app.agent.providers.ChatGoogleGenerativeAI") as ctor: + model = build_fallback_model() + assert model is ctor.return_value + assert ctor.call_args.kwargs["model"] == "gemini-3.5-flash-lite" + + +def test_no_network_call_is_made_when_building_the_model(): + # The agent is built at startup; a call here would stop the app from booting. + with patch("app.agent.providers.ChatGoogleGenerativeAI") as ctor: + model = build_chat_model() + assert model is ctor.return_value + ctor.return_value.invoke.assert_not_called() + + +def test_the_installed_gemini_class_accepts_every_kwarg_we_pass(): + from langchain_core.language_models import BaseChatModel + from langchain_google_genai import ChatGoogleGenerativeAI + + fields = ChatGoogleGenerativeAI.model_fields + accepted = set(fields) | {f.alias for f in fields.values() if f.alias} + assert {"model", "api_key", "max_retries"} <= accepted # api_key is an alias + assert issubclass(ChatGoogleGenerativeAI, BaseChatModel) + assert ChatGoogleGenerativeAI(model="m", api_key="k").google_api_key is not None diff --git a/backend/tests/agent/test_system_prompt.py b/backend/tests/agent/test_system_prompt.py new file mode 100644 index 0000000..e0aeacd --- /dev/null +++ b/backend/tests/agent/test_system_prompt.py @@ -0,0 +1,49 @@ +from app.agent.system_prompt import SYSTEM_PROMPT, build_system_prompt +from app.agent.tools import FAMILIES + + +def test_prompt_does_not_duplicate_tool_descriptions(): + # Tool descriptions are sent with every request as part of the tool schemas. + # Repeating them here would cost the same tokens twice on every turn. + for family in FAMILIES: + assert family.prompts.DESCRIPTION not in SYSTEM_PROMPT + + +def test_prompt_states_the_scope_and_the_answer_policy(): + lowered = SYSTEM_PROMPT.lower() + assert "men's" in lowered + assert "do not invent" in lowered or "never invent" in lowered + + +def test_prompt_forbids_revealing_reasoning_or_tools(): + lowered = SYSTEM_PROMPT.lower() + assert "reasoning" in lowered + assert "tool" in lowered + + +def test_prompt_sets_a_one_call_default(): + lowered = SYSTEM_PROMPT.lower() + assert "fewest calls" in lowered + assert "exactly one" in lowered + + +def test_prompt_forbids_refusing(): + lowered = SYSTEM_PROMPT.lower() + assert "never refuse" in lowered + + +def test_prompt_tells_the_model_not_to_resolve_names_with_identity_first(): + # The main way a one-call question becomes two (spec 7.1). + lowered = SYSTEM_PROMPT.lower() + assert "player_name" in lowered + assert "do not call identity first" in lowered + + +def test_prompt_requires_flagging_unreliable_rows(): + assert "low_sample_size" in SYSTEM_PROMPT + + +def test_prompt_stays_small_because_it_is_sent_every_turn(): + rebuilt = build_system_prompt() + assert rebuilt == SYSTEM_PROMPT + assert len(SYSTEM_PROMPT) < 2200, "system prompt grew; it is sent on every request" diff --git a/backend/tests/agent/test_tool_families.py b/backend/tests/agent/test_tool_families.py new file mode 100644 index 0000000..002b607 --- /dev/null +++ b/backend/tests/agent/test_tool_families.py @@ -0,0 +1,31 @@ +from unittest.mock import MagicMock + +from app.agent.tools import FAMILIES, build_tools +from app.domain.metric_fields import METRIC_FIELDS + +METRIC_FAMILIES = [f for f in FAMILIES if f.__name__.rsplit(".", 1)[-1] != "identity"] + + +def test_every_allowlisted_metric_belongs_to_exactly_one_family(): + seen: list[str] = [] + for family in METRIC_FAMILIES: + seen.extend(family.functions.METRICS) + assert len(seen) == len(set(seen)), "a metric appears in two families" + assert set(seen) == set(METRIC_FIELDS), "families must cover METRIC_FIELDS exactly" + + +def test_each_family_declares_a_description(): + # The description is the only routing text sent; it must say what the tool covers + # and when to prefer it, because the system prompt no longer repeats that. + for family in FAMILIES: + assert family.prompts.DESCRIPTION.strip() + assert not hasattr(family.prompts, "GUIDANCE"), ( + f"{family.__name__} still declares GUIDANCE; it would be sent twice per turn" + ) + + +def test_build_tools_returns_uniquely_named_tools(): + tools = build_tools(MagicMock()) + names = [t.name for t in tools] + assert len(names) == len(set(names)) + assert "attacking" in names and "goalkeeping" in names diff --git a/backend/tests/agent/test_tools_base.py b/backend/tests/agent/test_tools_base.py new file mode 100644 index 0000000..4ad7e31 --- /dev/null +++ b/backend/tests/agent/test_tools_base.py @@ -0,0 +1,108 @@ +from unittest.mock import MagicMock + +import pytest +from pydantic import ValidationError + +from app.agent.tools.base import MetricQuery, build_metric_tool, run_metric_query +from app.domain.models import AggregatedScores, PlayerDTO, Stats + +FAMILY = {"goals", "assists"} + + +def _player(name="Player A", goals=10): + return PlayerDTO( + sofascore_player_id="1", + name=name, + season="2025-2026", + position="FW", + position_exact="ST", + team="Team A", + nationality="Portugal", + photo_url="", + competitions=[], + aggregated_stats=Stats(goals=goals, assists=3, minutes=900), + aggregated_scores=AggregatedScores( + offensive=1, + defensive=0, + tactical=0, + s_final=5.5, + underpredicted_ratio=None, + underpredicted_flag=None, + ), + low_sample_size=False, + last_updated="2026-09-02T00:00:00+00:00", + ) + + +def _repo(players): + repo = MagicMock() + repo.get_players.return_value = (players, len(players)) + return repo + + +def test_rank_returns_rows_with_the_requested_metric(): + repo = _repo([_player(goals=10), _player("Player B", goals=7)]) + rows = run_metric_query(repo, FAMILY, MetricQuery(metric="goals")) + assert [r["name"] for r in rows] == ["Player A", "Player B"] + assert rows[0]["goals"] == 10 + assert rows[0]["s_final"] == 5.5 + assert repo.get_players.call_args.kwargs["sort_by"] == "goals" + + +def test_unknown_metric_is_rejected_before_reaching_mongo(): + repo = _repo([]) + rows = run_metric_query(repo, FAMILY, MetricQuery(metric="saves")) + assert rows and "error" in rows[0] + repo.get_players.assert_not_called() + + +def test_filter_translates_min_max_into_allowlisted_clauses(): + repo = _repo([_player()]) + run_metric_query(repo, FAMILY, MetricQuery(metric="goals", min_value=5, max_value=20)) + filters = repo.get_players.call_args.kwargs["filters"] + assert {"field": "goals", "op": "gte", "value": 5.0} in filters + assert {"field": "goals", "op": "lte", "value": 20.0} in filters + + +def test_limit_is_capped_by_max_rows(): + from app.agent.constants import MAX_ROWS + + repo = _repo([_player()]) + run_metric_query(repo, FAMILY, MetricQuery(metric="goals", limit=10_000)) + assert repo.get_players.call_args.kwargs["page_size"] == MAX_ROWS + + +def test_built_tool_exposes_only_its_family_metrics(): + tool = build_metric_tool( + _repo([]), name="attacking", description="d", metrics=["goals", "assists"] + ) + assert tool.name == "attacking" + schema = tool.args_schema.model_json_schema() + allowed = schema["properties"]["metric"]["enum"] + assert set(allowed) == {"goals", "assists"} + + +def test_limit_cannot_disable_the_row_cap(): + # Mongo reads limit(0) as "no limit", which would dump the whole collection. + for bad in (0, -1): + with pytest.raises(ValidationError): + MetricQuery(metric="goals", limit=bad) + + +def test_lowest_first_questions_can_be_answered(): + # goals_conceded, dribbled_past and errors_lead_to_goal are "lower is better". + repo = _repo([_player()]) + run_metric_query(repo, FAMILY, MetricQuery(metric="goals", order="asc")) + assert repo.get_players.call_args.kwargs["order"] == "asc" + + +def test_sorting_is_highest_first_unless_asked_otherwise(): + repo = _repo([_player()]) + run_metric_query(repo, FAMILY, MetricQuery(metric="goals")) + assert repo.get_players.call_args.kwargs["order"] == "desc" + + +def test_the_built_tool_offers_both_sort_directions(): + tool = build_metric_tool(_repo([]), name="attacking", description="d", metrics=["goals"]) + schema = tool.args_schema.model_json_schema() + assert set(schema["properties"]["order"]["enum"]) == {"asc", "desc"} diff --git a/backend/tests/api/test_chat_api.py b/backend/tests/api/test_chat_api.py new file mode 100644 index 0000000..5752a7d --- /dev/null +++ b/backend/tests/api/test_chat_api.py @@ -0,0 +1,120 @@ +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app import dependencies, main +from app.agent.agent import ChatResult +from app.agent.constants import GENERIC_ERROR +from app.main import app + + +@asynccontextmanager +async def _noop_lifespan(app): # type: ignore[type-arg] + yield + + +@pytest.fixture +def agent(): + return MagicMock() + + +@pytest.fixture +def client(agent): + app.dependency_overrides[dependencies.get_agent] = lambda: agent + app.router.lifespan_context = _noop_lifespan + with TestClient(app) as c: + yield c + app.dependency_overrides.clear() + + +def test_chat_returns_the_answer_only(client, agent): + agent.answer = AsyncMock(return_value=ChatResult(answer="Player A leads.", used_tools=True)) + r = client.post("/v1/chat", json={"session_id": "s1", "message": "top scorer?"}) + assert r.status_code == 200 + assert r.json() == {"answer": "Player A leads.", "session_id": "s1", "degraded": False} + agent.answer.assert_awaited_once_with("top scorer?", session_id="s1") + + +def test_chat_passes_the_degraded_flag_through(client, agent): + agent.answer = AsyncMock(return_value=ChatResult(answer="x", used_tools=False, degraded=True)) + r = client.post("/v1/chat", json={"session_id": "s1", "message": "q"}) + assert r.json()["degraded"] is True + + +def test_chat_hides_internal_failures_behind_a_generic_message(client, agent): + agent.answer = AsyncMock(side_effect=RuntimeError("mongo exploded at /srv/app/x.py")) + r = client.post("/v1/chat", json={"session_id": "s2", "message": "hi"}) + assert r.status_code == 200 + assert r.json()["answer"] == GENERIC_ERROR + assert r.json()["degraded"] is True + assert "mongo" not in r.text.lower() + + +def test_chat_rejects_an_empty_message(client, agent): + r = client.post("/v1/chat", json={"session_id": "s1", "message": ""}) + assert r.status_code == 422 + + +def test_chat_rejects_an_oversized_message(client, agent): + r = client.post("/v1/chat", json={"session_id": "s1", "message": "x" * 2001}) + assert r.status_code == 422 + + +def test_chat_rejects_an_empty_session_id(client, agent): + r = client.post("/v1/chat", json={"session_id": "", "message": "hi"}) + assert r.status_code == 422 + + +def test_get_session_replays_turns(client, agent): + turns = [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}] + agent.history.return_value = turns + r = client.get("/v1/chat/sessions/s3") + assert r.status_code == 200 + assert r.json() == {"turns": turns} + + +def test_delete_session_clears_the_thread(client, agent): + r = client.delete("/v1/chat/sessions/s4") + assert r.status_code == 204 + agent.clear.assert_called_once_with("s4") + + +@pytest.fixture +def client_without_agent(): + app.dependency_overrides[dependencies.get_agent] = lambda: None + app.router.lifespan_context = _noop_lifespan + with TestClient(app) as c: + yield c + app.dependency_overrides.clear() + + +def test_chat_without_an_agent_returns_the_generic_message(client_without_agent): + r = client_without_agent.post("/v1/chat", json={"session_id": "s1", "message": "hi"}) + assert r.status_code == 200 + assert r.json() == {"answer": GENERIC_ERROR, "session_id": "s1", "degraded": True} + + +def test_session_endpoints_without_an_agent_do_not_fail(client_without_agent): + assert client_without_agent.get("/v1/chat/sessions/s1").json() == {"turns": []} + assert client_without_agent.delete("/v1/chat/sessions/s1").status_code == 204 + + +def test_startup_survives_an_agent_that_cannot_be_built(): + # A missing GEMINI_API_KEY raises at construction; the rest of the app must still start. + original = app.router.lifespan_context + app.router.lifespan_context = main.lifespan + try: + with ( + patch.object(main, "MongoClient", MagicMock()), + patch.object(main, "MongoRepository", MagicMock()), + patch.object(main, "ModeFactory", MagicMock()), + patch.object(main, "build_agent", side_effect=ValueError("API key required")), + ): + with TestClient(app): + assert dependencies._agent is None + assert dependencies._repo is not None + finally: + app.router.lifespan_context = original + dependencies._repo = dependencies._mode_factory = dependencies._agent = None diff --git a/backend/tests/infrastructure/test_mongo_repository.py b/backend/tests/infrastructure/test_mongo_repository.py index dfb44fa..511aea2 100644 --- a/backend/tests/infrastructure/test_mongo_repository.py +++ b/backend/tests/infrastructure/test_mongo_repository.py @@ -1,3 +1,4 @@ +import dataclasses from datetime import UTC, datetime from app.domain.models import ( @@ -357,3 +358,12 @@ def test_list_fetched_leagues_returns_known_pairs(repo: MongoRepository) -> None pairs = {(d["competition"], d["season"]) for d in result} assert pairs == {("England Premier League", "2025-2026"), ("FIFA World Cup", "2026")} assert all(d["updated_at"] is not None for d in result) + + +def test_a_name_filter_is_matched_literally_not_as_a_regex(repo: MongoRepository) -> None: + # The name comes from user text (API query or chatbot tool), so "." must not match all. + first = dataclasses.replace(_make_player("1"), name="Nico O'Reilly") + repo.upsert_player(first) + repo.upsert_player(dataclasses.replace(_make_player("2"), name="Adrien Truffert")) + assert repo.get_players(season="2025-2026", name=".")[1] == 0 + assert repo.get_players(season="2025-2026", name="O'Reilly")[1] == 1 diff --git a/backend/tests/test_config_agent.py b/backend/tests/test_config_agent.py new file mode 100644 index 0000000..ea2f1cc --- /dev/null +++ b/backend/tests/test_config_agent.py @@ -0,0 +1,9 @@ +from app.config import settings + + +def test_agent_settings_defaults(): + assert settings.llm_provider == "gemini" + assert settings.llm_fallback_model == "" # opt-in only + assert settings.agent_max_tool_iterations >= 4 + assert settings.agent_max_rows >= 10 + assert settings.checkpoint_collection == "chat_checkpoints" diff --git a/docs/superpowers/specs/2026-09-02-chatbot-agent-design.md b/docs/superpowers/specs/2026-09-02-chatbot-agent-design.md new file mode 100644 index 0000000..ae9e968 --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-chatbot-agent-design.md @@ -0,0 +1,493 @@ +# ChatBot Agent — Design Spec + +- **Date:** 2026-09-02 +- **Status:** Approved design, pending implementation plan +- **Branch:** `dev/chatbot-agent` +- **Author:** Yonatan Hen (with Claude Code) +- **Replaces:** the cancelled RAG Chat feature (`dev/rag-chat`, never merged) + +## 1. Summary + +Add a conversational agent to the Football-Analytics app. The user asks a natural-language +question; a **tool-calling agent** decides which database tools to call, chains several +calls when needed, and answers from the returned rows. Every tool reads MongoDB through the +existing `MongoRepository` and the existing `METRIC_FIELDS` allowlist — the agent never +writes a query itself. When no tool covers the question, the agent falls back **once** to a +web-grounded model call. + +This replaces the RAG design from 2026-06-24. There are no embeddings, no vector index and +no Atlas dependency. Retrieval is structured queries chosen by the model, not semantic +similarity. + +## 2. Goals / Non-goals + +**Goals** + +- Answer analytical questions from the DB: rankings, filters, single-player values, + comparisons ("top 5 midfielders by key passes", "who has the best s_final in La Liga"). +- Chain tool calls within one turn (find the player, then read their metrics). +- Multi-turn conversation with history that survives a page reload and a new tab. +- Reach the web only when the tools cannot answer. +- Stay inside the Gemini free tier. +- Be extensible: adding a tool must not change the agent, the prompt assembly, or the API. + +**Non-goals (v1)** + +- Ollama or any second provider (the seam exists — see §14 — but only Gemini ships). +- Streaming responses. +- Hebrew / multilingual support. +- Showing the model's reasoning or tool trace to the user (explicitly forbidden — §11). +- Authentication or per-user isolation of chat sessions. +- Writing to the database from a tool. Every tool is read-only. + +## 3. Stack (decided) + +| Layer | Decision | +|---|---| +| Framework | **LangChain v1** (`langchain>=1.3`). Never a plain provider SDK. | +| Agent loop | **`langchain.agents.create_agent`** — the LangGraph-backed ReAct agent | +| LLM | **`ChatGoogleGenerativeAI`** (`langchain-google-genai>=4.4`), model from config, Gemini free tier | +| Sessions | **`langgraph-checkpoint-mongodb`** `MongoDBSaver`, `thread_id = session_id` | +| Resilience | `429` retry with backoff (`tenacity`) → fallback model → generic message | +| Tools | 8 LangChain `StructuredTool`s grouped by metric family (§6) | +| Data access | Existing `MongoRepository.get_players()` + `domain/metric_fields.METRIC_FIELDS` | +| Web fallback | One grounded Gemini call, taken only when no tool answered (§8) | +| Frontend | Plain Tailwind floating panel + full-screen view. No new npm dependency. | + +### Verified dependency facts + +These were checked against PyPI and the project venv, not assumed. They matter because the +previous RAG spec ruled some of them out on outdated grounds. + +1. **`langchain-google-genai` no longer pulls the legacy SDK.** v4.4.0 requires + `google-genai>=2.20.0` — the same unified SDK the RAG spec insisted on. The old ban on + this package is obsolete. +2. **LangChain v1 already bundles LangGraph.** `langchain 1.3.18` depends on + `langgraph>=1.2.11`, so `create_agent` costs no extra top-level dependency. +3. **LangChain v1 requires `langchain-core>=1.6.1`.** `master` has no LangChain at all, so + this is a clean install rather than a migration. +4. **`langgraph-checkpoint-mongodb` 0.4.0 requires `pymongo>=4.12,<4.17`.** The project + pins `pymongo==4.10.1`. **This bump is mandatory and is the one breaking change in the + feature** — it affects every existing repository test. It also pulls + `langchain-mongodb>=0.8.0`. +5. **The built-in fake chat models cannot test this agent.** + `BaseChatModel.bind_tools` is an unimplemented stub, so + `FakeMessagesListChatModel.bind_tools(...)` raises `NotImplementedError`, and + `create_agent` calls `bind_tools`. Tests supply their own `FakeToolCallingModel` (§15). + +### Why a tool-calling agent instead of RAG + +The questions this app gets are analytical, not semantic: "top scorers", "best defender by +clean sheets", "compare these two". Vector similarity is the wrong retrieval method for +those — the RAG spec itself listed ranking questions as a non-goal. Structured tools over +the existing repository answer them exactly and cheaply, with no index to rebuild after +every fetch. + +## 4. Architecture + +```mermaid +flowchart TD + A["POST /v1/chat {session_id, message}"] --> B[api/chat.py] + B --> C["ChatAgent.answer()"] + C --> D["LangGraph agent + thread_id = session_id + recursion_limit = MAX_TOOL_ITERATIONS"] + D --> E[MongoDBSaver restores prior messages] + E --> F[Model turn] + F -->|tool_calls| G["tools/<family>/functions.py"] + G --> H["METRIC_FIELDS allowlist + MongoRepository.get_players()"] + H --> I[ToolMessage appended to state] + I --> F + F -->|text answer| J[MongoDBSaver persists new state] + J --> K{Any tool returned rows?} + K -->|yes| L["{answer, session_id}"] + K -->|no| M["web_fallback: ONE grounded Gemini call"] + M --> L +``` + +Text form of the same flow: + +``` +user question + → POST /v1/chat {session_id, message} + → create_agent graph, config {thread_id: session_id, recursion_limit: MAX_ITERS} + MongoDBSaver restores the prior messages for this thread + ├─ model turn → tool_calls? → LangGraph runs tools//functions.py + │ → MongoRepository via METRIC_FIELDS allowlist + │ → ToolMessage back into state → model turn again + └─ text answer → graph ends, MongoDBSaver persists the new state + → no tool returned rows? → ONE web-grounded Gemini call (fallback only) + → return {answer, session_id} (no tool trace, no reasoning) +``` + +## 5. Backend components + +The agent lives in its own `agent/` package, as required by the source note. Existing +layers (`api/`, `domain/`, `infrastructure/`, `modes/`) are untouched apart from router +registration and one dependency provider. + +``` +backend/app/ + agent/ + __init__.py + agent.py # build_agent(repo, mongo_client) + ChatAgent.answer() — main logic + constants.py # MAX_TOOL_ITERATIONS, MAX_ROWS, GENERIC_ERROR, HISTORY_TRIM + system_prompt.py # SYSTEM_PROMPT assembled from each tool package's prompts.py + llm.py # build_chat_model() -> ChatGoogleGenerativeAI + retry + fallback + web_fallback.py # the single grounded call + tools/ + __init__.py # build_tools(repo) -> list[BaseTool] (the registry) + base.py # shared pydantic args schema + executor over METRIC_FIELDS + attacking/ prompts.py functions.py + shots/ prompts.py functions.py + defending/ prompts.py functions.py + goalkeeping/ prompts.py functions.py + discipline/ prompts.py functions.py + playing_time/ prompts.py functions.py + composite_scores/ prompts.py functions.py + identity/ prompts.py functions.py + api/ + chat.py # POST /v1/chat ; GET|DELETE /v1/chat/sessions/{session_id} + modals/chat_modals.py # request/response pydantic models + dependencies.py # + get_agent() +``` + +There is **no** `LLMProvider` protocol and **no** `chat_session_repository`. LangChain's +`BaseChatModel` is already the provider seam, and `MongoDBSaver` is already the session +store. Adding either would be duplicate machinery. + +Each tool package exposes exactly two things: + +- `prompts.py` — `DESCRIPTION` (the tool description the model sees) and `GUIDANCE` (the + paragraph appended to the system prompt explaining when to reach for this tool). +- `functions.py` — `build(repo) -> list[BaseTool]`, taking the repository by injection so + the tools are unit-testable against `mongomock` with no global state. + +## 6. Tool families + +The source note asked for one tool per metric. `Stats` has 35 numeric fields and there are +4 composite scores — 39 tool schemas would be sent on every turn, which hurts tool +selection and wastes free-tier tokens. Instead there are **8 tools grouped by metric +family**, each taking a `metric` argument restricted by a `Literal` to its own family. All +39 allowlisted metrics stay reachable, and adding a field to `Stats` needs no new tool. + +| Tool | Metrics | +|---|---| +| `attacking` | `goals`, `assists`, `xg`, `xa`, `key_passes`, `big_chances_created`, `pk_won`, `pk_scored` | +| `shots` | `total_shots`, `shots_on_target`, `shots_off_target`, `scoring_frequency`, `headed_goals`, `left_foot_goals`, `right_foot_goals`, `pk_taken`, `penalty_miss` | +| `defending` | `clean_sheets`, `goals_conceded`, `penalty_conceded` | +| `goalkeeping` | `saves`, `saves_outside_box`, `goals_prevented`, `high_claims`, `pk_saved`, `penalty_faced` | +| `discipline` | `yellow_cards`, `red_cards`, `yellow_red_cards`, `direct_red_cards`, `fouls_committed` | +| `playing_time` | `minutes`, `appearances`, `matches_started`, `rating` | +| `composite_scores` | `s_final`, `offensive`, `defensive`, `tactical` (plus `sleeper_flag` / `sleeper_ratio` in the output) | +| `identity` | non-metric: find a player by name, full profile, compare two players, competition and season coverage | + +The seven metric families cover 8 + 9 + 3 + 6 + 5 + 4 + 4 = **39 metrics**, exactly the +size of `METRIC_FIELDS`. Each metric appears in exactly one family. + +> **Note on `defending`.** `Stats` has no outfield defending metrics — no tackles, +> interceptions or clearances are collected from Sofascore today. The family is therefore +> limited to the three fields above, and its `GUIDANCE` says so plainly. Asked for tackles, +> the model must **not** substitute a different metric and must **not** write a refusal — it +> returns nothing, which is a genuine miss, so the web fallback (§8) answers and labels the +> source. Substituting `clean_sheets` for "tackles" is the failure to design against. If +> defensive fields are added later (see the defender-representation design), they join this +> family and nothing else changes. + +### Shared tool signature + +The seven metric tools share one pydantic args schema, defined once in `tools/base.py`: + +| Argument | Type | Meaning | +|---|---|---| +| `operation` | `"rank" \| "filter" \| "player_value"` | rank by the metric, filter on a range, or read one player's value | +| `metric` | `Literal[...]` per family | which metric to act on | +| `position` | `"GK" \| "DF" \| "MF" \| "FW" \| None` | optional filter | +| `team`, `nationality` | `str \| None` | optional filters | +| `competition` | `str \| None` | maps to `stats_view`, re-aggregating for one competition | +| `player_name` | `str \| None` | required for `player_value` | +| `min_value`, `max_value` | `float \| None` | for `filter` | +| `limit` | `int` (default 10, capped at `MAX_ROWS`) | result size | + +Every call resolves through `METRIC_FIELDS` and then `MongoRepository.get_players()`. The +allowlist is the injection guard: a `metric` outside it never reaches Mongo. Because the +schema is shared, tools compose naturally — the model calls `identity` to resolve a name, +then `attacking` and `playing_time` for that player, and answers from all three. + +Tools return compact JSON rows (name, team, position, the requested metric, and `s_final` +for context), truncated to `MAX_ROWS`, so a wide result never blows the context window. + +## 7. System prompt + +`system_prompt.py` assembles `SYSTEM_PROMPT` at import time from the tool registry, so it +can never drift from the tools that are actually bound. It contains: + +1. Role: a football analytics assistant for this specific database. +2. Scope: men's football only; the seasons and competitions actually loaded. +3. One paragraph per tool, taken verbatim from that package's `prompts.GUIDANCE`. +4. Call budget: **default to the fewest calls that answer the question — usually one.** + Do not resolve identity separately when a metric tool's `player_name` argument already + does it. Escalate to more calls only on the structural triggers in §7.1. +5. Answer policy: answer from tool results; never invent numbers. When the tools do not + cover the question, do not refuse — hand off to the web fallback (§8). +6. The output ban: never reveal reasoning, tool names, arguments, or raw rows. Give the + answer only. + +### 7.1 Call budget — cheap by default, precise on demand + +The agent cannot reliably judge its own answer as "imprecise", and asking it to costs a +model turn to produce a self-assessment worth little. Escalation is therefore driven by +**structural signals in the tool results**, which are cheap and objective: + +| Trigger | Response | +|---|---| +| A call returned zero rows | Retry once with the filters relaxed, then fall back (§8) | +| The question names two players or teams | One call per entity — a comparison needs both sides | +| The question spans two metric families ("creating vs finishing") | One call per family | +| A name matched several players | One `identity` call to disambiguate before reading metrics | +| None of the above | **Stop at one call.** | + +The 8-iteration cap is the backstop, not the target. A single-entity, single-metric +question ("who has the most assists?") must cost exactly one tool call. + +## 7.2 Measuring answer correctness + +There is no trustworthy runtime correctness score, and none is built. Asking the model to +grade its own answer costs a turn and mostly restates its own confidence. Three layers of +real verification replace it, strongest first. + +**1. Groundedness by construction.** Every figure in a DB-backed answer comes from a tool +row; the model is never asked to recall a statistic. This narrows the failure surface from +"wrong number" to "wrong rows fetched" or "right rows, wrong reading". + +**2. Numeric-citation check (runtime, no extra model call).** After the graph returns, +extract every number from the answer text and confirm each appears in the tool rows for +that turn. A figure present in neither is fabricated. On a mismatch, log the answer and the +rows at `WARNING` and return the answer flagged `degraded`. This catches the worst failure — +a confident invented statistic — for near-zero cost. + +**3. Offline eval set with computed ground truth (the real measurement).** For analytical +questions the correct answer *is* a direct Mongo query, so ground truth is computable rather +than judged. A fixed question set lives in `backend/tests/agent/eval/`, each entry pairing a +natural-language question with the repository call that produces its true answer: + +``` +"top 5 forwards by goals" -> get_players(position="FW", sort_by="goals", page_size=5) +``` + +The harness runs the agent, extracts the named players, and diffs against that result. It +reports a pass rate, is skipped by default in CI (it needs a live model and a populated DB), +and runs on demand when a prompt, tool or model changes. This is what catches a regression +that the unit tests cannot see. + +**What is not measurable.** Open-ended judgement questions ("is he creating more than he is +finishing?") have no ground truth. Layer 2 is the honest ceiling there: the cited numbers +are real and the comparison follows from them. The eval set therefore covers analytical +questions only, and that limit is stated rather than papered over. + +## 8. Web-search fallback + +Fallback only, never a bound tool. Two reasons for keeping it outside the tool list: it +stops the model from reaching for the web when a DB tool would do, and it avoids depending +on whether a given Gemini model can mix `google_search` with function declarations in one +request. + +The flow: after the graph returns, inspect the final state. If no `ToolMessage` produced +usable rows — the model called nothing, or every call came back empty — make **one** +grounded Gemini call with the original question and return that answer instead. Otherwise +return the graph's answer untouched. + +**The agent never refuses.** "That is not in my data" is not an acceptable answer. When the +database cannot answer, the user still gets an answer — from the web — with its origin +stated plainly, so they always know which source they are reading: + +> *Not from the app's data — from a web search:* … + +This is a labelling rule, not a hedge: the sentence names the source and then answers. The +label is prepended by `web_fallback.py`, not left to the model, so it cannot be forgotten or +reworded. `SYSTEM_PROMPT` correspondingly forbids the model from writing its own "I don't +have that" refusal — an empty-handed turn is what *triggers* the fallback, so refusing would +pre-empt it. + +**Cost note.** Because a miss now always reaches the web rather than stopping, grounded +calls will be more frequent than in a refuse-by-default design. The offsetting control is +that the fallback still fires only on a genuine miss (zero usable rows), and the §7.1 call +budget keeps DB-answerable questions from ever getting there. + +*Build-time verification:* whether `ChatGoogleGenerativeAI` exposes Google Search grounding +directly, or whether this call goes through the `google-genai` client (already installed as +a transitive dependency of `langchain-google-genai`). Either satisfies the design. + +## 9. Sessions + +`MongoDBSaver` persists the graph state per `thread_id`. The frontend mints a UUID +`session_id` on first use and keeps it in `localStorage`, so the full-screen tab and a +reloaded page both resume the same conversation. The backend passes +`config={"configurable": {"thread_id": session_id}}` and does nothing else — history is +restored and saved by LangGraph. + +The stored documents are **LangGraph checkpoint blobs, not readable turns**. This is the +accepted cost of the near-zero session code. Consequently `GET /v1/chat/sessions/{id}` +reads history back through `agent.aget_state(config)` and maps the messages to +`{role, content}` for the UI — it never queries the checkpoint collection directly. + +Old threads are not expired in v1. A TTL index on the checkpoint collection is a +possible follow-up. + +## 10. API endpoints + +| Method | Path | Purpose | +|---|---|---| +| `POST` | `/v1/chat` | Body `{ session_id, message }` → `{ answer, session_id, degraded }` | +| `GET` | `/v1/chat/sessions/{session_id}` | Replay history as `{ turns: [{role, content}] }` for a reopened tab | +| `DELETE` | `/v1/chat/sessions/{session_id}` | Clear the thread (new chat) | + +The `POST` response carries the answer only — no tool trace, no reasoning, no intermediate +messages. `degraded` is a boolean flag telling the UI the answer came from the error path. + +## 11. Error handling + +One `try/except` wraps the agent call in `api/chat.py`: + +- `logger.exception(...)` server-side, with the full traceback, so failures are debuggable + from the backend logs. +- The client gets the `GENERIC_ERROR` constant and `degraded: true`, with HTTP 200 — not a + 500 and not a stack trace. No model names, Mongo errors, quota messages or file paths + cross the boundary. +- The same applies inside tools: a tool that fails returns a short "could not read that + data" string to the model rather than raising, so one bad call does not kill the turn. + +## 12. Frontend + +- **Floating panel.** A collapsible bubble fixed bottom-right, rendered once in `App.tsx` + so it floats above every tab. Collapsed it is a small circular button; expanded it is a + panel with a 1–2 sentence instruction line at the top ("Ask about any player or metric in + the database. I can rank, filter and compare."). +- **Full screen.** A link inside the panel — not in the navbar — opens `?chat=1` in a new + tab. `App.tsx` reads that query param and renders `ChatFullScreen` instead of the tab + layout. This avoids adding `react-router` to a project that has none. +- **Session continuity.** `session_id` comes from `localStorage`, so the new tab loads the + same conversation through `GET /v1/chat/sessions/{id}`. +- **Styling.** Plain Tailwind, matching the existing dark theme. No chat UI library — the + previous design flagged a CSS clash between `@chatscope` and Tailwind, and the component + is small enough not to need one. + +``` +frontend/src/ + api/chat.ts # sendChat, getSession, clearSession + components/ChatWidget.tsx # floating collapsible panel + pages/ChatFullScreen.tsx # full-screen view + App.tsx # renders one or the other based on ?chat=1 +``` + +## 13. Config + +Added to `Settings` in `backend/app/config.py`, read from `secrets.env` / `.env`: + +| Setting | Default | +|---|---| +| `gemini_api_key` | from env `GEMINI_API_KEY` | +| `gemini_model` | a current free-tier Gemini flash model (confirmed at build) | +| `gemini_fallback_model` | a second free-tier flash model | +| `agent_max_tool_iterations` | `8` | +| `agent_max_rows` | `25` | +| `checkpoint_collection` | `chat_checkpoints` | + +`secrets.env` gains `GEMINI_API_KEY`. `OLLAMA_BASE_URL` / `OLLAMA_MODEL` are documented as +a future addition and are **not** read in v1. + +## 14. Extensibility + +- **A new tool** = one folder with `prompts.py` + `functions.py`, plus one line in + `build_tools()`. The system prompt and the model's tool declarations are both generated + from that registry, so nothing else changes. This is what the source note's + "scalable manner" requirement means in practice. +- **A new metric** = add the field to `Stats`; it enters `METRIC_FIELDS` automatically + (that dict is built from `dataclasses.fields(Stats)`), and joins a family's `Literal`. +- **A new provider** = build a different `BaseChatModel` in `llm.py`. For the Ollama + fallback the source note asked about, that is `langchain-ollama`'s `ChatOllama` with a + tool-calling model. `create_agent`, the tools and the API are unaffected. + +## 15. Testing strategy + +- **Tools** (`pytest` + `mongomock`): each family tool against a seeded repository — + correct metric resolved, filters applied, row cap respected, unknown metric rejected. +- **The agent** (`backend/tests/agent/conftest.py`): a `FakeToolCallingModel(BaseChatModel)` + that implements `_generate` to replay scripted `AIMessage`s and returns `self` from + `bind_tools`. The built-in LangChain fakes cannot be used — `bind_tools` raises + `NotImplementedError` on them (verified). Tests use `InMemorySaver`, not `MongoDBSaver`. +- **Covered agent behaviours:** a scripted tool call runs the right function; the + iteration cap stops a loop; the web fallback fires only when no tool returned rows; the + response contains the answer only. +- **API** (`TestClient` + `dependency_overrides`): success shape, and that a raising agent + produces the generic message with HTTP 200 rather than a 500. +- No live Gemini call in CI. No frontend tests, consistent with the project. +- All backend tooling runs via `.venv\Scripts\python` per project convention. + +## 16. Build-time verifications + +1. Confirm the configured `gemini_model` and `gemini_fallback_model` exist and are + available on the free AI Studio tier; adjust the config defaults if not. +2. Confirm the tool-calling response shape from `ChatGoogleGenerativeAI` matches what + `create_agent` expects for the installed versions. +3. Confirm how Google Search grounding is invoked for the fallback call (§8). +4. Confirm `mongomock==4.2.0.post1` still works after the `pymongo` bump, and that the full + existing test suite passes on the new pin **before** any agent code is written. +5. Confirm `MongoDBSaver` creates its collections on a plain `mongo:7` container — it needs + no Atlas features, unlike the abandoned vector design. + +All model names are config-driven, so a failed check is a config change, not a rewrite. + +## 17. Risks & mitigations + +| Risk | Mitigation | +|---|---| +| `pymongo` bump breaks existing repository tests | Bump and run the full suite as the first task, before any new code — a clean baseline or an early stop | +| Gemini free-tier quota exhaustion | `429` retry with backoff → fallback model → generic message; web grounding used only as a last resort | +| Model picks the wrong tool | Families are small and disjoint; each `GUIDANCE` paragraph says when *not* to use the tool; `defending` states its own data gap | +| Tool result floods the context | `MAX_ROWS` cap and compact JSON rows | +| Runaway tool loop | `recursion_limit = agent_max_tool_iterations` | +| LangChain v1 API churn | Pinned minimums in `requirements.txt`; the agent surface used is small (`create_agent`, `BaseChatModel`, `StructuredTool`) | +| Checkpoint documents are unreadable | Accepted and documented; history is read back through `aget_state`, never by querying the collection | +| Reasoning leaking to the user | The API returns only the final message content; the response model has no field that could carry a trace | + +## 18. Out of scope / future + +- Ollama or any second provider (the seam is ready). +- Streaming responses; Hebrew/multilingual support. +- Tools that write to the database, or that trigger a Sofascore fetch. +- Per-user auth and session isolation; TTL expiry of old threads. +- Tools over match-level data, news, or Sport-5 pricing. +- Defensive metrics for the `defending` family, pending the defender-representation work. + +## 19. Traceability to the source note + +The hand-written note `docs/superpowers/specs/chatbot-agent.md` had 14 numbered +requirements. It is deleted by this change; every requirement maps to a section here. + +| # | Requirement | Where | +|---|---|---| +| 1 | Tool-calling agent over MongoDB, chaining allowed | §4, §6 | +| 2 | Gemini primary, Ollama fallback, keys in gitignored env | §3, §13, §14, §18 | +| 3 | A tool per metric + a suggested prompt/flow | §6 (grouped into 8 families — see the rationale there), §7 | +| 4 | Web search as a fallback only | §8 | +| 5 | Floating modal, collapsible, on all pages, with instructions | §12 | +| 6 | Full-screen in a new tab, no navbar tab | §12 | +| 7 | System prompt describes agent + tools; history-aware chat | §7, §9 | +| 8 | Reasoning stays in the backend, never shown | §10, §11, §17 | +| 9 | Scalable — future tools drop in | §14 | +| 10 | Show the flow visually before implementing | §4, and Task 0 of the implementation plan | +| 11 | Generic errors to the user, full detail in server logs | §11 | +| 12 | Not RAG — delete the RAG, this replaces it | §1, §3 (`dev/rag-chat` deleted, tip was `16ccfd4`) | +| 13 | Pull up-to-date `master` before implementing | `dev/chatbot-agent` branched from `master` at `b19cd9a` | +| 14 | An `agent/` folder split into `agent.py`, `constants.py`, `system_prompt.py`, `tools//{prompts,functions}.py` | §5 | + +## 20. Process notes + +- Branch `dev/chatbot-agent`, created from `master` at `b19cd9a`. +- DB snapshot before implementation (project rule). +- The RAG branch `dev/rag-chat` was deleted local and remote; its tip was `16ccfd4`, + recoverable from the local reflog if ever needed. +- `pre-pr-lint` before opening the PR; PR to `master` only after CI passes; delete the + branch after merge. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bdab037..da3da81 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,9 @@ "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", - "recharts": "^2.13.3" + "react-markdown": "^10.1.0", + "recharts": "^2.13.3", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -1500,13 +1502,39 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1514,18 +1542,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1542,6 +1583,12 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.61.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", @@ -1837,6 +1884,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1986,6 +2039,16 @@ "postcss": "^8.1.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2118,6 +2181,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2135,6 +2208,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2202,6 +2315,16 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2385,7 +2508,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2405,6 +2527,19 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2412,6 +2547,28 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2682,6 +2839,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2698,6 +2865,12 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2930,6 +3103,56 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2967,6 +3190,12 @@ "node": ">=0.8.19" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -2976,6 +3205,30 @@ "node": ">=12" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3005,6 +3258,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3028,6 +3291,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3038,6 +3311,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3204,6 +3489,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3226,86 +3521,940 @@ "yallist": "^3.0.2" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", "license": "MIT", - "engines": { - "node": ">= 8" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" }, - "engines": { - "node": "*" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", "license": "MIT", "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, "license": "MIT" }, "node_modules/node-releases": { @@ -3410,6 +4559,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3667,6 +4841,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3729,6 +4913,33 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3826,6 +5037,72 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3990,6 +5267,30 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4003,6 +5304,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -4180,6 +5499,26 @@ "node": ">=8.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -4251,6 +5590,93 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4299,6 +5725,34 @@ "dev": true, "license": "MIT" }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -4426,6 +5880,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 34ce756..512de1d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,9 @@ "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", - "recharts": "^2.13.3" + "react-markdown": "^10.1.0", + "recharts": "^2.13.3", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@eslint/js": "^9.17.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2def8c5..d6e215e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,7 @@ import { useState, useEffect } from 'react' import { getPlayers } from './api/players' +import ChatWidget from './components/ChatWidget' +import ChatFullScreen from './pages/ChatFullScreen' import SeedPrompt from './components/SeedPrompt' import Rankings from './pages/Rankings' import PlayerDetail from './pages/PlayerDetail' @@ -17,7 +19,14 @@ const TABS: { id: Tab; label: string }[] = [ { id: 'scatter', label: 'Scatter Plot' }, ] +// One query param does not justify adding a router. The chat has no navbar tab by design. +const fullScreenChat = new URLSearchParams(window.location.search).get('chat') === '1' + export default function App() { + return fullScreenChat ? : +} + +function Dashboard() { const [tab, setTab] = useState('rankings') const [isEmpty, setIsEmpty] = useState(null) const [dbError, setDbError] = useState(false) @@ -70,6 +79,8 @@ export default function App() { )} + + {!dbError && isEmpty === false && } ) } diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts new file mode 100644 index 0000000..1d45e7e --- /dev/null +++ b/frontend/src/api/chat.ts @@ -0,0 +1,57 @@ +import { apiFetch } from './client' + +export interface ChatTurn { + role: 'user' | 'assistant' + content: string +} + +export interface ChatResponse { + answer: string + session_id: string + degraded: boolean +} + +const SESSION_KEY = 'fa.chat.session_id' +let memorySessionId: string | null = null + +function newId(): string { + // randomUUID exists only in secure contexts (https or localhost). + if (typeof crypto.randomUUID === 'function') return crypto.randomUUID() + return Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => + b.toString(16).padStart(2, '0'), + ).join('') +} + +/** The browser's chat session id, created once and kept across reloads. */ +export function getSessionId(): string { + try { + let id = localStorage.getItem(SESSION_KEY) + if (!id) { + id = newId() + localStorage.setItem(SESSION_KEY, id) + } + return id + } catch { + // Storage blocked (e.g. private mode): keep the session for this page load only. + memorySessionId ??= newId() + return memorySessionId + } +} + +const sessionPath = (sessionId: string) => `/v1/chat/sessions/${encodeURIComponent(sessionId)}` + +export function sendChat(message: string, sessionId: string): Promise { + return apiFetch('/v1/chat', { + method: 'POST', + body: JSON.stringify({ session_id: sessionId, message }), + }) +} + +export async function getSession(sessionId: string): Promise { + const res = await apiFetch<{ turns: ChatTurn[] }>(sessionPath(sessionId)) + return res.turns +} + +export function clearSession(sessionId: string): Promise { + return apiFetch(sessionPath(sessionId), { method: 'DELETE' }) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 29ec167..af1b06c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -9,5 +9,6 @@ export async function apiFetch(path: string, options?: RequestInit): Promise< const err = await res.json().catch(() => ({ detail: res.statusText })) throw new Error(err?.detail?.message ?? err?.detail ?? 'API error') } + if (res.status === 204) return undefined as T return res.json() as Promise } diff --git a/frontend/src/components/ChatPanel.tsx b/frontend/src/components/ChatPanel.tsx new file mode 100644 index 0000000..d16452c --- /dev/null +++ b/frontend/src/components/ChatPanel.tsx @@ -0,0 +1,145 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react' +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { clearSession, getSession, getSessionId, sendChat, type ChatTurn } from '../api/chat' + +interface Turn extends ChatTurn { + degraded?: boolean +} + +const NETWORK_ERROR = 'Could not reach the server. Please try again.' + +export default function ChatPanel({ fullScreen = false }: { fullScreen?: boolean }) { + const [turns, setTurns] = useState([]) + const [draft, setDraft] = useState('') + const [sending, setSending] = useState(false) + const bottom = useRef(null) + + useEffect(() => { + getSession(getSessionId()) + // Keep whatever the user sent while this was still loading. + .then((stored) => setTurns((current) => (current.length ? current : stored))) + .catch(() => {}) + }, []) + + useEffect(() => { + bottom.current?.scrollIntoView({ behavior: 'smooth' }) + }, [turns, sending]) + + async function send(e: FormEvent) { + e.preventDefault() + const message = draft.trim() + if (!message || sending) return + setDraft('') + setTurns((prev) => [...prev, { role: 'user', content: message }]) + setSending(true) + try { + const res = await sendChat(message, getSessionId()) + setTurns((prev) => [ + ...prev, + { role: 'assistant', content: res.answer, degraded: res.degraded }, + ]) + } catch { + // The backend answers 200 even when it fails, so this is a network error only. + setTurns((prev) => [...prev, { role: 'assistant', content: NETWORK_ERROR }]) + } finally { + setSending(false) + } + } + + async function startNewChat() { + setTurns([]) + try { + await clearSession(getSessionId()) + } catch { + // The thread stays on the server; the user still gets a clean view. + } + } + + return ( +
+
+ {turns.length === 0 && !sending && ( +

+ No messages yet. Try “Who are the top 5 scorers?” +

+ )} + {turns.map((turn, i) => ( + + ))} + {sending &&

Thinking…

} +
+
+ +
+ setDraft(e.target.value)} + placeholder="Ask about a player or a metric..." + maxLength={2000} + aria-label="Your question" + className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm" + /> + +
+ + +
+ ) +} + +function Bubble({ turn }: { turn: Turn }) { + const mine = turn.role === 'user' + return ( +
+
+ {mine ? ( + {turn.content} + ) : ( + + )} + {turn.degraded && ( +
Not verified against the app's data.
+ )} +
+
+ ) +} + +// Answers come back as markdown. react-markdown escapes HTML, so no raw html is rendered. +const MARKDOWN_STYLES = [ + '[&_p]:mb-2 [&_p:last-child]:mb-0', + '[&_ol]:list-decimal [&_ul]:list-disc [&_ol]:pl-5 [&_ul]:pl-5 [&_li]:mb-1', + '[&_strong]:font-semibold [&_code]:text-xs [&_code]:bg-black/30 [&_code]:px-1 [&_code]:rounded', + '[&_table]:w-full [&_th]:text-left [&_th]:pr-3 [&_td]:pr-3 [&_a]:underline', +].join(' ') + +function Answer({ text }: { text: string }) { + return ( + + ) +} diff --git a/frontend/src/components/ChatWidget.tsx b/frontend/src/components/ChatWidget.tsx new file mode 100644 index 0000000..93a95c9 --- /dev/null +++ b/frontend/src/components/ChatWidget.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react' +import ChatPanel from './ChatPanel' + +const INSTRUCTIONS = + 'Ask about any player or metric in the database. I can rank, filter and compare.' + +export default function ChatWidget() { + const [open, setOpen] = useState(false) + + if (!open) { + return ( + + ) + } + + return ( + + ) +} diff --git a/frontend/src/pages/ChatFullScreen.tsx b/frontend/src/pages/ChatFullScreen.tsx new file mode 100644 index 0000000..1800e28 --- /dev/null +++ b/frontend/src/pages/ChatFullScreen.tsx @@ -0,0 +1,20 @@ +import ChatPanel from '../components/ChatPanel' + +export default function ChatFullScreen() { + return ( +
+
+
+

Ask the data

+ + ← Back to rankings + +
+

+ Ask about any player or metric in the database. I can rank, filter and compare. +

+ +
+
+ ) +}