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

View File

@@ -0,0 +1,4 @@
from .blocking import Ada
from .nonblocking import AsyncAda
__all__ = ["Ada", "AsyncAda"]

View File

@@ -0,0 +1,51 @@
from typing import Protocol
class BaseAda:
def __init__(
self, model: str | None = None, *, endpoint: str, key: str, version: str
) -> None:
self.model = model
class Config(Protocol):
embedding_model: str
azure_endpoint: str
openai_api_key: str
openai_api_version: str
@classmethod
def from_config(cls, c: Config):
return cls(
model=c.embedding_model,
endpoint=c.azure_endpoint,
key=c.openai_api_key,
version=c.openai_api_version,
)
@classmethod
def from_vault(
cls,
vault: str,
*,
model: str | None = None,
url: str | None = None,
token: str | None = None,
mount_point: str = "secret",
):
from hvac import Client
client = Client(url=url or "https://vault.ia-innovacion.work", token=token)
if not client.is_authenticated():
raise Exception("Vault authentication failed")
secret_map = client.secrets.kv.v2.read_secret_version(
path=vault, mount_point=mount_point
)["data"]["data"]
return cls(
model=model,
endpoint=secret_map["azure_endpoint"],
key=secret_map["openai_api_key"],
version=secret_map["openai_api_version"],
)

View File

@@ -0,0 +1,47 @@
from langfuse.openai import AzureOpenAI
from openai.types.embedding import Embedding
from .base import BaseAda
class Ada(BaseAda):
def __init__(
self, model: str | None = None, *, endpoint: str, key: str, version: str
) -> None:
super().__init__(model, endpoint=endpoint, key=key, version=version)
self.client = AzureOpenAI(
azure_endpoint=endpoint, api_key=key, api_version=version
)
def embed(
self, input: str | list[str], *, model: str | None = None
) -> list[float] | list[list[float]]:
if isinstance(input, str):
return self.embed_query(input, model)
else:
return self.batch_embed(input, model)
def batch_embed(
self, texts: list[str], model: str | None = None
) -> list[list[float]]:
if model is None:
if self.model is None:
raise ValueError("No embedding model set")
model = self.model
batches = [texts[i : i + 2048] for i in range(0, len(texts), 2048)]
results = [
(self.client.embeddings.create(input=batch, model=model)).data
for batch in batches
]
flattened_results: list[Embedding] = sum(results, [])
return [result.embedding for result in flattened_results]
def embed_query(self, text: str, model: str | None = None) -> list[float]:
if model is None:
if self.model is None:
raise ValueError("No embedding model set")
model = self.model
response = self.client.embeddings.create(input=text, model=model)
return response.data[0].embedding

View File

@@ -0,0 +1,47 @@
from langfuse.openai import AsyncAzureOpenAI
from openai.types.embedding import Embedding
from .base import BaseAda
class AsyncAda(BaseAda):
def __init__(
self, model: str | None = None, *, endpoint: str, key: str, version: str
) -> None:
super().__init__(model, endpoint=endpoint, key=key, version=version)
self.client = AsyncAzureOpenAI(
azure_endpoint=endpoint, api_key=key, api_version=version
)
async def embed(
self, input: str | list[str], *, model: str | None = None
) -> list[float] | list[list[float]]:
if isinstance(input, str):
return await self.embed_query(input, model)
else:
return await self.batch_embed(input, model)
async def batch_embed(
self, texts: list[str], model: str | None = None
) -> list[list[float]]:
if model is None:
if self.model is None:
raise ValueError("No embedding model set")
model = self.model
batches = [texts[i : i + 2048] for i in range(0, len(texts), 2048)]
results = [
(await self.client.embeddings.create(input=batch, model=model)).data
for batch in batches
]
flattened_results: list[Embedding] = sum(results, [])
return [result.embedding for result in flattened_results]
async def embed_query(self, text: str, model: str | None = None) -> list[float]:
if model is None:
if self.model is None:
raise ValueError("No embedding model set")
model = self.model
response = await self.client.embeddings.create(input=text, model=model)
return response.data[0].embedding

View File

@@ -0,0 +1,15 @@
[project]
name = "azure-ada"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [{ name = "ajac-zero", email = "ajcardoza2000@gmail.com" }]
requires-python = ">=3.12"
dependencies = ["hvac","openai>=1.72.0"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["banortegpt"]

View File

View File

@@ -0,0 +1,61 @@
from typing import Protocol, cast
from openai import AsyncAzureOpenAI
from openai.types.chat import ChatCompletion
class AsyncGPT:
def __init__(self, azure_endpoint: str, api_key: str, api_version: str) -> None:
self.client = AsyncAzureOpenAI(
azure_endpoint=azure_endpoint, api_key=api_key, api_version=api_version
)
async def generate(self, messages, model, **kwargs):
response = await self.client.chat.completions.create(
messages=messages, model=model, **kwargs
)
response = cast(ChatCompletion, response)
return response.choices[0].message
async def stream(self, messages, model, **kwargs):
response = await self.client.chat.completions.create(
messages=messages, model=model, stream=True, **kwargs
)
async for chunk in response:
if choices := chunk.choices:
yield choices[0].delta
@staticmethod
def build_tool_call(tool_id: str, tool_name: str, tool_buffer: str):
tool_call = {
"tool_calls": [
{
"id": tool_id,
"function": {
"name": tool_name,
"arguments": tool_buffer,
},
"type": "function",
}
]
}
return tool_call
@staticmethod
def build_tool_call_id(tool_id: str):
return {"tool_call_id": tool_id}
class Config(Protocol):
azure_endpoint: str
openai_api_key: str
openai_api_version: str
@classmethod
def from_config(cls, config: Config):
return cls(
config.azure_endpoint, config.openai_api_key, config.openai_api_version
)

View File

@@ -0,0 +1,19 @@
[project]
name = "azure-gpt"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [{ name = "ajac-zero", email = "ajcardoza2000@gmail.com" }]
requires-python = ">=3.12"
dependencies = [ "openai>=1.72.0", "pydantic>=2.10.4"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["banortegpt"]
[tool.pyright]
venvPath = "../../."
venv = ".venv"

View File

View File

@@ -0,0 +1,54 @@
from datetime import UTC, datetime, timedelta
from typing import Protocol
from azure.storage.blob import BlobSasPermissions, generate_blob_sas
from azure.storage.blob.aio import BlobServiceClient
class AzureStorage:
def __init__(self, connection_string: str | None):
if connection_string:
self.client = BlobServiceClient.from_connection_string(connection_string)
def _generate_sas_token(self, filename: str, bucket: str, minute_duration: int):
expiry_time = datetime.now(UTC) + timedelta(minutes=minute_duration)
token = generate_blob_sas(
account_name=self.client.account_name, # type: ignore
container_name=bucket,
blob_name=filename,
account_key=self.client.credential.account_key,
permission=BlobSasPermissions(read=True),
expiry=expiry_time,
)
return token
async def get_file_url(
self, filename: str, bucket: str, minute_duration: int, image: bool
) -> str | None:
if not hasattr(self, "client"):
return None
blob_client = self.client.get_blob_client(container=bucket, blob=filename)
exists = await blob_client.exists()
if exists:
sas_token = self._generate_sas_token(filename, bucket, minute_duration)
return f"{blob_client.url}?{sas_token}"
else:
return None
async def get_blob_bytes(self, bucket: str, filename: str):
if not hasattr(self, "client"):
raise ValueError("No connection string provided to AzureStorage object.")
blob_client = self.client.get_blob_client(container=bucket, blob=filename)
return (await blob_client.download_blob()).readall()
class Config(Protocol):
azure_blob_connection_string: str
@classmethod
def from_config(cls, c: Config):
return cls(c.azure_blob_connection_string)

View File

@@ -0,0 +1,15 @@
[project]
name = "azure-storage"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [{ name = "ajac-zero", email = "ajcardoza2000@gmail.com" }]
requires-python = ">=3.12"
dependencies = ["azure-storage-blob>=12.25.1"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["banortegpt"]

208
packages/chat-ui/README.md Normal file
View File

@@ -0,0 +1,208 @@
# @banorte/chat-ui
A decoupled React chat UI component library with Tailwind CSS styling.
## Installation
```bash
npm install @banorte/chat-ui
```
## Setup
### 1. Add the Tailwind Plugin
To ensure all the necessary CSS classes are included, add the chat-ui Tailwind plugin to your `tailwind.config.js`:
```javascript
module.exports = {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
// ... your other content paths
],
theme: {
extend: {},
},
plugins: [
require("daisyui"),
require("@banorte/chat-ui/tailwind"), // Add this line
],
// ... rest of your config
}
```
### 2. Install Required Dependencies
Make sure you have the following peer dependencies installed:
```bash
npm install react react-dom @iconify-icon/react
```
## Components
### Chat
The main chat interface component.
```tsx
import { Chat } from "@banorte/chat-ui";
function App() {
const [messages, setMessages] = useState([]);
const [conversationId, setConversationId] = useState("");
const [receivingMsg, setReceivingMsg] = useState(false);
const pushMessage = (message) => {
setMessages(prev => [...prev, message]);
};
const handleStartConversation = async (user, assistant) => {
const response = await fetch("/api/v1/conversation", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user, assistant }),
});
const data = await response.json();
return data.conversation_id;
};
const handleFeedback = async (key, rating) => {
await fetch("/api/v1/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, rating }),
});
};
return (
<Chat
assistant="Maya"
messages={messages}
pushMessage={pushMessage}
conversationId={conversationId}
setConversationId={setConversationId}
setAssistantName={(name) => console.log("Assistant:", name)}
receivingMsg={receivingMsg}
setReceivingMsg={setReceivingMsg}
onStartConversation={handleStartConversation}
sendIcon="/path/to/send-icon.png"
userAvatar="/path/to/user-avatar.png"
botAvatar="/path/to/bot-avatar.png"
onFeedback={handleFeedback} // Optional
/>
);
}
```
### ChatSidebar
A sidebar component for the chat interface.
```tsx
import { ChatSidebar } from "@banorte/chat-ui";
function App() {
return (
<ChatSidebar
assistant="Maya"
logoSrc="/path/to/logo.png"
sidebarImageSrc="/path/to/sidebar-image.png"
assistantAvatarSrc="/path/to/assistant-avatar.png"
/>
);
}
```
### FeedbackButton
A standalone feedback component.
```tsx
import { FeedbackButton } from "@banorte/chat-ui";
function MessageComponent() {
const handleFeedback = async (key, rating) => {
// Handle feedback submission
console.log("Feedback:", key, rating);
};
return (
<div>
<p>Some message content...</p>
<FeedbackButton
messageKey="message-123"
onFeedback={handleFeedback}
/>
</div>
);
}
```
## Props
### Chat Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `assistant` | `string` | Yes | Name of the assistant |
| `messages` | `Message[]` | Yes | Array of chat messages |
| `pushMessage` | `(message: Message) => void` | Yes | Function to add new messages |
| `conversationId` | `string` | Yes | Current conversation ID |
| `setConversationId` | `(id: string) => void` | Yes | Function to set conversation ID |
| `setAssistantName` | `(name: string) => void` | Yes | Function to set assistant name |
| `receivingMsg` | `boolean` | Yes | Whether currently receiving a message |
| `setReceivingMsg` | `(receiving: boolean) => void` | Yes | Function to update receiving state |
| `onStartConversation` | `(user: string, assistant: string) => Promise<string>` | Yes | Function to start a new conversation |
| `sendIcon` | `string` | Yes | Image source for send button |
| `userAvatar` | `string` | Yes | User avatar image source |
| `botAvatar` | `string` | Yes | Bot avatar image source |
| `onFeedback` | `(key: string, rating: string) => Promise<void>` | No | Optional feedback handler |
### ChatSidebar Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `assistant` | `string` | Yes | Name of the assistant |
| `logoSrc` | `string` | Yes | Logo image source |
| `sidebarImageSrc` | `string` | Yes | Sidebar image source |
| `assistantAvatarSrc` | `string` | Yes | Assistant avatar image source |
### FeedbackButton Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `messageKey` | `string` | Yes | Unique identifier for the message |
| `onFeedback` | `(key: string, rating: string) => Promise<void>` | Yes | Callback function for feedback submission |
## Message Type
```typescript
interface Message {
user: boolean;
content: string;
}
```
## Features
- **Decoupled Architecture**: Components accept all dependencies as props
- **TypeScript Support**: Full TypeScript definitions included
- **Tailwind CSS**: Styled with Tailwind CSS classes
- **DaisyUI Integration**: Uses DaisyUI components for consistent styling
- **Responsive Design**: Mobile-friendly responsive layout
- **Image Support**: Built-in image viewer for AI-generated images
- **Feedback System**: Optional feedback collection for messages
- **Streaming Support**: Real-time message streaming via SSE
- **Markdown Support**: Rich text rendering with markdown support
## Requirements
- React 18+
- Tailwind CSS 3+
- DaisyUI plugin for Tailwind CSS
- Node.js 16+
## License
Private package - All rights reserved.

View File

@@ -0,0 +1,39 @@
{
"name": "@banorte/chat-ui",
"version": "1.0.0",
"private": true,
"main": "dist/index.mjs",
"types": "dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
},
"./tailwind": "./src/tailwind-plugin.js"
},
"scripts": {
"build": "tsup"
},
"dependencies": {
"react": "^18.2.0",
"react-markdown": "^9.0.1",
"react-spring": "^9.7.4",
"rehype-raw": "^7.0.0",
"sse.js": "^2.5.0"
},
"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",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.1",
"tsup": "^8.5.0",
"typescript": "^5.4.3"
}
}

View File

@@ -0,0 +1,123 @@
import { FormEvent, useState, useEffect } from "react";
import { ChatMessage } from "./ChatMessage";
import { useRef } from "react";
export { Chat };
interface Message {
user: boolean;
content: string;
}
interface ChatProps {
assistant: string;
messages: Message[];
pushMessage: (message: Message) => void;
conversationId: string;
setConversationId: (id: string) => void;
setAssistantName: (name: string) => void;
receivingMsg: boolean;
setReceivingMsg: (receiving: boolean) => void;
onStartConversation: (user: string, assistant: string) => Promise<string>;
sendIcon: string;
userAvatar: string;
botAvatar: string;
onFeedback?: (key: string, rating: string) => Promise<void>;
}
function Chat({
assistant,
messages,
pushMessage,
conversationId,
setConversationId,
setAssistantName,
receivingMsg,
setReceivingMsg,
onStartConversation,
sendIcon,
userAvatar,
botAvatar,
onFeedback
}: ChatProps) {
const [input, setInput] = useState("");
const bottomRef = useRef(null);
async function startConversation() {
const newId = await onStartConversation("user", assistant);
setConversationId(newId);
}
useEffect(() => {
setAssistantName(assistant);
startConversation();
}, []);
function changeInput(e: FormEvent<HTMLInputElement>) {
e.preventDefault();
setInput(e.currentTarget.value);
}
async function clearInput(e: FormEvent) {
e.preventDefault();
// Validar que el input no esté vacío
const trimmedInput = input.trim();
if (!trimmedInput) {
return;
}
pushMessage({ user: true, content: trimmedInput });
setInput("");
pushMessage({ user: false, content: trimmedInput });
}
function scrollToBottom() {
// @ts-expect-error idk
bottomRef.current.scrollIntoView({ behavior: "smooth" });
}
return (
<div className="flex flex-1 flex-col items-center bg-slate-100 h-screen">
<div className="mt-5 w-3/5 flex-1 overflow-y-auto scrollbar min-h-0">
{messages.map((message, index) => (
<ChatMessage
key={index}
isUser={message.user}
content={message.content}
event={scrollToBottom}
conversationId={conversationId}
setReceivingMsg={setReceivingMsg}
userAvatar={userAvatar}
botAvatar={botAvatar}
onFeedback={onFeedback}
/>
))}
<div ref={bottomRef}></div>
</div>
<form
className="flex-shrink-0 ml-5 my-5 flex w-3/4 items-center justify-center mr-5"
onSubmit={clearInput}
>
<input
autoFocus
type="text"
value={input}
onChange={changeInput}
disabled={receivingMsg}
placeholder="¡Pregúntame algo!"
className="input input-bordered focus:input-primary w-[90%] p-7 rounded-3xl"
/>
<button
type="submit"
className={`btn-error ml-4 hover:border-red-200 hover:opacity-80 ${
!input.trim() ? "opacity-50" : ""
}`}
disabled={receivingMsg || !input.trim()}
>
<img src={sendIcon} alt="Send" className="h-14 w-14" />
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,393 @@
import { useState, useEffect } from "react";
import { FeedbackButton } from "./FeedbackButton";
import { PDFModal } from "./PDFModal";
import Markdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import { SSE } from "sse.js";
export { ChatMessage };
interface ChatMessageProps {
isUser: boolean;
content: string;
event: CallableFunction;
conversationId: string;
setReceivingMsg: (receiving: boolean) => void;
userAvatar: string;
botAvatar: string;
onFeedback?: (key: string, rating: string) => Promise<void>;
}
function ChatMessage({
isUser,
content,
event,
conversationId,
setReceivingMsg,
userAvatar,
botAvatar,
onFeedback
}: ChatMessageProps) {
const [buff, setBuff] = useState("");
const [responseId, setResponseId] = useState("");
const [loading, setLoading] = useState(false);
const [images, setImages] = useState<string[]>([]);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [acceptFeedback, setAcceptFeedback] = useState(false);
const [streamIndex, setStreamIndex] = useState(0);
const [fullResponse, setFullResponse] = useState("");
const [pendingReferences, setPendingReferences] = useState<Array<any>>([]);
const [streamingComplete, setStreamingComplete] = useState(false);
const [pdfModal, setPdfModal] = useState({
isOpen: false,
filename: '',
page: undefined as number | undefined
});
const closePdfModal = () => {
setPdfModal({
isOpen: false,
filename: '',
page: undefined
});
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && pdfModal.isOpen) {
closePdfModal();
}
};
if (pdfModal.isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [pdfModal.isOpen]);
const nextImage = () => {
if (currentImageIndex < images.length - 1) {
setCurrentImageIndex((prev) => prev + 1);
}
};
const prevImage = () => {
if (currentImageIndex > 0) {
setCurrentImageIndex((prev) => prev - 1);
}
};
useEffect(() => {
if (fullResponse && streamIndex < fullResponse.length) {
setLoading(false);
const timer = setTimeout(() => {
setBuff((prev) => prev + fullResponse[streamIndex]);
setStreamIndex((prev) => prev + 1);
event();
}, 3);
return () => clearTimeout(timer);
} else if (fullResponse && streamIndex === fullResponse.length) {
setReceivingMsg(false);
setStreamingComplete(true);
setBuff(fullResponse);
}
}, [fullResponse, streamIndex]);
async function getStream() {
const payload = JSON.stringify({
prompt: content,
conversation_id: conversationId,
});
const url = "/api/v1/message?stream=True";
const eventSource = new SSE(url, {
withCredentials: true,
headers: { "Content-Type": "application/json" },
payload: payload,
});
eventSource.onmessage = async (event) => {
console.log(event.data);
const ResponseChunk = JSON.parse(event.data);
if (ResponseChunk["type"] === "text") {
const content = ResponseChunk["content"];
setFullResponse((prev) => prev + content);
} else if (ResponseChunk["type"] === "reference") {
setPendingReferences(ResponseChunk["content"]);
} else if (ResponseChunk["type"] === "end") {
setResponseId(ResponseChunk["content"]);
eventSource.close();
} else if (ResponseChunk["type"] === "image") {
const newImages = ResponseChunk.content.slice(0, 3);
setImages((prev) => {
const combinedImages = [...prev, ...newImages];
return combinedImages.slice(0, 3);
});
} else if (ResponseChunk["type"] == "tool") {
setAcceptFeedback(true);
} else if (ResponseChunk["type"] === "error") {
setFullResponse((prev) => prev + "\n\n" + ResponseChunk["content"]);
eventSource.close();
}
};
eventSource.onerror = async (e) => {
console.log("error" + e);
setReceivingMsg(false);
setLoading(false);
eventSource.close();
};
}
useEffect(() => {
if (!isUser) {
setLoading(true);
setReceivingMsg(true);
getStream();
} else {
setBuff(content);
event();
}
}, []);
const Metadata = ({ metadatas }: { metadatas: any[] }) => {
const [isExpanded, setIsExpanded] = useState(false);
if (!metadatas || metadatas.length === 0) return null;
// Mapeo de archivos a URLs públicas (mismo que en el backend)
const PDF_PUBLIC_URLS: { [key: string]: string } = {
"Disposiciones de carácter general aplicables a las casas de bolsa.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20las%20casas%20de%20bolsa.pdf",
"Disposiciones de carácter general aplicables a las instituciones de crédito.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20las%20instituciones%20de%20cr%C3%A9dito.pdf",
"Disposiciones de carácter general aplicables a las sociedades controladoras de grupos financieros y subcontroladoras que regulan las materias que corresponden de manera conjunta a las Comisio.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20las%20sociedades%20controladoras%20de%20grupos%20financieros%20y%20subcontroladoras%20que%20regulan%20las%20materias%20que%20corresponden%20de%20manera%20conjunta%20a%20las%20Comisiones%20Nacionales%20Supervisoras.pdf",
"Disposiciones de carácter general aplicables a los fondos de inversión y a las personas que les prestan servicios.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20los%20fondos%20de%20inversi%C3%B3n%20y%20a%20las%20personas%20que%20les%20prestan%20servicios.pdf",
"Ley para la Transparencia y Ordenamiento de los Servicios Financieros.pdf": "https://www.cnbv.gob.mx/Normatividad/Ley%20para%20la%20Transparencia%20y%20Ordenamiento%20de%20los%20Servicios%20Financieros.pdf",
"circular_servicios_de_inversion.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20las%20entidades%20financieras%20y%20dem%C3%A1s%20personas%20que%20proporcionen%20servicios%20de.pdf",
"circular_unica_de_auditores_externos.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20que%20establecen%20los%20requisitos%20que%20deber%C3%A1n%20cumplir%20los%20auditores%20y%20otros%20profesionales%20que.pdf",
"ley_de_instituciones_de_Credito.pdf": "https://www.cnbv.gob.mx/Normatividad/Ley%20de%20Instituciones%20de%20Cr%C3%A9dito.pdf",
"circular_13_2007.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-13-2007/cobro-intereses-por-adelantad.html",
"circular_13_2011.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-13-2011/%7BBA4CBC28-A468-16C9-6F17-9EA9D7B03318%7D.pdf",
"circular_14_2007.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-14-2007/%7BFB726B6B-D523-56F5-F9B1-BE5B3B95A504%7D.pdf",
"circular_17_2014.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-17-2014/%7BF36CEF03-9441-2DBE-082C-0DF274903782%7D.pdf",
"circular_1_2005.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-1-2005/%7B5CA4BA75-FEA8-199C-F129-E8E6A73E84F3%7D.pdf",
"circular_21_2009.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-21-2009/%7B29285862-EDE0-567A-BAFB-D261406641A3%7D.pdf",
"circular_22_2008.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-22-2008/%7BF15C8A26-C92E-BE2B-9344-51EDAA3C9B68%7D.pdf",
"circular_22_2010.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-22-2010/%7B0D531F59-1001-4D67-D7B4-D5854DD07A58%7D.pdf",
"circular_27_2008.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-27-2008/%7BBC4333FE-070F-E727-199E-CA6BCF2CBA66%7D.pdf",
"circular_34_2010.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-34-2010/%7B0C55B906-6DB4-6B88-FED0-67987E9FB3CC%7D.pdf",
"circular_35_2010.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-35-2010/%7B74C5641C-ED98-53C7-F08B-A3C7BAE0D480%7D.pdf",
"circular_36_2010.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-36-2010/%7B26C55DE6-CC3A-3368-34FC-1A6C50B11130%7D.pdf",
"circular_3_2012.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-3-2012/%7B4E0281A4-7AD8-1462-BC79-7F2925F3171D%7D.pdf",
"circular_4_2012.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-4-2012/%7B97C62974-1C94-19AE-AB5A-D0D949A36247%7D.pdf",
"circular_unica_de_condusef.pdf": "https://www.condusef.gob.mx/documentos/marco_legal/disposiciones-transparencia-if-sofom.pdf",
"ley_para_regular_las_sociedades_de_informacion_crediticia.pdf": "https://www.condusef.gob.mx/documentos/marco_legal/disposiciones-transparencia-if-sofom.pdf",
"ley_federal_de_proteccion_de_datos_personales_en_posesion_de_los_particulares.pdf": "https://www.diputados.gob.mx/LeyesBiblio/pdf/LFPDPPP.pdf",
"reglamento_de_la_ley_federal_de_proteccion_de_datos_personales_en_posesion_de_los_particulares.pdf": "https://www.diputados.gob.mx/LeyesBiblio/regley/Reg_LFPDPPP.pdf",
"Modificaciones Recursos Procedencia Ilícita jul 25 PLD.pdf": "https://gfbanorte.sharepoint.com/:w:/r/sites/Formatosyplantillas/Documentos%20compartidos/Otros/Modificaciones%20Recursos%20Procedencia%20Il%C3%ADcita%20jul%2025%20PLD.docx?d=w6a941e9e2c26403ea41c12de35536516&csf=1&web=1&e=EHtc9b",
};
const handlePdfClick = (fileName: string, page?: number) => {
const publicUrl = PDF_PUBLIC_URLS[fileName];
if (publicUrl) {
// Abrir PDF público directamente
let finalUrl = publicUrl;
if (page) {
finalUrl += `#page=${page}`;
}
window.open(finalUrl, '_blank');
} else {
// Fallback: usar tu modal para PDFs locales
setPdfModal({
isOpen: true,
filename: fileName,
page: page
});
}
};
return (
<div className="mt-4">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
>
<span className="font-medium text-gray-700 mr-2">Fuentes</span>
<span className="text-gray-500">
{isExpanded ? '▲' : '▼'}
</span>
</button>
{isExpanded && (
<div className="mt-3 border border-gray-300 rounded-lg bg-gray-50 p-4">
<div className="space-y-2">
{metadatas.map((metadata, index) => {
const fileName = metadata.file_name || metadata.file || 'Documento';
const page = metadata.page;
const displayText = page ? `${fileName} - Página ${page}` : fileName;
const isPublic = PDF_PUBLIC_URLS[fileName];
return (
<div key={index} className="flex items-start space-x-2 text-sm">
<span className="text-gray-400 mt-1">
{isPublic ? '🌐' : '📄'}
</span>
<button
onClick={() => handlePdfClick(fileName, page)}
className="text-blue-600 hover:text-blue-800 hover:underline cursor-pointer text-left flex items-center"
title={isPublic ? 'Documento público - Se abrirá en nueva pestaña' : 'Documento local'}
>
{displayText}
{isPublic && (
<span className="ml-1 text-xs text-gray-500"></span>
)}
</button>
</div>
);
})}
</div>
</div>
)}
</div>
);
};
const ImageViewer = () => {
if (images.length === 0) return null;
return (
<div className="mt-5 space-y-4">
<div className="relative">
<img
src={images[currentImageIndex]}
alt={`Generated image ${currentImageIndex + 1}`}
className="w-full h-auto rounded-lg"
/>
<div className="flex justify-between items-center mt-4">
<button
onClick={prevImage}
disabled={currentImageIndex === 0}
className={`px-4 py-2 rounded ${
currentImageIndex === 0
? "text-gray-400 cursor-not-allowed"
: "text-gray-700 hover:bg-gray-100"
}`}
>
</button>
<button
onClick={nextImage}
disabled={currentImageIndex === images.length - 1}
className={`px-4 py-2 rounded ${
currentImageIndex === images.length - 1
? "text-gray-400 cursor-not-allowed"
: "text-gray-700 hover:bg-gray-100"
}`}
>
</button>
</div>
<span className="text-sm text-gray-600 mt-2 block text-center">
Imagen {currentImageIndex + 1} de {images.length}
</span>
</div>
</div>
);
};
return (
<>
{isUser ? (
<div className="m-5 mr-5 flex flex-row-reverse items-start space-x-4">
<div className="avatar placeholder mx-4 w-14 -mt-1">
<img src={userAvatar} alt="user avatar icon" />
</div>
<div className="inline-block max-w-[82%] 2xl:max-w-[88%]">
<div className="border border-slate-400 rounded-3xl bg-white p-4 text-gray-500">
<div className="whitespace-pre-wrap text-left">
{loading && (
<span className="loading loading-dots loading-md"></span>
)}
<Markdown rehypePlugins={[rehypeRaw]}>{buff}</Markdown>
</div>
</div>
</div>
</div>
) : (
<div className="m-5 flex items-start space-x-4 w-full">
<div className="avatar placeholder mx-4 w-14 -mt-1 mr-2">
<img src={botAvatar} alt="bot avatar icon" />
</div>
<div className="inline-block max-w-[82%] 2xl:max-w-[88%]">
<div className="border-2 border-red-500 rounded-3xl bg-white p-4 text-gray-500 pl-6">
<div className="flex flex-col items-start">
<div className="text-left w-full">
{loading && (
<span className="loading loading-dots loading-md"></span>
)}
<Markdown
rehypePlugins={[rehypeRaw]}
components={{
h1: ({ ...props }) => (
<h1 className="text-2xl font-bold mb-4" {...props} />
),
h2: ({ ...props }) => (
<h2 className="text-xl font-bold mb-3" {...props} />
),
h3: ({ ...props }) => (
<h3 className="text-lg font-bold mb-2" {...props} />
),
p: ({ ...props }) => <p className="mb-4" {...props} />,
ul: ({ ...props }) => (
<ul
className="list-disc pl-6 mb-4 space-y-2"
{...props}
/>
),
ol: ({ ...props }) => (
<ol
className="list-decimal pl-6 mb-4 space-y-2"
{...props}
/>
),
li: ({ ...props }) => <li className="mb-1" {...props} />,
a: ({ ...props }) => (
<a
className="text-blue-600 underline hover:text-blue-800"
{...props}
/>
),
strong: ({ ...props }) => (
<strong className="font-bold" {...props} />
),
}}
>
{buff}
</Markdown>
{pendingReferences.length > 0 && <Metadata metadatas={pendingReferences} />}
<ImageViewer />
</div>
{streamingComplete && acceptFeedback && onFeedback && (
<FeedbackButton messageKey={responseId} onFeedback={onFeedback} />
)}
</div>
</div>
</div>
</div>
)}
<PDFModal
isOpen={pdfModal.isOpen}
onClose={closePdfModal}
filename={pdfModal.filename}
page={pdfModal.page}
/>
</>
);
}

View File

@@ -0,0 +1,49 @@
export { ChatSidebar };
interface ChatSidebarProps {
assistant: string;
logoSrc: string;
sidebarImageSrc: string;
assistantAvatarSrc: string;
}
function ChatSidebar({ assistant, logoSrc, sidebarImageSrc, assistantAvatarSrc }: ChatSidebarProps) {
return (
<>
<nav className="bg-[#1b0103] shadow-lg min-h-[641px] min-w-[250px] py-6 px-6 font-[sans-serif] flex flex-col overflow-auto w-[272px] 2xl:h-screen">
<div className="flex flex-wrap items-center cursor-pointer">
<div className="relative w-full mb-12 ">
<div className="mx-5 w-3/4 -inset-3mt-2">
<a href="/">
<img className="h-10" src={logoSrc} alt="Logo" />
</a>
</div>
</div>
<div className="relative items-center text-center mx-auto -mt-5">
<img src={assistantAvatarSrc} className="w-24 h-24 border-white" />
</div>
</div>
<div className="mx-auto mt-2">
<h2 className="text-xl font-extrabold text-gray-300 uppercase">
{assistant}
</h2>
</div>
<ul className="space-y-3 flex-1 mt-5 mb-10 pl-5">
</ul>
<ul className="w-full">
<li className="w-full">
<a
href=""
className="text-gray-300 hover:text-white text-base flex items-center rounded-md"
>
</a>
</li>
</ul>
<div className="w-[272px] -p-6 -m-6">
<img src={sidebarImageSrc} alt="Sidebar Image" className="w-[272px]" />
</div>
</nav>
</>
);
}

View File

@@ -0,0 +1,69 @@
import { Icon } from "@iconify-icon/react";
import { useState } from "react";
export { FeedbackButton };
interface FeedbackButtonProps {
messageKey: string;
onFeedback: (key: string, rating: string) => Promise<void>;
}
function FeedbackButton({ messageKey, onFeedback }: FeedbackButtonProps) {
const [likeStyle, setLikeStyle] = useState({ fontSize: "18px" });
const [dislikeStyle, setDislikeStyle] = useState({ fontSize: "18px" });
const [liked, setLiked] = useState(false);
const [disliked, setDisliked] = useState(false);
async function sendFeedback(rating: string) {
await onFeedback(messageKey, rating);
console.log("Sent feedback:");
console.log(rating);
}
function Like() {
if (!liked) {
const colorStyle = { color: "green" };
setLikeStyle({ fontSize: "18px", ...colorStyle });
setLiked(true);
setDislikeStyle({ fontSize: "18px" });
setDisliked(false);
sendFeedback("Good");
} else {
setLikeStyle({ fontSize: "18px" });
setLiked(false);
sendFeedback("None");
}
}
function Dislike() {
if (!disliked) {
const colorStyle = { color: "red" };
setDislikeStyle({ fontSize: "18px", ...colorStyle });
setDisliked(true);
setLikeStyle({ fontSize: "18px" });
setLiked(false);
sendFeedback("Bad");
} else {
setDislikeStyle({ fontSize: "18px" });
setDisliked(false);
sendFeedback("None");
}
}
return (
<div className="flex justify-end space-x-2 mt-3">
<button className="btn btn-xs btn-ghost" onClick={Like}>
<Icon style={likeStyle} icon="iconamoon:like" />
</button>
<button className="btn btn-xs btn-ghost" onClick={Dislike}>
<Icon style={dislikeStyle} flip="horizontal" icon="iconamoon:dislike" />
</button>
</div>
);
}

View File

@@ -0,0 +1,381 @@
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
interface PDFModalProps {
isOpen: boolean;
onClose: () => void;
filename: string;
page?: number;
}
// Mapeo COMPLETO con URLs públicas directas - IDÉNTICO AL BACKEND
const PDF_REFERENCES: Record<string, string> = {
// Disposiciones de CNBV
"Disposiciones de carácter general aplicables a las casas de bolsa.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20las%20casas%20de%20bolsa.pdf",
"Disposiciones de carácter general aplicables a las instituciones de crédito.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20las%20instituciones%20de%20cr%C3%A9dito.pdf",
"Disposiciones de carácter general aplicables a las sociedades controladoras de grupos financieros y subcontroladoras que regulan las materias que corresponden de manera conjunta a las Comisio.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20las%20sociedades%20controladoras%20de%20grupos%20financieros%20y%20subcontroladoras%20que%20regulan%20las%20materias%20que%20corresponden%20de%20manera%20conjunta%20a%20las%20Comisiones%20Nacionales%20Supervisoras.pdf",
"Disposiciones de carácter general aplicables a los fondos de inversión y a las personas que les prestan servicios.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20los%20fondos%20de%20inversi%C3%B3n%20y%20a%20las%20personas%20que%20les%20prestan%20servicios.pdf",
"Ley para la Transparencia y Ordenamiento de los Servicios Financieros.pdf": "https://www.cnbv.gob.mx/Normatividad/Ley%20para%20la%20Transparencia%20y%20Ordenamiento%20de%20los%20Servicios%20Financieros.pdf",
// Circulares CNBV adicionales
"circular_servicios_de_inversion.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20las%20entidades%20financieras%20y%20dem%C3%A1s%20personas%20que%20proporcionen%20servicios%20de.pdf",
"circular_unica_de_auditores_externos.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20que%20establecen%20los%20requisitos%20que%20deber%C3%A1n%20cumplir%20los%20auditores%20y%20otros%20profesionales%20que.pdf",
"ley_de_instituciones_de_Credito.pdf": "https://www.cnbv.gob.mx/Normatividad/Ley%20de%20Instituciones%20de%20Cr%C3%A9dito.pdf",
// Circulares de Banxico
"circular_13_2007.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-13-2007/cobro-intereses-por-adelantad.html",
"circular_13_2011.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-13-2011/%7BBA4CBC28-A468-16C9-6F17-9EA9D7B03318%7D.pdf",
"circular_14_2007.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-14-2007/%7BFB726B6B-D523-56F5-F9B1-BE5B3B95A504%7D.pdf",
"circular_17_2014.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-17-2014/%7BF36CEF03-9441-2DBE-082C-0DF274903782%7D.pdf",
"circular_1_2005.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-1-2005/%7B5CA4BA75-FEA8-199C-F129-E8E6A73E84F3%7D.pdf",
"circular_21_2009.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-21-2009/%7B29285862-EDE0-567A-BAFB-D261406641A3%7D.pdf",
"circular_22_2008.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-22-2008/%7BF15C8A26-C92E-BE2B-9344-51EDAA3C9B68%7D.pdf",
"circular_22_2010.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-22-2010/%7B0D531F59-1001-4D67-D7B4-D5854DD07A58%7D.pdf",
"circular_27_2008.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-27-2008/%7BBC4333FE-070F-E727-199E-CA6BCF2CBA66%7D.pdf",
"circular_34_2010.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-34-2010/%7B0C55B906-6DB4-6B88-FED0-67987E9FB3CC%7D.pdf",
"circular_35_2010.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-35-2010/%7B74C5641C-ED98-53C7-F08B-A3C7BAE0D480%7D.pdf",
"circular_36_2010.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-36-2010/%7B26C55DE6-CC3A-3368-34FC-1A6C50B11130%7D.pdf",
"circular_3_2012.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-3-2012/%7B4E0281A4-7AD8-1462-BC79-7F2925F3171D%7D.pdf",
"circular_4_2012.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-4-2012/%7B97C62974-1C94-19AE-AB5A-D0D949A36247%7D.pdf",
// CONDUSEF
"circular_unica_de_condusef.pdf": "https://www.condusef.gob.mx/documentos/marco_legal/disposiciones-transparencia-if-sofom.pdf",
"ley_para_regular_las_sociedades_de_informacion_crediticia.pdf": "https://www.condusef.gob.mx/documentos/marco_legal/disposiciones-transparencia-if-sofom.pdf",
// Leyes federales
"ley_federal_de_proteccion_de_datos_personales_en_posesion_de_los_particulares.pdf": "https://www.diputados.gob.mx/LeyesBiblio/pdf/LFPDPPP.pdf",
"reglamento_de_la_ley_federal_de_proteccion_de_datos_personales_en_posesion_de_los_particulares.pdf": "https://www.diputados.gob.mx/LeyesBiblio/regley/Reg_LFPDPPP.pdf",
// SharePoint Banorte
"Modificaciones Recursos Procedencia Ilícita jul 25 PLD.pdf": "https://gfbanorte.sharepoint.com/:w:/r/sites/Formatosyplantillas/Documentos%20compartidos/Otros/Modificaciones%20Recursos%20Procedencia%20Il%C3%ADcita%20jul%2025%20PLD.docx?d=w6a941e9e2c26403ea41c12de35536516&csf=1&web=1&e=EHtc9b",
};
// Función para determinar si es una URL externa
const isExternalUrl = (url: string): boolean => {
return url.startsWith('http://') || url.startsWith('https://');
};
// Función para resolver la URL del PDF - BYPASS completo del backend local
const resolvePdfUrl = (filename: string): { directUrl: string; viewerUrl: string; isExternal: boolean } => {
console.log(`Resolviendo PDF para: "${filename}"`);
// Buscar directamente en el mapeo de URLs públicas
const publicUrl = PDF_REFERENCES[filename];
if (!publicUrl) {
console.warn(`No se encontró URL pública para: "${filename}"`);
return {
directUrl: '',
viewerUrl: '',
isExternal: false
};
}
const external = isExternalUrl(publicUrl);
if (external) {
console.log(`✅ BYPASS COMPLETO - URL pública directa: ${publicUrl}`);
// PDF.js viewer apunta DIRECTAMENTE a la URL pública, sin pasar por tu API
return {
directUrl: publicUrl,
viewerUrl: `https://mozilla.github.io/pdf.js/web/viewer.html?file=${encodeURIComponent(publicUrl)}`,
isExternal: true
};
} else {
console.warn(`URL no es externa: ${publicUrl}`);
return {
directUrl: '',
viewerUrl: '',
isExternal: false
};
}
};
export function PDFModal({ isOpen, onClose, filename, page }: PDFModalProps) {
const [isLoading, setIsLoading] = useState(true);
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
const [pdfError, setPdfError] = useState(false);
// Resolver URLs del PDF
const { directUrl: directPdfUrl, viewerUrl: pdfUrl, isExternal } = resolvePdfUrl(filename);
// Crear un elemento para el portal al montar el componente
useEffect(() => {
if (typeof document !== 'undefined') {
let element = document.getElementById('pdf-modal-portal');
if (!element) {
element = document.createElement('div');
element.id = 'pdf-modal-portal';
element.style.position = 'fixed';
element.style.top = '0';
element.style.left = '0';
element.style.width = '100%';
element.style.height = '100%';
element.style.zIndex = '99999';
element.style.pointerEvents = 'none';
document.body.appendChild(element);
}
setPortalElement(element);
if (isOpen) {
document.body.style.overflow = 'hidden';
}
return () => {
if (isOpen) {
document.body.style.overflow = 'auto';
}
if (element && !isOpen && document.body.contains(element)) {
document.body.removeChild(element);
}
};
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
document.body.style.overflow = 'auto';
setPdfError(false);
setIsLoading(true);
}
}, [isOpen]);
// Manejar tecla ESC
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
if (!isOpen || !portalElement) return null;
const handleLoad = () => {
setIsLoading(false);
setPdfError(false);
};
const handleError = () => {
setIsLoading(false);
setPdfError(true);
console.error(`Error loading PDF: ${filename}`, { directPdfUrl, pdfUrl, isExternal });
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
const handleRetry = () => {
setPdfError(false);
setIsLoading(true);
const iframe = document.querySelector(`iframe[title="PDF Viewer - ${filename}"]`) as HTMLIFrameElement;
if (iframe) {
iframe.src = pdfUrl;
}
};
// Si no hay URL pública, mostrar mensaje de error
if (!directPdfUrl) {
return createPortal(
<div
className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center p-4"
style={{
zIndex: 99999,
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'auto'
}}
onClick={handleBackdropClick}
>
<div
className="bg-white flex flex-col p-8 rounded-xl max-w-lg w-full"
onClick={(e) => e.stopPropagation()}
>
<div className="text-center">
<div className="text-6xl mb-4"></div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">PDF no disponible</h3>
<p className="text-gray-600 mb-4">
No se encontró URL pública para: <strong>{filename}</strong>
</p>
<button
onClick={onClose}
className="px-6 py-2 bg-gray-600 text-white font-medium rounded-lg hover:bg-gray-700 transition-colors"
>
Cerrar
</button>
</div>
</div>
</div>,
portalElement
);
}
const modalContent = (
<div
className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center p-4"
style={{
zIndex: 99999,
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'auto'
}}
onClick={handleBackdropClick}
>
<div
className="bg-white flex flex-col"
style={{
width: '85vw',
height: '90vh',
maxWidth: 'none',
maxHeight: 'none',
borderRadius: '16px',
border: '3px solid #000000',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.05)',
overflow: 'hidden'
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 bg-gradient-to-r from-gray-50 to-gray-100 border-b-2 border-gray-200">
<div className="flex items-center space-x-3 min-w-0 flex-1">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<div className="ml-4 min-w-0 flex-1">
<h3 className="text-lg font-semibold text-gray-900 truncate">
📄 {filename}
</h3>
<div className="flex items-center space-x-4 text-xs text-gray-500 mt-1">
{page && <span>Página {page}</span>}
<span className="px-2 py-1 rounded-full bg-blue-100 text-blue-700">
Público
</span>
<span className="px-2 py-1 rounded-full bg-green-100 text-green-700">
Directo
</span>
</div>
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-red-500 text-2xl font-bold w-10 h-10 flex items-center justify-center hover:bg-red-50 rounded-full transition-all duration-200 flex-shrink-0 ml-4"
aria-label="Cerrar modal"
>
×
</button>
</div>
{/* PDF Content */}
<div className="flex-1 relative bg-gray-50">
{isLoading && !pdfError && (
<div className="absolute inset-0 flex items-center justify-center bg-white z-10">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-16 w-16 border-b-4 border-blue-600"></div>
<p className="mt-6 text-gray-600 text-lg font-medium">Cargando PDF...</p>
<p className="mt-2 text-gray-400 text-sm max-w-md break-words">{filename}</p>
<p className="mt-1 text-xs text-gray-300">Fuente pública</p>
</div>
</div>
)}
{pdfError && (
<div className="absolute inset-0 flex items-center justify-center bg-white z-10 p-8">
<div className="text-center max-w-2xl">
<div className="text-6xl mb-4"></div>
<p className="text-red-600 text-lg font-medium mb-2">Error al cargar el PDF</p>
<p className="text-gray-600 mb-4">
No se pudo cargar: <strong className="break-words">{filename}</strong>
</p>
<div className="bg-gray-50 p-4 rounded-lg mb-4 text-left text-xs">
<p className="font-medium text-gray-700 mb-2">Información:</p>
<p className="text-gray-600 break-all mb-1">
<strong>URL:</strong> {directPdfUrl}
</p>
<p className="text-gray-600">
<strong>Tipo:</strong> Fuente pública
</p>
</div>
<div className="space-x-2">
<button
onClick={handleRetry}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Reintentar
</button>
<a
href={directPdfUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-block px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
Abrir en nueva pestaña
</a>
</div>
</div>
</div>
)}
{!pdfError && (
<iframe
src={pdfUrl}
className="w-full h-full border-0"
onLoad={handleLoad}
onError={handleError}
title={`PDF Viewer - ${filename}`}
style={{
minHeight: '100%',
borderRadius: '0'
}}
/>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 bg-gradient-to-r from-gray-50 to-gray-100 border-t-2 border-gray-200">
<div className="flex items-center space-x-4">
<a
href={directPdfUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-4 py-2 bg-black text-white text-sm font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200"
>
🔗 Abrir en nueva pestaña
</a>
<span className="text-xs text-gray-500">
ESC para cerrar PDF.js Viewer Fuente pública
</span>
</div>
<button
onClick={onClose}
className="px-6 py-2 bg-gray-600 text-white font-medium rounded-lg hover:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
>
Cerrar
</button>
</div>
</div>
</div>
);
return createPortal(modalContent, portalElement);
}

View File

@@ -0,0 +1,4 @@
export * from "./components/FeedbackButton"
export * from "./components/ChatMessage"
export * from "./components/Chat"
export * from "./components/ChatSidebar"

View File

@@ -0,0 +1,166 @@
import plugin from 'tailwindcss/plugin';
export default plugin(function({ addUtilities }) {
// Add any custom utilities if needed
}, {
// Safelist all classes used in the chat-ui package
safelist: [
// Layout and flexbox
'flex',
'flex-1',
'flex-col',
'flex-row-reverse',
'flex-wrap',
'flex-shrink-0',
'items-center',
'items-start',
'justify-center',
'justify-between',
'justify-end',
'space-x-2',
'space-x-4',
'space-y-2',
'space-y-3',
'space-y-4',
// Positioning
'relative',
'absolute',
'inset-3mt-2',
'inline-block',
// Margins and padding
'm-5',
'mr-5',
'ml-4',
'ml-5',
'my-5',
'mx-4',
'mx-5',
'mx-auto',
'mt-2',
'mt-3',
'mt-4',
'mt-5',
'-mt-1',
'-mt-5',
'mb-1',
'mb-3',
'mb-4',
'mb-10',
'mb-12',
'mr-2',
'p-4',
'p-7',
'px-4',
'px-6',
'py-2',
'py-6',
'pl-5',
'pl-6',
'-p-6',
'-m-6',
// Width and height
'w-full',
'w-3/4',
'w-3/5',
'w-14',
'w-24',
'w-[90%]',
'w-[272px]',
'h-auto',
'h-10',
'h-14',
'h-24',
'h-screen',
'min-h-0',
'min-h-[641px]',
'min-w-[250px]',
'max-w-[82%]',
// Colors and backgrounds
'bg-slate-100',
'bg-white',
'bg-[#1b0103]',
'text-gray-300',
'text-gray-500',
'text-gray-600',
'text-gray-700',
'text-gray-400',
'text-blue-600',
'text-blue-700',
'text-blue-800',
'text-white',
'text-base',
'text-sm',
'text-lg',
'text-xl',
'text-2xl',
'border-white',
'border-slate-400',
'border-red-500',
'border-2',
'border',
'hover:text-white',
'hover:text-blue-800',
'hover:bg-gray-100',
'hover:border-red-200',
'hover:opacity-80',
// Typography
'font-bold',
'font-extrabold',
'font-[sans-serif]',
'text-left',
'text-center',
'uppercase',
'underline',
'whitespace-pre-wrap',
'list-disc',
'list-decimal',
// Borders and rounding
'rounded-lg',
'rounded-md',
'rounded-3xl',
'rounded',
// Shadows and effects
'shadow-lg',
'opacity-50',
// Scrolling
'overflow-auto',
'overflow-y-auto',
'scrollbar',
// Cursors
'cursor-pointer',
'cursor-not-allowed',
// Display
'block',
// DaisyUI components
'btn',
'btn-xs',
'btn-ghost',
'btn-error',
'input',
'input-bordered',
'focus:input-primary',
'avatar',
'placeholder',
'loading',
'loading-dots',
'loading-md',
// Responsive variants
'2xl:max-w-[88%]',
'2xl:h-screen',
// Custom negative margins
'-inset-3mt-2',
]
});

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2024",
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"declaration": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}

View File

@@ -0,0 +1,9 @@
// tsup.config.ts
export default {
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
outDir: 'dist',
external: ['react', 'react-dom'],
jsx: 'react-jsx'
}

View File

View File

@@ -0,0 +1,74 @@
import json
from datetime import UTC, datetime, timedelta
from google.cloud import storage
from google.cloud.storage.blob import Blob
from google.oauth2 import service_account
class GcpStorage:
def __init__(self, credentials: str | dict | None = None):
"""
Initialize GCP Storage client using either:
- A JSON string containing service account credentials
- A dict containing service account credentials
- None (will attempt to use default credentials)
"""
if credentials:
# Convert string to dict if needed
if isinstance(credentials, str):
try:
credentials_dict = json.loads(credentials)
except json.JSONDecodeError as e:
raise ValueError(
"Invalid JSON string provided for credentials"
) from e
else:
credentials_dict = credentials
# Create credentials object from dict
credentials_obj = service_account.Credentials.from_service_account_info(
credentials_dict
)
self.client = storage.Client(credentials=credentials_obj)
else:
# Use default credentials if none provided
self.client = storage.Client()
def _generate_signed_url(self, blob: Blob, minute_duration: int) -> str:
expiration_time = datetime.now(UTC) + timedelta(minutes=minute_duration)
url = blob.generate_signed_url(
version="v4", expiration=expiration_time, method="GET"
)
return url
async def get_file_url(
self, filename: str, bucket: str, minute_duration: int, image: bool
) -> str:
stock_url = (
"https://t3.ftcdn.net/jpg/04/34/72/82/360_F_434728286_OWQQvAFoXZLdGHlObozsolNeuSxhpr84.jpg"
if image
else "https://www.banorte.com"
)
if not hasattr(self, "client"):
return stock_url
bucket_client = self.client.bucket(bucket)
blob = bucket_client.blob(filename)
if blob.exists():
return self._generate_signed_url(blob, minute_duration)
else:
return stock_url
async def get_blob_bytes(self, bucket: str, filename: str) -> bytes:
if not hasattr(self, "client"):
raise ValueError("No credentials provided to GCPStorage object.")
bucket_client = self.client.bucket(bucket)
blob = bucket_client.blob(filename)
return blob.download_as_bytes()

View File

@@ -0,0 +1,15 @@
[project]
name = "google-storage"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [{ name = "ajac-zero", email = "ajcardoza2000@gmail.com" }]
requires-python = ">=3.12"
dependencies = ["google-cloud-storage>=2.0"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["banortegpt"]

View File

View File

@@ -0,0 +1,35 @@
from uuid import UUID
from .models import Conversation, Message
async def create_conversation(
conversation_id: UUID, system_prompt: str
) -> Conversation:
conversation = Conversation(
conversation_id=conversation_id,
messages=[Message(role="system", content=system_prompt)],
)
await conversation.create()
return conversation
async def get_conversation(conversation_id: UUID) -> Conversation | None:
conversation = await Conversation.find_one(
Conversation.conversation_id == conversation_id
)
return conversation
async def get_or_create_conversation(
conversation_id: UUID, system_prompt: str
) -> Conversation:
conversation = await get_conversation(conversation_id)
if not conversation:
conversation = await create_conversation(conversation_id, system_prompt)
else:
conversation.messages[0].content = system_prompt
return conversation

View File

@@ -0,0 +1,67 @@
from typing import Annotated, Any, Literal
from uuid import UUID
from beanie import Document, Indexed
from pydantic import BaseModel
type Role = Literal["user", "assistant", "system", "tool"]
class Function(BaseModel):
name: str
arguments: str
class Tool(BaseModel):
id: str
type: Literal["function"]
function: Function
class Message(BaseModel):
role: Role
content: str | None = None
tool_call_id: str | None = None
tool_calls: list[Tool] | None = None
class Conversation(Document):
conversation_id: Annotated[UUID, Indexed(unique=True)]
messages: list[Message]
class Settings:
name = "conversations"
def to_openai_format(self, limit: int, langchain_compat: bool = False):
history = [m.model_dump(exclude_none=True) for m in self.messages]
if langchain_compat:
for msg in history:
if "tool_calls" in msg:
msg["additional_kwargs"] = {"tool_calls": msg.pop("tool_calls")}
if "content" not in msg:
msg["content"] = ""
if len(history) > limit:
# Truncate the history to the last `limit` messages
# Always keep the first message (system prompt)
history = history[:1] + history[-limit:]
return history
def add(
self,
role: Role,
*,
content: str | None = None,
tool_call_id: str | None = None,
tool_calls: list[Any] | None = None,
):
self.messages.append(
Message(
role=role,
content=content,
tool_call_id=tool_call_id,
tool_calls=tool_calls,
)
)

View File

@@ -0,0 +1,15 @@
[project]
name = "mongo-memory"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [{ name = "ajac-zero", email = "ajcardoza2000@gmail.com" }]
requires-python = ">=3.12"
dependencies = ["beanie>=1.29.0"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["banortegpt"]

View File

View File

@@ -0,0 +1,49 @@
from pymongo import AsyncMongoClient
class AsyncMongo:
def __init__(
self, mongo_uri: str, database_name: str = "MayaContigo", mode: str = "cosmosdb"
) -> None:
self.mode = mode
self.client: AsyncMongoClient = AsyncMongoClient(mongo_uri)
self.db = self.client.get_database(database_name)
def build_pipeline(self, embedding, limit):
if self.mode == "native":
return {
"$vectorSearch": {
"index": "vector_index",
"queryVector": embedding,
"path": "embedding",
"limit": limit,
"numCandidates": 3 * limit,
}
}
elif self.mode == "cosmosdb":
return {
"$search": {
"cosmosSearch": {
"path": "vector",
"vector": embedding[:2000],
"k": limit,
}
}
}
else:
raise ValueError("Invalid mode")
async def semantic_search(
self,
collection: str,
embedding: list[float],
limit: int = 10,
conditions: dict | None = None,
threshold: float | None = None,
**kwargs,
):
db_collection = self.db[collection]
pipeline = self.build_pipeline(embedding, limit)
aggregate = await db_collection.aggregate([pipeline])
return [result async for result in aggregate]

View File

@@ -0,0 +1,15 @@
[project]
name = "mongo-search"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [{ name = "ajac-zero", email = "ajcardoza2000@gmail.com" }]
requires-python = ">=3.12"
dependencies = ["pymongo>=4.10.1"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["banortegpt"]

View File

View File

@@ -0,0 +1,181 @@
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError, NoResultFound
from sqlalchemy.ext.asyncio import AsyncSession
from .models import Assistant, Conversation, Message, User
### Users
async def get_all_users(session: AsyncSession) -> Sequence[User]:
statement = select(User)
scalars = await session.scalars(statement)
return scalars.all()
async def get_user(session: AsyncSession, user_id: int) -> User:
try:
return await session.get_one(User, user_id)
except NoResultFound as e:
raise ValueError("No user by that id!") from e
async def get_user_by_name(session: AsyncSession, name: str) -> User:
try:
statement = select(User).where(User.name == name)
scalar = await session.scalars(statement)
return scalar.one()
except NoResultFound as e:
raise ValueError("No user by that name!") from e
async def create_user(session: AsyncSession, name: str) -> User:
try:
new_user = User(name=name)
session.add(new_user)
await session.commit()
await session.refresh(new_user)
return new_user
except IntegrityError as e:
raise ValueError("User by that name already exists!") from e
async def delete_user(session: AsyncSession, name: str):
try:
statement = select(User).where(User.name == name)
db_user: User = (await session.scalars(statement)).one()
await session.delete(db_user)
await session.commit()
except NoResultFound as e:
raise ValueError("No assistant by that id exists.") from e
### Assistants
async def get_all_assistants(session: AsyncSession) -> Sequence[Assistant]:
statement = select(Assistant)
scalars = await session.scalars(statement)
return scalars.all()
async def get_assistant(session: AsyncSession, assistant_id: int) -> Assistant:
try:
statement = select(Assistant).where(Assistant.id == assistant_id)
scalars = await session.scalars(statement)
return scalars.one()
except NoResultFound as e:
raise ValueError("No assistant by that id!") from e
async def get_assistant_by_name(session: AsyncSession, name: str) -> Assistant:
try:
statement = select(Assistant).where(Assistant.name == name)
scalars = await session.scalars(statement)
return scalars.one()
except NoResultFound as e:
raise ValueError("No assistant by that name!") from e
async def create_assistant(
session: AsyncSession, name: str, system_prompt: str
) -> Assistant:
try:
new_assistant = Assistant(name=name, system_prompt=system_prompt)
session.add(new_assistant)
await session.commit()
await session.refresh(new_assistant)
return new_assistant
except IntegrityError as e:
raise ValueError("Assistant with that name already exists.") from e
async def delete_assistant(session: AsyncSession, assistant_name: str) -> None:
try:
statement = select(Assistant).where(Assistant.name == assistant_name)
db_assistant: Assistant = (await session.scalars(statement)).one()
await session.delete(db_assistant)
await session.commit()
except NoResultFound as e:
raise ValueError("No assistant by that name exists.") from e
### Conversations
async def get_conversation(session: AsyncSession, conversation_id: int) -> Conversation:
try:
conversation = await session.get_one(Conversation, conversation_id)
await session.refresh(conversation, ["messages", "id"])
return conversation
except NoResultFound as e:
raise ValueError("No conversation by that id exists.") from e
async def create_conversation(
session: AsyncSession, user: str, assistant: str, system_prompt: str | None = None
) -> Conversation:
try:
db_user = await get_user_by_name(session=session, name=user)
db_assistant = await get_assistant_by_name(session=session, name=assistant)
await session.refresh(db_user, ["id"])
await session.refresh(db_assistant, ["id"])
db_conversation = Conversation(user_id=db_user.id, assistant_id=db_assistant.id)
if system_prompt is not None:
db_conversation.add_message(role="system", content=system_prompt)
session.add(db_conversation)
await session.commit()
return db_conversation
except ValueError as e:
raise ValueError("User or assistant do not exist!") from e
async def delete_conversation(session: AsyncSession, conversation_id: int) -> None:
try:
db_conversation = await session.get_one(Conversation, conversation_id)
await session.delete(db_conversation)
await session.commit()
except NoResultFound as e:
raise ValueError("No conversation by that id exists.") from e
async def soft_delete_conversation(session: AsyncSession, conversation_id: int) -> None:
try:
db_conversation = await session.get_one(Conversation, conversation_id)
await session.refresh(db_conversation, ["active"])
db_conversation.active = False
await session.commit()
except NoResultFound as e:
raise ValueError("No conversation by that id exists.") from e
### Messages
async def get_message_by_id(session: AsyncSession, message_id: int) -> Message:
return await session.get_one(Message, message_id)
async def update_message_feedback_by_id(
session: AsyncSession, message_id: int, rating: bool | None
):
try:
db_message = await session.get_one(Message, message_id)
db_message.feedback = rating
session.add(db_message)
await session.commit()
await session.refresh(db_message)
return db_message
except ValueError as e:
raise ValueError("Message does not exist!") from e

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import ForeignKey, select
from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
MappedAsDataclass,
column_property,
mapped_column,
relationship,
)
class Base(DeclarativeBase, MappedAsDataclass, AsyncAttrs):
type_annotation_map = {
datetime: TIMESTAMP(timezone=True),
dict: JSONB,
}
class CommonMixin(MappedAsDataclass):
id: Mapped[int] = mapped_column(init=False, primary_key=True)
created_at: Mapped[datetime] = mapped_column(
init=False, default_factory=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime | None] = mapped_column(
init=False, default=None, onupdate=lambda: datetime.now(UTC)
)
active: Mapped[bool] = mapped_column(init=False, default=True)
class User(CommonMixin, Base):
__tablename__ = "users"
name: Mapped[str] = mapped_column(unique=True, index=True)
conversations: Mapped[list[Conversation]] = relationship(
init=False, back_populates="user"
)
class Assistant(CommonMixin, Base):
__tablename__ = "assistants"
name: Mapped[str] = mapped_column(unique=True, index=True)
system_prompt: Mapped[str]
conversations: Mapped[list[Conversation]] = relationship(
init=False, back_populates="assistant"
)
class Conversation(CommonMixin, Base):
__tablename__ = "conversations"
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
assistant_id: Mapped[int] = mapped_column(ForeignKey("assistants.id"))
user: Mapped[User] = relationship(init=False, back_populates="conversations")
assistant: Mapped[Assistant] = relationship(
init=False, back_populates="conversations"
)
messages: Mapped[list[Message]] = relationship(
init=False, order_by="Message.created_at"
)
assistant_name = column_property(
select(Assistant.name).where(Assistant.id == assistant_id).scalar_subquery()
)
def add_message(
self,
role: str,
content: str | None = None,
tools: dict | None = None,
query_id: int | None = None,
):
self.messages.append(
Message(
role=role,
content=content,
tools=tools,
query_id=query_id,
conversation_id=self.id,
)
)
async def to_openai_format(self):
messages = await self.awaitable_attrs.messages
return [(await m.to_openai_format()) for m in messages]
class Message(CommonMixin, Base):
__tablename__ = "messages"
conversation_id: Mapped[int] = mapped_column(ForeignKey("conversations.id"))
role: Mapped[str]
content: Mapped[str | None] = mapped_column(default=None)
feedback: Mapped[bool | None] = mapped_column(default=None)
tools: Mapped[dict | None] = mapped_column(default=None)
query_id: Mapped[int | None] = mapped_column(default=None)
async def to_openai_format(self):
role = await self.awaitable_attrs.role
content = await self.awaitable_attrs.content
tools = await self.awaitable_attrs.tools
return {
"role": role,
"content": content,
**(tools or {}),
}
class Comment(CommonMixin, Base):
__tablename__ = "comments"
message_id: Mapped[int] = mapped_column(ForeignKey("messages.id"))
content: Mapped[str]

View File

@@ -0,0 +1,15 @@
[project]
name = "postgres"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [{ name = "ajac-zero", email = "ajcardoza2000@gmail.com" }]
requires-python = ">=3.12"
dependencies = ["sqlalchemy[asyncio,postgresql-asyncpg]>=2.0.40"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["banortegpt"]

View File

View File

@@ -0,0 +1,4 @@
from .blocking import Qdrant
from .nonblocking import AsyncQdrant
__all__ = ["Qdrant", "AsyncQdrant"]

View File

@@ -0,0 +1,43 @@
from typing import Protocol
class BaseQdrant:
def __init__(
self, *, url: str, api_key: str | None, collection: str | None = None
) -> None:
self.collection = collection
class Config(Protocol):
qdrant_url: str
qdrant_api_key: str | None
@classmethod
def from_config(cls, c: Config):
return cls(url=c.qdrant_url, api_key=c.qdrant_api_key)
@classmethod
def from_vault(
cls,
vault: str,
*,
collection: str | None = None,
url: str | None = None,
token: str | None = None,
mount_point: str = "secret",
):
from hvac import Client
client = Client(url=url or "https://vault.ia-innovacion.work", token=token)
if not client.is_authenticated():
raise Exception("Vault authentication failed")
secret_map = client.secrets.kv.v2.read_secret_version(
path=vault, mount_point=mount_point
)["data"]["data"]
return cls(
url=secret_map["qdrant_api_url"],
api_key=secret_map["qdrant_api_key"],
collection=collection,
)

View File

@@ -0,0 +1,99 @@
from collections.abc import Sequence
from typing import Any
from langfuse.decorators import langfuse_context, observe
from qdrant_client import QdrantClient, models
from .base import BaseQdrant
class Qdrant(BaseQdrant):
def __init__(
self, *, url: str, api_key: str | None, collection: str | None = None
) -> None:
super().__init__(url=url, api_key=api_key, collection=collection)
self.client = QdrantClient(url=url, api_key=api_key)
def list_collections(self) -> Sequence[str]:
return [
collection.name for collection in self.client.get_collections().collections
]
@observe(capture_input=False)
def semantic_search(
self,
embedding: Sequence[float] | models.NamedVector,
collection: str | None = None,
limit: int = 10,
conditions: Any | None = None,
threshold: float | None = None,
**kwargs,
) -> Sequence[dict[str, Any]]:
if collection is None:
if self.collection is None:
raise ValueError(
"No collection set; Please set a collection before calling 'semantic_search'"
)
collection = self.collection
langfuse_context.update_current_observation(
input={
"collection": collection,
"limit": limit,
"embedding": embedding,
"conditions": conditions,
}
)
points = self.client.search(
collection_name=collection,
query_vector=embedding,
query_filter=conditions,
limit=limit,
with_payload=True,
with_vectors=False,
score_threshold=threshold,
**kwargs,
)
return [point.payload for point in points if point.payload is not None]
def create_collection_if_not_exists(
self,
*,
collection: str | None = None,
vector_config: dict[str, models.VectorParams],
):
if collection is None:
if self.collection is None:
raise ValueError(
"No collection is set; Please set a collection before calling 'create_collection_if_not_exists'"
)
collection = self.collection
result = self.client.get_collections()
collection_names = [collection.name for collection in result.collections]
if collection not in collection_names:
return self.client.create_collection(
collection_name=collection,
vectors_config=vector_config,
)
return False
def upload_to_collection(
self,
*,
points: list[models.PointStruct],
collection: str | None = None,
):
if collection is None:
if self.collection is None:
raise ValueError(
"No collection is set; Please set a collection before calling 'create_collection_if_not_exists'"
)
collection = self.collection
for point in points:
self.client.upsert(collection_name=collection, points=[point], wait=True)

View File

@@ -0,0 +1,96 @@
from collections.abc import Sequence
from typing import Any
from langfuse.decorators import langfuse_context, observe
from qdrant_client import AsyncQdrantClient, models
from .base import BaseQdrant
class AsyncQdrant(BaseQdrant):
def __init__(
self, *, url: str, api_key: str | None, collection: str | None = None
) -> None:
super().__init__(url=url, api_key=api_key, collection=collection)
self.client = AsyncQdrantClient(url=url, api_key=api_key)
@observe(capture_input=False)
async def semantic_search(
self,
embedding: Sequence[float] | models.NamedVector,
collection: str | None = None,
limit: int = 10,
conditions: Any | None = None,
threshold: float | None = None,
**kwargs,
) -> Sequence[dict[str, Any]]:
if collection is None:
if self.collection is None:
raise ValueError(
"No collection set; Please set a collection before calling 'semantic_search'"
)
collection = self.collection
langfuse_context.update_current_observation(
input={
"collection": collection,
"limit": limit,
"embedding": embedding,
"conditions": conditions,
}
)
points = await self.client.search(
collection_name=collection,
query_vector=embedding,
query_filter=conditions,
limit=limit,
with_payload=True,
with_vectors=False,
score_threshold=threshold,
**kwargs,
)
return [point.payload for point in points if point.payload is not None]
async def create_collection_if_not_exists(
self,
*,
collection: str | None = None,
vector_config: dict[str, models.VectorParams],
):
if collection is None:
if self.collection is None:
raise ValueError(
"No collection is set; Please set a collection before calling 'create_collection_if_not_exists'"
)
collection = self.collection
result = await self.client.get_collections()
collection_names = [collection.name for collection in result.collections]
if collection not in collection_names:
return await self.client.create_collection(
collection_name=collection,
vectors_config=vector_config,
)
return False
async def upload_to_collection(
self,
*,
points: list[models.PointStruct],
collection: str | None = None,
):
if collection is None:
if self.collection is None:
raise ValueError(
"No collection is set; Please set a collection before calling 'create_collection_if_not_exists'"
)
collection = self.collection
for point in points:
await self.client.upsert(
collection_name=collection, points=[point], wait=True
)

View File

@@ -0,0 +1,19 @@
[project]
name = "qdrant"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [{ name = "ajac-zero", email = "ajcardoza2000@gmail.com" }]
requires-python = ">=3.12"
dependencies = ["langfuse>=2.60.2", "qdrant-client>=1.12.2"]
[tool.pyright]
venvPath = "."
venv = ".venv"
[tool.hatch.build.targets.wheel]
packages = ["banortegpt"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View File

View File

@@ -0,0 +1,94 @@
import io
import json
import os
from pathlib import Path
import google.oauth2.service_account as sa
import vertexai
import vertexai.generative_models as gm
from PIL.Image import Image
class Gemini:
def __init__(
self, model: str | None = None, *, account_info: dict[str, str] | None = None
) -> None:
if account_info is None:
account_info = json.loads(os.environ["GCP_SERVICE_ACCOUNT"])
assert account_info is not None
credentials = sa.Credentials.from_service_account_info(account_info)
vertexai.init(project=account_info["project_id"], credentials=credentials)
self.model = gm.GenerativeModel(model) if model else None
def set_model(self, model: str):
self.model = gm.GenerativeModel(model)
return self
@classmethod
def from_path(cls, model: str, path: Path):
account_info = json.loads(path.read_text())
return cls(model, account_info=account_info)
def generate(self, contents, response_schema=None):
if self.model is None:
raise ValueError(
"No model set; Please choose a model before calling 'generate'"
)
generation_config = None
if response_schema:
generation_config = gm.GenerationConfig(
response_mime_type="application/json", response_schema=response_schema
)
return self.model.generate_content(
contents, generation_config=generation_config
)
@staticmethod
def create_part_from_pdf_bytes(pdf_bytes: bytes):
part = gm.Part.from_data(
data=pdf_bytes,
mime_type="application/pdf",
)
return part
@staticmethod
def create_part_from_PIL_image(pil_image: Image, format="jpeg"):
with io.BytesIO() as img_buffer:
pil_image.save(img_buffer, format=format.upper())
img_bytes = img_buffer.getvalue()
part = gm.Part.from_data(
data=img_bytes,
mime_type="image/" + format,
)
return part
@classmethod
def from_vault(
cls,
vault: str,
*,
model: str | None = None,
url: str | None = None,
token: str | None = None,
mount_point: str = "secret",
):
from hvac import Client
client = Client(url=url or "https://vault.ia-innovacion.work", token=token)
if not client.is_authenticated():
raise Exception("Vault authentication failed")
secret_map = client.secrets.kv.v2.read_secret_version(
path=vault, mount_point=mount_point
)["data"]["data"]
return cls(
account_info=secret_map["gcp_service_account"],
model=model,
)

View File

@@ -0,0 +1,18 @@
[project]
name = "vertex-ai-gemini"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"google-auth>=2.40.1",
"google-cloud-aiplatform>=1.92.0",
"pillow>=11.2.1",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["banortegpt"]