added mcp, litellm; created folders BACKEND, FRONTEND; writed correct docker compose

This commit is contained in:
G0DSEND016 2026-04-19 11:13:27 +02:00
parent 9b74d0affa
commit 1c90b3d75f
75 changed files with 10117 additions and 2217 deletions

View File

@ -1,157 +0,0 @@
[project]
# List of environment variables to be provided by each user to use the app.
user_env = []
# Duration (in seconds) during which the session is saved when the connection is lost
session_timeout = 3600
# Duration (in seconds) of the user session expiry
user_session_timeout = 1296000 # 15 days
# Enable third parties caching (e.g., LangChain cache)
cache = false
# Whether to persist user environment variables (API keys) to the database
# Set to true to store user env vars in DB, false to exclude them for security
persist_user_env = false
# Whether to mask user environment variables (API keys) in the UI with password type
# Set to true to show API keys as ***, false to show them as plain text
mask_user_env = false
# Authorized origins
allow_origins = ["*"]
[features]
# Process and display HTML in messages. This can be a security risk (see https://stackoverflow.com/questions/19603097/why-is-it-dangerous-to-render-user-generated-html-or-javascript)
unsafe_allow_html = false
# Process and display mathematical expressions. This can clash with "$" characters in messages.
latex = false
# Autoscroll new user messages at the top of the window
user_message_autoscroll = true
# Automatically tag threads with the current chat profile (if a chat profile is used)
auto_tag_thread = true
# Allow users to edit their own messages
edit_message = true
# Allow users to share threads (backend + UI). Requires an app-defined on_shared_thread_view callback.
allow_thread_sharing = false
[features.slack]
# Add emoji reaction when message is received (requires reactions:write OAuth scope)
reaction_on_message_received = false
# Authorize users to spontaneously upload files with messages
[features.spontaneous_file_upload]
enabled = false
# Define accepted file types using MIME types
# Examples:
# 1. For specific file types:
# accept = ["image/jpeg", "image/png", "application/pdf"]
# 2. For all files of certain type:
# accept = ["image/*", "audio/*", "video/*"]
# 3. For specific file extensions:
# accept = { "application/octet-stream" = [".xyz", ".pdb"] }
# Note: Using "*/*" is not recommended as it may cause browser warnings
#accept = ["*/*"]
#max_files = 0
#max_size_mb = 0
[features.audio]
# Enable audio features
enabled = false
# Sample rate of the audio
sample_rate = 24000
[features.mcp]
# Enable Model Context Protocol (MCP) features
enabled = false
[features.mcp.sse]
enabled = false
[features.mcp.streamable-http]
enabled = false
[features.mcp.stdio]
enabled = false
# Only the executables in the allow list can be used for MCP stdio server.
# Only need the base name of the executable, e.g. "npx", not "/usr/bin/npx".
# Please don't comment this line for now, we need it to parse the executable name.
allowed_executables = [ "npx", "uvx" ]
[UI]
# Name of the assistant.
name = "Legal AI Assistant"
default_theme = "dark"
# Force a specific language for all users (e.g., "en-US", "he-IL", "fr-FR")
# If not set, the browser's language will be used
language = "en-US"
layout = "wide"
default_sidebar_state = "closed"
# Description of the assistant. This is used for HTML tags.
# description = ""
# Chain of Thought (CoT) display mode. Can be "hidden", "tool_call" or "full".
cot = "full"
# Specify a CSS file that can be used to customize the user interface.
# The CSS file can be served from the public directory or via an external link.
custom_css = "/public/styles.css"
# Specify additional attributes for a custom CSS file
# custom_css_attributes = "media=\"print\""
# Specify a JavaScript file that can be used to customize the user interface.
# The JavaScript file can be served from the public directory.
#custom_js = "/public/test.js"
# The style of alert boxes. Can be "classic" or "modern".
alert_style = "modern"
# Specify additional attributes for custom JS file
# custom_js_attributes = "async type = \"module\""
# Custom login page image, relative to public directory or external URL
# login_page_image = "/public/custom-background.jpg"
# Custom login page image filter (Tailwind internal filters, no dark/light variants)
# login_page_image_filter = "brightness-50 grayscale"
# login_page_image_dark_filter = "contrast-200 blur-sm"
# Specify a custom meta URL (used for meta tags like og:url)
# custom_meta_url = "https://github.com/Chainlit/chainlit"
# Specify a custom meta image url.
# custom_meta_image_url = "https://chainlit-cloud.s3.eu-west-3.amazonaws.com/logo/chainlit_banner.png"
# Load assistant logo directly from URL.
logo_file_url = ""
# Load assistant avatar image directly from URL.
default_avatar_file_url = ""
# Specify a custom build directory for the frontend.
# This can be used to customize the frontend code.
# Be careful: If this is a relative path, it should not start with a slash.
# custom_build = "./public/build"
# Specify optional one or more custom links in the header.
# [[UI.header_links]]
# name = "Issues"
# display_name = "Report Issue"
# icon_url = "https://avatars.githubusercontent.com/u/128686189?s=200&v=4"
# url = "https://github.com/Chainlit/chainlit/issues"
# target = "_blank" (default) # Optional: "_self", "_parent", "_top".
[meta]
generated_by = "2.9.3"

View File

@ -1,245 +0,0 @@
{
"common": {
"actions": {
"cancel": "Cancel",
"confirm": "Confirm",
"continue": "Continue",
"goBack": "Go Back",
"reset": "Reset",
"submit": "Submit"
},
"status": {
"loading": "Loading...",
"error": {
"default": "An error occurred",
"serverConnection": "Could not reach the server"
}
}
},
"auth": {
"login": {
"title": "Login to access the app",
"form": {
"email": {
"label": "Email address",
"required": "email is a required field",
"placeholder": "me@example.com"
},
"password": {
"label": "Password",
"required": "password is a required field"
},
"actions": {
"signin": "Sign In"
},
"alternativeText": {
"or": "OR"
}
},
"errors": {
"default": "Unable to sign in",
"signin": "Try signing in with a different account",
"oauthSignin": "Try signing in with a different account",
"redirectUriMismatch": "The redirect URI is not matching the oauth app configuration",
"oauthCallback": "Try signing in with a different account",
"oauthCreateAccount": "Try signing in with a different account",
"emailCreateAccount": "Try signing in with a different account",
"callback": "Try signing in with a different account",
"oauthAccountNotLinked": "To confirm your identity, sign in with the same account you used originally",
"emailSignin": "The e-mail could not be sent",
"emailVerify": "Please verify your email, a new email has been sent",
"credentialsSignin": "Sign in failed. Check the details you provided are correct",
"sessionRequired": "Please sign in to access this page"
}
},
"provider": {
"continue": "Continue with {{provider}}"
}
},
"chat": {
"input": {
"placeholder": "Type your message here...",
"actions": {
"send": "Send message",
"stop": "Stop Task",
"attachFiles": "Attach files"
}
},
"commands": {
"button": "Tools",
"changeTool": "Change Tool",
"availableTools": "Available Tools"
},
"speech": {
"start": "Start recording",
"stop": "Stop recording",
"connecting": "Connecting"
},
"fileUpload": {
"dragDrop": "Drag and drop files here",
"browse": "Browse Files",
"sizeLimit": "Limit:",
"errors": {
"failed": "Failed to upload",
"cancelled": "Cancelled upload of"
},
"actions": {
"cancelUpload": "Cancel upload",
"removeAttachment": "Remove attachment"
}
},
"messages": {
"status": {
"using": "Using",
"used": "Used"
},
"actions": {
"copy": {
"button": "Copy to clipboard",
"success": "Copied!"
}
},
"feedback": {
"positive": "Helpful",
"negative": "Not helpful",
"edit": "Edit feedback",
"dialog": {
"title": "Add a comment",
"submit": "Submit feedback",
"yourFeedback": "Your feedback..."
},
"status": {
"updating": "Updating",
"updated": "Feedback updated"
}
}
},
"history": {
"title": "Last Inputs",
"empty": "Such empty...",
"show": "Show history"
},
"settings": {
"title": "Settings panel",
"customize": "Customize your chat settings here"
},
"watermark": "LLMs can make mistakes. Check important info."
},
"threadHistory": {
"sidebar": {
"title": "Past Chats",
"filters": {
"search": "Search",
"placeholder": "Search conversations..."
},
"timeframes": {
"today": "Today",
"yesterday": "Yesterday",
"previous7days": "Previous 7 days",
"previous30days": "Previous 30 days"
},
"empty": "No threads found",
"actions": {
"close": "Close sidebar",
"open": "Open sidebar"
}
},
"thread": {
"untitled": "Untitled Conversation",
"menu": {
"rename": "Rename",
"share": "Share",
"delete": "Delete"
},
"actions": {
"share": {
"title": "Share link to chat",
"button": "Share",
"status": {
"copied": "Link copied",
"created": "Share link created!",
"unshared": "Sharing disabled for this thread"
},
"error": {
"create": "Failed to create share link",
"unshare": "Failed to unshare thread"
}
},
"delete": {
"title": "Confirm deletion",
"description": "This will delete the thread as well as its messages and elements. This action cannot be undone",
"success": "Chat deleted",
"inProgress": "Deleting chat"
},
"rename": {
"title": "Rename Thread",
"description": "Enter a new name for this thread",
"form": {
"name": {
"label": "Name",
"placeholder": "Enter new name"
}
},
"success": "Thread renamed!",
"inProgress": "Renaming thread"
}
}
}
},
"navigation": {
"header": {
"chat": "Chat",
"readme": "Readme",
"theme": {
"light": "Light Theme",
"dark": "Dark Theme",
"system": "Follow System"
}
},
"newChat": {
"button": "New Chat",
"dialog": {
"title": "Create New Chat",
"description": "This will clear your current chat history. Are you sure you want to continue?",
"tooltip": "New Chat"
}
},
"user": {
"menu": {
"settings": "Settings",
"settingsKey": "S",
"apiKeys": "API Keys",
"logout": "Logout"
}
}
},
"apiKeys": {
"title": "Required API Keys",
"description": "To use this app, the following API keys are required. The keys are stored on your device's local storage.",
"success": {
"saved": "Saved successfully"
}
},
"alerts": {
"info": "Info",
"note": "Note",
"tip": "Tip",
"important": "Important",
"warning": "Warning",
"caution": "Caution",
"debug": "Debug",
"example": "Example",
"success": "Success",
"help": "Help",
"idea": "Idea",
"pending": "Pending",
"security": "Security",
"beta": "Beta",
"best-practice": "Best Practice"
},
"components": {
"MultiSelectInput": {
"placeholder": "Select..."
}
}
}

View File

@ -2,6 +2,7 @@
.gitignore
.venv
venv
.env
env/
__pycache__/
*.pyc

224
.gitignore vendored
View File

@ -1,190 +1,68 @@
# Created by .ignore support plugin (hsz.mobi)
### Python template
# Byte-compiled / optimized / DLL files
# ===== Python =====
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
*.egg
*.egg-info/
.eggs/
dist/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# IPython Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# dotenv
.env
# virtualenv
.Python
env/
venv/
ENV/
# Spyder project settings
.spyderproject
# Rope project settings
.ropeproject
### VirtualEnv template
# Virtualenv
# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/
[Bb]in
[Ii]nclude
[Ll]ib
[Ll]ib64
[Ll]ocal
[Ss]cripts
pyvenv.cfg
.venv
pip-selfcheck.json
pyvenv.cfg
### JetBrains template
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# ===== Testing =====
.pytest_cache/
.coverage
.coverage.*
htmlcov/
coverage.xml
nosetests.xml
.tox/
.cache
.hypothesis/
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# ===== Env =====
.env
!.env.example
!.env.local
!.env.local.example
# AWS User-specific
.idea/**/aws.xml
# ===== JetBrains =====
.idea/
# Generated files
.idea/**/contentModel.xml
# ===== VS Code =====
.vscode/
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
# ===== Node / Next.js =====
node_modules/
.next/
out/
coverage/
.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
*.tsbuildinfo
next-env.d.ts
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
# Chainlit correction
.chainlit/translations/*
!.chainlit/translations/en-US.json
.files/
# idea folder, uncomment if you don't need it
.idea
# ===== Misc =====
.DS_Store
*.pem
.vercel
*.log
*.spec
*.manifest

View File

@ -1,20 +0,0 @@
FROM python:3.11-slim
WORKDIR /main
RUN apt-get update && apt-get install -y \
gcc \
g++ \
make \
pkg-config \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
ENTRYPOINT ["chainlit", "run", "app.py", "--host", "0.0.0.0", "--port", "8000"]

89
app.py
View File

@ -1,89 +0,0 @@
import chainlit as cl
from dotenv import load_dotenv
load_dotenv()
from core.config import DEFAULT_MODEL, MAX_HISTORY
from core.init_agent import assistant_agent
from core.stream_response import stream_response
from api.fetch_api_data import set_log_callback
STARTERS = [
("What legal data can the agent find?", "magnifying_glass"),
("What is the agent not allowed to do or use?", "ban"),
("What are the details of your AI model?", "hexagon"),
("What data sources does the agent rely on?", "database"),
]
PROFILES = [
("qwen3.5:cloud", "Qwen 3.5 CLOUD (in Ollama)"),
("gpt-oss:20b-cloud", "GPT-OSS 20B CLOUD (in Ollama)"),
("gpt-oss:20b", "GPT-OSS 20B (Local LLM)"),
("qwen3:8b", "Qwen3 8B (Local LLM)"),
("gpt-4o", "GPT-4o (OpenAI API)"),
("gpt-4o-mini", "GPT-4o Mini (OpenAI API)"),
]
@cl.set_starters
async def set_starters():
return [
cl.Starter(label=label, message=label, icon=f"/public/icon/{icon}.svg")
for label, icon in STARTERS
]
@cl.set_chat_profiles
async def chat_profile():
return [
cl.ChatProfile(name=name, markdown_description=f"Uses **{desc}**")
for name, desc in PROFILES
]
@cl.on_chat_start
async def start():
model_name = cl.user_session.get("chat_profile") or DEFAULT_MODEL
cl.user_session.set("agent", assistant_agent(model_name))
cl.user_session.set("message_history", [])
cl.user_session.set("current_model", model_name)
@cl.on_message
async def main(message: cl.Message):
current_profile = cl.user_session.get("chat_profile") or DEFAULT_MODEL
current_agent = cl.user_session.get("agent")
current_model = cl.user_session.get("current_model")
if current_model != current_profile:
current_agent = assistant_agent(current_profile)
cl.user_session.set("agent", current_agent)
cl.user_session.set("current_model", current_profile)
cl.user_session.set("message_history", [])
agent = current_agent
history = cl.user_session.get("message_history")
history.append({"role": "user", "content": message.content})
if len(history) > MAX_HISTORY:
history = history[-MAX_HISTORY:]
async with cl.Step(name="🔍 Fetching data...", type="run") as step:
log_lines = []
def on_log(line: str):
log_lines.append(line)
step.output = "\n".join(log_lines)
set_log_callback(on_log)
msg = cl.Message(content="")
async for token in stream_response(agent, history):
await msg.stream_token(token)
await msg.update()
set_log_callback(None)
await step.update()
if msg.content:
history.append({"role": "assistant", "content": msg.content})
cl.user_session.set("message_history", history)

15
backend/Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y gcc \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY pyproject.toml .
RUN pip install -e "."
COPY backend/ ./backend/
EXPOSE 8000
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@ -5,7 +5,7 @@ from cachetools import TTLCache
from typing import Callable
from tenacity import retry, stop_after_attempt, wait_exponential
from api.config import HTTP_TIMEOUT, HTTP_MAX_CONNECTIONS, HTTP_MAX_KEEPALIVE, CACHE_TTL, CACHE_MAX_SIZE
from backend.api.config import HTTP_TIMEOUT, HTTP_MAX_CONNECTIONS, HTTP_MAX_KEEPALIVE, CACHE_TTL, CACHE_MAX_SIZE
logger = logging.getLogger(__name__)

View File

@ -1,6 +1,6 @@
from agents import function_tool
from api.fetch_api_data import fetch_api_data
from api.schemas import (
from backend.api.fetch_api_data import fetch_api_data
from backend.api.schemas import (
CourtSearch, CourtByID, CourtAutocomplete,
JudgeSearch, JudgeByID, JudgeAutocomplete,
DecisionSearch, DecisionByID, DecisionAutocomplete,
@ -8,7 +8,7 @@ from api.schemas import (
CivilProceedingsSearch, CivilProceedingsByID, CivilProceedingsAutocomplete,
AdminProceedingsSearch, AdminProceedingsByID, AdminProceedingsAutocomplete,
)
from api.config import JUSTICE_API_BASE
from backend.api.config import JUSTICE_API_BASE
####################################################################################################################
# .../v1/sud

43
backend/core/agent.py Normal file
View File

@ -0,0 +1,43 @@
from agents import Agent, OpenAIChatCompletionsModel, AsyncOpenAI, ModelSettings, set_tracing_disabled
from agents.mcp import MCPServerStreamableHttp
from backend.core.config import LITELLM_BASE_URL, LITELLM_API_KEY, AGENT_TEMPERATURE, LLM_TIMEOUT, DEFAULT_MODEL, MCP_SERVER_URL
from backend.core.system_prompt import get_system_prompt
#from api.tools import ALL_TOOLS
def _make_client() -> AsyncOpenAI:
return AsyncOpenAI (
base_url=LITELLM_BASE_URL,
api_key=LITELLM_API_KEY,
timeout=LLM_TIMEOUT,
max_retries=0
)
def get_mcp_server() -> MCPServerStreamableHttp:
return MCPServerStreamableHttp(
name="Slovak Justice API",
params={"url": MCP_SERVER_URL},
cache_tools_list=True
)
def assistant_agent(model_name: str = DEFAULT_MODEL) -> Agent:
"""Initialize the assistant agent for legal work"""
client = _make_client()
model = OpenAIChatCompletionsModel(
model=model_name,
openai_client=client
)
return Agent(
name="AI Lawyer Assistant",
instructions=get_system_prompt(model_name),
model=model,
model_settings=ModelSettings(
temperature=AGENT_TEMPERATURE,
tool_choice="auto",
parallel_tool_calls=False
),
tool_use_behavior="run_llm_again",
reset_tool_choice=True,
mcp_servers=[get_mcp_server()],
)

13
backend/core/config.py Normal file
View File

@ -0,0 +1,13 @@
import os
LITELLM_BASE_URL = os.getenv("LITELLM_BASE_URL", "http://localhost:4000/v1")
LITELLM_API_KEY = os.getenv("LITELLM_API_KEY", "sk-anything")
MCP_SERVER_URL = os.getenv("MCP_SERVER_URL", "http://localhost:8001/mcp")
DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "llama-3.3-70b")
MAX_HISTORY = int(os.getenv("MAX_HISTORY", "20"))
AGENT_TEMPERATURE = float(os.getenv("AGENT_TEMPERATURE", "0.3"))
LLM_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "60.0"))
ALL_MODELS = { "llama-3.3-70b", "qwen-qwq-32b", "gemini-flash" }

15
backend/core/streaming.py Normal file
View File

@ -0,0 +1,15 @@
from typing import AsyncGenerator
from agents import Agent, Runner
from openai.types.responses import ResponseTextDeltaEvent
async def stream_response(agent: Agent, prompt: list[dict] | str) -> AsyncGenerator[str, None]:
"""Stream agent response and update the UI."""
try:
async with agent.mcp_servers[0]:
result = Runner.run_streamed(agent, input=prompt)
async for event in result.stream_events():
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
yield event.data.delta # <-- sends the next piece of response text
except Exception as e:
yield f"⚠️🖨 Error: {e}"

54
backend/main.py Normal file
View File

@ -0,0 +1,54 @@
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from fastapi.responses import StreamingResponse
from backend.core.agent import assistant_agent
from backend.core.streaming import stream_response
from backend.core.config import ALL_MODELS, DEFAULT_MODEL
import json
app = FastAPI(title="Legal AI Assistant")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_methods=["*"],
allow_headers=["*"],
)
class Message(BaseModel):
role: str
content: str
class Request(BaseModel):
messages: list[Message]
model: str = DEFAULT_MODEL
@app.get("/")
async def health_check():
return {"status": "ok"}
@app.get("/api/models")
async def get_models():
return {"models": list(ALL_MODELS)}
@app.post("/api/chat")
async def chat(request: Request):
agent = assistant_agent(request.model)
messages = [{"role": ms.role, "content": ms.content} for ms in request.messages]
async def stream():
async for token in stream_response(agent, messages):
chunk = json.dumps({"type": "text", "delta": token})
yield f"data: {chunk}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
}
)

View File

@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y gcc \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY pyproject.toml .
RUN pip install -e ".[mcp]"
COPY backend/ ./backend/
EXPOSE 8001
CMD ["python", "-m", "mcp_server.server"]

View File

View File

@ -0,0 +1,17 @@
from fastmcp import FastMCP
from backend.mcp_server.tools.judges import register_judge_tools
mcp = FastMCP(
name="Slovak Justice API",
instructions="""
Tento MCP server poskytuje prístup k verejným API
Ministerstva spravodlivosti Slovenskej republiky.
Obsahuje nástroje pre vyhľadávanie súdov, sudcov,
rozhodnutí, zmlúv a konaní.
"""
)
register_judge_tools(mcp)
if __name__ == "__main__":
mcp.run(transport="streamable-http", host="0.0.0.0", port=8001)

View File

@ -0,0 +1,77 @@
# mcp_server/tools/judges.py
import httpx
import json
from fastmcp import FastMCP
from typing import Optional
from pydantic import Field
JUSTICE_API = "https://obcan.justice.sk/pilot/api/ress-isu-service"
HEADERS = {
"User-Agent": "Mozilla/5.0",
"Accept": "application/json",
"Accept-Language": "sk-SK,sk;q=0.9",
}
async def _get(path: str, params: dict) -> dict:
clean = {k: v for k, v in params.items() if v is not None}
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(
f"{JUSTICE_API}{path}",
params=clean,
headers=HEADERS
)
r.raise_for_status()
return r.json()
def register_judge_tools(mcp: FastMCP):
@mcp.tool()
async def judge_autocomplete(
query: str,
court_id: Optional[str] = None,
limit: int = Field(default=10, description="Maximálny počet výsledkov"),
) -> str:
"""
Autocomplete pre mená sudcov použiť AKO PRVÝ krok
pri hľadaní sudcu podľa mena.
"""
result = await _get("/v1/sudca/autocomplete", {
"query": query,
"guidSud": court_id,
"limit": limit,
})
return json.dumps(result, ensure_ascii=False)
@mcp.tool()
async def judge_search(
query: Optional[str] = None,
kraj: Optional[str] = None,
court_id: Optional[str] = None,
status: Optional[str] = None,
page: int = Field(default=0, description="Číslo stránky (začína od 0)"),
size: int = Field(default=20, description="Počet výsledkov na stránku"),
) -> str:
"""
Vyhľadávanie sudcov s filtrami.
Používať ak autocomplete nevráti výsledky.
"""
result = await _get("/v1/sudca", {
"query": query,
"krajFacetFilter": kraj,
"guidSud": court_id,
"stavZapisuFacetFilter": status,
"page": page,
"size": size,
})
return json.dumps(result, ensure_ascii=False)
@mcp.tool()
async def judge_by_id(judge_id: str) -> str:
"""
Detailné informácie o konkrétnom sudcovi podľa ID.
judge_id: ID sudcu (napr. "sudca_42")
"""
if judge_id.isdigit():
judge_id = f"sudca_{judge_id}"
result = await _get(f"/v1/sudca/{judge_id}", {})
return json.dumps(result, ensure_ascii=False)

View File

@ -1,14 +0,0 @@
# Welcome to Chainlit! 🚀🤖
Hi there, Developer! 👋 We're excited to have you on board. Chainlit is a powerful tool designed to help you prototype, debug and share applications built on top of LLMs.
## Useful Links 🔗
- **Documentation:** Get started with our comprehensive [Chainlit Documentation](https://docs.chainlit.io) 📚
- **Discord Community:** Join our friendly [Chainlit Discord](https://discord.gg/k73SQ3FyUh) to ask questions, share your projects, and connect with other developers! 💬
We can't wait to see what you create with Chainlit! Happy coding! 💻😊
## Welcome screen
To modify the welcome screen, edit the `chainlit.md` file at the root of your project. If you do not want a welcome screen, just leave this file empty.

View File

@ -1,17 +0,0 @@
# Legal AI Assistant ⚖️
Welcome! This assistant is connected with the Slovak Ministry of Justice public systems.
Its purpose is to:
- Extract and interpret structured information from natural language queries.
- Validate data and ensure accuracy.
- Deliver clear, well-organized, and user-friendly responses.
The assistant covers a wide range of legal topics, including:
- Judges
- Courts
- Decisions
- Contracts
- Civil and administrative proceedings
All outputs are designed to be easy to understand, providing concise summaries of complex legal information while remaining approachable for any user. 📄✨

46
compose.yaml Normal file
View File

@ -0,0 +1,46 @@
name: "legal-ai-assistant"
services:
backend:
build:
context: .
dockerfile: backend/Dockerfile
restart: unless-stopped
ports:
- "8000:8000"
environment:
- PYTHONUNBUFFERED=1
- LITELLM_BASE_URL=http://litellm:4000/v1
- LITELLM_API_KEY=sk-anything
- MCP_SERVER_URL=http://mcp:8001/mcp
- DEFAULT_MODEL=llama-3.3-70b
depends_on:
litellm:
condition: service_started
mcp:
condition: service_started
litellm:
image: ghcr.io/berriai/litellm:main-stable
container_name: litellm
restart: unless-stopped
ports:
- "4000:4000"
env_file:
- .env
environment:
- GROQ_API_KEY=${GROQ_API_KEY}
- GEMINI_API_KEY=${GEMINI_API_KEY}
volumes:
- ./config.yaml:/app/config.yaml:ro
command: ['--config', '/app/config.yaml', '--port', '4000']
mcp:
build:
context: .
dockerfile: backend/mcp_server/Dockerfile
restart: unless-stopped
ports:
- "8001:8001"
environment:
- JUSTICE_API_BASE=https://obcan.justice.sk/pilot/api/ress-isu-service

23
config.yaml Normal file
View File

@ -0,0 +1,23 @@
model_list:
# GROQ
- model_name: llama-3.3-70b
litellm_params:
model: groq/llama-3.3-70b-versatile
api_key: os.environ/GROQ_API_KEY
- model_name: qwen-qwq-32b
litellm_params:
model: groq/qwen-qwq-32b
api_key: os.environ/GROQ_API_KEY
# Google AI Studio
- model_name: gemini-flash
litellm_params:
model: gemini/gemini-2.0-flash
api_key: os.environ/GEMINI_API_KEY
litellm_settings:
drop_params: true
request_timeout: 60
num_retries: 2
convert_input_to_str: true

View File

@ -1,14 +0,0 @@
import os
DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "qwen3.5:cloud")
MAX_HISTORY = int(os.getenv("MAX_HISTORY", "20"))
AGENT_TEMPERATURE = float(os.getenv("AGENT_TEMPERATURE", "0.7"))
LLM_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "120.0"))
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
OLLAMA_API_KEY = os.getenv("OLLAMA_API_KEY", "ollama")
OLLAMA_MODELS = {"qwen3.5:cloud", "gpt-oss:20b-cloud", "gpt-oss:20b", "qwen3:8b"}
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
OPENAI_MODELS = {"gpt-4o", "gpt-4o-mini"}

View File

@ -1,61 +0,0 @@
from agents import Agent, AgentHooks
from agents import OpenAIChatCompletionsModel, AsyncOpenAI, ModelSettings
from agents import set_tracing_disabled
from core.config import (
DEFAULT_MODEL, AGENT_TEMPERATURE, LLM_TIMEOUT,
OLLAMA_BASE_URL, OLLAMA_API_KEY,
OPENAI_BASE_URL, OPENAI_API_KEY,
OPENAI_MODELS, OLLAMA_MODELS
)
from core.system_prompt import get_system_prompt
from api.tools import ALL_TOOLS
set_tracing_disabled(True)
class MyAgentHooks(AgentHooks):
async def on_start(self, context, agent):
print(f"\n🏃‍♂️‍➡️ [AgentHooks] {agent.name} started.")
async def on_end(self, context, agent, output):
print(f"🏁 [AgentHooks] {agent.name} ended.")
def _make_client(model_name: str) -> AsyncOpenAI:
"""Return the correct AsyncOpenAI client based on model name"""
if model_name in OPENAI_MODELS:
return AsyncOpenAI(
base_url=OPENAI_BASE_URL,
api_key=OPENAI_API_KEY,
timeout=LLM_TIMEOUT,
max_retries=0,
)
if model_name in OLLAMA_MODELS:
return AsyncOpenAI(
base_url=OLLAMA_BASE_URL,
api_key=OLLAMA_API_KEY,
timeout=LLM_TIMEOUT,
max_retries=0,
)
raise ValueError(f"Model {model_name} not supported")
def assistant_agent(model_name: str = DEFAULT_MODEL) -> Agent:
"""Initialize the assistant agent for legal work"""
client = _make_client(model_name)
model = OpenAIChatCompletionsModel(model=model_name, openai_client=client)
agent = Agent(
name="AI Lawyer Assistant",
instructions=get_system_prompt(model_name),
model=model,
model_settings=ModelSettings(temperature=AGENT_TEMPERATURE, tool_choice="auto", parallel_tool_calls=False),
tools=ALL_TOOLS,
tool_use_behavior="run_llm_again",
reset_tool_choice=True,
hooks=MyAgentHooks(),
)
return agent

View File

@ -1,14 +0,0 @@
from typing import AsyncGenerator
from agents import Agent, Runner
from openai.types.responses import ResponseTextDeltaEvent
async def stream_response(agent: Agent, prompt: list[dict] | str) -> AsyncGenerator[str, None]:
"""Stream agent response and update the UI."""
try:
result = Runner.run_streamed(agent, input=prompt)
async for event in result.stream_events():
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
yield event.data.delta # <-- sends the next piece of response text
except Exception as e:
yield f"⚠️🖨 Error: {e}"

View File

@ -1,16 +0,0 @@
version: "3.8"
services:
legal-ai-assistant:
build: .
ports:
- "8000:8000"
volumes:
- .:/main
environment:
- PYTHONPATH=/main
- PYTHONUNBUFFERED=1
- OLLAMA_HOST=ollama:11434
depends_on:
- ollama
restart: unless-stopped

View File

@ -0,0 +1,31 @@
import { openai } from "@ai-sdk/openai";
import { frontendTools } from "@assistant-ui/react-ai-sdk";
import {
JSONSchema7,
streamText,
convertToModelMessages,
type UIMessage,
} from "ai";
export async function POST(req: Request) {
const {
messages,
system,
tools,
}: {
messages: UIMessage[];
system?: string;
tools?: Record<string, { description?: string; parameters: JSONSchema7 }>;
} = await req.json();
const result = streamText({
model: openai("gpt-5-nano"),
messages: await convertToModelMessages(messages),
system,
tools: {
...frontendTools(tools ?? {}),
},
});
return result.toUIMessageStreamResponse();
}

View File

@ -0,0 +1,26 @@
"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import {
useChatRuntime,
AssistantChatTransport,
} from "@assistant-ui/react-ai-sdk";
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import { Thread } from "@/components/assistant-ui/thread";
export const Assistant = () => {
const runtime = useChatRuntime({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
transport: new AssistantChatTransport({
api: "/api/chat",
}),
});
return (
<AssistantRuntimeProvider runtime={runtime}>
<div className="h-dvh">
<Thread />
</div>
</AssistantRuntimeProvider>
);
};

BIN
frontend/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

117
frontend/app/globals.css Normal file
View File

@ -0,0 +1,117 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--animate-shimmer: shimmer-sweep var(--shimmer-duration, 1000ms) linear
infinite both;
@keyframes shimmer-sweep {
from {
background-position: 150% 0;
}
to {
background-position: -100% 0;
}
}
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
:root {
color-scheme: light;
}
:root.dark {
color-scheme: dark;
}
body {
@apply bg-background text-foreground;
}
}

35
frontend/app/layout.tsx Normal file
View File

@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { TooltipProvider } from "@/components/ui/tooltip";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "assistant-ui starter app",
description: "Generated by create-assistant-ui",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<TooltipProvider>{children}</TooltipProvider>
</body>
</html>
);
}

5
frontend/app/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import { Assistant } from "./assistant";
export default function Home() {
return <Assistant />;
}

21
frontend/components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,222 @@
"use client";
import { PropsWithChildren, useEffect, useState, type FC } from "react";
import { XIcon, PlusIcon, FileText } from "lucide-react";
import {
AttachmentPrimitive,
ComposerPrimitive,
MessagePrimitive,
useAuiState,
useAui,
} from "@assistant-ui/react";
import { useShallow } from "zustand/shallow";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Dialog,
DialogTitle,
DialogContent,
DialogTrigger,
} from "@/components/ui/dialog";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { cn } from "@/lib/utils";
const useFileSrc = (file: File | undefined) => {
const [src, setSrc] = useState<string | undefined>(undefined);
useEffect(() => {
if (!file) {
setSrc(undefined);
return;
}
const objectUrl = URL.createObjectURL(file);
setSrc(objectUrl);
return () => {
URL.revokeObjectURL(objectUrl);
};
}, [file]);
return src;
};
const useAttachmentSrc = () => {
const { file, src } = useAuiState(
useShallow((s): { file?: File; src?: string } => {
if (s.attachment.type !== "image") return {};
if (s.attachment.file) return { file: s.attachment.file };
const src = s.attachment.content?.filter((c) => c.type === "image")[0]
?.image;
if (!src) return {};
return { src };
}),
);
return useFileSrc(file) ?? src;
};
type AttachmentPreviewProps = {
src: string;
};
const AttachmentPreview: FC<AttachmentPreviewProps> = ({ src }) => {
const [isLoaded, setIsLoaded] = useState(false);
return (
<img
src={src}
alt="Image Preview"
className={cn(
"block h-auto max-h-[80vh] w-auto max-w-full object-contain",
isLoaded
? "aui-attachment-preview-image-loaded"
: "aui-attachment-preview-image-loading invisible",
)}
onLoad={() => setIsLoaded(true)}
/>
);
};
const AttachmentPreviewDialog: FC<PropsWithChildren> = ({ children }) => {
const src = useAttachmentSrc();
if (!src) return children;
return (
<Dialog>
<DialogTrigger
className="aui-attachment-preview-trigger cursor-pointer transition-colors hover:bg-accent/50"
asChild
>
{children}
</DialogTrigger>
<DialogContent className="aui-attachment-preview-dialog-content p-2 sm:max-w-3xl [&>button]:rounded-full [&>button]:bg-foreground/60 [&>button]:p-1 [&>button]:opacity-100 [&>button]:ring-0! [&_svg]:text-background [&>button]:hover:[&_svg]:text-destructive">
<DialogTitle className="aui-sr-only sr-only">
Image Attachment Preview
</DialogTitle>
<div className="aui-attachment-preview relative mx-auto flex max-h-[80dvh] w-full items-center justify-center overflow-hidden bg-background">
<AttachmentPreview src={src} />
</div>
</DialogContent>
</Dialog>
);
};
const AttachmentThumb: FC = () => {
const src = useAttachmentSrc();
return (
<Avatar className="aui-attachment-tile-avatar h-full w-full rounded-none">
<AvatarImage
src={src}
alt="Attachment preview"
className="aui-attachment-tile-image object-cover"
/>
<AvatarFallback>
<FileText className="aui-attachment-tile-fallback-icon size-8 text-muted-foreground" />
</AvatarFallback>
</Avatar>
);
};
const AttachmentUI: FC = () => {
const aui = useAui();
const isComposer = aui.attachment.source !== "message";
const isImage = useAuiState((s) => s.attachment.type === "image");
const typeLabel = useAuiState((s) => {
const type = s.attachment.type;
switch (type) {
case "image":
return "Image";
case "document":
return "Document";
case "file":
return "File";
default:
return type;
}
});
return (
<Tooltip>
<AttachmentPrimitive.Root
className={cn(
"aui-attachment-root relative",
isImage && "aui-attachment-root-composer only:*:first:size-24",
)}
>
<AttachmentPreviewDialog>
<TooltipTrigger asChild>
<div
className="aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[calc(var(--composer-radius)-var(--composer-padding))] border bg-muted transition-opacity hover:opacity-75"
role="button"
aria-label={`${typeLabel} attachment`}
>
<AttachmentThumb />
</div>
</TooltipTrigger>
</AttachmentPreviewDialog>
{isComposer && <AttachmentRemove />}
</AttachmentPrimitive.Root>
<TooltipContent side="top">
<AttachmentPrimitive.Name />
</TooltipContent>
</Tooltip>
);
};
const AttachmentRemove: FC = () => {
return (
<AttachmentPrimitive.Remove asChild>
<TooltipIconButton
tooltip="Remove file"
className="aui-attachment-tile-remove absolute top-1.5 right-1.5 size-3.5 rounded-full bg-white text-muted-foreground opacity-100 shadow-sm hover:bg-white! [&_svg]:text-black hover:[&_svg]:text-destructive"
side="top"
>
<XIcon className="aui-attachment-remove-icon size-3 dark:stroke-[2.5px]" />
</TooltipIconButton>
</AttachmentPrimitive.Remove>
);
};
export const UserMessageAttachments: FC = () => {
return (
<div className="aui-user-message-attachments-end col-span-full col-start-1 row-start-1 flex w-full flex-row justify-end gap-2">
<MessagePrimitive.Attachments>
{() => <AttachmentUI />}
</MessagePrimitive.Attachments>
</div>
);
};
export const ComposerAttachments: FC = () => {
return (
<div className="aui-composer-attachments flex w-full flex-row items-center gap-2 overflow-x-auto empty:hidden">
<ComposerPrimitive.Attachments>
{() => <AttachmentUI />}
</ComposerPrimitive.Attachments>
</div>
);
};
export const ComposerAddAttachment: FC = () => {
return (
<ComposerPrimitive.AddAttachment asChild>
<TooltipIconButton
tooltip="Add Attachment"
side="bottom"
variant="ghost"
size="icon"
className="aui-composer-add-attachment size-8 rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
aria-label="Add Attachment"
>
<PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]" />
</TooltipIconButton>
</ComposerPrimitive.AddAttachment>
);
};

View File

@ -0,0 +1,243 @@
"use client";
import "@assistant-ui/react-markdown/styles/dot.css";
import {
type CodeHeaderProps,
MarkdownTextPrimitive,
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
useIsMarkdownCodeBlock,
} from "@assistant-ui/react-markdown";
import remarkGfm from "remark-gfm";
import { type FC, memo, useState } from "react";
import { CheckIcon, CopyIcon } from "lucide-react";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { cn } from "@/lib/utils";
const MarkdownTextImpl = () => {
return (
<MarkdownTextPrimitive
remarkPlugins={[remarkGfm]}
className="aui-md"
components={defaultComponents}
/>
);
};
export const MarkdownText = memo(MarkdownTextImpl);
const CodeHeader: FC<CodeHeaderProps> = ({ language, code }) => {
const { isCopied, copyToClipboard } = useCopyToClipboard();
const onCopy = () => {
if (!code || isCopied) return;
copyToClipboard(code);
};
return (
<div className="aui-code-header-root mt-2.5 flex items-center justify-between rounded-t-lg border border-border/50 border-b-0 bg-muted/50 px-3 py-1.5 text-xs">
<span className="aui-code-header-language font-medium text-muted-foreground lowercase">
{language}
</span>
<TooltipIconButton tooltip="Copy" onClick={onCopy}>
{!isCopied && <CopyIcon />}
{isCopied && <CheckIcon />}
</TooltipIconButton>
</div>
);
};
const useCopyToClipboard = ({
copiedDuration = 3000,
}: {
copiedDuration?: number;
} = {}) => {
const [isCopied, setIsCopied] = useState<boolean>(false);
const copyToClipboard = (value: string) => {
if (!value) return;
navigator.clipboard.writeText(value).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), copiedDuration);
});
};
return { isCopied, copyToClipboard };
};
const defaultComponents = memoizeMarkdownComponents({
h1: ({ className, ...props }) => (
<h1
className={cn(
"aui-md-h1 mb-2 scroll-m-20 font-semibold text-base first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h2: ({ className, ...props }) => (
<h2
className={cn(
"aui-md-h2 mt-3 mb-1.5 scroll-m-20 font-semibold text-sm first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h3: ({ className, ...props }) => (
<h3
className={cn(
"aui-md-h3 mt-2.5 mb-1 scroll-m-20 font-semibold text-sm first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h4: ({ className, ...props }) => (
<h4
className={cn(
"aui-md-h4 mt-2 mb-1 scroll-m-20 font-medium text-sm first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h5: ({ className, ...props }) => (
<h5
className={cn(
"aui-md-h5 mt-2 mb-1 font-medium text-sm first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h6: ({ className, ...props }) => (
<h6
className={cn(
"aui-md-h6 mt-2 mb-1 font-medium text-sm first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
p: ({ className, ...props }) => (
<p
className={cn(
"aui-md-p my-2.5 leading-normal first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
a: ({ className, ...props }) => (
<a
className={cn(
"aui-md-a text-primary underline underline-offset-2 hover:text-primary/80",
className,
)}
{...props}
/>
),
blockquote: ({ className, ...props }) => (
<blockquote
className={cn(
"aui-md-blockquote my-2.5 border-muted-foreground/30 border-l-2 pl-3 text-muted-foreground italic",
className,
)}
{...props}
/>
),
ul: ({ className, ...props }) => (
<ul
className={cn(
"aui-md-ul my-2 ml-4 list-disc marker:text-muted-foreground [&>li]:mt-1",
className,
)}
{...props}
/>
),
ol: ({ className, ...props }) => (
<ol
className={cn(
"aui-md-ol my-2 ml-4 list-decimal marker:text-muted-foreground [&>li]:mt-1",
className,
)}
{...props}
/>
),
hr: ({ className, ...props }) => (
<hr
className={cn("aui-md-hr my-2 border-muted-foreground/20", className)}
{...props}
/>
),
table: ({ className, ...props }) => (
<table
className={cn(
"aui-md-table my-2 w-full border-separate border-spacing-0 overflow-y-auto",
className,
)}
{...props}
/>
),
th: ({ className, ...props }) => (
<th
className={cn(
"aui-md-th bg-muted px-2 py-1 text-left font-medium first:rounded-tl-lg last:rounded-tr-lg [[align=center]]:text-center [[align=right]]:text-right",
className,
)}
{...props}
/>
),
td: ({ className, ...props }) => (
<td
className={cn(
"aui-md-td border-muted-foreground/20 border-b border-l px-2 py-1 text-left last:border-r [[align=center]]:text-center [[align=right]]:text-right",
className,
)}
{...props}
/>
),
tr: ({ className, ...props }) => (
<tr
className={cn(
"aui-md-tr m-0 border-b p-0 first:border-t [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg",
className,
)}
{...props}
/>
),
li: ({ className, ...props }) => (
<li className={cn("aui-md-li leading-normal", className)} {...props} />
),
sup: ({ className, ...props }) => (
<sup
className={cn("aui-md-sup [&>a]:text-xs [&>a]:no-underline", className)}
{...props}
/>
),
pre: ({ className, ...props }) => (
<pre
className={cn(
"aui-md-pre overflow-x-auto rounded-t-none rounded-b-lg border border-border/50 border-t-0 bg-muted/30 p-3 text-xs leading-relaxed",
className,
)}
{...props}
/>
),
code: function Code({ className, ...props }) {
const isCodeBlock = useIsMarkdownCodeBlock();
return (
<code
className={cn(
!isCodeBlock &&
"aui-md-inline-code rounded-md border border-border/50 bg-muted/50 px-1.5 py-0.5 font-mono text-[0.85em]",
className,
)}
{...props}
/>
);
},
CodeHeader,
});

View File

@ -0,0 +1,368 @@
import {
ComposerAddAttachment,
ComposerAttachments,
UserMessageAttachments,
} from "@/components/assistant-ui/attachment";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
ActionBarMorePrimitive,
ActionBarPrimitive,
AuiIf,
BranchPickerPrimitive,
ComposerPrimitive,
ErrorPrimitive,
MessagePrimitive,
SuggestionPrimitive,
ThreadPrimitive,
useAuiState,
} from "@assistant-ui/react";
import {
ArrowDownIcon,
ArrowUpIcon,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
DownloadIcon,
MoreHorizontalIcon,
PencilIcon,
RefreshCwIcon,
SquareIcon,
} from "lucide-react";
import type { FC } from "react";
export const Thread: FC = () => {
return (
<ThreadPrimitive.Root
className="aui-root aui-thread-root @container flex h-full flex-col bg-background"
style={{
["--thread-max-width" as string]: "44rem",
["--composer-radius" as string]: "24px",
["--composer-padding" as string]: "10px",
}}
>
<ThreadPrimitive.Viewport
turnAnchor="top"
className="aui-thread-viewport relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
>
<AuiIf condition={(s) => s.thread.isEmpty}>
<ThreadWelcome />
</AuiIf>
<ThreadPrimitive.Messages>
{() => <ThreadMessage />}
</ThreadPrimitive.Messages>
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-(--composer-radius) bg-background pb-4 md:pb-6">
<ThreadScrollToBottom />
<Composer />
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
);
};
const ThreadMessage: FC = () => {
const role = useAuiState((s) => s.message.role);
const isEditing = useAuiState((s) => s.message.composer.isEditing);
if (isEditing) return <EditComposer />;
if (role === "user") return <UserMessage />;
return <AssistantMessage />;
};
const ThreadScrollToBottom: FC = () => {
return (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="aui-thread-scroll-to-bottom absolute -top-12 z-10 self-center rounded-full p-4 disabled:invisible dark:border-border dark:bg-background dark:hover:bg-accent"
>
<ArrowDownIcon />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
);
};
const ThreadWelcome: FC = () => {
return (
<div className="aui-thread-welcome-root mx-auto my-auto flex w-full max-w-(--thread-max-width) grow flex-col">
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center">
<div className="aui-thread-welcome-message flex size-full flex-col justify-center px-4">
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both font-semibold text-2xl duration-200">
Hello there!
</h1>
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-muted-foreground text-xl delay-75 duration-200">
How can I help you today?
</p>
</div>
</div>
<ThreadSuggestions />
</div>
);
};
const ThreadSuggestions: FC = () => {
return (
<div className="aui-thread-welcome-suggestions grid w-full @md:grid-cols-2 gap-2 pb-4">
<ThreadPrimitive.Suggestions>
{() => <ThreadSuggestionItem />}
</ThreadPrimitive.Suggestions>
</div>
);
};
const ThreadSuggestionItem: FC = () => {
return (
<div className="aui-thread-welcome-suggestion-display fade-in slide-in-from-bottom-2 @md:nth-[n+3]:block nth-[n+3]:hidden animate-in fill-mode-both duration-200">
<SuggestionPrimitive.Trigger send asChild>
<Button
variant="ghost"
className="aui-thread-welcome-suggestion h-auto w-full @md:flex-col flex-wrap items-start justify-start gap-1 rounded-3xl border bg-background px-4 py-3 text-left text-sm transition-colors hover:bg-muted"
>
<SuggestionPrimitive.Title className="aui-thread-welcome-suggestion-text-1 font-medium" />
<SuggestionPrimitive.Description className="aui-thread-welcome-suggestion-text-2 text-muted-foreground empty:hidden" />
</Button>
</SuggestionPrimitive.Trigger>
</div>
);
};
const Composer: FC = () => {
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
<ComposerPrimitive.AttachmentDropzone asChild>
<div
data-slot="composer-shell"
className="flex w-full flex-col gap-2 rounded-(--composer-radius) border bg-background p-(--composer-padding) transition-shadow focus-within:border-ring/75 focus-within:ring-2 focus-within:ring-ring/20 data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50"
>
<ComposerAttachments />
<ComposerPrimitive.Input
placeholder="Send a message..."
className="aui-composer-input max-h-32 min-h-10 w-full resize-none bg-transparent px-1.75 py-1 text-sm outline-none placeholder:text-muted-foreground/80"
rows={1}
autoFocus
aria-label="Message input"
/>
<ComposerAction />
</div>
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Root>
);
};
const ComposerAction: FC = () => {
return (
<div className="aui-composer-action-wrapper relative flex items-center justify-between">
<ComposerAddAttachment />
<AuiIf condition={(s) => !s.thread.isRunning}>
<ComposerPrimitive.Send asChild>
<TooltipIconButton
tooltip="Send message"
side="bottom"
type="button"
variant="default"
size="icon"
className="aui-composer-send size-8 rounded-full"
aria-label="Send message"
>
<ArrowUpIcon className="aui-composer-send-icon size-4" />
</TooltipIconButton>
</ComposerPrimitive.Send>
</AuiIf>
<AuiIf condition={(s) => s.thread.isRunning}>
<ComposerPrimitive.Cancel asChild>
<Button
type="button"
variant="default"
size="icon"
className="aui-composer-cancel size-8 rounded-full"
aria-label="Stop generating"
>
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
</Button>
</ComposerPrimitive.Cancel>
</AuiIf>
</div>
);
};
const MessageError: FC = () => {
return (
<MessagePrimitive.Error>
<ErrorPrimitive.Root className="aui-message-error-root mt-2 rounded-md border border-destructive bg-destructive/10 p-3 text-destructive text-sm dark:bg-destructive/5 dark:text-red-200">
<ErrorPrimitive.Message className="aui-message-error-message line-clamp-2" />
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
);
};
const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
data-role="assistant"
>
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
<MessagePrimitive.Parts>
{({ part }) => {
if (part.type === "text") return <MarkdownText />;
if (part.type === "tool-call")
return part.toolUI ?? <ToolFallback {...part} />;
return null;
}}
</MessagePrimitive.Parts>
<MessageError />
</div>
<div className="aui-assistant-message-footer mt-1 ml-2 flex">
<BranchPicker />
<AssistantActionBar />
</div>
</MessagePrimitive.Root>
);
};
const AssistantActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
autohideFloat="single-branch"
className="aui-assistant-action-bar-root col-start-3 row-start-2 -ml-1 flex gap-1 text-muted-foreground data-floating:absolute data-floating:rounded-md data-floating:border data-floating:bg-background data-floating:p-1 data-floating:shadow-sm"
>
<ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy">
<AuiIf condition={(s) => s.message.isCopied}>
<CheckIcon />
</AuiIf>
<AuiIf condition={(s) => !s.message.isCopied}>
<CopyIcon />
</AuiIf>
</TooltipIconButton>
</ActionBarPrimitive.Copy>
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton tooltip="Refresh">
<RefreshCwIcon />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
<ActionBarMorePrimitive.Root>
<ActionBarMorePrimitive.Trigger asChild>
<TooltipIconButton
tooltip="More"
className="data-[state=open]:bg-accent"
>
<MoreHorizontalIcon />
</TooltipIconButton>
</ActionBarMorePrimitive.Trigger>
<ActionBarMorePrimitive.Content
side="bottom"
align="start"
className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
>
<ActionBarPrimitive.ExportMarkdown asChild>
<ActionBarMorePrimitive.Item className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
<DownloadIcon className="size-4" />
Export as Markdown
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.ExportMarkdown>
</ActionBarMorePrimitive.Content>
</ActionBarMorePrimitive.Root>
</ActionBarPrimitive.Root>
);
};
const UserMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2"
data-role="user"
>
<UserMessageAttachments />
<div className="aui-user-message-content-wrapper relative col-start-2 min-w-0">
<div className="aui-user-message-content wrap-break-word peer rounded-2xl bg-muted px-4 py-2.5 text-foreground empty:hidden">
<MessagePrimitive.Parts />
</div>
<div className="aui-user-action-bar-wrapper absolute top-1/2 left-0 -translate-x-full -translate-y-1/2 pr-2 peer-empty:hidden">
<UserActionBar />
</div>
</div>
<BranchPicker className="aui-user-branch-picker col-span-full col-start-1 row-start-3 -mr-1 justify-end" />
</MessagePrimitive.Root>
);
};
const UserActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="aui-user-action-bar-root flex flex-col items-end"
>
<ActionBarPrimitive.Edit asChild>
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
<PencilIcon />
</TooltipIconButton>
</ActionBarPrimitive.Edit>
</ActionBarPrimitive.Root>
);
};
const EditComposer: FC = () => {
return (
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-max-width) flex-col px-2 py-3">
<ComposerPrimitive.Root className="aui-edit-composer-root ml-auto flex w-full max-w-[85%] flex-col rounded-2xl bg-muted">
<ComposerPrimitive.Input
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm outline-none"
autoFocus
/>
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">
<ComposerPrimitive.Cancel asChild>
<Button variant="ghost" size="sm">
Cancel
</Button>
</ComposerPrimitive.Cancel>
<ComposerPrimitive.Send asChild>
<Button size="sm">Update</Button>
</ComposerPrimitive.Send>
</div>
</ComposerPrimitive.Root>
</MessagePrimitive.Root>
);
};
const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({
className,
...rest
}) => {
return (
<BranchPickerPrimitive.Root
hideWhenSingleBranch
className={cn(
"aui-branch-picker-root mr-2 -ml-2 inline-flex items-center text-muted-foreground text-xs",
className,
)}
{...rest}
>
<BranchPickerPrimitive.Previous asChild>
<TooltipIconButton tooltip="Previous">
<ChevronLeftIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Previous>
<span className="aui-branch-picker-state font-medium">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next asChild>
<TooltipIconButton tooltip="Next">
<ChevronRightIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
);
};

View File

@ -0,0 +1,324 @@
"use client";
import { memo, useCallback, useRef, useState } from "react";
import {
AlertCircleIcon,
CheckIcon,
ChevronDownIcon,
LoaderIcon,
XCircleIcon,
} from "lucide-react";
import {
useScrollLock,
type ToolCallMessagePartStatus,
type ToolCallMessagePartComponent,
} from "@assistant-ui/react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
const ANIMATION_DURATION = 200;
export type ToolFallbackRootProps = Omit<
React.ComponentProps<typeof Collapsible>,
"open" | "onOpenChange"
> & {
open?: boolean;
onOpenChange?: (open: boolean) => void;
defaultOpen?: boolean;
};
function ToolFallbackRoot({
className,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
defaultOpen = false,
children,
...props
}: ToolFallbackRootProps) {
const collapsibleRef = useRef<HTMLDivElement>(null);
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
lockScroll();
}
if (!isControlled) {
setUncontrolledOpen(open);
}
controlledOnOpenChange?.(open);
},
[lockScroll, isControlled, controlledOnOpenChange],
);
return (
<Collapsible
ref={collapsibleRef}
data-slot="tool-fallback-root"
open={isOpen}
onOpenChange={handleOpenChange}
className={cn(
"aui-tool-fallback-root group/tool-fallback-root w-full rounded-lg border py-3",
className,
)}
style={
{
"--animation-duration": `${ANIMATION_DURATION}ms`,
} as React.CSSProperties
}
{...props}
>
{children}
</Collapsible>
);
}
type ToolStatus = ToolCallMessagePartStatus["type"];
const statusIconMap: Record<ToolStatus, React.ElementType> = {
running: LoaderIcon,
complete: CheckIcon,
incomplete: XCircleIcon,
"requires-action": AlertCircleIcon,
};
function ToolFallbackTrigger({
toolName,
status,
className,
...props
}: React.ComponentProps<typeof CollapsibleTrigger> & {
toolName: string;
status?: ToolCallMessagePartStatus;
}) {
const statusType = status?.type ?? "complete";
const isRunning = statusType === "running";
const isCancelled =
status?.type === "incomplete" && status.reason === "cancelled";
const Icon = statusIconMap[statusType];
const label = isCancelled ? "Cancelled tool" : "Used tool";
return (
<CollapsibleTrigger
data-slot="tool-fallback-trigger"
className={cn(
"aui-tool-fallback-trigger group/trigger flex w-full items-center gap-2 px-4 text-sm transition-colors",
className,
)}
{...props}
>
<Icon
data-slot="tool-fallback-trigger-icon"
className={cn(
"aui-tool-fallback-trigger-icon size-4 shrink-0",
isCancelled && "text-muted-foreground",
isRunning && "animate-spin",
)}
/>
<span
data-slot="tool-fallback-trigger-label"
className={cn(
"aui-tool-fallback-trigger-label-wrapper relative inline-block grow text-left leading-none",
isCancelled && "text-muted-foreground line-through",
)}
>
<span>
{label}: <b>{toolName}</b>
</span>
{isRunning && (
<span
aria-hidden
data-slot="tool-fallback-trigger-shimmer"
className="aui-tool-fallback-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none"
>
{label}: <b>{toolName}</b>
</span>
)}
</span>
<ChevronDownIcon
data-slot="tool-fallback-trigger-chevron"
className={cn(
"aui-tool-fallback-trigger-chevron size-4 shrink-0",
"transition-transform duration-(--animation-duration) ease-out",
"group-data-[state=closed]/trigger:-rotate-90",
"group-data-[state=open]/trigger:rotate-0",
)}
/>
</CollapsibleTrigger>
);
}
function ToolFallbackContent({
className,
children,
...props
}: React.ComponentProps<typeof CollapsibleContent>) {
return (
<CollapsibleContent
data-slot="tool-fallback-content"
className={cn(
"aui-tool-fallback-content relative overflow-hidden text-sm outline-none",
"group/collapsible-content ease-out",
"data-[state=closed]:animate-collapsible-up",
"data-[state=open]:animate-collapsible-down",
"data-[state=closed]:fill-mode-forwards",
"data-[state=closed]:pointer-events-none",
"data-[state=open]:duration-(--animation-duration)",
"data-[state=closed]:duration-(--animation-duration)",
className,
)}
{...props}
>
<div className="mt-3 flex flex-col gap-2 border-t pt-2">{children}</div>
</CollapsibleContent>
);
}
function ToolFallbackArgs({
argsText,
className,
...props
}: React.ComponentProps<"div"> & {
argsText?: string;
}) {
if (!argsText) return null;
return (
<div
data-slot="tool-fallback-args"
className={cn("aui-tool-fallback-args px-4", className)}
{...props}
>
<pre className="aui-tool-fallback-args-value whitespace-pre-wrap">
{argsText}
</pre>
</div>
);
}
function ToolFallbackResult({
result,
className,
...props
}: React.ComponentProps<"div"> & {
result?: unknown;
}) {
if (result === undefined) return null;
return (
<div
data-slot="tool-fallback-result"
className={cn(
"aui-tool-fallback-result border-t border-dashed px-4 pt-2",
className,
)}
{...props}
>
<p className="aui-tool-fallback-result-header font-semibold">Result:</p>
<pre className="aui-tool-fallback-result-content whitespace-pre-wrap">
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
);
}
function ToolFallbackError({
status,
className,
...props
}: React.ComponentProps<"div"> & {
status?: ToolCallMessagePartStatus;
}) {
if (status?.type !== "incomplete") return null;
const error = status.error;
const errorText = error
? typeof error === "string"
? error
: JSON.stringify(error)
: null;
if (!errorText) return null;
const isCancelled = status.reason === "cancelled";
const headerText = isCancelled ? "Cancelled reason:" : "Error:";
return (
<div
data-slot="tool-fallback-error"
className={cn("aui-tool-fallback-error px-4", className)}
{...props}
>
<p className="aui-tool-fallback-error-header font-semibold text-muted-foreground">
{headerText}
</p>
<p className="aui-tool-fallback-error-reason text-muted-foreground">
{errorText}
</p>
</div>
);
}
const ToolFallbackImpl: ToolCallMessagePartComponent = ({
toolName,
argsText,
result,
status,
}) => {
const isCancelled =
status?.type === "incomplete" && status.reason === "cancelled";
return (
<ToolFallbackRoot
className={cn(isCancelled && "border-muted-foreground/30 bg-muted/30")}
>
<ToolFallbackTrigger toolName={toolName} status={status} />
<ToolFallbackContent>
<ToolFallbackError status={status} />
<ToolFallbackArgs
argsText={argsText}
className={cn(isCancelled && "opacity-60")}
/>
{!isCancelled && <ToolFallbackResult result={result} />}
</ToolFallbackContent>
</ToolFallbackRoot>
);
};
const ToolFallback = memo(
ToolFallbackImpl,
) as unknown as ToolCallMessagePartComponent & {
Root: typeof ToolFallbackRoot;
Trigger: typeof ToolFallbackTrigger;
Content: typeof ToolFallbackContent;
Args: typeof ToolFallbackArgs;
Result: typeof ToolFallbackResult;
Error: typeof ToolFallbackError;
};
ToolFallback.displayName = "ToolFallback";
ToolFallback.Root = ToolFallbackRoot;
ToolFallback.Trigger = ToolFallbackTrigger;
ToolFallback.Content = ToolFallbackContent;
ToolFallback.Args = ToolFallbackArgs;
ToolFallback.Result = ToolFallbackResult;
ToolFallback.Error = ToolFallbackError;
export {
ToolFallback,
ToolFallbackRoot,
ToolFallbackTrigger,
ToolFallbackContent,
ToolFallbackArgs,
ToolFallbackResult,
ToolFallbackError,
};

View File

@ -0,0 +1,42 @@
"use client";
import { ComponentPropsWithRef, forwardRef } from "react";
import { Slottable } from "@radix-ui/react-slot";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & {
tooltip: string;
side?: "top" | "bottom" | "left" | "right";
};
export const TooltipIconButton = forwardRef<
HTMLButtonElement,
TooltipIconButtonProps
>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
{...rest}
className={cn("aui-button-icon size-6 p-1", className)}
ref={ref}
>
<Slottable>{children}</Slottable>
<span className="aui-sr-only sr-only">{tooltip}</span>
</Button>
</TooltipTrigger>
<TooltipContent side={side}>{tooltip}</TooltipContent>
</Tooltip>
);
});
TooltipIconButton.displayName = "TooltipIconButton";

View File

@ -0,0 +1,109 @@
"use client";
import * as React from "react";
import { Avatar as AvatarPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg";
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 select-none overflow-hidden rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground text-sm group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex select-none items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className,
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className,
)}
{...props}
/>
);
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground text-sm ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className,
)}
{...props}
/>
);
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
};

View File

@ -0,0 +1,64 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@ -0,0 +1,33 @@
"use client";
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@ -0,0 +1,158 @@
"use client";
import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg outline-none duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("font-semibold text-lg leading-none", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@ -0,0 +1,57 @@
"use client";
import * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-foreground px-3 py-1.5 text-background text-xs data-[state=closed]:animate-out",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

6
frontend/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

7
frontend/next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

7755
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
frontend/package.json Normal file
View File

@ -0,0 +1,47 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"format": "biome format .",
"format:fix": "biome format --write .",
"lint": "biome check .",
"lint:fix": "biome check --write ."
},
"dependencies": {
"@ai-sdk/openai": "^3.0.52",
"@assistant-ui/react": "latest",
"@assistant-ui/react-ai-sdk": "latest",
"@assistant-ui/react-markdown": "latest",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"ai": "^6.0.159",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
"next": "^16.2.3",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"tw-shimmer": "^0.4.10",
"zustand": "^5.0.12"
},
"devDependencies": {
"@biomejs/biome": "^2.4.11",
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2"
}
}

View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

33
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path fill="#ff0000" d="M431.2 476.5L163.5 208.8C141.1 240.2 128 278.6 128 320C128 426 214 512 320 512C361.5 512 399.9 498.9 431.2 476.5zM476.5 431.2C498.9 399.8 512 361.4 512 320C512 214 426 128 320 128C278.5 128 240.1 141.1 208.8 163.5L476.5 431.2zM64 320C64 178.6 178.6 64 320 64C461.4 64 576 178.6 576 320C576 461.4 461.4 576 320 576C178.6 576 64 461.4 64 320z"/></svg>

Before

Width:  |  Height:  |  Size: 586 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path fill="#74C0FC" d="M544 269.8C529.2 279.6 512.2 287.5 494.5 293.8C447.5 310.6 385.8 320 320 320C254.2 320 192.4 310.5 145.5 293.8C127.9 287.5 110.8 279.6 96 269.8L96 352C96 396.2 196.3 432 320 432C443.7 432 544 396.2 544 352L544 269.8zM544 192L544 144C544 99.8 443.7 64 320 64C196.3 64 96 99.8 96 144L96 192C96 236.2 196.3 272 320 272C443.7 272 544 236.2 544 192zM494.5 453.8C447.6 470.5 385.9 480 320 480C254.1 480 192.4 470.5 145.5 453.8C127.9 447.5 110.8 439.6 96 429.8L96 496C96 540.2 196.3 576 320 576C443.7 576 544 540.2 544 496L544 429.8C529.2 439.6 512.2 447.5 494.5 453.8z"/></svg>

Before

Width:  |  Height:  |  Size: 808 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path fill="#5f00bd" d="M344 170.6C362.9 161.6 376 142.3 376 120C376 89.1 350.9 64 320 64C289.1 64 264 89.1 264 120C264 142.3 277.1 161.6 296 170.6L296 269.4C293.2 270.7 290.5 272.3 288 274.1L207.9 228.3C209.5 207.5 199.3 186.7 180 175.5C153.2 160 119 169.2 103.5 196C88 222.8 97.2 257 124 272.5C125.3 273.3 126.6 274 128 274.6L128 365.4C126.7 366 125.3 366.7 124 367.5C97.2 383 88 417.2 103.5 444C119 470.8 153.2 480 180 464.5C199.3 453.4 209.4 432.5 207.8 411.7L258.3 382.8C246.8 371.6 238.4 357.2 234.5 341.1L184 370.1C181.4 368.3 178.8 366.8 176 365.4L176 274.6C178.8 273.3 181.5 271.7 184 269.9L264.1 315.7C264 317.1 263.9 318.5 263.9 320C263.9 342.3 277 361.6 295.9 370.6L295.9 469.4C277 478.4 263.9 497.7 263.9 520C263.9 550.9 289 576 319.9 576C350.8 576 375.9 550.9 375.9 520C375.9 497.7 362.8 478.4 343.9 469.4L343.9 370.6C346.7 369.3 349.4 367.7 351.9 365.9L432 411.7C430.4 432.5 440.6 453.3 459.8 464.5C486.6 480 520.8 470.8 536.3 444C551.8 417.2 542.6 383 515.8 367.5C514.5 366.7 513.1 366 511.8 365.4L511.8 274.6C513.2 274 514.5 273.3 515.8 272.5C542.6 257 551.8 222.8 536.3 196C520.8 169.2 486.8 160 460 175.5C440.7 186.6 430.6 207.5 432.2 228.3L381.6 257.2C393.1 268.4 401.5 282.8 405.4 298.9L456 269.9C458.6 271.7 461.2 273.2 464 274.6L464 365.4C461.2 366.7 458.5 368.3 456 370L375.9 324.2C376 322.8 376.1 321.4 376.1 319.9C376.1 297.6 363 278.3 344.1 269.3L344.1 170.5z"/></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path fill="#FFD43B" d="M480 272C480 317.9 465.1 360.3 440 394.7L566.6 521.4C579.1 533.9 579.1 554.2 566.6 566.7C554.1 579.2 533.8 579.2 521.3 566.7L394.7 440C360.3 465.1 317.9 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272zM272 416C351.5 416 416 351.5 416 272C416 192.5 351.5 128 272 128C192.5 128 128 192.5 128 272C128 351.5 192.5 416 272 416z"/></svg>

Before

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

View File

@ -1,54 +0,0 @@
#readme-button {
display: none !important;
}
.text-xs {
display: none !important;
}
.rounded-3xl{
border-radius: 0.9rem !important;
}
#chat-submit {
border-radius: 0.5rem !important;
}
#starters {
order: 1 !important;
display: grid;
grid-template-columns: repeat(4, 1fr);
}
#starters button {
height: auto !important;
white-space: normal !important;
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
}
#starters button img {
margin-bottom: 6px;
}
#starters p {
white-space: normal !important;
overflow: visible !important;
text-overflow: unset !important;
}
#welcome-screen {
position: relative;
}
#message-composer {
order: 2 !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
gap: 8px !important;
min-height: 1rem;
}

View File

@ -1,68 +0,0 @@
{
"custom_fonts": [],
"variables": {
"light": {
"--font-sans": "'Inter', sans-serif",
"--font-mono": "source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace",
"--background": "0 0% 100%",
"--foreground": "0 0% 5%",
"--card": "0 0% 100%",
"--card-foreground": "0 0% 5%",
"--popover": "0 0% 100%",
"--popover-foreground": "0 0% 5%",
"--primary": "208 95% 56%",
"--primary-foreground": "0 0% 100%",
"--secondary": "210 40% 96.1%",
"--secondary-foreground": "222.2 47.4% 11.2%",
"--muted": "0 0% 90%",
"--muted-foreground": "0 0% 36%",
"--accent": "0 0% 95%",
"--accent-foreground": "222.2 47.4% 11.2%",
"--destructive": "0 84.2% 60.2%",
"--destructive-foreground": "210 40% 98%",
"--border": "0 0% 90%",
"--input": "0 0% 90%",
"--ring": "208 95% 56%",
"--radius": "0.75rem",
"--sidebar-background": "0 0% 98%",
"--sidebar-foreground": "240 5.3% 26.1%",
"--sidebar-primary": "240 5.9% 10%",
"--sidebar-primary-foreground": "0 0% 98%",
"--sidebar-accent": "240 4.8% 95.9%",
"--sidebar-accent-foreground": "240 5.9% 10%",
"--sidebar-border": "220 13% 91%",
"--sidebar-ring": "217.2 91.2% 59.8%"
},
"dark": {
"--font-sans": "'Inter', sans-serif",
"--font-mono": "source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace",
"--background": "0 0% 13%",
"--foreground": "0 0% 93%",
"--card": "0 0% 18%",
"--card-foreground": "210 40% 98%",
"--popover": "0 0% 18%",
"--popover-foreground": "210 40% 98%",
"--primary": "208 95% 56%",
"--primary-foreground": "0 0% 100%",
"--secondary": "0 0% 19%",
"--secondary-foreground": "210 40% 98%",
"--muted": "0 1% 26%",
"--muted-foreground": "0 0% 71%",
"--accent": "0 0% 26%",
"--accent-foreground": "210 40% 98%",
"--destructive": "0 62.8% 30.6%",
"--destructive-foreground": "210 40% 98%",
"--border": "0 1% 26%",
"--input": "0 1% 26%",
"--ring": "208 95% 56%",
"--sidebar-background": "0 0% 9%",
"--sidebar-foreground": "240 4.8% 95.9%",
"--sidebar-primary": "224.3 76.3% 48%",
"--sidebar-primary-foreground": "0 0% 100%",
"--sidebar-accent": "0 0% 13%",
"--sidebar-accent-foreground": "240 4.8% 95.9%",
"--sidebar-border": "240 3.7% 15.9%",
"--sidebar-ring": "217.2 91.2% 59.8%"
}
}
}

35
pyproject.toml Normal file
View File

@ -0,0 +1,35 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "ai-lawyer-agent"
version = "0.3.0"
description = "Legal AI Assistant - Slovak Ministry of Justice API"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.136.0",
"uvicorn[standard]>=0.34.0",
"openai-agents==0.6.3",
"httpx==0.28.1",
"pydantic==2.12.5",
"cachetools>=7.0.5",
"tenacity>=9.1.4",
]
[project.optional-dependencies]
dev = ["ruff", "mypy", "pytest"]
mcp = ["fastmcp>=2.7.0,<3.0.0"]
[tool.setuptools.packages.find]
where = ["."]
[tool.ruff]
line-length = 88
target-version = "py311"
[tool.mypy]
python_version = "3.11"
strict = false
ignore_missing_imports = true

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

View File

@ -1,65 +0,0 @@
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).parent.parent
TESTS_DIR = Path(__file__).parent
CHARTS_DIR = TESTS_DIR / "charts"
REPORT_HTML = CHARTS_DIR / "report.html"
DB_PATH = TESTS_DIR / "test_cases.db"
def check_db():
if not DB_PATH.exists():
sys.exit(1)
def run_pytest() -> int:
CHARTS_DIR.mkdir(parents=True, exist_ok=True)
args = [
sys.executable, "-m", "pytest",
str(TESTS_DIR / "tests" / "test_schemas.py"),
str(TESTS_DIR / "tests" / "test_fetch.py"),
str(TESTS_DIR / "tests" / "test_tools.py"),
str(TESTS_DIR / "tests" / "test_sys_prompt.py"),
str(TESTS_DIR / "tests" / "test_api.py"),
str(TESTS_DIR / "tests" / "test_llm_compare.py"),
str(TESTS_DIR / "tests" / "test_project.py"),
f"--html={REPORT_HTML}",
"--self-contained-html",
"--cov=api",
"--cov=core",
"--cov-report=term-missing",
f"--cov-report=html:{CHARTS_DIR / 'coverage'}",
"--tb=no",
]
return subprocess.run(args, cwd=str(ROOT), check=False).returncode
def open_in_browser(path: Path):
if path.exists():
subprocess.Popen(["start", str(path)], shell=True)
if __name__ == "__main__":
print("Start testing")
check_db()
code = run_pytest()
cov_path = CHARTS_DIR / "coverage" / "index.html"
print(f"\npytest-html: {REPORT_HTML}")
print(f"pytest-cov: {cov_path}")
open_in_browser(REPORT_HTML)
open_in_browser(cov_path)
print("\nStop testing")
sys.exit(code)

Binary file not shown.

View File

@ -1,160 +0,0 @@
import pytest
import httpx
from api.config import JUSTICE_API_BASE, HTTP_TIMEOUT
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/124.0.0.0 Safari/537.36",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "sk-SK,sk;q=0.9",
"Referer": "https://obcan.justice.sk/",
"Origin": "https://obcan.justice.sk",
}
def api_available() -> bool:
try:
r = httpx.get(f"{JUSTICE_API_BASE}/v1/sud", params={"size": 1}, headers=HEADERS, timeout=5)
return r.status_code == 200
except Exception:
return False
skip_if_offline = pytest.mark.skipif(
not api_available(),
reason="justice.sk API is not reachable"
)
@pytest.fixture(scope="module")
def client():
with httpx.Client(headers=HEADERS, timeout=HTTP_TIMEOUT, follow_redirects=True) as c:
yield c
@skip_if_offline
class TestCourtsEndpoint:
def test_returns_200(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/sud", params={"size": 5})
assert r.status_code == 200
def test_response_has_results(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/sud", params={"size": 5})
data = r.json()
assert isinstance(data, dict)
assert len(data) > 0
def test_total_elements_is_positive(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/sud", params={"size": 1})
data = r.json()
total = data.get("numFound") or data.get("totalElements") or data.get("total")
assert total is not None and total > 0
def test_court_by_id_returns_valid_record(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/sud/sud_175")
assert r.status_code == 200
data = r.json()
assert "id" in data or "nazov" in data
def test_court_autocomplete_returns_list(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/sud/autocomplete", params={"query": "Bratislava", "limit": 5})
assert r.status_code == 200
assert isinstance(r.json(), list)
def test_nonexistent_court_returns_404(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/sud/sud_999999999")
assert r.status_code == 404
@skip_if_offline
class TestJudgesEndpoint:
def test_judge_search_returns_200(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/sudca", params={"size": 5})
assert r.status_code == 200
def test_judge_autocomplete_returns_results(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/sudca/autocomplete", params={"query": "Novák", "limit": 10})
assert r.status_code == 200
def test_judge_search_by_kraj(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/sudca", params={
"krajFacetFilter": "Bratislavský kraj",
"size": 5
})
assert r.status_code == 200
def test_judge_search_pagination_without_page(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/sudca", params={"size": 10})
assert r.status_code == 200
data = r.json()
assert isinstance(data, dict)
def test_judge_search_pagination_page_zero_known_issue(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/sudca", params={"page": 0, "size": 10})
assert r.status_code in (200, 500), f"Unexpected status: {r.status_code}"
@skip_if_offline
class TestDecisionsEndpoint:
def test_decision_search_returns_200(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/rozhodnutie", params={"size": 3})
assert r.status_code == 200
def test_decision_search_with_date_range(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/rozhodnutie", params={
"vydaniaOd": "01.01.2023",
"vydaniaDo": "31.12.2023",
"size": 3,
})
assert r.status_code == 200
def test_decision_autocomplete(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/rozhodnutie/autocomplete", params={"query": "Rozsudok", "limit": 5})
assert r.status_code == 200
@skip_if_offline
class TestContractsEndpoint:
def test_contract_search_returns_200(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/zmluvy", params={"size": 5})
assert r.status_code == 200
def test_contract_search_by_typ_dokumentu(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/zmluvy", params={
"typDokumentuFacetFilter": "ZMLUVA",
"size": 3,
})
assert r.status_code == 200
@skip_if_offline
class TestCivilProceedingsEndpoint:
def test_civil_proceedings_returns_200(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/obcianPojednavania", params={"size": 3})
assert r.status_code == 200
def test_civil_proceedings_date_filter_known_issue(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/obcianPojednavania", params={
"pojednavaniaOd": "01.01.2024",
"pojednavaniaDo": "31.01.2024",
"size": 3,
})
# Known server-side bug — accept 200 or 500
assert r.status_code in (200, 500), f"Unexpected status: {r.status_code}"
@skip_if_offline
class TestAdminProceedingsEndpoint:
def test_admin_proceedings_returns_200(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/spravneKonanie", params={"size": 3})
assert r.status_code == 200
def test_admin_proceedings_autocomplete(self, client):
r = client.get(f"{JUSTICE_API_BASE}/v1/spravneKonanie/autocomplete", params={"query": "test", "limit": 5})
assert r.status_code == 200

View File

@ -1,122 +0,0 @@
import pytest
import pytest_asyncio
import httpx
import respx
from api.fetch_api_data import fetch_api_data, set_log_callback, _cache
BASE = "https://obcan.justice.sk/pilot/api/ress-isu-service"
@pytest.fixture(autouse=True)
def clear_cache():
_cache.clear()
yield
_cache.clear()
@pytest.mark.asyncio
class TestFetchApiData:
@respx.mock
async def test_successful_request_returns_dict(self):
url = f"{BASE}/v1/sud"
respx.get(url).mock(return_value=httpx.Response(200, json={"content": [], "totalElements": 0}))
result = await fetch_api_data(icon="", url=url, params={})
assert isinstance(result, dict)
assert "totalElements" in result
@respx.mock
async def test_cache_hit_on_second_call(self):
url = f"{BASE}/v1/sud"
mock = respx.get(url).mock(return_value=httpx.Response(200, json={"content": []}))
await fetch_api_data(icon="", url=url, params={})
await fetch_api_data(icon="", url=url, params={})
assert mock.call_count == 1
@respx.mock
async def test_http_404_returns_error_dict(self):
url = f"{BASE}/v1/sud/sud_99999"
respx.get(url).mock(return_value=httpx.Response(404, text="Not Found"))
result = await fetch_api_data(icon="", url=url, params={})
assert result["error"] == "http_error"
assert result["status_code"] == 404
@respx.mock
async def test_http_500_returns_error_dict(self):
url = f"{BASE}/v1/sud"
respx.get(url).mock(return_value=httpx.Response(500, text="Server Error"))
result = await fetch_api_data(icon="", url=url, params={})
assert result["error"] == "http_error"
assert result["status_code"] == 500
@respx.mock
async def test_remove_keys_strips_specified_fields(self):
url = f"{BASE}/v1/sud/sud_1"
respx.get(url).mock(return_value=httpx.Response(200, json={"name": "Súd", "foto": "base64data"}))
result = await fetch_api_data(icon="", url=url, params={}, remove_keys=["foto"])
assert "foto" not in result
assert "name" in result
@respx.mock
async def test_remove_keys_missing_key_no_error(self):
url = f"{BASE}/v1/sud/sud_1"
respx.get(url).mock(return_value=httpx.Response(200, json={"name": "Súd"}))
result = await fetch_api_data(icon="", url=url, params={}, remove_keys=["foto", "nonexistent"])
assert result["name"] == "Súd"
@respx.mock
async def test_log_callback_is_called(self):
url = f"{BASE}/v1/sud"
respx.get(url).mock(return_value=httpx.Response(200, json={}))
log_lines = []
set_log_callback(lambda line: log_lines.append(line))
await fetch_api_data(icon="", url=url, params={})
set_log_callback(None)
assert len(log_lines) > 0
@respx.mock
async def test_params_are_passed_in_request(self):
url = f"{BASE}/v1/sud"
mock = respx.get(url).mock(return_value=httpx.Response(200, json={}))
await fetch_api_data(icon="", url=url, params={"query": "Bratislava", "size": 10})
assert mock.called
sent_params = dict(mock.calls[0].request.url.params)
assert sent_params["query"] == "Bratislava"
assert sent_params["size"] == "10"
@respx.mock
async def test_connect_error_returns_error_dict(self):
url = f"{BASE}/v1/sud"
respx.get(url).mock(side_effect=httpx.ConnectError("Connection refused"))
result = await fetch_api_data(icon="", url=url, params={})
assert result["error"] == "request_error"
@respx.mock
async def test_unexpected_error_returns_error_dict(self):
url = f"{BASE}/v1/sud"
respx.get(url).mock(side_effect=ValueError("unexpected"))
result = await fetch_api_data(icon="", url=url, params={})
assert result["error"] == "unexpected_error"

View File

@ -1,118 +0,0 @@
import time
import pytest
from openai import OpenAI
from core.config import (
OLLAMA_BASE_URL, OLLAMA_API_KEY, LLM_TIMEOUT,
)
from core.system_prompt import get_system_prompt
MODELS = ["qwen3.5:cloud"]
TEST_QUERIES = [
{
"id": "court_search",
"query": "Nájdi súdy v Bratislavskom kraji.",
"expected_keywords": ["súd", "bratislava", "kraj"],
},
{
"id": "judge_search",
"query": "Vyhľadaj sudcu Novák.",
"expected_keywords": ["sudca", "novák", "novak"],
},
{
"id": "no_legal_advice",
"query": "Mám spor so zamestnávateľom, čo mám robiť?",
"forbidden_keywords": ["musíte", "odporúčam vám podať žalobu", "právne poradenstvo"],
"expected_keywords": ["api", "ministerstvo", "nie som právnik", "právny poradca"],
},
{
"id": "slovak_response",
"query": "What courts exist in Slovakia?",
"expected_keywords": ["súd", "slovensko", "kraj"],
},
]
def ollama_available() -> bool:
try:
client = OpenAI(base_url=OLLAMA_BASE_URL, api_key=OLLAMA_API_KEY)
client.models.list()
return True
except Exception:
return False
skip_if_no_ollama = pytest.mark.skipif(
not ollama_available(),
reason="Ollama is not running"
)
def query_model(model: str, user_message: str) -> tuple[str, float]:
client = OpenAI(base_url=OLLAMA_BASE_URL, api_key=OLLAMA_API_KEY, timeout=LLM_TIMEOUT)
start = time.perf_counter()
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": get_system_prompt(model)},
{"role": "user", "content": user_message},
],
temperature=0.0,
max_tokens=2048,
)
elapsed = time.perf_counter() - start
text = response.choices[0].message.content or ""
return text, elapsed
llm_results: dict[str, list[dict]] = {m: [] for m in MODELS}
@skip_if_no_ollama
@pytest.mark.parametrize("model", MODELS)
@pytest.mark.parametrize("case", TEST_QUERIES, ids=[c["id"] for c in TEST_QUERIES])
class TestLLMResponses:
def test_response_is_not_empty(self, model, case):
text, _ = query_model(model, case["query"])
assert len(text.strip()) > 0
def test_response_in_slovak(self, model, case):
text, _ = query_model(model, case["query"])
slovak_markers = ["je", "", "som", "nie", "súd", "sudca", "kraj", "ale", "alebo", "pre"]
assert any(m in text.lower() for m in slovak_markers)
def test_expected_keywords_present(self, model, case):
if "expected_keywords" not in case:
pytest.skip("No expected_keywords defined")
text, _ = query_model(model, case["query"])
assert any(kw.lower() in text.lower() for kw in case["expected_keywords"])
def test_forbidden_keywords_absent(self, model, case):
if "forbidden_keywords" not in case:
pytest.skip("No forbidden_keywords defined")
text, _ = query_model(model, case["query"])
for kw in case["forbidden_keywords"]:
assert kw.lower() not in text.lower(), f"Forbidden keyword found: {kw}"
def test_response_time_under_threshold(self, model, case):
_, elapsed = query_model(model, case["query"])
assert elapsed < float(LLM_TIMEOUT), f"Response took {elapsed:.1f}s"
def test_response_length_reasonable(self, model, case):
text, _ = query_model(model, case["query"])
assert 10 < len(text) < 4000
@skip_if_no_ollama
@pytest.mark.parametrize("model", MODELS)
class TestLLMBenchmark:
def test_collect_benchmark_data(self, model):
times = []
for case in TEST_QUERIES:
_, elapsed = query_model(model, case["query"])
times.append(elapsed)
llm_results[model].extend(times)
assert len(times) == len(TEST_QUERIES)

View File

@ -1,243 +0,0 @@
import json
import os
import re
import sqlite3
import pytest
from openai import OpenAI
from core.config import OLLAMA_BASE_URL, OLLAMA_API_KEY, LLM_TIMEOUT, DEFAULT_MODEL
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "test_cases.db")
EXTRACTION_SYSTEM_PROMPT = """
You are a parameter extraction engine for the Slovak Ministry of Justice API.
Your ONLY job: read the user query and return a JSON object.
You MUST always return ONLY a JSON object nothing else.
No explanations. No markdown. No ```json fences. Just the raw JSON.
Return format:
{"tool": "<tool_name>", "params": {<extracted parameters>}}
Available tools and their parameters:
court_search : query, typSuduFacetFilter[], krajFacetFilter[], okresFacetFilter[],
zahrnutZaniknuteSudy, sortProperty, sortDirection, page, size
court_id : id (format: "sud_<number>")
court_autocomplete : query, limit
judge_search : query, funkciaFacetFilter[], typSuduFacetFilter[], krajFacetFilter[],
okresFacetFilter[], stavZapisuFacetFilter[], guidSud, page, size,
sortProperty, sortDirection
judge_id : id (format: "sudca_<number>")
judge_autocomplete : query, guidSud, limit
decision_search : query, typSuduFacetFilter[], krajFacetFilter[], okresFacetFilter[],
formaRozhodnutiaFacetFilter[], vydaniaOd, vydaniaDo,
ecli, spisovaZnacka, guidSudca, guidSud, sortProperty, sortDirection, page, size
decision_id : id (ECLI string, e.g. "ECLI:SK:OSPO:1965:8114010264.1")
decision_autocomplete : query, guidSud, limit
contract_search : query, typDokumentuFacetFilter[], hodnotaZmluvyFacetFilter[],
datumZverejneniaOd, datumZverejeneniaDo, guidSud, page, size
contract_id : idZmluvy (numeric string, e.g. "2156252")
contract_autocomplete : query, guidSud, limit
civil_proceedings_search : query, krajFacetFilter[], usekFacetFilter[],
formaUkonuFacetFilter[], pojednavaniaOd, pojednavaniaDo,
guidSudca, guidSud, verejneVyhlasenie, page, size
civil_proceedings_id : id (UUID string)
civil_proceedings_autocomplete : query, guidSud, guidSudca, verejneVyhlasenie, limit
admin_proceedings_search : query, druhFacetFilter[], datumPravoplatnostiOd,
datumPravoplatnostiDo, sortProperty, sortDirection, page, size
admin_proceedings_id : id (format: "spravneKonanie_<number>")
admin_proceedings_autocomplete : query, limit
Rules:
- Dates MUST be in DD.MM.YYYY format.
- IDs MUST use the correct prefix (sud_, sudca_, spravneKonanie_).
- Arrays MUST be JSON arrays even with one value: ["value"]
- stavZapisuFacetFilter values: use exact labels like "label.sudca.aktivny"
- If a number is given without prefix (e.g. "súde číslo 100"), add it: "sud_100"
- NEVER output anything except the JSON object. No thinking, no prose.
"""
def ollama_available() -> bool:
try:
client = OpenAI(base_url=OLLAMA_BASE_URL, api_key=OLLAMA_API_KEY, timeout=5)
client.models.list()
return True
except Exception:
return False
def db_available() -> bool:
return os.path.exists(DB_PATH)
def load_cases():
conn = sqlite3.connect(DB_PATH)
rows = conn.execute(
"SELECT id, query, expected FROM test_cases ORDER BY id"
).fetchall()
conn.close()
return rows
def extract_json_from_text(text: str) -> dict:
if not text or not text.strip():
raise ValueError("LLM returned empty response")
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
text = re.sub(r"```(?:json)?", "", text).replace("```", "").strip()
match = re.search(r"\{.*\}", text, re.DOTALL)
if not match:
raise ValueError(
f"No JSON object found in LLM response. "
f"Raw text (first 300 chars): {text[:300]!r}"
)
return json.loads(match.group())
def ask_llm(query: str) -> dict:
client = OpenAI(
base_url=OLLAMA_BASE_URL,
api_key=OLLAMA_API_KEY,
timeout=LLM_TIMEOUT,
)
response = client.chat.completions.create(
model=DEFAULT_MODEL,
messages=[
{"role": "system", "content": EXTRACTION_SYSTEM_PROMPT},
{"role": "user", "content": query},
],
temperature=0.0,
max_tokens=1024,
)
choice = response.choices[0]
raw = choice.message.content or ""
if not raw.strip():
try:
raw = choice.message.model_extra.get("reasoning_content", "") or ""
except (AttributeError, TypeError):
pass
if not raw.strip():
raise ValueError(
f"LLM returned completely empty response for query: {query!r}. "
"Check if Ollama is running and the model is loaded."
)
return extract_json_from_text(raw)
def compare(llm_result: dict, expected: dict, case_id: int, query: str):
assert llm_result.get("tool") == expected["tool"], (
f"\n[Case {case_id}] Tool mismatch:\n"
f" Query : {query}\n"
f" Expected: {expected['tool']}\n"
f" Got : {llm_result.get('tool')}\n"
f" Full LLM: {json.dumps(llm_result, ensure_ascii=False)}"
)
llm_params = llm_result.get("params", {})
exp_params = expected.get("params", {})
for key, exp_val in exp_params.items():
assert key in llm_params, (
f"\n[Case {case_id}] Missing param '{key}':\n"
f" Query : {query}\n"
f" Expected params : {json.dumps(exp_params, ensure_ascii=False)}\n"
f" LLM params : {json.dumps(llm_params, ensure_ascii=False)}"
)
llm_val = llm_params[key]
if isinstance(exp_val, list):
assert isinstance(llm_val, list), (
f"\n[Case {case_id}] Param '{key}' should be list, "
f"got {type(llm_val).__name__}:\n Query: {query}"
)
assert sorted(str(v) for v in exp_val) == sorted(str(v) for v in llm_val), (
f"\n[Case {case_id}] Param '{key}' list mismatch:\n"
f" Query : {query}\n"
f" Expected: {exp_val}\n"
f" Got : {llm_val}"
)
elif isinstance(exp_val, bool):
assert bool(llm_val) == exp_val, (
f"\n[Case {case_id}] Param '{key}' bool mismatch:\n"
f" Query : {query}\n"
f" Expected: {exp_val}\n"
f" Got : {llm_val}"
)
else:
assert str(exp_val) == str(llm_val), (
f"\n[Case {case_id}] Param '{key}' value mismatch:\n"
f" Query : {query}\n"
f" Expected: {exp_val!r}\n"
f" Got : {llm_val!r}"
)
skip_if_no_ollama = pytest.mark.skipif(
not ollama_available(),
reason="Ollama is not running",
)
skip_if_no_db = pytest.mark.skipif(
not db_available(),
reason=f"Database not found: {DB_PATH}. Copy test_cases.db to testing/",
)
def pytest_generate_tests(metafunc):
if "db_case" in metafunc.fixturenames:
if db_available():
cases = load_cases()
metafunc.parametrize(
"db_case",
cases,
ids=[f"{row[0]:02d}" for row in cases],
)
else:
metafunc.parametrize("db_case", [])
# ── tests ─────────────────────────────────────────────────────────────────────
@skip_if_no_ollama
@skip_if_no_db
def test_llm_extracts_params(db_case):
case_id, query, expected_raw = db_case
expected = json.loads(expected_raw)
llm_result = ask_llm(query)
compare(llm_result, expected, case_id, query)
@skip_if_no_db
def test_db_has_54_rows():
cases = load_cases()
assert len(cases) == 54, f"Expected 54 rows, got {len(cases)}"
@skip_if_no_db
def test_db_columns_are_valid():
cases = load_cases()
for case_id, query, expected_raw in cases:
assert query.strip(), f"Row {case_id}: empty query"
try:
expected = json.loads(expected_raw)
except json.JSONDecodeError as e:
pytest.fail(f"Row {case_id}: invalid JSON in expected — {e}")
assert "tool" in expected, f"Row {case_id}: missing 'tool' in expected"
assert "params" in expected, f"Row {case_id}: missing 'params' in expected"

View File

@ -1,179 +0,0 @@
import pytest
from pydantic import ValidationError
from api.schemas import (
CourtByID,
JudgeByID,
AdminProceedingsByID,
CourtSearch,
JudgeSearch,
DecisionSearch,
ContractSearch,
CivilProceedingsSearch,
CourtAutocomplete,
JudgeAutocomplete,
)
class TestCourtByID:
def test_digit_gets_prefix(self):
assert CourtByID(id="175").id == "sud_175"
def test_already_prefixed_unchanged(self):
assert CourtByID(id="sud_175").id == "sud_175"
def test_strips_whitespace(self):
assert CourtByID(id=" 42 ").id == "sud_42"
def test_single_digit(self):
assert CourtByID(id="1").id == "sud_1"
def test_large_number(self):
assert CourtByID(id="9999").id == "sud_9999"
def test_non_numeric_string_unchanged(self):
assert CourtByID(id="some_string").id == "some_string"
def test_whitespace_digit_combination(self):
assert CourtByID(id=" 7 ").id == "sud_7"
class TestJudgeByID:
def test_digit_gets_prefix(self):
assert JudgeByID(id="1").id == "sudca_1"
def test_already_prefixed_unchanged(self):
assert JudgeByID(id="sudca_99").id == "sudca_99"
def test_strips_whitespace(self):
assert JudgeByID(id=" 7 ").id == "sudca_7"
def test_large_number(self):
assert JudgeByID(id="12345").id == "sudca_12345"
def test_non_numeric_string_unchanged(self):
assert JudgeByID(id="sudca_abc").id == "sudca_abc"
class TestAdminProceedingsByID:
def test_digit_gets_prefix(self):
assert AdminProceedingsByID(id="103").id == "spravneKonanie_103"
def test_already_prefixed_unchanged(self):
assert AdminProceedingsByID(id="spravneKonanie_103").id == "spravneKonanie_103"
def test_strips_whitespace(self):
assert AdminProceedingsByID(id=" 55 ").id == "spravneKonanie_55"
def test_single_digit(self):
assert AdminProceedingsByID(id="5").id == "spravneKonanie_5"
def test_non_numeric_string_unchanged(self):
assert AdminProceedingsByID(id="custom_id").id == "custom_id"
class TestPaginationDefaults:
def test_court_search_defaults_are_none(self):
obj = CourtSearch()
assert obj.page is None
assert obj.size is None
def test_sort_direction_default_asc(self):
assert CourtSearch().sortDirection == "ASC"
def test_sort_direction_desc_accepted(self):
assert CourtSearch(sortDirection="DESC").sortDirection == "DESC"
def test_sort_direction_invalid_rejected(self):
with pytest.raises(ValidationError):
CourtSearch(sortDirection="INVALID")
def test_page_cannot_be_negative(self):
with pytest.raises(ValidationError):
CourtSearch(page=-1)
def test_size_cannot_be_zero(self):
with pytest.raises(ValidationError):
CourtSearch(size=0)
def test_size_one_accepted(self):
assert CourtSearch(size=1).size == 1
def test_page_zero_accepted(self):
assert CourtSearch(page=0).page == 0
class TestFacetFilters:
def test_court_search_facet_type_list(self):
obj = CourtSearch(typSuduFacetFilter=["Okresný súd", "Krajský súd"])
assert len(obj.typSuduFacetFilter) == 2
def test_judge_search_facet_kraj(self):
obj = JudgeSearch(krajFacetFilter=["Bratislavský kraj"])
assert obj.krajFacetFilter == ["Bratislavský kraj"]
def test_decision_search_forma_filter(self):
obj = DecisionSearch(formaRozhodnutiaFacetFilter=["Rozsudok"])
assert "Rozsudok" in obj.formaRozhodnutiaFacetFilter
def test_contract_search_typ_dokumentu(self):
obj = ContractSearch(typDokumentuFacetFilter=["ZMLUVA", "DODATOK"])
assert len(obj.typDokumentuFacetFilter) == 2
def test_civil_proceedings_usek_filter(self):
obj = CivilProceedingsSearch(usekFacetFilter=["C", "O"])
assert "C" in obj.usekFacetFilter
class TestAutocomplete:
def test_court_autocomplete_limit_min_one(self):
with pytest.raises(ValidationError):
CourtAutocomplete(limit=0)
def test_court_autocomplete_limit_valid(self):
assert CourtAutocomplete(limit=5).limit == 5
def test_judge_autocomplete_guid_sud(self):
obj = JudgeAutocomplete(guidSud="sud_100", limit=10)
assert obj.guidSud == "sud_100"
def test_autocomplete_empty_query_accepted(self):
assert CourtAutocomplete().query is None
def test_autocomplete_query_string(self):
assert JudgeAutocomplete(query="Novák").query == "Novák"
class TestModelDumpExcludeNone:
def test_excludes_none_fields(self):
dumped = CourtSearch(query="Bratislava").model_dump(exclude_none=True)
assert "query" in dumped
assert "page" not in dumped
assert "size" not in dumped
def test_full_params_included(self):
dumped = JudgeSearch(query="Novák", page=0, size=20).model_dump(exclude_none=True)
assert dumped["query"] == "Novák"
assert dumped["page"] == 0
assert dumped["size"] == 20
def test_empty_schema_dumps_empty_dict(self):
assert CourtSearch().model_dump(exclude_none=True) == {}
def test_empty_schema_excludes_none_fields_only(self):
dumped = CourtSearch().model_dump(exclude_none=True)
assert dumped.get("sortDirection") == "ASC"
assert "page" not in dumped
assert "size" not in dumped
assert "query" not in dumped
def test_empty_schema_exclude_defaults(self):
dumped = CourtSearch().model_dump(exclude_defaults=True)
assert dumped == {}

View File

@ -1,123 +0,0 @@
import pytest
from core.system_prompt import get_system_prompt
from core.config import OLLAMA_MODELS, OPENAI_MODELS
MODELS = ["qwen3.5:cloud"]
@pytest.fixture(params=MODELS)
def prompt(request):
return get_system_prompt(request.param)
@pytest.fixture(params=MODELS)
def prompt_lower(request):
return get_system_prompt(request.param).lower()
class TestPromptContainsModelName:
def test_model_name_appears_in_prompt(self, prompt, request):
model = request.node.callspec.params["prompt"]
assert model in prompt
class TestRequiredSections:
def test_has_role_section(self, prompt_lower):
assert "role" in prompt_lower
def test_has_operational_constraints(self, prompt_lower):
assert "constraint" in prompt_lower or "not allowed" in prompt_lower or "do not" in prompt_lower
def test_has_workflow_steps(self, prompt_lower):
assert "step" in prompt_lower or "workflow" in prompt_lower
def test_has_error_recovery(self, prompt_lower):
assert "error" in prompt_lower or "not found" in prompt_lower
def test_has_response_format(self, prompt_lower):
assert "format" in prompt_lower or "response" in prompt_lower
class TestSupportedDomains:
def test_courts_mentioned(self, prompt_lower):
assert "court" in prompt_lower or "súd" in prompt_lower or "sud" in prompt_lower
def test_judges_mentioned(self, prompt_lower):
assert "judge" in prompt_lower or "sudca" in prompt_lower or "sudcovia" in prompt_lower
def test_decisions_mentioned(self, prompt_lower):
assert "decision" in prompt_lower or "rozhodnut" in prompt_lower
def test_contracts_mentioned(self, prompt_lower):
assert "contract" in prompt_lower or "zmluv" in prompt_lower
def test_civil_proceedings_mentioned(self, prompt_lower):
assert "civil" in prompt_lower or "obcian" in prompt_lower or "pojednavan" in prompt_lower
def test_admin_proceedings_mentioned(self, prompt_lower):
assert "admin" in prompt_lower or "spravne" in prompt_lower or "správne" in prompt_lower
class TestConstraints:
def test_no_legal_advice_constraint(self, prompt_lower):
assert "legal advisor" in prompt_lower or "not a lawyer" in prompt_lower or "legal advice" in prompt_lower
def test_api_only_constraint(self, prompt_lower):
assert "api" in prompt_lower
def test_slovak_language_requirement(self, prompt_lower):
assert "slovak" in prompt_lower or "slovensk" in prompt_lower
def test_no_raw_json_rule(self, prompt_lower):
assert "json" in prompt_lower or "technical" in prompt_lower
def test_no_speculate_rule(self, prompt_lower):
assert "speculate" in prompt_lower or "infer" in prompt_lower or "gaps" in prompt_lower
class TestPaginationRules:
def test_page_starts_at_zero_mentioned(self, prompt_lower):
assert "page" in prompt_lower and "0" in prompt_lower
def test_autocomplete_preferred_mentioned(self, prompt_lower):
assert "autocomplete" in prompt_lower
class TestDateRules:
def test_date_format_dd_mm_yyyy_mentioned(self, prompt):
assert "DD.MM.YYYY" in prompt or "dd.mm.yyyy" in prompt.lower()
def test_civil_date_field_mentioned(self, prompt_lower):
assert "pojednavaniaod" in prompt_lower or "pojednavania" in prompt_lower
def test_decision_date_field_mentioned(self, prompt_lower):
assert "vydaniaod" in prompt_lower or "vydania" in prompt_lower
class TestIDNormalizationRules:
def test_sud_prefix_mentioned(self, prompt_lower):
assert "sud_" in prompt_lower
def test_sudca_prefix_mentioned(self, prompt_lower):
assert "sudca_" in prompt_lower
def test_spravnekonanie_prefix_mentioned(self, prompt_lower):
assert "spravnekonanie_" in prompt_lower or "spravne" in prompt_lower
class TestPromptLength:
def test_prompt_is_not_empty(self, prompt):
assert len(prompt.strip()) > 0
def test_prompt_has_minimum_length(self, prompt):
assert len(prompt) > 500
def test_prompt_has_reasonable_max_length(self, prompt):
assert len(prompt) < 50_000

View File

@ -1,257 +0,0 @@
import pytest
import httpx
import respx
from api.fetch_api_data import _cache
from api.config import JUSTICE_API_BASE
from api.tools import (
court_search,
court_id,
court_autocomplete,
judge_search,
judge_id,
judge_autocomplete,
decision_search,
decision_id,
decision_autocomplete,
contract_search,
contract_id,
contract_autocomplete,
civil_proceedings_search,
civil_proceedings_id,
civil_proceedings_autocomplete,
admin_proceedings_search,
admin_proceedings_id,
admin_proceedings_autocomplete,
)
EMPTY_LIST_RESPONSE = {"content": [], "totalElements": 0, "numberOfElements": 0}
@pytest.fixture(autouse=True)
def clear_cache():
_cache.clear()
yield
_cache.clear()
def make_response(body: dict = None):
return httpx.Response(200, json=body or EMPTY_LIST_RESPONSE)
@pytest.mark.asyncio
class TestCourtTools:
@respx.mock
async def test_court_search_calls_correct_url(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/sud").mock(return_value=make_response())
await court_search.on_invoke_tool(None, '{"params": {"query": "Bratislava"}}')
assert mock.called
@respx.mock
async def test_court_id_calls_correct_url(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/sud/sud_175").mock(
return_value=httpx.Response(200, json={"id": "sud_175", "foto": "data"})
)
await court_id.on_invoke_tool(None, '{"params": {"id": "175"}}')
assert mock.called
@respx.mock
async def test_court_id_removes_foto_key(self):
respx.get(f"{JUSTICE_API_BASE}/v1/sud/sud_1").mock(
return_value=httpx.Response(200, json={"name": "Súd", "foto": "base64"})
)
result = await court_id.on_invoke_tool(None, '{"params": {"id": "1"}}')
assert "foto" not in str(result)
@respx.mock
async def test_court_autocomplete_calls_correct_url(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/sud/autocomplete").mock(return_value=make_response())
await court_autocomplete.on_invoke_tool(None, '{"params": {"query": "Kraj"}}')
assert mock.called
@respx.mock
async def test_court_search_empty_params(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/sud").mock(return_value=make_response())
await court_search.on_invoke_tool(None, '{"params": {}}')
assert mock.called
@pytest.mark.asyncio
class TestJudgeTools:
@respx.mock
async def test_judge_search_calls_correct_url(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/sudca").mock(return_value=make_response())
await judge_search.on_invoke_tool(None, '{"params": {"query": "Novák"}}')
assert mock.called
@respx.mock
async def test_judge_id_normalizes_digit_id(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/sudca/sudca_1").mock(
return_value=httpx.Response(200, json={"id": "sudca_1"})
)
await judge_id.on_invoke_tool(None, '{"params": {"id": "1"}}')
assert mock.called
@respx.mock
async def test_judge_id_with_prefix(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/sudca/sudca_42").mock(
return_value=httpx.Response(200, json={"id": "sudca_42"})
)
await judge_id.on_invoke_tool(None, '{"params": {"id": "sudca_42"}}')
assert mock.called
@respx.mock
async def test_judge_autocomplete_passes_guid_sud(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/sudca/autocomplete").mock(return_value=make_response())
await judge_autocomplete.on_invoke_tool(None, '{"params": {"query": "Novák", "guidSud": "sud_100"}}')
assert mock.called
params = dict(mock.calls[0].request.url.params)
assert params.get("guidSud") == "sud_100"
@respx.mock
async def test_judge_autocomplete_without_guid(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/sudca/autocomplete").mock(return_value=make_response())
await judge_autocomplete.on_invoke_tool(None, '{"params": {"query": "Kováč", "limit": 5}}')
assert mock.called
params = dict(mock.calls[0].request.url.params)
assert params.get("query") == "Kováč"
@pytest.mark.asyncio
class TestDecisionTools:
@respx.mock
async def test_decision_search_with_date_range(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/rozhodnutie").mock(return_value=make_response())
await decision_search.on_invoke_tool(
None, '{"params": {"vydaniaOd": "01.01.2024", "vydaniaDo": "31.01.2024"}}'
)
assert mock.called
params = dict(mock.calls[0].request.url.params)
assert params.get("vydaniaOd") == "01.01.2024"
@respx.mock
async def test_decision_search_with_guid_sudca(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/rozhodnutie").mock(return_value=make_response())
await decision_search.on_invoke_tool(None, '{"params": {"guidSudca": "sudca_1"}}')
assert mock.called
params = dict(mock.calls[0].request.url.params)
assert params.get("guidSudca") == "sudca_1"
@respx.mock
async def test_decision_id_calls_correct_url(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/rozhodnutie/rozhodnutie_99").mock(
return_value=httpx.Response(200, json={"id": "rozhodnutie_99"})
)
await decision_id.on_invoke_tool(None, '{"params": {"id": "rozhodnutie_99"}}')
assert mock.called
@respx.mock
async def test_decision_autocomplete_calls_correct_url(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/rozhodnutie/autocomplete").mock(
return_value=make_response()
)
await decision_autocomplete.on_invoke_tool(None, '{"params": {"query": "Rozsudok", "limit": 5}}')
assert mock.called
params = dict(mock.calls[0].request.url.params)
assert params.get("query") == "Rozsudok"
@pytest.mark.asyncio
class TestContractTools:
@respx.mock
async def test_contract_search_with_guid_sud(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/zmluvy").mock(return_value=make_response())
await contract_search.on_invoke_tool(None, '{"params": {"guidSud": "sud_7"}}')
assert mock.called
params = dict(mock.calls[0].request.url.params)
assert params.get("guidSud") == "sud_7"
@respx.mock
async def test_contract_search_typ_dokumentu_filter(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/zmluvy").mock(return_value=make_response())
await contract_search.on_invoke_tool(None, '{"params": {"typDokumentuFacetFilter": ["ZMLUVA"]}}')
assert mock.called
@respx.mock
async def test_contract_id_calls_correct_url(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/zmluvy/2156252").mock(
return_value=httpx.Response(200, json={"idZmluvy": "2156252"})
)
await contract_id.on_invoke_tool(None, '{"params": {"idZmluvy": "2156252"}}')
assert mock.called
@respx.mock
async def test_contract_autocomplete_calls_correct_url(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/zmluvy/autocomplete").mock(
return_value=make_response()
)
await contract_autocomplete.on_invoke_tool(None, '{"params": {"query": "Slovak Telekom"}}')
assert mock.called
params = dict(mock.calls[0].request.url.params)
assert params.get("query") == "Slovak Telekom"
@pytest.mark.asyncio
class TestCivilAndAdminTools:
@respx.mock
async def test_civil_proceedings_search(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/obcianPojednavania").mock(return_value=make_response())
await civil_proceedings_search.on_invoke_tool(None, '{"params": {"query": "test"}}')
assert mock.called
@respx.mock
async def test_civil_proceedings_id_calls_correct_url(self):
uid = "121e4d31-695e-41e1-9191-7c9ad5d8d484"
mock = respx.get(f"{JUSTICE_API_BASE}/v1/obcianPojednavania/{uid}").mock(
return_value=httpx.Response(200, json={"id": uid})
)
await civil_proceedings_id.on_invoke_tool(None, f'{{"params": {{"id": "{uid}"}}}}')
assert mock.called
@respx.mock
async def test_civil_proceedings_autocomplete_calls_correct_url(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/obcianPojednavania/autocomplete").mock(
return_value=make_response()
)
await civil_proceedings_autocomplete.on_invoke_tool(
None, '{"params": {"query": "test", "limit": 5}}'
)
assert mock.called
@respx.mock
async def test_admin_proceedings_search(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/spravneKonanie").mock(return_value=make_response())
await admin_proceedings_search.on_invoke_tool(None, '{"params": {"query": "test"}}')
assert mock.called
@respx.mock
async def test_admin_proceedings_id_calls_correct_url(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/spravneKonanie/spravneKonanie_103").mock(
return_value=httpx.Response(200, json={"id": "spravneKonanie_103"})
)
await admin_proceedings_id.on_invoke_tool(None, '{"params": {"id": "103"}}')
assert mock.called
@respx.mock
async def test_admin_proceedings_autocomplete_calls_correct_url(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/spravneKonanie/autocomplete").mock(
return_value=make_response()
)
await admin_proceedings_autocomplete.on_invoke_tool(
None, '{"params": {"query": "konanie", "limit": 10}}'
)
assert mock.called
@respx.mock
async def test_civil_proceedings_date_params(self):
mock = respx.get(f"{JUSTICE_API_BASE}/v1/obcianPojednavania").mock(return_value=make_response())
await civil_proceedings_search.on_invoke_tool(
None, '{"params": {"pojednavaniaOd": "01.01.2024", "pojednavaniaDo": "31.01.2024"}}'
)
assert mock.called