added mcp, litellm; created folders BACKEND, FRONTEND; writed correct docker compose
@ -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"
|
||||
@ -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..."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
.gitignore
|
||||
.venv
|
||||
venv
|
||||
.env
|
||||
env/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
224
.gitignore
vendored
@ -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
|
||||
20
Dockerfile
@ -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
@ -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
@ -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"]
|
||||
@ -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__)
|
||||
|
||||
@ -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
@ -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
@ -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
@ -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
@ -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",
|
||||
}
|
||||
)
|
||||
15
backend/mcp_server/Dockerfile
Normal 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"]
|
||||
0
backend/mcp_server/__init__.py
Normal file
17
backend/mcp_server/mcp_server.py
Normal 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)
|
||||
77
backend/mcp_server/tools/judges.py
Normal 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)
|
||||
14
chainlit.md
@ -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.
|
||||
@ -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
@ -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
@ -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
|
||||
@ -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"}
|
||||
@ -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
|
||||
@ -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}"
|
||||
|
||||
@ -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
|
||||
31
frontend/app/api/chat/route.ts
Normal 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();
|
||||
}
|
||||
26
frontend/app/assistant.tsx
Normal 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
|
After Width: | Height: | Size: 25 KiB |
117
frontend/app/globals.css
Normal 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
@ -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
@ -0,0 +1,5 @@
|
||||
import { Assistant } from "./assistant";
|
||||
|
||||
export default function Home() {
|
||||
return <Assistant />;
|
||||
}
|
||||
21
frontend/components.json
Normal 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"
|
||||
}
|
||||
222
frontend/components/assistant-ui/attachment.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
243
frontend/components/assistant-ui/markdown-text.tsx
Normal 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,
|
||||
});
|
||||
368
frontend/components/assistant-ui/thread.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
324
frontend/components/assistant-ui/tool-fallback.tsx
Normal 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,
|
||||
};
|
||||
42
frontend/components/assistant-ui/tooltip-icon-button.tsx
Normal 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";
|
||||
109
frontend/components/ui/avatar.tsx
Normal 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,
|
||||
};
|
||||
64
frontend/components/ui/button.tsx
Normal 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 };
|
||||
33
frontend/components/ui/collapsible.tsx
Normal 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 };
|
||||
158
frontend/components/ui/dialog.tsx
Normal 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,
|
||||
};
|
||||
57
frontend/components/ui/tooltip.tsx
Normal 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
@ -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
@ -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
47
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
33
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.0 KiB |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
|
Before Width: | Height: | Size: 255 KiB |
|
Before Width: | Height: | Size: 255 KiB |
@ -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;
|
||||
}
|
||||
@ -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
@ -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
|
||||
BIN
requirements.txt
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 35 KiB |
@ -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)
|
||||
@ -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
|
||||
@ -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"
|
||||
@ -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", "sú", "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)
|
||||
@ -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"
|
||||
@ -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 == {}
|
||||
@ -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
|
||||
@ -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
|
||||