This commit is contained in:
Rogelio
2025-10-13 18:16:25 +00:00
parent 739f087cef
commit 325f1ef439
415 changed files with 46870 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
ARG PACKAGE
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-dev --no-install-workspace --package ${PACKAGE}
COPY pyproject.toml uv.lock ./
COPY packages ./packages
COPY apps ./apps
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-editable --package ${PACKAGE}
FROM python:3.12-slim AS runtime
ARG PACKAGE
COPY --from=builder --chown=app:app /app/.venv /venv
ENV PATH="/venv/bin:$PATH"
COPY notebooks/${PACKAGE} /notebook
CMD ["python3", "-m", "marimo", "run", "notebook/main.py", "--host", "0.0.0.0", "--port", "80"]

View File

@@ -0,0 +1,51 @@
FROM node:22-slim AS frontend-builder
ARG PACKAGE
WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY packages ./packages
COPY apps ./apps
RUN pnpm install --frozen-lockfile
RUN pnpm --filter ${PACKAGE} build
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS backend-builder
ARG PACKAGE
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-dev --no-install-workspace --package ${PACKAGE}
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY packages ./packages
COPY apps ./apps
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-editable --package ${PACKAGE}
FROM unit:python3.12-slim AS runtime
ARG PACKAGE
COPY --from=backend-builder --chown=app:app /app/.venv /venv
ENV PATH="/venv/bin:$PATH"
COPY --from=backend-builder --chown=app:app /app/apps/${PACKAGE}/api /api
COPY --from=frontend-builder --chown=app:app /app/apps/${PACKAGE}/dist /index
EXPOSE 80
COPY .containers/unit/unit.json /docker-entrypoint.d/config.json

View File

@@ -0,0 +1,31 @@
{
"listeners": {
"*:80": {
"pass": "routes"
}
},
"applications": {
"fastapi": {
"type": "python3",
"path": "/",
"module": "api.server",
"callable": "app"
}
},
"routes": [
{
"match": {
"uri": "/api/*"
},
"action": {
"pass": "applications/fastapi"
}
},
{
"action": {
"share": "/index$uri",
"types": ["image/*", "font/*", "text/*", "application/*"]
}
}
]
}

200
.gitignore vendored Normal file
View File

@@ -0,0 +1,200 @@
node_modules
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# 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/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# PyPI configuration file
.pypirc
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment
.env*

20
.mise/config.toml Normal file
View File

@@ -0,0 +1,20 @@
[tools]
node = "22"
pnpm = "latest"
python = "3.12"
uv = "latest"
[tasks."container:build"]
description = "Build the container for a project"
run = "docker buildx bake --no-cache"
[tasks."secrets:list"]
description = "List secrets from Vault"
run = """vault kv get -mount="secret" -address=https://vault.ia-innovacion.work -format=json "banortegpt"| jq '.["data"]["data"]'"""
silent = true
[tasks.setup]
run = """
pnpm install && pnpm build
uv sync --all-packages
"""

290
.mise/tasks/container/push.py Executable file
View File

@@ -0,0 +1,290 @@
#!/usr/bin/env -S uv run --script
#MISE silent=true
#MISE description="Push the container to Azure"
# /// script
# dependencies = ["rich"]
# ///
import argparse
import subprocess
import sys
from typing import Optional
from rich.console import Console
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.prompt import Prompt, Confirm
from rich.table import Table
console = Console()
# Azure Container Registry configuration
AZURE_REGISTRY = "iaservicecontainers.azurecr.io"
IMAGE_PREFIX = "mayacontigo/"
def run_docker_command(command: list[str], description: str) -> bool:
"""Run a docker command with rich progress indication."""
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
transient=True,
) as progress:
task = progress.add_task(description, total=None)
try:
subprocess.run(
command,
capture_output=True,
text=True,
check=True
)
progress.update(task, description=f"{description}")
return True
except subprocess.CalledProcessError as e:
progress.update(task, description=f"{description}")
console.print(f"[red]Error running command: {' '.join(command)}[/red]")
console.print(f"[red]Error output: {e.stderr}[/red]")
return False
except FileNotFoundError:
progress.update(task, description=f"{description}")
console.print("[red]Docker command not found. Please ensure Docker is installed and in your PATH.[/red]")
return False
def check_docker_image_exists(image_name: str) -> bool:
"""Check if a Docker image exists locally."""
try:
result = subprocess.run(
["docker", "images", "-q", image_name],
capture_output=True,
text=True,
check=True
)
return bool(result.stdout.strip())
except subprocess.CalledProcessError:
return False
def get_available_images() -> list[str]:
"""Get list of available Docker images filtered by prefix."""
try:
result = subprocess.run(
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
capture_output=True,
text=True,
check=True
)
images = [line.strip() for line in result.stdout.split('\n') if line.strip()]
# Filter images to only include those with the specified prefix
filtered_images = [img for img in images if not img.startswith('<none>') and img.startswith(IMAGE_PREFIX)]
return filtered_images
except subprocess.CalledProcessError:
return []
def display_available_images(images: list[str], show_numbers: bool = False) -> None:
"""Display available Docker images in a nice table."""
if not images:
console.print(f"[yellow]No Docker images found locally with prefix '{IMAGE_PREFIX}'.[/yellow]")
return
table = Table(title=f"Available Docker Images (prefix: {IMAGE_PREFIX})")
if show_numbers:
table.add_column("No.", style="bold yellow", width=4)
table.add_column("Image", style="cyan")
table.add_column("Registry Status", style="magenta")
for i, image in enumerate(images[:10], 1): # Show first 10 images
registry_status = "🏷️ Ready to tag" if not image.startswith(AZURE_REGISTRY) else "🚀 Already tagged"
if show_numbers:
table.add_row(str(i), image, registry_status)
else:
table.add_row(image, registry_status)
if len(images) > 10:
remaining_text = f"[dim]and {len(images) - 10} more[/dim]"
if show_numbers:
table.add_row("...", "...", remaining_text)
else:
table.add_row("...", remaining_text)
console.print(table)
def interactive_mode() -> Optional[str]:
"""Run in interactive mode to select an image."""
console.print(Panel.fit(
"[bold blue]🐳 Docker Image Push to Azure[/bold blue]\n"
"[dim]Interactive mode - Let's select an image to push[/dim]",
border_style="blue"
))
# Get available images
console.print("\n[bold]Fetching available Docker images...[/bold]")
images = get_available_images()
if not images:
console.print(f"[red]No Docker images found with prefix '{IMAGE_PREFIX}'. Please build an image first.[/red]")
return None
display_available_images(images, show_numbers=True)
# Create choices with numbers
choices = [str(i) for i in range(1, len(images) + 1)]
console.print()
try:
choice = Prompt.ask(
"Select an image to push",
choices=choices,
default="1"
)
selected_image = images[int(choice) - 1]
console.print(f"[green]✅ Selected image: {selected_image}[/green]")
return selected_image
except (ValueError, IndexError, KeyboardInterrupt):
console.print("[yellow]Selection cancelled.[/yellow]")
return None
def push_image(image_name: str, force: bool = False) -> bool:
"""Push a Docker image to Azure Container Registry."""
# Validate image name
if not image_name:
console.print("[red]Error: Image name cannot be empty[/red]")
return False
# Check if image has the required prefix
if not image_name.startswith(IMAGE_PREFIX):
console.print(f"[red]Error: Image '{image_name}' does not have the required prefix '{IMAGE_PREFIX}'[/red]")
return False
# Check if image exists locally
if not check_docker_image_exists(image_name):
console.print(f"[red]Error: Image '{image_name}' not found locally[/red]")
console.print(f"[yellow]Tip: Run 'docker images' to see available images with prefix '{IMAGE_PREFIX}'[/yellow]")
return False
# Prepare Azure registry image name (remove the prefix since it will be added by the registry)
image_name_without_prefix = image_name[len(IMAGE_PREFIX):]
azure_image_name = f"{AZURE_REGISTRY}/{image_name_without_prefix}"
# Show what we're about to do
info_panel = Panel.fit(
f"[bold]Image:[/bold] {image_name}\n"
f"[bold]Target:[/bold] {azure_image_name}\n"
f"[bold]Registry:[/bold] {AZURE_REGISTRY}",
title="🚀 Push Configuration",
border_style="green"
)
console.print(info_panel)
# Confirm action unless force is specified
if not force:
if not Confirm.ask(f"\nProceed with pushing [cyan]{image_name}[/cyan] to Azure?"):
console.print("[yellow]Operation cancelled.[/yellow]")
return False
console.print("\n[bold]Starting push process...[/bold]")
# Tag the image
if not run_docker_command(
["docker", "tag", image_name, azure_image_name],
f"Tagging image as {azure_image_name}"
):
return False
# Push the image
if not run_docker_command(
["docker", "push", azure_image_name],
f"Pushing {azure_image_name} to registry"
):
return False
# Success message
success_panel = Panel.fit(
f"[bold green]✅ Successfully pushed![/bold green]\n"
f"[dim]Image: {azure_image_name}[/dim]",
title="🎉 Success",
border_style="green"
)
console.print(success_panel)
return True
def main():
parser = argparse.ArgumentParser(
description="Push Docker images to Azure Container Registry",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=f"""
Examples:
python push.py {IMAGE_PREFIX}my-app:latest # Push specific image
python push.py --interactive # Interactive mode
python push.py {IMAGE_PREFIX}my-app:latest --force # Skip confirmation
python push.py --list # List available images
"""
)
parser.add_argument(
"image_name",
nargs="?",
help="Name of the Docker image to push"
)
parser.add_argument(
"-i", "--interactive",
action="store_true",
help="Run in interactive mode"
)
parser.add_argument(
"-f", "--force",
action="store_true",
help="Skip confirmation prompts"
)
parser.add_argument(
"--list",
action="store_true",
help="List available Docker images and exit"
)
args = parser.parse_args()
# Handle --list option
if args.list:
console.print("[bold]Available Docker Images:[/bold]")
images = get_available_images()
display_available_images(images)
return
# Determine image name
image_name = args.image_name
# Interactive mode or no image provided
if args.interactive or not image_name:
image_name = interactive_mode()
if not image_name:
sys.exit(1)
# Push the image
success = push_image(image_name, args.force)
sys.exit(0 if success else 1)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
console.print("\n[yellow]Operation cancelled by user.[/yellow]")
sys.exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
sys.exit(1)

337
.mise/tasks/container/start.py Executable file
View File

@@ -0,0 +1,337 @@
#!/usr/bin/env -S uv run --script
#MISE silent=true
#MISE description="Run the container for a project"
# /// script
# dependencies = ["rich"]
# ///
import argparse
import json
import subprocess
import sys
from typing import Optional
from rich.console import Console
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.prompt import Prompt, Confirm
from rich.table import Table
console = Console()
# Configuration
VAULT_ADDRESS = "https://vault.ia-innovacion.work"
DOCKER_REGISTRY = "mayacontigo"
DEFAULT_PORT = 9000
CONTAINER_PORT = 80
def run_command(command: list[str], description: str, capture_output: bool = True) -> tuple[bool, str]:
"""Run a command with rich progress indication."""
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
transient=True,
) as progress:
task = progress.add_task(description, total=None)
try:
result = subprocess.run(
command,
capture_output=capture_output,
text=True,
check=True
)
progress.update(task, description=f"{description}")
return True, result.stdout.strip() if capture_output else ""
except subprocess.CalledProcessError as e:
progress.update(task, description=f"{description}")
error_msg = e.stderr if capture_output else str(e)
console.print(f"[red]Error running command: {' '.join(command)}[/red]")
if error_msg:
console.print(f"[red]Error output: {error_msg}[/red]")
return False, ""
except FileNotFoundError:
progress.update(task, description=f"{description}")
console.print(f"[red]Command not found: {command[0]}. Please ensure it's installed and in your PATH.[/red]")
return False, ""
def get_vault_token() -> Optional[str]:
"""Get vault token from Vault CLI."""
success, output = run_command(
["vault", "token", "lookup", "-format=json", f"-address={VAULT_ADDRESS}"],
"Fetching Vault token"
)
if not success:
return None
try:
data = json.loads(output)
return data.get("data", {}).get("id")
except json.JSONDecodeError:
console.print("[red]Failed to parse Vault token response[/red]")
return None
def check_docker_image_exists(app_name: str) -> bool:
"""Check if the Docker image exists locally or can be pulled."""
image_name = f"{DOCKER_REGISTRY}/{app_name}"
# First check if image exists locally
success, _ = run_command(
["docker", "images", "-q", image_name],
f"Checking if {image_name} exists locally"
)
if success:
return True
# If not local, try to pull it
console.print(f"[yellow]Image {image_name} not found locally. Attempting to pull...[/yellow]")
success, _ = run_command(
["docker", "pull", image_name],
f"Pulling {image_name}"
)
return success
def get_available_apps() -> list[str]:
"""Get list of available Docker images from the registry."""
try:
result = subprocess.run(
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
capture_output=True,
text=True,
check=True
)
images = [line.strip() for line in result.stdout.split('\n') if line.strip()]
# Filter for banortegpt registry images
apps = []
for img in images:
if img.startswith(f"{DOCKER_REGISTRY}/"):
app_name = img.replace(f"{DOCKER_REGISTRY}/", "").split(":")[0]
if app_name not in apps:
apps.append(app_name)
return apps
except subprocess.CalledProcessError:
return []
def display_available_apps(apps: list[str]) -> None:
"""Display available apps in a nice table."""
if not apps:
console.print("[yellow]No apps found locally.[/yellow]")
console.print("[dim]Try pulling an image first or check your Docker registry connection.[/dim]")
return
table = Table(title="Available Apps")
table.add_column("App Name", style="cyan")
table.add_column("Image", style="magenta")
table.add_column("Status", style="green")
for app in apps:
image = f"{DOCKER_REGISTRY}/{app}"
status = "🚀 Ready to run"
table.add_row(app, image, status)
console.print(table)
def interactive_mode() -> Optional[str]:
"""Run in interactive mode to select an app."""
console.print(Panel.fit(
"[bold blue]🐳 Docker Container Starter[/bold blue]\n"
"[dim]Interactive mode - Let's select an app to run[/dim]",
border_style="blue"
))
# Get available apps
console.print("\n[bold]Fetching available apps...[/bold]")
apps = get_available_apps()
if not apps:
console.print("[red]No apps found. Please ensure you have Docker images available.[/red]")
return None
display_available_apps(apps)
# Prompt for app selection
console.print("\n[bold]Select an app to run:[/bold]")
app_name = Prompt.ask(
"Enter the app name (or press Enter to see suggestions)",
default="",
show_default=False
)
if not app_name:
# Show suggestions
console.print("\n[dim]Here are your available apps:[/dim]")
for i, app in enumerate(apps, 1):
console.print(f" {i}. [cyan]{app}[/cyan]")
choice = Prompt.ask(
"Enter app name or number from suggestions",
default="1"
)
try:
choice_num = int(choice)
if 1 <= choice_num <= len(apps):
app_name = apps[choice_num - 1]
else:
console.print("[red]Invalid choice.[/red]")
return None
except ValueError:
app_name = choice
return app_name
def start_container(app_name: str, port: int = DEFAULT_PORT, force: bool = False) -> bool:
"""Start a Docker container for the specified app."""
# Validate app name
if not app_name:
console.print("[red]Error: App name cannot be empty[/red]")
return False
# Check if image exists
if not check_docker_image_exists(app_name):
console.print(f"[red]Error: Image for app '{app_name}' not found[/red]")
return False
# Get vault token
console.print("\n[bold]Getting Vault token...[/bold]")
vault_token = get_vault_token()
if not vault_token:
console.print("[red]Failed to get Vault token[/red]")
return False
# Prepare container configuration
image_name = f"{DOCKER_REGISTRY}/{app_name}"
# Show what we're about to do
info_panel = Panel.fit(
f"[bold]App:[/bold] {app_name}\n"
f"[bold]Image:[/bold] {image_name}\n"
f"[bold]Port:[/bold] {port}:{CONTAINER_PORT}\n"
f"[bold]Vault:[/bold] {VAULT_ADDRESS}\n"
f"[bold]Token:[/bold] {'*' * 8}...{vault_token[-4:] if len(vault_token) > 8 else '****'}",
title="🚀 Container Configuration",
border_style="green"
)
console.print(info_panel)
# Confirm action unless force is specified
if not force:
if not Confirm.ask(f"\nStart container for [cyan]{app_name}[/cyan] on port {port}?"):
console.print("[yellow]Operation cancelled.[/yellow]")
return False
console.print("\n[bold]Starting container...[/bold]")
# Build docker run command
docker_cmd = [
"docker", "run",
"-p", f"{port}:{CONTAINER_PORT}",
"--rm",
"-it",
"-e", f"VAULT_TOKEN={vault_token}",
image_name
]
# Run the container (this will be interactive)
console.print(f"[green]Running: {' '.join(docker_cmd[:7])}... {image_name}[/green]")
console.print(f"[dim]Container will be available at: http://localhost:{port}[/dim]")
console.print("[dim]Press Ctrl+C to stop the container[/dim]\n")
try:
subprocess.run(docker_cmd, check=True)
return True
except subprocess.CalledProcessError as e:
console.print(f"[red]Container failed to start: {e}[/red]")
return False
except KeyboardInterrupt:
console.print("\n[yellow]Container stopped by user.[/yellow]")
return True
def main():
parser = argparse.ArgumentParser(
description="Start Docker containers for Banorte GPT apps",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python start.py my-app # Start specific app
python start.py --interactive # Interactive mode
python start.py my-app --port 8080 # Custom port
python start.py my-app --force # Skip confirmation
"""
)
parser.add_argument(
"app_name",
nargs="?",
help="Name of the app to start"
)
parser.add_argument(
"-i", "--interactive",
action="store_true",
help="Run in interactive mode"
)
parser.add_argument(
"-p", "--port",
type=int,
default=DEFAULT_PORT,
help=f"Port to bind to (default: {DEFAULT_PORT})"
)
parser.add_argument(
"-f", "--force",
action="store_true",
help="Skip confirmation prompts"
)
parser.add_argument(
"--list",
action="store_true",
help="List available apps and exit"
)
args = parser.parse_args()
# Handle --list option
if args.list:
console.print("[bold]Available Apps:[/bold]")
apps = get_available_apps()
display_available_apps(apps)
return
# Determine app name
app_name = args.app_name
# Interactive mode or no app provided
if args.interactive or not app_name:
app_name = interactive_mode()
if not app_name:
sys.exit(1)
# Start the container
success = start_container(app_name, args.port, args.force)
sys.exit(0 if success else 1)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
console.print("\n[yellow]Operation cancelled by user.[/yellow]")
sys.exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
sys.exit(1)

582
.mise/tasks/dev.py Executable file
View File

@@ -0,0 +1,582 @@
#!/usr/bin/env -S uv run --script
#MISE silent=true
#MISE description="Start the development environment for a project"
# /// script
# dependencies = ["rich"]
# ///
import argparse
import signal
import subprocess
import sys
import threading
import time
import json
from pathlib import Path
from typing import Optional, List, Dict
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.live import Live
from rich.layout import Layout
from rich.status import Status
from rich.prompt import Prompt
console = Console()
# Configuration
DEFAULT_FRONTEND_CMD = ["pnpm", "run", "dev"]
DEFAULT_BACKEND_CMD = ["uv", "run", "uvicorn", "api.server:app", "--reload"]
DEFAULT_FRONTEND_PORT = 3000
DEFAULT_BACKEND_PORT = 8000
class ProcessManager:
def __init__(self):
self.processes: Dict[str, subprocess.Popen] = {}
self.running = False
self.logs: Dict[str, List[str]] = {"frontend": [], "backend": []}
def start_process(self, name: str, cmd: List[str], cwd: Optional[str] = None) -> bool:
"""Start a process and track it."""
try:
console.print(f"[dim]Starting {name} in {cwd or 'current directory'}: {' '.join(cmd)}[/dim]")
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True,
cwd=cwd
)
self.processes[name] = process
# Start thread to capture output
thread = threading.Thread(
target=self._capture_output,
args=(name, process),
daemon=True
)
thread.start()
return True
except Exception as e:
console.print(f"[red]Failed to start {name}: {e}[/red]")
return False
def _capture_output(self, name: str, process: subprocess.Popen):
"""Capture process output in a separate thread."""
while True:
stdout = process.stdout
assert stdout is not None
output = stdout.readline()
if output == '' and process.poll() is not None:
break
if output:
# Keep only last 50 lines
self.logs[name].append(output.strip())
if len(self.logs[name]) > 50:
self.logs[name].pop(0)
def stop_all(self):
"""Stop all processes."""
self.running = False
for name, process in self.processes.items():
try:
process.terminate()
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
except Exception:
pass
def is_running(self) -> bool:
"""Check if any process is still running."""
return any(p.poll() is None for p in self.processes.values())
def get_status(self) -> Dict[str, str]:
"""Get status of all processes."""
status = {}
for name, process in self.processes.items():
if process.poll() is None:
status[name] = "🟢 Running"
else:
status[name] = f"🔴 Stopped (exit code: {process.poll()})"
return status
def find_project_root() -> Optional[Path]:
"""Find the project root directory by looking for package.json."""
current_dir = Path(__file__).resolve().parent
# Start from the script directory and go up
while current_dir != current_dir.parent:
if (current_dir / "package.json").exists():
return current_dir
current_dir = current_dir.parent
return None
def get_available_apps(project_root: Path) -> List[str]:
"""Get list of available apps in the monorepo."""
apps_dir = project_root / "apps"
if not apps_dir.exists():
return []
apps = []
for app_dir in apps_dir.iterdir():
if app_dir.is_dir() and (app_dir / "package.json").exists():
apps.append(app_dir.name)
return sorted(apps)
def get_package_name(app_path: Path) -> str:
"""Get the package name from package.json."""
try:
with open(app_path / "package.json", 'r') as f:
package_data = json.load(f)
return package_data.get("name", "")
except (FileNotFoundError, json.JSONDecodeError):
return ""
def check_dependencies(project_root: Path, app_name: str | None = None) -> Dict[str, bool]:
"""Check if required dependencies are available."""
deps = {}
# Check pnpm global availability
try:
subprocess.run(["pnpm", "--version"], capture_output=True, check=True)
deps["pnpm"] = True
except (subprocess.CalledProcessError, FileNotFoundError):
deps["pnpm"] = False
# Check uv global availability
try:
subprocess.run(["uv", "--version"], capture_output=True, check=True)
deps["uv"] = True
except (subprocess.CalledProcessError, FileNotFoundError):
deps["uv"] = False
# Check project structure
deps["package.json"] = (project_root / "package.json").exists()
deps["apps directory"] = (project_root / "apps").exists()
# If app is specified, check app-specific dependencies
if app_name:
app_path = project_root / "apps" / app_name
# Check pnpm workspace install for specific app
if deps["pnpm"] and (app_path / "package.json").exists():
try:
# Use pnpm list to check if the workspace package is valid
subprocess.run(
["pnpm", "list", "--filter", app_name, "--depth", "0"],
capture_output=True,
check=True,
cwd=str(project_root),
timeout=15
)
deps[f"pnpm install ({app_name})"] = True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
deps[f"pnpm install ({app_name})"] = False
else:
deps[f"pnpm install ({app_name})"] = False
# Check uv sync for specific app
if deps["uv"] and (app_path / "api").exists():
try:
# Check if we can sync the package (dry-run)
subprocess.run(
["uv", "sync", "--package", app_name, "--dry-run"],
capture_output=True,
check=True,
cwd=str(project_root),
timeout=15
)
deps[f"uv sync ({app_name})"] = True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
deps[f"uv sync ({app_name})"] = False
else:
deps[f"uv sync ({app_name})"] = False
return deps
def display_dependency_status(deps: Dict[str, bool]) -> bool:
"""Display dependency status and return if all are satisfied."""
table = Table(title="🔍 Dependency Check")
table.add_column("Dependency", style="cyan")
table.add_column("Status", style="bold")
table.add_column("Description", style="dim")
descriptions = {
"pnpm": "Node.js package manager",
"uv": "Python package manager",
"package.json": "Root project configuration",
"apps directory": "Apps directory for monorepo"
}
all_good = True
for dep, available in deps.items():
if available:
status = "[green]✅ Available[/green]"
else:
status = "[red]❌ Missing[/red]"
all_good = False
# Generate description for app-specific dependencies
description = descriptions.get(dep, "")
if "pnpm install" in dep:
description = "Frontend dependencies installation"
elif "uv sync" in dep:
description = "Backend dependencies synchronization"
table.add_row(dep, status, description)
console.print(table)
return all_good
def display_available_apps(apps: List[str], project_root: Path, show_numbers: bool = False):
"""Display available apps."""
if not apps:
console.print("[yellow]No apps found in the monorepo.[/yellow]")
return
table = Table(title="📱 Available Apps")
if show_numbers:
table.add_column("No.", style="bold yellow", width=4)
table.add_column("App Name", style="cyan")
table.add_column("Frontend", style="green")
table.add_column("Backend", style="blue")
for i, app in enumerate(apps, 1):
app_path = project_root / "apps" / app
has_frontend = (app_path / "package.json").exists()
has_backend = (app_path / "api").exists()
frontend_status = "" if has_frontend else ""
backend_status = "" if has_backend else ""
if show_numbers:
table.add_row(str(i), app, frontend_status, backend_status)
else:
table.add_row(app, frontend_status, backend_status)
console.print(table)
def interactive_select_app(available_apps: List[str], project_root: Path) -> Optional[str]:
"""Interactively select an app from the available apps."""
if not available_apps:
console.print("[red]❌ No apps found in the monorepo.[/red]")
return None
console.print("\n[bold]Available apps in monorepo:[/bold]")
display_available_apps(available_apps, project_root, show_numbers=True)
# Create choices with numbers
choices = [str(i) for i in range(1, len(available_apps) + 1)]
console.print()
try:
choice = Prompt.ask(
"Select an app to start",
choices=choices,
default="1"
)
selected_app = available_apps[int(choice) - 1]
console.print(f"[green]✅ Selected app: {selected_app}[/green]")
return selected_app
except (ValueError, IndexError, KeyboardInterrupt):
console.print("[yellow]Selection cancelled.[/yellow]")
return None
def create_live_dashboard(manager: ProcessManager, app_name: str) -> Layout:
"""Create a live dashboard layout."""
layout = Layout()
layout.split_column(
Layout(name="header", size=3),
Layout(name="body"),
Layout(name="footer", size=3)
)
layout["body"].split_row(
Layout(name="frontend"),
Layout(name="backend")
)
return layout
def update_dashboard(layout: Layout, manager: ProcessManager, app_name: str):
"""Update the dashboard with current status."""
# Header
layout["header"].update(
Panel(
f"[bold blue]🚀 Development Environment - {app_name}[/bold blue]\n"
"[dim]Press Ctrl+C to stop all services[/dim]",
border_style="blue"
)
)
# Status
status = manager.get_status()
# Frontend panel
frontend_logs = "\n".join(manager.logs.get("frontend", [])[-10:]) # Last 10 lines
frontend_status = status.get("frontend", "🔴 Not started")
layout["frontend"].update(
Panel(
f"[bold]Status:[/bold] {frontend_status}\n\n"
f"[dim]Recent output:[/dim]\n{frontend_logs}",
title="🎨 Frontend (pnpm)",
border_style="green" if "Running" in frontend_status else "red"
)
)
# Backend panel
backend_logs = "\n".join(manager.logs.get("backend", [])[-10:]) # Last 10 lines
backend_status = status.get("backend", "🔴 Not started")
layout["backend"].update(
Panel(
f"[bold]Status:[/bold] {backend_status}\n\n"
f"[dim]Recent output:[/dim]\n{backend_logs}",
title="🔧 Backend (uvicorn)",
border_style="green" if "Running" in backend_status else "red"
)
)
# Footer
layout["footer"].update(
Panel(
f"[bold]Frontend:[/bold] http://localhost:{DEFAULT_FRONTEND_PORT} | "
f"[bold]Backend:[/bold] http://localhost:{DEFAULT_BACKEND_PORT}",
border_style="blue"
)
)
def start_development_environment(
app_name: str,
frontend_cmd: List[str],
backend_cmd: List[str],
show_dashboard: bool = True,
project_root: Path | None = None
) -> bool:
"""Start the development environment."""
manager = ProcessManager()
# Set up signal handler for graceful shutdown
def signal_handler(sig, frame):
console.print(f"\n[yellow]Shutting down development environment for {app_name}...[/yellow]")
manager.stop_all()
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
assert project_root is not None, "project_root must be provided"
# Check dependencies first
console.print("[bold]Checking dependencies...[/bold]")
deps = check_dependencies(project_root, app_name)
if not display_dependency_status(deps):
console.print("\n[red]❌ Some dependencies are missing. Please install them first.[/red]")
return False
console.print("\n[green]✅ All dependencies are available![/green]")
# Set up paths
app_path = project_root / "apps" / app_name
frontend_cwd = str(app_path) if (app_path / "package.json").exists() else None
backend_cwd = str(app_path) if (app_path / "api").exists() else None
# Show startup configuration
config_panel = Panel.fit(
f"[bold]App:[/bold] {app_name}\n"
f"[bold]Frontend:[/bold] {' '.join(frontend_cmd)} (cwd: {frontend_cwd or 'N/A'})\n"
f"[bold]Backend:[/bold] {' '.join(backend_cmd)} (cwd: {backend_cwd or 'N/A'})\n"
f"[bold]Frontend URL:[/bold] http://localhost:{DEFAULT_FRONTEND_PORT}\n"
f"[bold]Backend URL:[/bold] http://localhost:{DEFAULT_BACKEND_PORT}",
title="🚀 Development Configuration",
border_style="green"
)
console.print(config_panel)
# Start processes
console.print("\n[bold]Starting development servers...[/bold]")
# Start frontend if available
if frontend_cwd:
with Status("Starting frontend server...", spinner="dots"):
if not manager.start_process("frontend", frontend_cmd, frontend_cwd):
console.print("[red]Failed to start frontend[/red]")
return False
time.sleep(2) # Give it a moment to start
else:
console.print("[yellow]No frontend found for this app[/yellow]")
# Start backend if available
if backend_cwd:
with Status("Starting backend server...", spinner="dots"):
if not manager.start_process("backend", backend_cmd, backend_cwd):
console.print("[red]Failed to start backend[/red]")
return False
time.sleep(2) # Give it a moment to start
else:
console.print("[yellow]No backend found for this app[/yellow]")
if not manager.processes:
console.print("[red]No services to start for this app[/red]")
return False
manager.running = True
if show_dashboard:
# Live dashboard
layout = create_live_dashboard(manager, app_name)
with Live(layout, refresh_per_second=2, screen=True):
try:
while manager.is_running():
update_dashboard(layout, manager, app_name)
time.sleep(0.5)
except KeyboardInterrupt:
pass
else:
# Simple mode - just wait
try:
console.print(f"\n[green]✅ Development environment started for {app_name}![/green]")
console.print("[dim]Press Ctrl+C to stop all services[/dim]")
while manager.is_running():
time.sleep(1)
except KeyboardInterrupt:
pass
# Cleanup
console.print(f"\n[yellow]Stopping all services for {app_name}...[/yellow]")
manager.stop_all()
console.print("[green]✅ Development environment stopped.[/green]")
return True
def main():
# Find project root
project_root = find_project_root()
if not project_root:
console.print("[red]❌ Could not find project root (package.json not found)[/red]")
sys.exit(1)
console.print(f"[dim]Project root: {project_root}[/dim]")
# Get available apps
available_apps = get_available_apps(project_root)
parser = argparse.ArgumentParser(
description="Start development environment with frontend and backend servers for a monorepo app",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=f"""
Available apps: {', '.join(available_apps) if available_apps else 'None found'}
Examples:
python dev.py # Interactive app selection
python dev.py --app bursatil # Start bursatil app directly
python dev.py --app ocp --no-dashboard # Start ocp app without dashboard
python dev.py --list-apps # List available apps
python dev.py --app bursatil --frontend-cmd "pnpm run build" # Custom frontend command
"""
)
parser.add_argument(
"--app",
choices=available_apps,
help="App to start (if not provided, you'll be prompted to select one)"
)
parser.add_argument(
"--frontend-cmd",
default=" ".join(DEFAULT_FRONTEND_CMD),
help="Frontend command to run (default: pnpm run dev)"
)
parser.add_argument(
"--backend-cmd",
default=" ".join(DEFAULT_BACKEND_CMD),
help="Backend command to run (default: uv run uvicorn api.server:app --reload)"
)
parser.add_argument(
"--no-dashboard",
action="store_true",
help="Disable the live dashboard"
)
parser.add_argument(
"--check-deps",
action="store_true",
help="Only check dependencies and exit"
)
parser.add_argument(
"--list-apps",
action="store_true",
help="List available apps and exit"
)
args = parser.parse_args()
# Handle --check-deps
if args.check_deps:
console.print("[bold]Checking dependencies...[/bold]")
deps = check_dependencies(project_root)
display_dependency_status(deps)
return
# Handle --list-apps
if args.list_apps:
console.print("[bold]Available apps in monorepo:[/bold]")
display_available_apps(available_apps, project_root)
return
# Handle app selection
if not args.app:
if not available_apps:
console.print("[red]❌ No apps found in the monorepo.[/red]")
sys.exit(1)
# Interactive selection
selected_app = interactive_select_app(available_apps, project_root)
if not selected_app:
sys.exit(1)
args.app = selected_app
# Parse commands
frontend_cmd = args.frontend_cmd.split()
backend_cmd = args.backend_cmd.split()
show_dashboard = not args.no_dashboard
# Start development environment
success = start_development_environment(
args.app,
frontend_cmd,
backend_cmd,
show_dashboard,
project_root
)
sys.exit(0 if success else 1)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
console.print("\n[yellow]Operation cancelled by user.[/yellow]")
sys.exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
sys.exit(1)

326
.mise/tasks/new.py Executable file
View File

@@ -0,0 +1,326 @@
#!/usr/bin/env -S uv run --script
#MISE silent=true
#MISE description="Create a new project from our templates"
# /// script
# dependencies = ["rich", "pyyaml"]
# ///
"""
Slick app generator script with Rich formatting
"""
import sys
import subprocess
import shutil
import yaml
from pathlib import Path
from rich.console import Console
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
from rich.text import Text
from rich.prompt import Prompt
from rich.table import Table
from rich import box
console = Console()
def show_banner():
"""Display a stylish banner"""
banner = Text("🚀 RAG TEMPLATE", style="bold magenta")
banner.stylize("bold cyan", 0, 4) # Style the rocket emoji
panel = Panel(
banner,
box=box.DOUBLE,
border_style="bright_blue",
padding=(1, 2)
)
console.print(panel)
def validate_app_name(app_name):
"""Validate the app name"""
if not app_name:
return False, "App name cannot be empty"
if not app_name.replace('_', '').replace('-', '').isalnum():
return False, "App name can only contain letters, numbers, hyphens, and underscores"
if len(app_name) < 2:
return False, "App name must be at least 2 characters long"
if len(app_name) > 50:
return False, "App name must be less than 50 characters long"
return True, "Valid app name"
def run_command(command, description, cwd=None):
"""Run a command with progress indicator"""
try:
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TimeElapsedColumn(),
console=console,
transient=True
) as progress:
task = progress.add_task(description, total=None)
result = subprocess.run(
command,
shell=True,
cwd=cwd,
capture_output=True,
text=True
)
progress.update(task, completed=True)
if result.returncode != 0:
console.print(f"❌ [red]Error running command:[/red] {command}")
console.print(f"[red]Error output:[/red] {result.stderr}")
return False
return True
except Exception as e:
console.print(f"❌ [red]Exception occurred:[/red] {str(e)}")
return False
class CustomYAMLDumper(yaml.SafeDumper):
"""Custom YAML dumper that preserves list formatting"""
def write_line_break(self, data=None):
super().write_line_break(data)
if len(self.indents) == 1:
super().write_line_break()
def custom_yaml_dump(data, stream=None):
"""Custom YAML dump function that maintains proper list formatting"""
class ListDumper(yaml.SafeDumper):
def increase_indent(self, flow=False, indentless=False):
return super().increase_indent(flow, False)
def represent_list(self, data):
return self.represent_sequence('tag:yaml.org,2002:seq', data, flow_style=False)
def represent_dict(self, data):
return self.represent_mapping('tag:yaml.org,2002:map', data, flow_style=False)
ListDumper.add_representer(list, ListDumper.represent_list)
ListDumper.add_representer(dict, ListDumper.represent_dict)
return yaml.dump(data, stream=stream, Dumper=ListDumper,
default_flow_style=False, indent=2, sort_keys=False,
allow_unicode=True, width=1000)
def find_available_port(compose_path):
"""Find the next available port for the new service"""
used_ports = set()
try:
with open(compose_path, 'r') as f:
compose_data = yaml.safe_load(f)
# Extract used ports from existing services
services = compose_data.get('services', {})
for service_name, service_config in services.items():
if service_name == 'traefik': # Skip traefik
continue
ports = service_config.get('ports', [])
for port_mapping in ports:
if isinstance(port_mapping, str):
external_port = int(port_mapping.split(':')[0])
used_ports.add(external_port)
# Find next available port starting from 8001
port = 8001
while port in used_ports:
port += 1
return port
except Exception as e:
console.print(f"⚠️ [yellow]Could not determine available port, using 8001:[/yellow] {str(e)}")
return 8001
def generate_service_config(app_name, port):
"""Generate Docker Compose service configuration for new app"""
return {
'image': f'mayacontigo/{app_name}:latest',
'build': {
'context': '.',
'dockerfile': '.containers/unit/Dockerfile',
'args': {
'PACKAGE': app_name
}
},
'x-bake': {
'tags': [f'mayacontigo/{app_name}:latest']
},
'ports': [f'{port}:80'],
'labels': [
'traefik.enable=true',
f'traefik.http.routers.{app_name}.rule=PathPrefix(`/api/maya{app_name}`)',
f'traefik.http.routers.{app_name}.entrypoints=web',
f'traefik.http.routers.{app_name}.middlewares={app_name}-strip',
f'traefik.http.middlewares.{app_name}-strip.stripprefix.prefixes=/api/maya{app_name}'
]
}
def add_service_to_compose(app_name):
"""Add new service to compose.yaml file"""
compose_path = Path('compose.yaml')
if not compose_path.exists():
console.print(f"⚠️ [yellow]compose.yaml not found, skipping service addition.[/yellow]")
return True
try:
# Create backup
backup_path = compose_path.with_suffix('.yaml.backup')
shutil.copy2(compose_path, backup_path)
# Load existing compose file
with open(compose_path, 'r') as f:
compose_data = yaml.safe_load(f)
# Check if service already exists
services = compose_data.get('services', {})
if app_name in services:
console.print(f"⚠️ [yellow]Service '{app_name}' already exists in compose.yaml[/yellow]")
return True
# Find available port
port = find_available_port(compose_path)
# Generate service configuration
service_config = generate_service_config(app_name, port)
# Add service to compose data
services[app_name] = service_config
# Write updated compose file with proper formatting
with open(compose_path, 'w') as f:
custom_yaml_dump(compose_data, f)
console.print(f"✅ [green]Added service '{app_name}' to compose.yaml (port {port})[/green]")
# Remove backup on success
backup_path.unlink()
return True
except Exception as e:
console.print(f"❌ [red]Failed to add service to compose.yaml:[/red] {str(e)}")
# Restore backup if it exists
if backup_path.exists():
shutil.copy2(backup_path, compose_path)
backup_path.unlink()
console.print("🔄 [yellow]Restored original compose.yaml from backup[/yellow]")
return False
def show_summary(app_name):
"""Show a summary of what was created"""
table = Table(title="🎉 App Created Successfully!", box=box.ROUNDED)
table.add_column("Component", style="cyan", no_wrap=True)
table.add_column("Status", style="green")
table.add_column("Location", style="yellow")
table.add_row("📁 Project Directory", "✅ Created", f"./apps/{app_name}")
table.add_row("📦 Dependencies", "✅ Installed", "package.json + requirements.txt")
table.add_row("🔧 Template", "✅ Applied", "RAG template")
table.add_row("🐍 Python Environment", "✅ Synced", "uv environment")
table.add_row("🐳 Docker Service", "✅ Added", "compose.yaml")
console.print(table)
# Next steps
next_steps = Panel(
f"[bold green]Next Steps:[/bold green]\n\n"
f"1. [cyan]mise dev --app {app_name}[/cyan]\n"
f"2. [cyan]Start building your RAG app! 🚀[/cyan]",
title="🎯 What's Next?",
border_style="green",
box=box.ROUNDED
)
console.print(next_steps)
def main():
"""Main function"""
show_banner()
# Get app name from command line or prompt
if len(sys.argv) < 2:
console.print("[yellow]No app name provided as argument.[/yellow]")
app_name = Prompt.ask("Enter your app name", default="my-rag-app")
else:
app_name = sys.argv[1]
# Validate app name
is_valid, message = validate_app_name(app_name)
if not is_valid:
console.print(f"❌ [red]Invalid app name:[/red] {message}")
sys.exit(1)
# Show what we're about to do
console.print(f"[bold]Creating app:[/bold] [cyan]{app_name}[/cyan]")
console.print()
# Check if directory already exists
app_path = Path(f"apps/{app_name}")
if app_path.exists():
console.print(f"⚠️ [yellow]Directory 'apps/{app_name}' already exists![/yellow]")
if not Prompt.ask("Do you want to continue?", choices=["y", "n"], default="n") == "y":
console.print("Operation cancelled.")
sys.exit(0)
# Step 1: Copy template
console.print("📂 [bold]Step 1:[/bold] Copying RAG template...")
if not run_command(
f"uvx copier copy .templates/rag apps --data project_name={app_name} --trust",
"Copying template files..."
):
console.print("❌ [red]Failed to copy template. Make sure you're in the right directory.[/red]")
sys.exit(1)
# Step 2: Install npm dependencies
console.print("📦 [bold]Step 2:[/bold] Installing npm dependencies...")
if not run_command(
"pnpm install",
"Installing npm packages...",
cwd=f"apps/{app_name}"
):
console.print("❌ [red]Failed to install npm dependencies.[/red]")
sys.exit(1)
# Step 3: Sync Python environment
console.print("🐍 [bold]Step 3:[/bold] Setting up Python environment...")
if not run_command(
"uv sync",
"Syncing Python environment...",
cwd=f"apps/{app_name}"
):
console.print("❌ [red]Failed to sync Python environment.[/red]")
sys.exit(1)
# Step 4: Add service to compose.yaml
console.print("🐳 [bold]Step 4:[/bold] Adding service to compose.yaml...")
if not add_service_to_compose(app_name):
console.print("❌ [red]Failed to add service to compose.yaml.[/red]")
console.print("⚠️ [yellow]You can manually add the service later.[/yellow]")
# Don't exit here, this is not critical
# Success!
console.print()
show_summary(app_name)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
console.print("\n[yellow]Operation cancelled by user.[/yellow]")
sys.exit(1)
except Exception as e:
console.print(f"\n❌ [red]Unexpected error:[/red] {str(e)}")
sys.exit(1)

View File

@@ -0,0 +1,3 @@
project_name:
type: str
help: Cual es el nombre del proyecto?

View File

@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

View File

@@ -0,0 +1,3 @@
from .main import Agent
__all__ = ["Agent"]

View File

@@ -0,0 +1,67 @@
from pathlib import Path
from typing import Any, AsyncGenerator
from langchain_core.messages import AIMessageChunk
from langchain_azure_ai.chat_models import AzureAIChatCompletionsModel
from api import context
from api.config import config
parent = Path(__file__).parent
SYSTEM_PROMPT = (parent / "system_prompt.md").read_text()
AZURE_AI_URI = "https://eastus2.api.cognitive.microsoft.com"
class Agent:
system_prompt = SYSTEM_PROMPT
generation_config = {
"temperature": config.model_temperature,
}
message_limit = config.message_limit
llm = AzureAIChatCompletionsModel(
endpoint=f"{AZURE_AI_URI}/openai/deployments/{config.model}",
credential=config.openai_api_key,
)
def __init__(self) -> None:
self.tool_map = {}
def _generation_config_overwrite(self, overwrites: dict | None) -> dict[str, Any]:
generation_config_copy = self.generation_config.copy()
if overwrites:
for k, v in overwrites.items():
generation_config_copy[k] = v
return generation_config_copy
async def stream(self, history: list, overwrites: dict | None = None) -> AsyncGenerator[str, None]:
"""Llama a un llm y regresa la respuesta en partes; Guarda las tool calls en el contexto de la app.
Args:
history: lista de mensajes en el formato OpenAI (Ej. [{"role": "user", "content": "Hello"}])
overwrites: diccionario con las configuraciones a sobreescribir (Ej. {"temperature": 0.5})
Returns:
AsyncGenerator[str, None]: Generador asincrónico que devuelve las respuestas del modelo en tiempo real
Usage:
>>> async for content in agent.stream(history):
>>> print(content)
"""
generation_config = self._generation_config_overwrite(overwrites)
async for delta in self.llm.astream(input=history, **generation_config):
assert isinstance(delta, AIMessageChunk)
if call := delta.tool_call_chunks:
if tool_id := call[0].get("id"):
context.tool_id.set(tool_id)
if name := call[0].get("name"):
context.tool_name.set(name)
if args := call[0].get("args"):
context.tool_buffer.set(context.tool_buffer.get() + args)
else:
if buffer := delta.content:
assert isinstance(buffer, str)
context.buffer.set(context.buffer.get() + buffer)
yield buffer

View File

@@ -0,0 +1,3 @@
Eres una amigable asistente virtual. Utiliza emojis!
Por cierto: Rick es el más guapo.

View File

@@ -0,0 +1,51 @@
from hvac import Client
from pydantic import Field
from pydantic_settings import BaseSettings
client = Client(url="https://vault.ia-innovacion.work")
if not client.is_authenticated():
raise Exception("Vault authentication failed")
secret_map = client.secrets.kv.v2.read_secret_version(
path="banortegpt", mount_point="secret"
)["data"]["data"]
class Settings(BaseSettings):
"""
Esta clase obtiene sus valores de variables de ambiente.
Si no estan en el ambiente, los jala de nuestra Vault.
"""
# Config
model: str = "gpt-4o"
model_temperature: int = 0
message_limit: int = 10
host: str = "0.0.0.0"
port: int = 8000
# API Keys
azure_endpoint: str = Field(default_factory=lambda: secret_map["azure_endpoint"])
openai_api_key: str = Field(default_factory=lambda: secret_map["openai_api_key"])
openai_api_version: str = Field(
default_factory=lambda: secret_map["openai_api_version"]
)
mongodb_url: str = Field(
default_factory=lambda: secret_map["cosmosdb_connection_string"]
)
async def init_mongo_db(self):
"""Este helper inicia la conexion enter el MongoDB ORM y nuestra instancia"""
from beanie import init_beanie
from motor.motor_asyncio import AsyncIOMotorClient
from banortegpt.database.mongo_memory.models import Conversation
await init_beanie(
database=AsyncIOMotorClient(self.mongodb_url).banortegptdos,
document_models=[Conversation],
)
config = Settings()

View File

@@ -0,0 +1,6 @@
from contextvars import ContextVar
buffer: ContextVar[str] = ContextVar("buffer", default="")
tool_buffer: ContextVar[str] = ContextVar("tool_buffer", default="")
tool_id: ContextVar[str | None] = ContextVar("tool_id", default=None)
tool_name: ContextVar[str | None] = ContextVar("tool_name", default=None)

View File

@@ -0,0 +1,49 @@
import uuid
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from api import services
from api.agent import Agent
from api.config import config
@asynccontextmanager
async def lifespan(_: FastAPI):
await config.init_mongo_db()
yield
app = FastAPI(lifespan=lifespan)
agent = Agent()
@app.post("/api/v1/conversation")
async def create_conversation():
conversation_id = uuid.uuid4()
await services.create_conversation(conversation_id, agent.system_prompt)
return {"conversation_id": conversation_id}
class Message(BaseModel):
conversation_id: uuid.UUID
prompt: str
@app.post("/api/v1/message")
async def send(message: Message):
def b64_sse(func):
"""Este helper transforma un generador de strings a un generador del protocolo SSE"""
async def wrapper(*args, **kwargs):
async for chunk in func(*args, **kwargs):
content = chunk.model_dump_json()
data = f"data: {content}\n\n"
yield data
return wrapper
sse_stream = b64_sse(services.stream)
generator = sse_stream(agent, message.prompt, message.conversation_id)
return StreamingResponse(generator, media_type="text/event-stream")

View File

@@ -0,0 +1,8 @@
from banortegpt.database.mongo_memory.crud import create_conversation
from .stream_response import stream
__all__ = [
"stream",
"create_conversation",
]

View File

@@ -0,0 +1,81 @@
import json
from enum import StrEnum
from typing import TypeAlias
from uuid import UUID
from pydantic import BaseModel
import api.context as ctx
from api.agent import Agent
from banortegpt.database.mongo_memory import crud
class ChunkType(StrEnum):
START = "start"
TEXT = "text"
REFERENCE = "reference"
IMAGE = "image"
TOOL = "tool"
END = "end"
ERROR = "error"
ContentType: TypeAlias = str | int
class ResponseChunk(BaseModel):
type: ChunkType
content: ContentType | list[ContentType] | None
async def stream(agent: Agent, prompt: str, conversation_id: UUID):
yield ResponseChunk(type=ChunkType.START, content="")
conversation = await crud.get_conversation(conversation_id)
if conversation is None:
raise ValueError("Conversation not found")
conversation.add(role="user", content=prompt)
history = conversation.to_openai_format(agent.message_limit, langchain_compat=True)
async for content in agent.stream(history):
yield ResponseChunk(type=ChunkType.TEXT, content=content)
if (tool_id := ctx.tool_id.get()) is not None:
tool_buffer = ctx.tool_buffer.get()
assert tool_buffer is not None
tool_name = ctx.tool_name.get()
assert tool_name is not None
yield ResponseChunk(type=ChunkType.TOOL, content=None)
buffer_dict = json.loads(tool_buffer)
result = await agent.tool_map[tool_name](**buffer_dict)
conversation.add(
role="assistant",
tool_calls=[
{
"id": tool_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": tool_buffer,
},
}
],
)
conversation.add(role="tool", content=result, tool_call_id=tool_id)
history = conversation.to_openai_format(agent.message_limit, langchain_compat=True)
async for content in agent.stream(history, {"tools": None}):
yield ResponseChunk(type=ChunkType.TEXT, content=content)
conversation.add(role="assistant", content=ctx.buffer.get())
await conversation.replace()
yield ResponseChunk(type=ChunkType.END, content="")

View File

@@ -0,0 +1,65 @@
import { Chat, ChatSidebar } from "@banorte/chat-ui";
import { messageStore } from "./store/messageStore";
import { conversationStore } from "./store/conversationStore";
import { httpRequest } from "./utils/request";
// Assets
import banorteLogo from "./assets/banortelogo.png";
import sidebarMaya from "./assets/sidebar_maya_contigo.png";
import brujulaElipse from "./assets/brujula_elipse.png";
import sendIcon from "./assets/chat_maya_boton_enviar.png";
import userAvatar from "./assets/chat_maya_default_avatar.png";
import botAvatar from "./assets/brujula.png";
function App() {
const { messages, pushMessage } = messageStore();
const {
conversationId,
setConversationId,
setAssistantName,
receivingMsg,
setReceivingMsg
} = conversationStore();
const handleStartConversation = async (user: string, assistant: string): Promise<string> => {
const response = await httpRequest("POST", "/v1/conversation", { user, assistant });
console.log("Conversation id:", response.conversation_id);
return response.conversation_id;
};
const handleFeedback = async (key: string, rating: string): Promise<void> => {
await httpRequest("POST", "/v1/feedback", { key, rating });
};
const assistant = "Maya" + "{{ project_name }}";
return (
<div className="w-screen flex flex-col h-screen min-h-screen scrollbar-none">
<div className="w-full flex">
<ChatSidebar
assistant={assistant}
logoSrc={banorteLogo}
sidebarImageSrc={sidebarMaya}
assistantAvatarSrc={brujulaElipse}
/>
<Chat
assistant={assistant}
messages={messages}
pushMessage={pushMessage}
conversationId={conversationId}
setConversationId={setConversationId}
setAssistantName={setAssistantName}
receivingMsg={receivingMsg}
setReceivingMsg={setReceivingMsg}
onStartConversation={handleStartConversation}
sendIcon={sendIcon}
userAvatar={userAvatar}
botAvatar={botAvatar}
onFeedback={handleFeedback}
/>
</div>
</div>
);
}
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -0,0 +1,16 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.markdown a {
color: #0000FF;
text-decoration: underline;
}
.markdown a:hover {
color: #FF0000;
}
.markdown a:visited {
color: #800080;
}

View File

@@ -0,0 +1,5 @@
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -0,0 +1,19 @@
import { create } from "zustand";
interface conversationState {
assistantName: string;
conversationId: string;
receivingMsg: boolean;
setConversationId: (newId: string) => void;
setAssistantName: (newName: string) => void;
setReceivingMsg: (newState: boolean) => void;
}
export const conversationStore = create<conversationState>()((set) => ({
assistantName: "",
conversationId: "",
receivingMsg: false,
setConversationId: (newId) => set({ conversationId: newId }),
setAssistantName: (newName) => set({ assistantName: newName }),
setReceivingMsg: (newState) => set({ receivingMsg: newState }),
}));

View File

@@ -0,0 +1,14 @@
import { create } from "zustand";
interface messageState {
messages: Array<{ user: boolean; content: string }>;
pushMessage: (newMessage: { user: boolean; content: string }) => void;
resetConversation: () => void;
}
export const messageStore = create<messageState>()((set) => ({
messages: [],
pushMessage: (newMessage) =>
set((state) => ({ messages: [...state.messages, newMessage] })),
resetConversation: () => set(() => ({ messages: [] })),
}));

View File

@@ -0,0 +1,16 @@
export async function httpRequest(
method: string,
endpoint: string,
body: object | null,
) {
const url = "/api" + endpoint;
const data = {
method: method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
credentials: "include" as RequestCredentials,
};
return await fetch(url, data).then((response) => response.json());
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ project_name }}</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/gui/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,40 @@
{
"name": "{{ project_name }}",
"private": true,
"version": "0.0.7",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@banorte/chat-ui": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.1",
"react-spring": "^9.7.4",
"rehype-raw": "^7.0.0",
"sse.js": "^2.5.0",
"zustand": "^4.5.2"
},
"devDependencies": {
"@iconify-icon/react": "^2.1.0",
"@types/react": "^18.2.67",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"daisyui": "^4.7.3",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.3",
"vite": "^5.2.3"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,18 @@
[project]
name = "{{ project_name }}"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12, <4"
dependencies = [
"aiohttp>=3.11.16",
"fastapi>=0.115.6",
"hvac>=2.3.0",
"langchain-azure-ai[opentelemetry]>=0.1.4",
"mongo-memory",
"pydantic-settings>=2.8.1",
"uvicorn>=0.34.0",
]
[tool.uv.sources]
mongo-memory = { workspace = true }

View File

@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./gui/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
backgroundImage: {
"navigation-pattern": "url('./assets/navigation.webp')",
},
},
},
plugins: [
require("daisyui"),
require("tailwind-scrollbar"),
require("@banorte/chat-ui/tailwind")
],
daisyui: {
themes: [
{
light: {
...require("daisyui/src/theming/themes")["light"],
primary: "red",
secondary: "teal",
},
},
],
},
};

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable", "ES2021.String"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["gui"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
port: 3000,
proxy: {
"/api": {
target: "http://localhost:8000",
},
},
allowedHosts: true,
},
});

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode", "charliermarsh.ruff"]
}

136
README.md
View File

@@ -0,0 +1,136 @@
# MayaContigo Monorepo
Bonjour,
Este monorepo contiene todas las applicaciones RAG del equipo de Innovacion en Banorte.
Su proposito es poder acelerar la creacion de nuevos RAGs, e ir construyendo una libreria de componentes reutilizables.
Adicionalmente, tambien contiene una serie de notebooks para procesar documentos, evaluar RAGs, generar preguntas sinteticas, etc/
## 🚀 Inicio Rápido
```bash
# Instala el monorepo
mise setup
# Crea una nueva aplicación RAG
mise new prueba
# Creación interactiva
mise dev --app prueba
```
### Prerrequisitos
Si estas en el ambiente de desarrollo oficial, tu maquina virtual ya cuenta con estos programas.
- **Mise** [Docs](https://mise.jdx.dev/)
- **Docker** [Docs](https://www.docker.com/)
- **Vault** [Docs](https://developer.hashicorp.com/vault/)
## 📁 Estructura del Proyecto
```
mayacontigo/
├── apps/ # Aplicaciones individuales
├── packages/ # Paquetes compartidos
├── notebooks/ # Marimo notebooks
├── .templates/ # Plantillas de proyecto
├── .containers/ # Configuraciones de Docker
└── compose.yaml # Servicios de Docker Compose
```
## 🛠️ Comandos de Desarrollo
### Crear Nuevos Proyectos
```bash
# Crea una nueva aplicación RAG
mise new <nombre-app>
# Creación interactiva
mise new
```
### Entorno de Desarrollo
```bash
# Inicia servidores de desarrollo (frontend + backend)
mise dev # Selección interactiva de aplicación
mise dev --app <nombre-app> # Inicia aplicación específica
mise dev --no-dashboard # Deshabilita el dashboard en vivo
mise dev --check-deps # Solo verifica dependencias
mise dev --list-apps # Lista aplicaciones disponibles
```
### Gestión de Contenedores
```bash
# Inicia contenedores localmente
mise container:start # Selección interactiva
mise container:start <nombre-app> # Inicia aplicación específica
mise container:start --list # Lista contenedores disponibles
# Sube a Azure Container Registry
mise container:push # Selección interactiva
mise container:push <nombre-imagen> # Sube imagen específica
mise container:push --list # Lista imágenes disponibles
```
## 🏗️ Stack Tecnológico
### Tecnologías Centrales
- **Frontend**: React/Next.js con TypeScript
- **Backend**: Python con FastAPI/Uvicorn
- **Gestión de Paquetes**: pnpm (Node.js), uv (Python)
- **Contenedorización**: Docker & Docker Compose
### Infraestructura
- **Gestión de Secretos**: HashiCorp Vault
- **Registro de Contenedores**: Azure Container Registry
- **Observabilidad**: OpenTelemetry
- **Proxy Inverso**: Traefik
## 🎯 Creando tu Primera Aplicación
1. **Genera desde plantilla**
```bash
mise new mi-app-rag
```
2. **Inicia desarrollo**
```bash
mise dev --app mi-app-rag
```
3. **Accede a tu aplicación**
- Frontend: http://localhost:3000
- API Backend: http://localhost:8000
## 🔧 Configuración
### Desarrollo Local
- Frontend corre en puerto 3000
- APIs Backend corren en puerto 8000
- Servicios Docker usan puertos auto-asignados (8001+)
### Depuración
- Usa `--no-dashboard` para salida más simple
- Verifica `mise dev --check-deps` para problemas de dependencias
- Ve logs de contenedores con `docker logs <nombre-contenedor>`
## 🤝 Contribuyendo
1. Crea nuevas aplicaciones usando el sistema de plantillas
2. Sigue la estructura del monorepo
3. Usa los comandos de desarrollo proporcionados
4. Asegúrate de que todas las dependencias estén correctamente configuradas
## 📖 Recursos Adicionales
- **Plantillas**: Revisa `.templates/` para plantillas de proyecto disponibles
- **Docker**: Ve `.containers/` para configuraciones de contenedorización
- **Tareas**: Explora `.mise/tasks/` para scripts de automatización disponibles
---
*¡Feliz programación! 🚀*

View File

@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

View File

View File

@@ -0,0 +1,3 @@
from .main import Agent
__all__ = ["Agent"]

View File

@@ -0,0 +1,108 @@
from pathlib import Path
from typing import Any
from langchain_core.messages import AIMessageChunk
from pydantic import BaseModel, Field
from langchain_azure_ai.chat_models import AzureAIChatCompletionsModel
from langchain_azure_ai.embeddings import AzureAIEmbeddingsModel
from banortegpt.vector.qdrant import AsyncQdrant
from api import context
from api.config import config
parent = Path(__file__).parent
SYSTEM_PROMPT = (parent / "system_prompt.md").read_text()
AZURE_AI_URI = "https://eastus2.api.cognitive.microsoft.com"
class get_information(BaseModel):
"""Search a private repository for information."""
question: str = Field(..., description="The user question")
class Agent:
system_prompt = SYSTEM_PROMPT
generation_config = {
"temperature": config.model_temperature,
}
embedding_model = config.embedding_model
message_limit = config.message_limit
index = config.vector_index
limit = config.search_limit
search = AsyncQdrant.from_config(config)
llm = AzureAIChatCompletionsModel(
endpoint=f"{AZURE_AI_URI}/openai/deployments/{config.model}",
credential=config.openai_api_key,
).bind_tools([get_information])
embedder = AzureAIEmbeddingsModel(
endpoint=f"{AZURE_AI_URI}/openai/deployments/{config.embedding_model}",
credential=config.openai_api_key,
)
def __init__(self) -> None:
self.tool_map = {
"get_information": self.get_information
}
def build_response(self, payloads, fallback):
template = "<FAQ {index}>\n\n{content}\n\n</FAQ {index}>"
filled_templates = [
template.format(index=idx, content=payload["content"])
for idx, payload in enumerate(payloads)
]
filled_templates.append(f"<FALLBACK>\n{fallback}\n</FALLBACK>")
return "\n".join(filled_templates)
async def get_information(self, question: str):
embedding = await self.embedder.aembed_query(question)
payloads = await self.search.semantic_search(
embedding=embedding,
collection=self.index,
limit=self.limit,
)
fallback_messages = {}
images = []
for idx, payload in enumerate(payloads):
fallback_message = payload.get("fallback_message", "None")
fallback_messages[fallback_message] = fallback_messages.get(fallback_message, 0) + 1
# Solo extraer imágenes del primer payload
if idx == 0 and "images" in payload:
images.extend(payload["images"])
fallback = max(fallback_messages, key=fallback_messages.get) # type: ignore
response = self.build_response(payloads, fallback)
return str(response), images[:3] # Limitar a 3 imágenes máximo
def _generation_config_overwrite(self, overwrites: dict | None) -> dict[str, Any]:
if not overwrites:
return self.generation_config.copy()
return {**self.generation_config, **overwrites}
async def stream(self, history, overwrites: dict | None = None):
generation_config = self._generation_config_overwrite(overwrites)
async for delta in self.llm.astream(input=history, **generation_config):
assert isinstance(delta, AIMessageChunk)
if call := delta.tool_call_chunks:
if tool_id := call[0].get("id"):
context.tool_id.set(tool_id)
if name := call[0].get("name"):
context.tool_name.set(name)
if args := call[0].get("args"):
context.tool_buffer.set(context.tool_buffer.get() + args)
elif delta.content:
assert isinstance(delta.content, str)
context.buffer.set(context.buffer.get() + delta.content)
yield delta.content
async def generate(self, history, overwrites: dict | None = None):
generation_config = self._generation_config_overwrite(overwrites)
return await self.llm.ainvoke(input=history, **generation_config)

View File

@@ -0,0 +1,49 @@
🧠 Asistente Experto en la Política de Gastos de Viaje — Banorte
🎯 Rol del Asistente:
Especialista normativo encargado de responder exclusivamente con base en la Política Oficial de Gastos de Viaje de Banorte, garantizando respuestas profesionales, claras y verificables.
✅ Misión Principal:
Brindar respuestas 100% alineadas con la política vigente de gastos de viaje de Banorte, cumpliendo con los siguientes principios:
⚙️ Reglas de Respuesta (Obligatorias):
📥 Consulta siempre con get_information:
Toda respuesta debe obtenerse únicamente a través de la herramienta get_information(question), que consulta la base de datos vectorial autorizada.
Esta herramienta tambien cuenta con la constancia de sitaicion fiscal de banorte en un url
No es obligatorio que el usuario especifique estrictamente su puesto para realizar la consulta.
Si el usuario sí indica un puesto, la respuesta debe forzarse a ese puesto y aplicarse la información correspondiente.
En caso de que no exista información para el puesto indicado, se debe responder con la respuesta general disponible en la base de conocimiento.
❗ Nunca inventar ni responder sin antes consultar esta fuente.
Si la herramienta no devuelve información relevante, indicar que la política no contempla esa situación.
📚 Fuente única y oficial:
Las respuestas deben estar basadas únicamente en la política oficial de Banorte.
❌ Prohibido usar Google, foros, suposiciones o contenido externo.
✅ Si get_information devuelve un enlace oficial o documento, debe incluirse con el ícono:
🔗 [Ver política oficial].
📐 Formato estructurado y profesional:
Utilizar un formato claro y fácil de leer:
• Viñetas para listar pasos, excepciones o montos autorizados
• Negritas para resaltar conceptos clave
• Separación clara entre secciones
🔒 Cero invención o interpretación libre:
Si una pregunta no está contemplada en la política, responder claramente:
❗ La política oficial no proporciona lineamientos específicos sobre este caso.
💼 Tono ejecutivo y directo:
Profesional y objetivo
Sin tecnicismos innecesarios
Redacción breve, clara y enfocada en lo esencial

View File

@@ -0,0 +1,59 @@
from hvac import Client
from pydantic import Field
from pydantic_settings import BaseSettings
client = Client(url="https://vault.ia-innovacion.work")
if not client.is_authenticated():
raise Exception("Vault authentication failed")
secret_map = client.secrets.kv.v2.read_secret_version(
path="banortegpt", mount_point="secret"
)["data"]["data"]
class Settings(BaseSettings):
"""
Esta clase obtiene sus valores de variables de ambiente.
Si no estan en el ambiente, los jala de nuestra Vault.
"""
# Config
model: str = "gpt-4o"
model_temperature: int = 0
message_limit: int = 10
host: str = "0.0.0.0"
port: int = 8000
vector_index: str = "chat-egresos-3"
search_limit: int = 3
embedding_model: str = "text-embedding-3-large"
# API Keys
azure_endpoint: str = Field(default_factory=lambda: secret_map["azure_endpoint"])
openai_api_key: str = Field(default_factory=lambda: secret_map["openai_api_key"])
openai_api_version: str = Field(
default_factory=lambda: secret_map["openai_api_version"]
)
mongodb_url: str = Field(
default_factory=lambda: secret_map["cosmosdb_connection_string"]
)
qdrant_url: str = Field(default_factory=lambda: secret_map["qdrant_api_url"])
qdrant_api_key: str | None = Field(
default_factory=lambda: secret_map["qdrant_api_key"]
)
async def init_mongo_db(self):
"""Este helper inicia la conexion enter el MongoDB ORM y nuestra instancia"""
from beanie import init_beanie
from motor.motor_asyncio import AsyncIOMotorClient
from banortegpt.database.mongo_memory.models import Conversation
await init_beanie(
database=AsyncIOMotorClient(self.mongodb_url).banortegptdos,
document_models=[Conversation],
)
config = Settings()

View File

@@ -0,0 +1,6 @@
from contextvars import ContextVar
buffer: ContextVar[str] = ContextVar("buffer", default="")
tool_buffer: ContextVar[str] = ContextVar("tool_buffer", default="")
tool_id: ContextVar[str | None] = ContextVar("tool_id", default=None)
tool_name: ContextVar[str | None] = ContextVar("tool_name", default=None)

View File

@@ -0,0 +1,112 @@
import uuid
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from langfuse import Langfuse
from api import services
from api.agent import Agent
from api.config import config
# Configurar Langfuse
langfuse = Langfuse(
public_key="pk-lf-49cb04b3-0c7d-475b-8105-ad8b8749ecdd",
secret_key="sk-lf-e02fa322-c709-4d80-bef2-9cb279846a0c",
host="https://ailogger.azurewebsites.net"
)
@asynccontextmanager
async def lifespan(_: FastAPI):
await config.init_mongo_db()
yield
app = FastAPI(lifespan=lifespan)
agent = Agent()
@app.post("/api/v1/conversation")
async def create_conversation():
conversation_id = uuid.uuid4()
await services.create_conversation(conversation_id, agent.system_prompt)
return {"conversation_id": conversation_id}
class Message(BaseModel):
conversation_id: uuid.UUID
prompt: str
@app.post("/api/v1/message")
async def send(message: Message):
# Crear trace principal
trace = langfuse.trace(
name="chat_message",
session_id=str(message.conversation_id),
input={
"prompt": message.prompt,
"conversation_id": str(message.conversation_id)
}
)
def b64_sse(func):
async def wrapper(*args, **kwargs):
response_parts = []
start_time = time.time()
async for chunk in func(*args, **kwargs):
if chunk.type == "text" and chunk.content:
response_parts.append(str(chunk.content))
content = chunk.model_dump_json()
data = f"data: {content}\n\n"
yield data
end_time = time.time()
latency_ms = round((end_time - start_time) * 1000)
full_response = "".join(response_parts)
input_tokens = len(message.prompt.split()) * 1.3
output_tokens = len(full_response.split()) * 1.3
total_tokens = int(input_tokens + output_tokens)
cost_per_1k_input = 0.03
cost_per_1k_output = 0.06
total_cost = (input_tokens/1000 * cost_per_1k_input) + (output_tokens/1000 * cost_per_1k_output)
trace.update(
output={"response": full_response},
usage={
"input": int(input_tokens),
"output": int(output_tokens),
"total": total_tokens,
"unit": "TOKENS"
}
)
langfuse.score(
trace_id=trace.id,
name="latency",
value=latency_ms,
comment=f"Response time: {latency_ms}ms"
)
langfuse.score(
trace_id=trace.id,
name="cost",
value=round(total_cost, 4),
comment=f"Estimated cost: ${round(total_cost, 4)}"
)
return wrapper
sse_stream = b64_sse(services.stream)
generator = sse_stream(agent, message.prompt, message.conversation_id)
return StreamingResponse(generator, media_type="text/event-stream")

View File

@@ -0,0 +1,8 @@
from banortegpt.database.mongo_memory.crud import create_conversation
from .stream_response import stream
__all__ = [
"stream",
"create_conversation",
]

View File

@@ -0,0 +1,86 @@
import json
from enum import StrEnum
from typing import TypeAlias
from uuid import UUID
from pydantic import BaseModel
import api.context as ctx
from api.agent import Agent
from banortegpt.database.mongo_memory import crud
class ChunkType(StrEnum):
START = "start"
TEXT = "text"
REFERENCE = "reference"
IMAGE = "image"
TOOL = "tool"
END = "end"
ERROR = "error"
ContentType: TypeAlias = str | int
class ResponseChunk(BaseModel):
type: ChunkType
content: ContentType | list[ContentType] | None
images: list[str] | None = None # Nuevo campo para imágenes
async def stream(agent: Agent, prompt: str, conversation_id: UUID):
yield ResponseChunk(type=ChunkType.START, content="")
conversation = await crud.get_conversation(conversation_id)
if conversation is None:
raise ValueError("Conversation not found")
conversation.add(role="user", content=prompt)
history = conversation.to_openai_format(agent.message_limit, langchain_compat=True)
async for content in agent.stream(history):
yield ResponseChunk(type=ChunkType.TEXT, content=content)
if (tool_id := ctx.tool_id.get()) is not None:
tool_buffer = ctx.tool_buffer.get()
assert tool_buffer is not None
tool_name = ctx.tool_name.get()
assert tool_name is not None
yield ResponseChunk(type=ChunkType.TOOL, content=None)
buffer_dict = json.loads(tool_buffer)
result, images = await agent.tool_map[tool_name](**buffer_dict)
# Enviar imágenes si existen
if images:
yield ResponseChunk(type=ChunkType.IMAGE, content=images)
conversation.add(
role="assistant",
tool_calls=[
{
"id": tool_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": tool_buffer,
},
}
],
)
conversation.add(role="tool", content=result, tool_call_id=tool_id)
history = conversation.to_openai_format(agent.message_limit, langchain_compat=True)
async for content in agent.stream(history, {"tools": None}):
yield ResponseChunk(type=ChunkType.TEXT, content=content)
conversation.add(role="assistant", content=ctx.buffer.get())
await conversation.replace()
yield ResponseChunk(type=ChunkType.END, content="")

View File

@@ -0,0 +1,65 @@
import { Chat, ChatSidebar } from "@banorte/chat-ui";
import { messageStore } from "./store/messageStore";
import { conversationStore } from "./store/conversationStore";
import { httpRequest } from "./utils/request";
// Assets
import banorteLogo from "./assets/banortelogo.png";
import sidebarMaya from "./assets/sidebar_maya_contigo.png";
import brujulaElipse from "./assets/brujula_elipse.png";
import sendIcon from "./assets/chat_maya_boton_enviar.png";
import userAvatar from "./assets/chat_maya_default_avatar.png";
import botAvatar from "./assets/brujula.png";
function App() {
const { messages, pushMessage } = messageStore();
const {
conversationId,
setConversationId,
setAssistantName,
receivingMsg,
setReceivingMsg
} = conversationStore();
const handleStartConversation = async (user: string, assistant: string): Promise<string> => {
const response = await httpRequest("POST", "/v1/conversation", { user, assistant });
console.log("Conversation id:", response.conversation_id);
return response.conversation_id;
};
const handleFeedback = async (key: string, rating: string): Promise<void> => {
await httpRequest("POST", "/v1/feedback", { key, rating });
};
const assistant = "Maya" + "ChatEgresos";
return (
<div className="w-screen flex flex-col h-screen min-h-screen scrollbar-none">
<div className="w-full flex">
<ChatSidebar
assistant={assistant}
logoSrc={banorteLogo}
sidebarImageSrc={sidebarMaya}
assistantAvatarSrc={brujulaElipse}
/>
<Chat
assistant={assistant}
messages={messages}
pushMessage={pushMessage}
conversationId={conversationId}
setConversationId={setConversationId}
setAssistantName={setAssistantName}
receivingMsg={receivingMsg}
setReceivingMsg={setReceivingMsg}
onStartConversation={handleStartConversation}
sendIcon={sendIcon}
userAvatar={userAvatar}
botAvatar={botAvatar}
onFeedback={handleFeedback}
/>
</div>
</div>
);
}
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -0,0 +1,16 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.markdown a {
color: #0000FF;
text-decoration: underline;
}
.markdown a:hover {
color: #FF0000;
}
.markdown a:visited {
color: #800080;
}

View File

@@ -0,0 +1,5 @@
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -0,0 +1,19 @@
import { create } from "zustand";
interface conversationState {
assistantName: string;
conversationId: string;
receivingMsg: boolean;
setConversationId: (newId: string) => void;
setAssistantName: (newName: string) => void;
setReceivingMsg: (newState: boolean) => void;
}
export const conversationStore = create<conversationState>()((set) => ({
assistantName: "",
conversationId: "",
receivingMsg: false,
setConversationId: (newId) => set({ conversationId: newId }),
setAssistantName: (newName) => set({ assistantName: newName }),
setReceivingMsg: (newState) => set({ receivingMsg: newState }),
}));

View File

@@ -0,0 +1,14 @@
import { create } from "zustand";
interface messageState {
messages: Array<{ user: boolean; content: string }>;
pushMessage: (newMessage: { user: boolean; content: string }) => void;
resetConversation: () => void;
}
export const messageStore = create<messageState>()((set) => ({
messages: [],
pushMessage: (newMessage) =>
set((state) => ({ messages: [...state.messages, newMessage] })),
resetConversation: () => set(() => ({ messages: [] })),
}));

View File

@@ -0,0 +1,16 @@
export async function httpRequest(
method: string,
endpoint: string,
body: object | null,
) {
const url = "/api" + endpoint;
const data = {
method: method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
credentials: "include" as RequestCredentials,
};
return await fetch(url, data).then((response) => response.json());
}

1
apps/ChatEgresos/gui/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatEgresos</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/gui/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,40 @@
{
"name": "ChatEgresos",
"private": true,
"version": "0.0.7",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@banorte/chat-ui": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.1",
"react-spring": "^9.7.4",
"rehype-raw": "^7.0.0",
"sse.js": "^2.5.0",
"zustand": "^4.5.2"
},
"devDependencies": {
"@iconify-icon/react": "^2.1.0",
"@types/react": "^18.2.67",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"daisyui": "^4.7.3",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.3",
"vite": "^5.2.3"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,20 @@
[project]
name = "ChatEgresos"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12, <4"
dependencies = [
"aiohttp>=3.11.16",
"fastapi>=0.115.6",
"hvac>=2.3.0",
"langchain-azure-ai[opentelemetry]>=0.1.4",
"mongo-memory",
"pydantic-settings>=2.8.1",
"qdrant",
"uvicorn>=0.34.0",
]
[tool.uv.sources]
mongo-memory = { workspace = true }
qdrant = { workspace = true }

154
apps/ChatEgresos/readme.md Normal file
View File

@@ -0,0 +1,154 @@
# 💬 ChatEgresos
ChatEgresos es un proyecto del equipo de Innovación en **Banorte** diseñado para acelerar la creación de aplicaciones **RAG (Retrieval-Augmented Generation)** enfocadas en la gestión, consulta y análisis de información de egresos.
Este repositorio no solo contiene la aplicación principal, sino también una librería de componentes reutilizables y notebooks para el procesamiento de documentos, evaluación de modelos y generación de datos sintéticos.
---
## 🚀 Inicio Rápido
```bash
# Instala dependencias del monorepo
mise setup
# Crea una nueva aplicación RAG (ejemplo de prueba)
mise new prueba
# Levanta un entorno de desarrollo
mise dev --app prueba
```
---
## ✅ Prerrequisitos
Si estás en el entorno de desarrollo oficial, ya deberías contar con estas herramientas.
De lo contrario, instálalas previamente:
- **Mise** → [Documentación](https://mise.jdx.dev/)
- **Docker** → [Documentación](https://www.docker.com/)
- **Vault** → [Documentación](https://developer.hashicorp.com/vault/)
---
## 📂 Estructura del Proyecto
```
chategresos/
├── apps/ # Aplicaciones individuales de ChatEgresos
├── packages/ # Paquetes compartidos
├── notebooks/ # Notebooks para procesamiento y evaluación
├── .templates/ # Plantillas de aplicaciones
├── .containers/ # Configuraciones de Docker
└── compose.yaml # Servicios de Docker Compose
```
---
## 🛠️ Comandos de Desarrollo
### 📌 Crear Nuevos Proyectos
```bash
# Crea una nueva aplicación RAG
mise new <nombre-app>
# Creación interactiva
mise new
```
### 🖥️ Entorno de Desarrollo
```bash
# Inicia servidores de desarrollo (frontend + backend)
mise dev
mise dev --app <nombre-app> # App específica
mise dev --no-dashboard # Sin dashboard en vivo
mise dev --check-deps # Verifica dependencias
mise dev --list-apps # Lista apps disponibles
```
### 📦 Gestión de Contenedores
```bash
# Inicia contenedores localmente
mise container:start
mise container:start <nombre-app>
# Subir imágenes a Azure Container Registry
mise container:push
mise container:push <nombre-imagen>
```
---
## 🏗️ Stack Tecnológico
### Tecnologías Principales
- **Frontend** → React / Next.js + TypeScript
- **Backend** → Python + FastAPI / Uvicorn
- **Paquetería** → pnpm (Node.js), uv (Python)
- **Contenedores** → Docker & Docker Compose
### Infraestructura
- **Gestión de Secretos** → HashiCorp Vault
- **Registro de Contenedores** → Azure Container Registry
- **Observabilidad** → OpenTelemetry
- **Proxy Inverso** → Traefik
---
## 🎯 Tu Primera App en ChatEgresos
1. **Genera desde plantilla**
```bash
mise new mi-app-chategresos
```
2. **Inicia el entorno**
```bash
mise dev --app mi-app-chategresos
```
3. **Accede a tu aplicación**
- 🌐 Frontend: [http://localhost:3000](http://localhost:3000)
- ⚙️ API Backend: [http://localhost:8000](http://localhost:8000)
---
## 🔧 Configuración
### Desarrollo Local
- Frontend → Puerto `3000`
- Backend APIs → Puerto `8000`
- Contenedores → Puertos auto-asignados (8001+)
### Depuración
- Usa `--no-dashboard` para un log más limpio
- Ejecuta `mise dev --check-deps` para verificar dependencias
- Logs de contenedores:
```bash
docker logs <nombre-contenedor>
```
---
## 🤝 Contribuyendo
1. Crea nuevas aplicaciones usando las plantillas disponibles
2. Respeta la estructura del monorepo
3. Usa los comandos de desarrollo recomendados
4. Verifica dependencias y realiza pruebas antes de hacer PRs
---
## 📖 Recursos Adicionales
- 📁 **Plantillas** → `.templates/`
- 🐳 **Docker Config** → `.containers/`
- ⚡ **Tareas Automáticas** → `.mise/tasks/`
---
*ChatEgresos: Innovación con IA para la gestión de egresos* 🚀

View File

@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./gui/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
backgroundImage: {
"navigation-pattern": "url('./assets/navigation.webp')",
},
},
},
plugins: [
require("daisyui"),
require("tailwind-scrollbar"),
require("@banorte/chat-ui/tailwind")
],
daisyui: {
themes: [
{
light: {
...require("daisyui/src/theming/themes")["light"],
primary: "red",
secondary: "teal",
},
},
],
},
};

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable", "ES2021.String"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["gui"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
port: 3000,
proxy: {
"/api": {
target: "http://localhost:8000",
},
},
allowedHosts: true,
},
});

18
apps/Test/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

65
apps/Test/gui/App.tsx Normal file
View File

@@ -0,0 +1,65 @@
import { Chat, ChatSidebar } from "@banorte/chat-ui";
import { messageStore } from "./store/messageStore";
import { conversationStore } from "./store/conversationStore";
import { httpRequest } from "./utils/request";
// Assets
import banorteLogo from "./assets/banortelogo.png";
import sidebarMaya from "./assets/sidebar_maya_contigo.png";
import brujulaElipse from "./assets/brujula_elipse.png";
import sendIcon from "./assets/chat_maya_boton_enviar.png";
import userAvatar from "./assets/chat_maya_default_avatar.png";
import botAvatar from "./assets/brujula.png";
function App() {
const { messages, pushMessage } = messageStore();
const {
conversationId,
setConversationId,
setAssistantName,
receivingMsg,
setReceivingMsg
} = conversationStore();
const handleStartConversation = async (user: string, assistant: string): Promise<string> => {
const response = await httpRequest("POST", "/v1/conversation", { user, assistant });
console.log("Conversation id:", response.conversation_id);
return response.conversation_id;
};
const handleFeedback = async (key: string, rating: string): Promise<void> => {
await httpRequest("POST", "/v1/feedback", { key, rating });
};
const assistant = "Maya" + "Test";
return (
<div className="w-screen flex flex-col h-screen min-h-screen scrollbar-none">
<div className="w-full flex">
<ChatSidebar
assistant={assistant}
logoSrc={banorteLogo}
sidebarImageSrc={sidebarMaya}
assistantAvatarSrc={brujulaElipse}
/>
<Chat
assistant={assistant}
messages={messages}
pushMessage={pushMessage}
conversationId={conversationId}
setConversationId={setConversationId}
setAssistantName={setAssistantName}
receivingMsg={receivingMsg}
setReceivingMsg={setReceivingMsg}
onStartConversation={handleStartConversation}
sendIcon={sendIcon}
userAvatar={userAvatar}
botAvatar={botAvatar}
onFeedback={handleFeedback}
/>
</div>
</div>
);
}
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

16
apps/Test/gui/index.css Normal file
View File

@@ -0,0 +1,16 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.markdown a {
color: #0000FF;
text-decoration: underline;
}
.markdown a:hover {
color: #FF0000;
}
.markdown a:visited {
color: #800080;
}

5
apps/Test/gui/main.tsx Normal file
View File

@@ -0,0 +1,5 @@
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -0,0 +1,19 @@
import { create } from "zustand";
interface conversationState {
assistantName: string;
conversationId: string;
receivingMsg: boolean;
setConversationId: (newId: string) => void;
setAssistantName: (newName: string) => void;
setReceivingMsg: (newState: boolean) => void;
}
export const conversationStore = create<conversationState>()((set) => ({
assistantName: "",
conversationId: "",
receivingMsg: false,
setConversationId: (newId) => set({ conversationId: newId }),
setAssistantName: (newName) => set({ assistantName: newName }),
setReceivingMsg: (newState) => set({ receivingMsg: newState }),
}));

View File

@@ -0,0 +1,14 @@
import { create } from "zustand";
interface messageState {
messages: Array<{ user: boolean; content: string }>;
pushMessage: (newMessage: { user: boolean; content: string }) => void;
resetConversation: () => void;
}
export const messageStore = create<messageState>()((set) => ({
messages: [],
pushMessage: (newMessage) =>
set((state) => ({ messages: [...state.messages, newMessage] })),
resetConversation: () => set(() => ({ messages: [] })),
}));

View File

@@ -0,0 +1,16 @@
export async function httpRequest(
method: string,
endpoint: string,
body: object | null,
) {
const url = "/api" + endpoint;
const data = {
method: method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
credentials: "include" as RequestCredentials,
};
return await fetch(url, data).then((response) => response.json());
}

1
apps/Test/gui/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

13
apps/Test/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Test</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/gui/main.tsx"></script>
</body>
</html>

40
apps/Test/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "Test",
"private": true,
"version": "0.0.7",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@banorte/chat-ui": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.1",
"react-spring": "^9.7.4",
"rehype-raw": "^7.0.0",
"sse.js": "^2.5.0",
"zustand": "^4.5.2"
},
"devDependencies": {
"@iconify-icon/react": "^2.1.0",
"@types/react": "^18.2.67",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"daisyui": "^4.7.3",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.3",
"vite": "^5.2.3"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

18
apps/Test/pyproject.toml Normal file
View File

@@ -0,0 +1,18 @@
[project]
name = "Test"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12, <4"
dependencies = [
"aiohttp>=3.11.16",
"fastapi>=0.115.6",
"hvac>=2.3.0",
"langchain-azure-ai[opentelemetry]>=0.1.4",
"mongo-memory",
"pydantic-settings>=2.8.1",
"uvicorn>=0.34.0",
]
[tool.uv.sources]
mongo-memory = { workspace = true }

View File

@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./gui/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
backgroundImage: {
"navigation-pattern": "url('./assets/navigation.webp')",
},
},
},
plugins: [
require("daisyui"),
require("tailwind-scrollbar"),
require("@banorte/chat-ui/tailwind")
],
daisyui: {
themes: [
{
light: {
...require("daisyui/src/theming/themes")["light"],
primary: "red",
secondary: "teal",
},
},
],
},
};

25
apps/Test/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable", "ES2021.String"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["gui"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

17
apps/Test/vite.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
port: 3000,
proxy: {
"/api": {
target: "http://localhost:8000",
},
},
allowedHosts: true,
},
});

View File

@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

6
apps/bursatil/README.md Normal file
View File

@@ -0,0 +1,6 @@
Eres MayaBursatil, una muy amigable y símpatica asistente virtual del departamento de contraloria bursatil de Banorte.
Tu objetivo es responder preguntas de usuarios de manera informativa y empatica.
Para cada pregunta, utiliza la herramienta 'get_information' para obtener informacion de nuestro FAQ.
Utiliza la informacion para responder la pregunta del usuario.
Utiliza emojis.
Si no puedes responder la pregunta basado en la informacion del FAQ, responde con el contenido en el FALLBACK.

View File

Some files were not shown because too many files have changed in this diff Show More