diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..4666b216919a454916f8e9f712d37422417a2213 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +.agents +.codex +.env +.venv +venv +__pycache__ +*.pyc +chroma_db_docs +chroma_db_sql +chroma_db_sql_backup_* +MODELS diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..fa9aab63afd92057c4d5e5680ccf083e8b05afb0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +*.mp4 filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text +*.gif filter=lfs diff=lfs merge=lfs -text +*.webp filter=lfs diff=lfs merge=lfs -text +*.pdf filter=lfs diff=lfs merge=lfs -text +*.sqlite3 filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +# Hugging Face's GitHub sync may omit LFS-only directories. Keep the packaged +# Chroma store as regular Git blobs so it is always present in the Docker build. +chroma_db_docs/** -filter -diff -merge -text +chroma_package/** filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..ea102e7925aabdd13d52b93c4b5e25f1126fca5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.keras +__pycache__/ +.venv/ +chroma_db_sql +chroma_db_sql_backup_* +chroma_db_docs/ +venv +.venv/ +.env +MODELS/ +chroma_db_sql_backup_20260710_211319 +.jpg diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..993bbc3677d2d366e2b8c686f9e9dfb20a205423 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM python:3.11-slim + +# Crea y asigna un usuario para HF Spaces (recomendado) +RUN useradd -m -u 1000 user +ENV PATH="/home/user/.local/bin:$PATH" +ENV PYTHONUNBUFFERED=1 +ENV PORT=7860 +ENV ANONYMIZED_TELEMETRY=False +ENV LOCAL_MODEL_CONTEXT_SIZE=2048 +ENV LOCAL_MODEL_MAX_NEW_TOKENS=256 +ENV LOCAL_MODEL_THREADS=2 +ENV LOCAL_MODEL_BATCH_THREADS=2 +ENV LOCAL_MODEL_BATCH_SIZE=256 + +WORKDIR /home/user/app +RUN chown -R user:user /home/user/app + +USER user + +COPY --chown=user ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt + +COPY --chown=user . /home/user/app +RUN python download_model.py \ + --download-only \ + --repo AlbertiTechnology/qwen3-4b-instruct-gguf \ + --local-dir MODELS/qwen3-4b-instruct-gguf +RUN python index_documents_to_chroma.py \ + --documents-dir documentos/ASTM \ + --persist-dir chroma_db_docs \ + --collection document_context \ + --reset-collection + +# Valida la base Chroma generada durante el build y luego inicia la API. +CMD ["python", "-u", "start_hf.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..217773ba33dd4f47ec4188ff948092bb4ce61b5a --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +--- +title: Agent API +emoji: 🤖 +colorFrom: green +colorTo: green +sdk: docker +pinned: false +--- + +# Modelos principales +ollama pull llama3.2:3b +ollama pull llava:7b +ollama pull nomic-embed-text + +## Modelo Qwen3-4B-Instruct GGUF local + +Al construir la imagen, Docker descarga el GGUF desde +`AlbertiTechnology/qwen3-4b-instruct-gguf` y deja una copia local dentro de la +imagen. Docker ejecuta automaticamente: + +```powershell +python download_model.py --download-only --repo AlbertiTechnology/qwen3-4b-instruct-gguf --local-dir MODELS/qwen3-4b-instruct-gguf +``` + +El modelo queda almacenado en +`/home/user/app/MODELS/qwen3-4b-instruct-gguf/model.gguf` dentro de la imagen y +la app lo ejecuta localmente mediante llama.cpp. + +Esta descarga no requiere un token durante el build porque el repositorio es +publico. + +## Contexto documental (Chroma) + +El agente consulta la coleccion `document_context` de `chroma_db_docs`. Durante +el build de Docker, la base se reconstruye automaticamente usando solamente los +PDF y archivos de metadata ubicados en `documentos/ASTM`. + +Para reconstruirla manualmente fuera de Docker, ejecute: + +```powershell +venv\Scripts\python.exe index_documents_to_chroma.py --documents-dir "documentos/ASTM" --reset-collection +``` + +## Despliegue en Hugging Face Spaces + +El contenedor ejecuta automaticamente `start_hf.py`. En cada arranque: + +1. valida `DATABASE_URL`; +2. valida el modelo local `MODELS/qwen3-4b-instruct-gguf/model.gguf`; +3. valida la base Chroma y comprueba que `document_context` no este vacia; +4. inicia FastAPI/Uvicorn en el puerto `7860`. + +Los reinicios del contenedor reutilizan la base creada dentro de la imagen. Cada +nuevo build vuelve a generarla desde `documentos/ASTM`. + +Configure en **Settings > Variables and secrets** del Space: + +- `DATABASE_URL`: conexion PostgreSQL usada para conversaciones y mensajes. + +Los logs del Space muestran el proceso completo sin imprimir secrets: + +```text +[2026-07-10T12:00:00Z] [BOOT:START] Starting Hugging Face Space initialization. +[2026-07-10T12:00:00Z] [BOOT:ENV] Required secrets are present (values are not printed). +[2026-07-10T12:00:00Z] [BOOT:CHROMA] Using build-generated document_context collection (119 chunks). +[2026-07-10T12:00:00Z] [BOOT:MODEL] Local Qwen model is available at /home/user/app/MODELS/qwen3-4b-instruct-gguf. +[2026-07-10T12:01:00Z] [BOOT:API] Starting Agent API on 0.0.0.0:7860... +``` + +Si falta un secret o la base Chroma empaquetada no es valida, el despliegue se +detiene con un mensaje `[BOOT:ERROR]` visible en **Logs**. La API no arranca con +una base incompleta. + +## Lanzar el chat del agente + +Inicie la API desde la raiz del proyecto: + +```powershell +venv\Scripts\python.exe -m uvicorn main:app --host 0.0.0.0 --port 8011 --reload +``` + +Abra el frontend de chat en `http://127.0.0.1:8011/` (o en +`http://127.0.0.1:8011/ui`). Se usa el puerto 8011 porque en este entorno los +puertos 8000 y 8010 ya estan ocupados por otras instancias. + +La API queda disponible mediante `POST http://127.0.0.1:8011/chat` y por +WebSocket en `ws://127.0.0.1:8011/ws/chat`. La documentacion interactiva se +abre en `http://127.0.0.1:8011/docs`. diff --git a/assemble_chroma.py b/assemble_chroma.py new file mode 100644 index 0000000000000000000000000000000000000000..b2e31cdeffca028d56fe8f64d26a9e22181cbe97 --- /dev/null +++ b/assemble_chroma.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import hashlib +import tarfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent +CHROMA_DIR = ROOT / "chroma_db_docs" +PARTS_DIR = ROOT / "chroma_package" +ARCHIVE_PATH = ROOT / "chroma_db_docs.tar" +DATABASE_PATH = CHROMA_DIR / "chroma.sqlite3" +EXPECTED_ARCHIVE_SHA256 = "656a7155238ba2b6ec99baad3fc22698a4765696ba5cad613a6469ab8dba8add" +EXPECTED_DATABASE_SHA256 = "55b525aca0298422c27393485f9feb146ceb2119fb60f382c81ec44e2b670db6" + + +def main() -> None: + parts = sorted(PARTS_DIR.glob("chroma.tar.part*")) + if not parts: + raise FileNotFoundError(f"No packaged Chroma archive parts found in {PARTS_DIR}") + + digest = hashlib.sha256() + with ARCHIVE_PATH.open("wb") as output: + for part in parts: + payload = part.read_bytes() + output.write(payload) + digest.update(payload) + + actual_sha256 = digest.hexdigest() + if actual_sha256 != EXPECTED_ARCHIVE_SHA256: + ARCHIVE_PATH.unlink(missing_ok=True) + raise RuntimeError( + f"Invalid assembled Chroma archive: expected {EXPECTED_ARCHIVE_SHA256}, got {actual_sha256}" + ) + + with tarfile.open(ARCHIVE_PATH) as archive: + archive.extractall(ROOT, filter="data") + ARCHIVE_PATH.unlink() + + database_sha256 = hashlib.sha256(DATABASE_PATH.read_bytes()).hexdigest() + if database_sha256 != EXPECTED_DATABASE_SHA256: + raise RuntimeError(f"Invalid extracted Chroma database: {database_sha256}") + print(f"Assembled and extracted packaged Chroma database from {len(parts)} parts.") + + +if __name__ == "__main__": + main() diff --git a/chat_history.py b/chat_history.py new file mode 100644 index 0000000000000000000000000000000000000000..2e5afbec85c0aee8cf1afa101af555fadb3e4111 --- /dev/null +++ b/chat_history.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import json +import os + +from dotenv import load_dotenv +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Engine + + +load_dotenv() + +_engine: Engine | None = None + + +def get_engine() -> Engine: + global _engine + if _engine is None: + database_url = os.getenv("DATABASE_URL", "").strip("'\"") + if not database_url: + raise RuntimeError("DATABASE_URL is missing in .env") + _engine = create_engine(database_url, pool_pre_ping=True) + return _engine + + +def list_users() -> list[dict]: + query = text("SELECT id, username FROM auth_user ORDER BY username") + with get_engine().connect() as connection: + return [dict(row) for row in connection.execute(query).mappings()] + + +def list_conversations(user_id: int) -> list[dict]: + query = text(""" + SELECT id, title, created_at, updated_at + FROM chat_conversation + WHERE user_id = :user_id + ORDER BY updated_at DESC + """) + with get_engine().connect() as connection: + return [dict(row) for row in connection.execute(query, {"user_id": user_id}).mappings()] + + +def create_conversation(user_id: int, title: str) -> dict: + query = text(""" + INSERT INTO chat_conversation (user_id, title, created_at, updated_at) + VALUES (:user_id, :title, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id, title, created_at, updated_at + """) + with get_engine().begin() as connection: + user_exists = connection.execute( + text("SELECT 1 FROM auth_user WHERE id = :user_id"), + {"user_id": user_id}, + ).scalar_one_or_none() + if not user_exists: + raise ValueError("User does not exist") + return dict(connection.execute(query, {"user_id": user_id, "title": title[:255]}).mappings().one()) + + +def ensure_conversation_owner(conversation_id: int, user_id: int) -> None: + query = text(""" + SELECT 1 FROM chat_conversation + WHERE id = :conversation_id AND user_id = :user_id + """) + with get_engine().connect() as connection: + if connection.execute(query, {"conversation_id": conversation_id, "user_id": user_id}).scalar_one_or_none() is None: + raise ValueError("Conversation does not exist for this user") + + +def list_messages(conversation_id: int, user_id: int, limit: int = 100) -> list[dict]: + ensure_conversation_owner(conversation_id, user_id) + query = text(""" + SELECT id, role, content, metadata, created_at + FROM ( + SELECT id, role, content, metadata, created_at + FROM chat_message + WHERE conversation_id = :conversation_id + ORDER BY created_at DESC, id DESC + LIMIT :limit + ) recent + ORDER BY created_at, id + """) + with get_engine().connect() as connection: + return [dict(row) for row in connection.execute( + query, + {"conversation_id": conversation_id, "limit": limit}, + ).mappings()] + + +def add_message(conversation_id: int, role: str, content: str, metadata: dict | None = None) -> dict: + if role not in {"user", "assistant", "system", "tool"}: + raise ValueError("Invalid message role") + query = text(""" + INSERT INTO chat_message (conversation_id, role, content, metadata, created_at) + VALUES (:conversation_id, :role, :content, CAST(:metadata AS jsonb), CURRENT_TIMESTAMP) + RETURNING id, role, content, metadata, created_at + """) + with get_engine().begin() as connection: + result = connection.execute(query, { + "conversation_id": conversation_id, + "role": role, + "content": content, + "metadata": json.dumps(metadata or {}), + }).mappings().one() + connection.execute( + text("UPDATE chat_conversation SET updated_at = CURRENT_TIMESTAMP WHERE id = :id"), + {"id": conversation_id}, + ) + return dict(result) + + +def format_history(messages: list[dict]) -> str: + labels = {"user": "Usuario", "assistant": "Asistente", "system": "Sistema", "tool": "Herramienta"} + return "\n".join(f"{labels.get(item['role'], item['role'])}: {item['content']}" for item in messages) diff --git a/chroma_package/chroma.tar.part000 b/chroma_package/chroma.tar.part000 new file mode 100644 index 0000000000000000000000000000000000000000..b2a37a2a074e3d5682cca365e65e75520beb142a --- /dev/null +++ b/chroma_package/chroma.tar.part000 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65c35fd47bd4477e6191c8c21089ca964234e3c9828584727900001954967551 +size 1048576 diff --git a/chroma_package/chroma.tar.part001 b/chroma_package/chroma.tar.part001 new file mode 100644 index 0000000000000000000000000000000000000000..338cddad904beb0969a20cde76f3a268aa587f83 --- /dev/null +++ b/chroma_package/chroma.tar.part001 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa014588b4933dd06d56e4e687a30d7306996b4ec26d36781f28617e32b70646 +size 1048576 diff --git a/chroma_package/chroma.tar.part002 b/chroma_package/chroma.tar.part002 new file mode 100644 index 0000000000000000000000000000000000000000..587b07eed007d8af6c62de769a34fac9394bc1cb --- /dev/null +++ b/chroma_package/chroma.tar.part002 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ba27ec61709167e861a5be3f6201559bae9681dc1486da1d71faa6fd9b419e7 +size 1048576 diff --git a/chroma_package/chroma.tar.part003 b/chroma_package/chroma.tar.part003 new file mode 100644 index 0000000000000000000000000000000000000000..220018f00bbf950cd5ecd2b5d1cc8faffe54474f --- /dev/null +++ b/chroma_package/chroma.tar.part003 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe340cf3d33052d8182c77aeb3348eda508cf3b24d8e3d26062a652e91aa8514 +size 1048576 diff --git a/chroma_package/chroma.tar.part004 b/chroma_package/chroma.tar.part004 new file mode 100644 index 0000000000000000000000000000000000000000..60c7097be5c2b3444361eea121cb7464c1251cc5 --- /dev/null +++ b/chroma_package/chroma.tar.part004 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:575558a748fe946d0d0739ab89561d6f0dd6b5a4604f7761d0b02922a95bbe47 +size 1048576 diff --git a/chroma_package/chroma.tar.part005 b/chroma_package/chroma.tar.part005 new file mode 100644 index 0000000000000000000000000000000000000000..2ff83c0090e2a2f5a7c5dd634a91f9cd7b07b65c --- /dev/null +++ b/chroma_package/chroma.tar.part005 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45176f26eb2b299c40cf3f9299537da87f832850d1d4919e4b64dbc1eb50f4ad +size 1048576 diff --git a/chroma_package/chroma.tar.part006 b/chroma_package/chroma.tar.part006 new file mode 100644 index 0000000000000000000000000000000000000000..64ef2ca6dee15ceed0fad3d2ed2895e4205513c7 --- /dev/null +++ b/chroma_package/chroma.tar.part006 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a1a1a9eb15c41f08b17647435808ae0043d9202834328da808aca3291a9f29c +size 1048576 diff --git a/chroma_package/chroma.tar.part007 b/chroma_package/chroma.tar.part007 new file mode 100644 index 0000000000000000000000000000000000000000..079138ee0e7207965e91533ab80417a1df30f210 --- /dev/null +++ b/chroma_package/chroma.tar.part007 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e980c7b513a6a215a0e695d608e4e458fb032bda66989ab06a3c7143d7aca38f +size 1048576 diff --git a/chroma_package/chroma.tar.part008 b/chroma_package/chroma.tar.part008 new file mode 100644 index 0000000000000000000000000000000000000000..181712cb40e926175e47232ef16c29943fa2ccad --- /dev/null +++ b/chroma_package/chroma.tar.part008 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e0b9f5a70402f580dc5f3e22d81f3cc2974d8b3097616eb862e90d52a994ba0 +size 1048576 diff --git a/chroma_package/chroma.tar.part009 b/chroma_package/chroma.tar.part009 new file mode 100644 index 0000000000000000000000000000000000000000..7153c22cb1b94e9d5b47c95b5e530917adb4b04d --- /dev/null +++ b/chroma_package/chroma.tar.part009 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca2ba0cae914969c2b40b6968cac201e369c4a491ec6d2bb8a51ad8548e1be1d +size 1048576 diff --git a/chroma_package/chroma.tar.part010 b/chroma_package/chroma.tar.part010 new file mode 100644 index 0000000000000000000000000000000000000000..d9e0f742a18445fe68e4df2ae715a2b24f4b9766 --- /dev/null +++ b/chroma_package/chroma.tar.part010 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb03e8b9b6ca1b7ff3ce1036204813c9329a31ee43629597ba9a012fa056d99a +size 1048576 diff --git a/chroma_package/chroma.tar.part011 b/chroma_package/chroma.tar.part011 new file mode 100644 index 0000000000000000000000000000000000000000..8e9c31a8317345dbffcc1651b88d00cca316304f --- /dev/null +++ b/chroma_package/chroma.tar.part011 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0c9cfe276a0ec34a38e919797cf0f78ea7ef699f07b8d3369f4bea6edf399d1 +size 1048576 diff --git a/chroma_package/chroma.tar.part012 b/chroma_package/chroma.tar.part012 new file mode 100644 index 0000000000000000000000000000000000000000..7ee0293c380fddf975da0a993bdf7b5235a2c22a --- /dev/null +++ b/chroma_package/chroma.tar.part012 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d15559befdb6feba40ba6f1b1837f89a04fd57d656966e5b6cac80e656f749d2 +size 1048576 diff --git a/chroma_package/chroma.tar.part013 b/chroma_package/chroma.tar.part013 new file mode 100644 index 0000000000000000000000000000000000000000..60afaba3f01ad1268c2e27d25ddaacbb219c4b95 --- /dev/null +++ b/chroma_package/chroma.tar.part013 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f07de4d98338f834410f87f9e9b8370dd0a5c5bdc025c4a7a31e15154291741 +size 1048576 diff --git a/chroma_package/chroma.tar.part014 b/chroma_package/chroma.tar.part014 new file mode 100644 index 0000000000000000000000000000000000000000..6ff8a74c15d524bdf8ccce040b03b55a7b764dfb --- /dev/null +++ b/chroma_package/chroma.tar.part014 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f3511b64d121fb3bf7d153953d337b6ca927e8b5b663ffed966ea2e0c4b4628e +size 1048576 diff --git a/chroma_package/chroma.tar.part015 b/chroma_package/chroma.tar.part015 new file mode 100644 index 0000000000000000000000000000000000000000..8f782ffb2e2b2edeefea5eb02793704cbbfb4a30 --- /dev/null +++ b/chroma_package/chroma.tar.part015 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e4f060d4976062708cc284f17f5d7c1abfd96a1a2731906a692ae8e2056f1b0 +size 1048576 diff --git a/chroma_package/chroma.tar.part016 b/chroma_package/chroma.tar.part016 new file mode 100644 index 0000000000000000000000000000000000000000..f57794d6cfb9b5bad1352d521ae215f4df4db425 --- /dev/null +++ b/chroma_package/chroma.tar.part016 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d31f964daa207c8e01212eec3b65b2b7c0e822230da8050d63071bb48fd8104 +size 1048576 diff --git a/chroma_package/chroma.tar.part017 b/chroma_package/chroma.tar.part017 new file mode 100644 index 0000000000000000000000000000000000000000..a029b94cd4fa40d48fbc4b50d0d7f256bd248600 --- /dev/null +++ b/chroma_package/chroma.tar.part017 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:452e566c99d6ae83dcc51f76fa6e05ca973b5252b583197c203cd408503edcaa +size 1048576 diff --git a/chroma_package/chroma.tar.part018 b/chroma_package/chroma.tar.part018 new file mode 100644 index 0000000000000000000000000000000000000000..20e02eb52c2c3b8b2f9291a6a99ae07303eba2c6 --- /dev/null +++ b/chroma_package/chroma.tar.part018 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d520d26d67d9417e106e476412062397d8da26eeaaff990a28d7d8d4a25f9a5 +size 1048576 diff --git a/chroma_package/chroma.tar.part019 b/chroma_package/chroma.tar.part019 new file mode 100644 index 0000000000000000000000000000000000000000..72c6717470e7eac3aafc69290d66b9fc1063566c --- /dev/null +++ b/chroma_package/chroma.tar.part019 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6b44242930fedc80d1f296090ec1c6430cf5ee82d6a831fc0c1ccfab556d8839 +size 1048576 diff --git a/chroma_package/chroma.tar.part020 b/chroma_package/chroma.tar.part020 new file mode 100644 index 0000000000000000000000000000000000000000..a4b5e1519ed8189e1615e45a40bca13a37d3fad7 --- /dev/null +++ b/chroma_package/chroma.tar.part020 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ced66eb812af87fdd7d4510dec1d961b11305a0f41f62524908bee06039ae7e +size 1048576 diff --git a/chroma_package/chroma.tar.part021 b/chroma_package/chroma.tar.part021 new file mode 100644 index 0000000000000000000000000000000000000000..4d7515fc1008b84935484151e12403e383a85d4b --- /dev/null +++ b/chroma_package/chroma.tar.part021 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98852c1de48470bb0e376fe2ea1c993bf45f1fe7219d7bb9f6160e6cbfd24fe6 +size 1048576 diff --git a/chroma_package/chroma.tar.part022 b/chroma_package/chroma.tar.part022 new file mode 100644 index 0000000000000000000000000000000000000000..c7b090eb10c8c1efde6f995bb5ad239a1e1918bc --- /dev/null +++ b/chroma_package/chroma.tar.part022 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:669b5a07aab5765ae82aac8c8fc9f1983b59c155da1ccde62fbf1f9b9aca5fda +size 1048576 diff --git a/chroma_package/chroma.tar.part023 b/chroma_package/chroma.tar.part023 new file mode 100644 index 0000000000000000000000000000000000000000..0f1443052053d63eb2e4792cff106957c6e3408d --- /dev/null +++ b/chroma_package/chroma.tar.part023 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8770292cfbd0bb1f21ac2790430c70bc0f59f9192f7870ee440cc03ef2b71a83 +size 1048576 diff --git a/chroma_package/chroma.tar.part024 b/chroma_package/chroma.tar.part024 new file mode 100644 index 0000000000000000000000000000000000000000..72b6a2882e9ac14e47f9ea7c84e861b33e297bbb --- /dev/null +++ b/chroma_package/chroma.tar.part024 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c93c7f96953e1111142a9bc1d4028802b3b23eaca468f1d50dbc7364beeae3c +size 1048576 diff --git a/chroma_package/chroma.tar.part025 b/chroma_package/chroma.tar.part025 new file mode 100644 index 0000000000000000000000000000000000000000..83f74b984d11ffe6779b0dc5bd441de3b4e32fa7 --- /dev/null +++ b/chroma_package/chroma.tar.part025 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d36867e83340a7c71db5d24dd58d62e18cae88d3fdad220e594f65d51f558028 +size 1048576 diff --git a/chroma_package/chroma.tar.part026 b/chroma_package/chroma.tar.part026 new file mode 100644 index 0000000000000000000000000000000000000000..b57268f0ef3d3145e0ea6cf780ff225f19ef2f5e --- /dev/null +++ b/chroma_package/chroma.tar.part026 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3f11fe483337ab16ca169371a7ad9073eba0439d81f510bb38da77b2dbc6a66 +size 1048576 diff --git a/chroma_package/chroma.tar.part027 b/chroma_package/chroma.tar.part027 new file mode 100644 index 0000000000000000000000000000000000000000..990c987f16ec4a6c6fbb122b18b6ee10d8755004 --- /dev/null +++ b/chroma_package/chroma.tar.part027 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1acff85b6b1c5562abcfc1bfcdfde113af93b93c7e21bb3090c77886265b13b1 +size 1048576 diff --git a/chroma_package/chroma.tar.part028 b/chroma_package/chroma.tar.part028 new file mode 100644 index 0000000000000000000000000000000000000000..7300afd407a4bbe763b90a6905a6d5b9fc208645 --- /dev/null +++ b/chroma_package/chroma.tar.part028 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:caae7161018565b34a3415ba796afc3ce6d416adc88839529bd583b21ddbafc2 +size 1048576 diff --git a/chroma_package/chroma.tar.part029 b/chroma_package/chroma.tar.part029 new file mode 100644 index 0000000000000000000000000000000000000000..c40c188b33cab144c4b3e7db257aa76f8046f9aa --- /dev/null +++ b/chroma_package/chroma.tar.part029 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa653f4e877833b34c9c0d4872864dede62a7cb3342fe3e30f0a802f6240806c +size 1048576 diff --git a/chroma_package/chroma.tar.part030 b/chroma_package/chroma.tar.part030 new file mode 100644 index 0000000000000000000000000000000000000000..9aff37a0b069f71509a5de09d041842dc63e0004 --- /dev/null +++ b/chroma_package/chroma.tar.part030 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34a833b0ddfd963065e6495865615c55551ef1e303e1b7d35e445eaf9f132cf9 +size 1048576 diff --git a/chroma_package/chroma.tar.part031 b/chroma_package/chroma.tar.part031 new file mode 100644 index 0000000000000000000000000000000000000000..2e49a0c66c5cf192dc9acf9fef08f43c37f31018 --- /dev/null +++ b/chroma_package/chroma.tar.part031 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a6a871bad03781ede5e0b2bf8ac942b863fb0d148f8c543173e112c06c3a13e0 +size 1048576 diff --git a/chroma_package/chroma.tar.part032 b/chroma_package/chroma.tar.part032 new file mode 100644 index 0000000000000000000000000000000000000000..9dff7a142b98d833c58ecc59a9e03a1e5bc4fc65 --- /dev/null +++ b/chroma_package/chroma.tar.part032 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4bfab78062f40a1584d3d2e770bcb68bf2fe3dfde4748212b05f1ac0b7479e4c +size 1048576 diff --git a/chroma_package/chroma.tar.part033 b/chroma_package/chroma.tar.part033 new file mode 100644 index 0000000000000000000000000000000000000000..1ee94379b4908a1fac943db1634e0b998b7d042b --- /dev/null +++ b/chroma_package/chroma.tar.part033 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:10a8257a9fe92f51f5ed87073e2b2f8bb9581e5f89b21c0d15a4a430a6faacb2 +size 1048576 diff --git a/chroma_package/chroma.tar.part034 b/chroma_package/chroma.tar.part034 new file mode 100644 index 0000000000000000000000000000000000000000..fd6db2663a4124d584d078dfc5cfec5fb781c793 --- /dev/null +++ b/chroma_package/chroma.tar.part034 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b43d1c0a5ebc09ee9946e27d946f0b330e29395fa4a28d2854c28c0895edc13 +size 1048576 diff --git a/chroma_package/chroma.tar.part035 b/chroma_package/chroma.tar.part035 new file mode 100644 index 0000000000000000000000000000000000000000..f0400767607ae93f32a847ba749c102eda622894 --- /dev/null +++ b/chroma_package/chroma.tar.part035 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4926d441aa6853986161bf90d6f72b4e03e645172c2a3771fd884126991df108 +size 1048576 diff --git a/chroma_package/chroma.tar.part036 b/chroma_package/chroma.tar.part036 new file mode 100644 index 0000000000000000000000000000000000000000..4f76b660e7b53680f7e7aa1deb99d9f16a10ba8d --- /dev/null +++ b/chroma_package/chroma.tar.part036 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7188e748dd4299c9ffe9a29dfe335f042ceb4a1e013b0136f58f2ee2cf81490 +size 1048576 diff --git a/chroma_package/chroma.tar.part037 b/chroma_package/chroma.tar.part037 new file mode 100644 index 0000000000000000000000000000000000000000..a1bab3dd1089ffc58a794d5f699cfe8d9842dbaa --- /dev/null +++ b/chroma_package/chroma.tar.part037 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c07f9538a0897a8fc376bde94411119d9d78a488090eba6b899be1099ec2657 +size 1048576 diff --git a/chroma_package/chroma.tar.part038 b/chroma_package/chroma.tar.part038 new file mode 100644 index 0000000000000000000000000000000000000000..755de81cf73302e97b23e19c413964d0253da41d --- /dev/null +++ b/chroma_package/chroma.tar.part038 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:792bf9b178553098f3bb8093f62da2745d7df06e8a6fd83cf78d62fe8d5e6b9f +size 1048576 diff --git a/chroma_package/chroma.tar.part039 b/chroma_package/chroma.tar.part039 new file mode 100644 index 0000000000000000000000000000000000000000..c65a721aea67a5d406520a60d0c6da7e183196b0 --- /dev/null +++ b/chroma_package/chroma.tar.part039 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7e54b2490d46a2da6c5624b95a41fbe2d3ebd9ca1abc08aae8b394894692fb94 +size 1048576 diff --git a/chroma_package/chroma.tar.part040 b/chroma_package/chroma.tar.part040 new file mode 100644 index 0000000000000000000000000000000000000000..a34eac796e2f6fde0660d7270482521d6a79eafd --- /dev/null +++ b/chroma_package/chroma.tar.part040 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea33273014fb24f70ee5fab9dc7ea9c5d1d6425aaeb6e5f2b7aa653437e10642 +size 1048576 diff --git a/chroma_package/chroma.tar.part041 b/chroma_package/chroma.tar.part041 new file mode 100644 index 0000000000000000000000000000000000000000..d72bd24a8c2aae7ec1d1229b4407188af85db534 --- /dev/null +++ b/chroma_package/chroma.tar.part041 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:727da8b64de123485c3fb80e3deefcbb23561773fe36ca2fd6765d38fe0e70b7 +size 1048576 diff --git a/chroma_package/chroma.tar.part042 b/chroma_package/chroma.tar.part042 new file mode 100644 index 0000000000000000000000000000000000000000..cc2f93a5491cbaab497a1cb44f6ce2599d575c4e --- /dev/null +++ b/chroma_package/chroma.tar.part042 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:47befd62d954e37220dfcb702e0aee94c78337ce74bee676600a91941fbe2ac7 +size 1048576 diff --git a/chroma_package/chroma.tar.part043 b/chroma_package/chroma.tar.part043 new file mode 100644 index 0000000000000000000000000000000000000000..7271e79ebba2bf5e1ccdaade4ea3beb7f7e09749 --- /dev/null +++ b/chroma_package/chroma.tar.part043 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2019fc2002845779c3e50c300077fcc5dfc91842a1af4fd0a2f6639468754bd +size 1048576 diff --git a/chroma_package/chroma.tar.part044 b/chroma_package/chroma.tar.part044 new file mode 100644 index 0000000000000000000000000000000000000000..31da0fdf6b14e3307f2d12b8fc60f98d6fa53b71 --- /dev/null +++ b/chroma_package/chroma.tar.part044 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3e2a6f60248e664ce69212c51d3f1984cf631c5d555c586ac5f8e32110652bc +size 1048576 diff --git a/chroma_package/chroma.tar.part045 b/chroma_package/chroma.tar.part045 new file mode 100644 index 0000000000000000000000000000000000000000..895e51de7bd1efa7abfabdbf9abb931b6ffe0860 --- /dev/null +++ b/chroma_package/chroma.tar.part045 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:28a67d81fdaedc487990c8ae731bc8d2099e8d7828ad7086cca85889c59f896d +size 1048576 diff --git a/chroma_package/chroma.tar.part046 b/chroma_package/chroma.tar.part046 new file mode 100644 index 0000000000000000000000000000000000000000..0f7f3d76a5eec359415fac7cd0f8b82e33c22ef4 --- /dev/null +++ b/chroma_package/chroma.tar.part046 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19cd190b4bd82d0f7b4eaaeed3a3d04961ddae127d84cf7b7f131c684f3b849f +size 1048576 diff --git a/chroma_package/chroma.tar.part047 b/chroma_package/chroma.tar.part047 new file mode 100644 index 0000000000000000000000000000000000000000..bc190e916392dffe66c8ae274fff756316409acb --- /dev/null +++ b/chroma_package/chroma.tar.part047 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb566d227d244b240c9c6ae8c78425920295af2e27848bd6894e02a013a82b45 +size 1048576 diff --git a/chroma_package/chroma.tar.part048 b/chroma_package/chroma.tar.part048 new file mode 100644 index 0000000000000000000000000000000000000000..ac21c68fdfdb16704dc65ed07807da0b48bd5bbf --- /dev/null +++ b/chroma_package/chroma.tar.part048 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a89d9bb0e8f9ea7cba0bf63b5cf6104cc6b83975ff0f8b077731db4b8d08e08c +size 1048576 diff --git a/chroma_package/chroma.tar.part049 b/chroma_package/chroma.tar.part049 new file mode 100644 index 0000000000000000000000000000000000000000..7e5eb42e4d0c239ba5e990a40b9d62c5f40b407e --- /dev/null +++ b/chroma_package/chroma.tar.part049 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:60282af5c16569a0a5d638c50b71256a20f1624dce3e5f448d23ecab0d5fa4d6 +size 1048576 diff --git a/chroma_package/chroma.tar.part050 b/chroma_package/chroma.tar.part050 new file mode 100644 index 0000000000000000000000000000000000000000..d201de1d405206840221cb7371d97a93f376a83a --- /dev/null +++ b/chroma_package/chroma.tar.part050 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:37f57be6932f75c9c537af574ebdf9f87763203f83eb44aa0d397d10b2f709aa +size 1048576 diff --git a/chroma_package/chroma.tar.part051 b/chroma_package/chroma.tar.part051 new file mode 100644 index 0000000000000000000000000000000000000000..acb69685445b7dce2494b67c58fcd28f4f1eae07 --- /dev/null +++ b/chroma_package/chroma.tar.part051 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbb7bfe7bfab20a2e8c8106975fe08f3e133261e59c4f83df8c49635738009dc +size 577024 diff --git a/documentos/ASTM/ASTM-E112.metadata.json b/documentos/ASTM/ASTM-E112.metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..e9b30fed47dda12708ae9306ac31e8fe91dd11fd --- /dev/null +++ b/documentos/ASTM/ASTM-E112.metadata.json @@ -0,0 +1,5 @@ +{ + "document_class": "ASTM", + "document_type": "ASTM E112", + "keywords": "tamaño de grano, método de interceptos, intercepción, planimétrico, método comparativo" +} diff --git a/documentos/ASTM/ASTM-E112.pdf b/documentos/ASTM/ASTM-E112.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1d406d1ed54b3110919eed64876357db2eab0a46 --- /dev/null +++ b/documentos/ASTM/ASTM-E112.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57dc2bb60c0a5c987923b2d4e93c369779cc03148d57daffeb91984db2ce3add +size 480012 diff --git a/documentos/ASTM/ASTM-E45.metadata.json b/documentos/ASTM/ASTM-E45.metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..c70ff760eeccc48e78d6aea35f9587be8a6a44fd --- /dev/null +++ b/documentos/ASTM/ASTM-E45.metadata.json @@ -0,0 +1,5 @@ +{ + "document_class": "ASTM", + "document_type": "ASTM E45", + "keywords": "Inclusiones, Nivel inclusionario, óxidos, sulfuros, silicatos, alumina, ASTM E45, inclusiones en aceros" +} diff --git a/documentos/ASTM/ASTM-E45.pdf b/documentos/ASTM/ASTM-E45.pdf new file mode 100644 index 0000000000000000000000000000000000000000..62712f7a021c2ccebd0508575ad07b874173e06b --- /dev/null +++ b/documentos/ASTM/ASTM-E45.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef45e02c491cd9fbe2e7679e0db963bfe647904ea2f0b99516eb525ebfa4bf03 +size 431681 diff --git a/download_model.py b/download_model.py new file mode 100644 index 0000000000000000000000000000000000000000..4e2ffe4cc66c78570577911b492fb022f2715fa6 --- /dev/null +++ b/download_model.py @@ -0,0 +1,72 @@ +"""Descarga el repositorio GGUF de Qwen para uso local.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path + +from huggingface_hub import snapshot_download + + +DEST_REPO = os.getenv( + "LOCAL_MODEL_REPO", + "AlbertiTechnology/qwen3-4b-instruct-gguf", +) +MODELS_DIR = Path(os.getenv("MODELS_DIR", "MODELS")) +LOCAL_MODEL_DIR = MODELS_DIR / "qwen3-4b-instruct-gguf" + + +def get_token(required: bool = False) -> str | None: + token = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACEHUB_API_TOKEN") + if required and not token: + raise RuntimeError("HF_TOKEN is required to create and upload the model repository.") + return token + + +def download_local_model( + repo_id: str = DEST_REPO, + local_dir: Path = LOCAL_MODEL_DIR, +) -> Path: + """Descarga el repositorio propio dentro de MODELS para inferencia local.""" + local_dir.parent.mkdir(parents=True, exist_ok=True) + local_path = snapshot_download( + repo_id=repo_id, + token=get_token(), + local_dir=local_dir, + ) + print(f"Modelo local descargado en: {local_path}", flush=True) + return Path(local_path) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "--download-only", + action="store_true", + help="Sólo descarga DEST_REPO dentro de MODELS.", + ) + parser.add_argument( + "--repo", + default=DEST_REPO, + help="Repositorio que se descargara dentro de MODELS.", + ) + parser.add_argument( + "--local-dir", + type=Path, + default=LOCAL_MODEL_DIR, + help="Directorio local donde se guardara el repositorio.", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if args.download_only: + download_local_model(args.repo, args.local_dir) + return + raise SystemExit("Use --download-only.") + + +if __name__ == "__main__": + main() diff --git a/hf_qwen_client.py b/hf_qwen_client.py new file mode 100644 index 0000000000000000000000000000000000000000..6af84862751d80c0933ee35464676465a31edbdc --- /dev/null +++ b/hf_qwen_client.py @@ -0,0 +1,160 @@ +"""Inferencia local con el GGUF Qwen3-4B-Instruct mediante llama.cpp.""" + +from __future__ import annotations + +import os +import threading +import time +from pathlib import Path +from typing import Iterator + +from dotenv import load_dotenv +from llama_cpp import Llama + + +load_dotenv() + +DEFAULT_MODEL = "AlbertiTechnology/qwen3-4b-instruct-gguf" +DEFAULT_MODEL_PATH = ( + Path(__file__).resolve().parent + / "MODELS" + / "qwen3-4b-instruct-gguf" + / "model.gguf" +) +MODEL_ALIASES = { + "Qwen/Qwen3-4B": DEFAULT_MODEL, + "Qwen/Qwen3-4B-Instruct-2507": DEFAULT_MODEL, + "Qwen/Qwen2.5-7B-Instruct": DEFAULT_MODEL, +} + +_load_lock = threading.Lock() +_generation_lock = threading.Lock() +_model: Llama | None = None + + +def local_model_path() -> Path: + return Path(os.getenv("LOCAL_MODEL_PATH", str(DEFAULT_MODEL_PATH))).resolve() + + +def _load_local_model() -> Llama: + global _model + if _model is not None: + return _model + + with _load_lock: + if _model is not None: + return _model + + model_path = local_model_path() + if not model_path.is_file(): + raise RuntimeError(f"Local GGUF model was not found at {model_path}") + + print(f"Loading local GGUF model from {model_path}...", flush=True) + _model = Llama( + model_path=str(model_path), + n_ctx=int(os.getenv("LOCAL_MODEL_CONTEXT_SIZE", "2048")), + n_threads=int(os.getenv("LOCAL_MODEL_THREADS", "2")), + n_threads_batch=int(os.getenv("LOCAL_MODEL_BATCH_THREADS", "2")), + n_batch=int(os.getenv("LOCAL_MODEL_BATCH_SIZE", "256")), + n_gpu_layers=int(os.getenv("LOCAL_MODEL_GPU_LAYERS", "0")), + verbose=os.getenv("LLAMA_CPP_VERBOSE", "false").lower() == "true", + ) + print("Local GGUF model loaded.", flush=True) + return _model + + +def generate_response( + text: str, + model: str = DEFAULT_MODEL, + max_new_tokens: int | None = None, +) -> str: + """Genera una respuesta usando exclusivamente el GGUF local.""" + return "".join(generate_response_stream(text, model, max_new_tokens)).strip() + + +def generate_response_stream( + text: str, + model: str = DEFAULT_MODEL, + max_new_tokens: int | None = None, +) -> Iterator[str]: + """Genera la respuesta local y entrega cada fragmento apenas esta disponible.""" + del model + local_model = _load_local_model() + token_limit = max_new_tokens or int( + os.getenv("LOCAL_MODEL_MAX_NEW_TOKENS", "256") + ) + context_size = int(os.getenv("LOCAL_MODEL_CONTEXT_SIZE", "2048")) + prompt_budget = max(256, context_size - token_limit - 192) + prompt_tokens = local_model.tokenize( + text.encode("utf-8"), + add_bos=False, + special=True, + ) + if len(prompt_tokens) > prompt_budget: + marker = "\n\n[Contexto intermedio recortado por límite de tokens]\n\n" + marker_tokens = local_model.tokenize( + marker.encode("utf-8"), + add_bos=False, + special=True, + ) + available = max(1, prompt_budget - len(marker_tokens)) + start_count = int(available * 0.6) + end_count = available - start_count + kept_tokens = ( + prompt_tokens[:start_count] + + marker_tokens + + prompt_tokens[-end_count:] + ) + text = local_model.detokenize(kept_tokens).decode( + "utf-8", errors="ignore" + ) + print( + "Local prompt truncated | " + f"original_tokens={len(prompt_tokens)} | kept_tokens={len(kept_tokens)} " + f"| budget={prompt_budget}", + flush=True, + ) + messages = [ + { + "role": "system", + "content": ( + "Responde en español de forma directa y concisa. Usa el contexto " + "recuperado de Chroma como fuente principal. Si el contexto no " + "alcanza, dilo claramente. No muestres razonamiento interno." + ), + }, + {"role": "user", "content": f"/no_think\n{text}"}, + ] + + with _generation_lock: + started_at = time.monotonic() + first_chunk = True + print( + f"Local generation prompt submitted | max_tokens={token_limit}", + flush=True, + ) + response = local_model.create_chat_completion( + messages=messages, + max_tokens=token_limit, + temperature=0.7, + top_p=0.8, + top_k=20, + repeat_penalty=1.05, + stream=True, + ) + for chunk in response: + delta = chunk["choices"][0].get("delta", {}) + content = delta.get("content") or delta.get("reasoning_content") + if content: + if first_chunk: + print( + "Local model produced a chunk | " + f"elapsed={time.monotonic() - started_at:.1f}s", + flush=True, + ) + first_chunk = False + yield str(content) + + +if __name__ == "__main__": + print(generate_response("Responde solamente: Qwen GGUF local OK")) diff --git a/hf_text_embeddings.py b/hf_text_embeddings.py new file mode 100644 index 0000000000000000000000000000000000000000..7cea0ba15059d22bb2fa03ed9c6b2f99c2b08465 --- /dev/null +++ b/hf_text_embeddings.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import os +import time +from typing import Iterable + +from dotenv import load_dotenv +from huggingface_hub import InferenceClient +from langchain_core.embeddings import Embeddings +import numpy as np +from sklearn.feature_extraction.text import HashingVectorizer + + +DEFAULT_EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2" +DEFAULT_EMBED_PROVIDER = "local" +DEFAULT_EMBED_BATCH_SIZE = 16 +DEFAULT_EMBED_RETRIES = 3 + +load_dotenv() + + +def get_hf_token() -> str | None: + return os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACEHUB_API_TOKEN") + + +def to_single_vector(result) -> list[float]: + array = np.asarray(result, dtype=np.float32) + if array.ndim == 1: + vector = array + elif array.ndim == 2: + vector = array.mean(axis=0) + elif array.ndim == 3: + vector = array[0].mean(axis=0) + else: + raise RuntimeError(f"Unexpected embedding shape: {array.shape}") + + norm = np.linalg.norm(vector) + if norm: + vector = vector / norm + return vector.astype(float).tolist() + + +class HFTextEmbeddings(Embeddings): + def __init__(self, model: str | None = None, provider: str | None = None) -> None: + self.model = model or os.getenv("HF_EMBED_MODEL", DEFAULT_EMBED_MODEL) + self.provider = provider or os.getenv("HF_EMBED_PROVIDER", DEFAULT_EMBED_PROVIDER) + self.batch_size = int(os.getenv("HF_EMBED_BATCH_SIZE", str(DEFAULT_EMBED_BATCH_SIZE))) + self.retries = int(os.getenv("HF_EMBED_RETRIES", str(DEFAULT_EMBED_RETRIES))) + self.client = None + self.local_vectorizer = None + if self.provider == "local": + # A stateless vectorizer keeps indexing and querying compatible without + # downloading a model or consuming Hugging Face inference credits. + self.local_vectorizer = HashingVectorizer( + n_features=384, + analyzer="char_wb", + ngram_range=(3, 5), + lowercase=True, + alternate_sign=False, + norm="l2", + ) + else: + self.client = InferenceClient(provider=self.provider, api_key=get_hf_token()) + + def embed_documents(self, texts: list[str]) -> list[list[float]]: + return self._embed(texts) + + def embed_query(self, text: str) -> list[float]: + return self._embed([text])[0] + + def _embed(self, texts: Iterable[str]) -> list[list[float]]: + inputs = list(texts) + if not inputs: + return [] + + if self.local_vectorizer is not None: + print(f"Local embeddings payload: inputs={len(inputs)} dimensions=384", flush=True) + return self.local_vectorizer.transform(inputs).toarray().astype(float).tolist() + + vectors: list[list[float]] = [] + for start in range(0, len(inputs), self.batch_size): + batch = inputs[start : start + self.batch_size] + print( + f"Outgoing HF embeddings payload: model={self.model} inputs={len(batch)} " + f"offset={start}", + flush=True, + ) + for text in batch: + vectors.append(self._embed_one(text)) + return vectors + + def _embed_one(self, text: str) -> list[float]: + last_error: Exception | None = None + for attempt in range(1, self.retries + 1): + try: + return to_single_vector( + self.client.feature_extraction( + text, + model=self.model, + normalize=True, + truncate=True, + ) + ) + except Exception as exc: + last_error = exc + if attempt >= self.retries: + break + time.sleep(min(2 * attempt, 5)) + + raise RuntimeError(f"HF embeddings failed after {self.retries} attempts: {last_error}") from last_error diff --git a/index_documents_to_chroma.py b/index_documents_to_chroma.py new file mode 100644 index 0000000000000000000000000000000000000000..045f9b9bf52dd59d269ae3fa716d1c3d5ab595a4 --- /dev/null +++ b/index_documents_to_chroma.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +from typing import Any + +import chromadb +from dotenv import load_dotenv +from langchain.text_splitter import RecursiveCharacterTextSplitter +from langchain_community.vectorstores import Chroma +from langchain_core.documents import Document +from pypdf import PdfReader + +from hf_text_embeddings import HFTextEmbeddings + + +DEFAULT_PERSIST_DIR = "./chroma_db_docs" +DEFAULT_COLLECTION = "document_context" +DEFAULT_DOCUMENTS_DIR = "documentos" +PDF_EXTENSIONS = {".pdf"} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Index local documents into Chroma for RAG.") + parser.add_argument("--documents-dir", default=DEFAULT_DOCUMENTS_DIR) + parser.add_argument("--files", help="Comma-separated document paths. Defaults to all PDFs under documents-dir.") + parser.add_argument("--persist-dir", default=DEFAULT_PERSIST_DIR) + parser.add_argument("--collection", default=DEFAULT_COLLECTION) + parser.add_argument("--chunk-size", type=int, default=1200) + parser.add_argument("--chunk-overlap", type=int, default=180) + parser.add_argument("--reset-collection", action="store_true") + return parser.parse_args() + + +def iter_document_paths(documents_dir: str, files: str | None) -> list[Path]: + if files: + return [Path(item.strip()) for item in files.split(",") if item.strip()] + + root = Path(documents_dir) + if not root.exists(): + return [] + + return [ + path + for path in sorted(root.rglob("*")) + if path.is_file() and path.suffix.lower() in PDF_EXTENSIONS + ] + + +def clean_pdf_text(text: str) -> str: + lines = [line.strip() for line in text.splitlines()] + return "\n".join(line for line in lines if line) + + +def metadata_path_for(path: Path) -> Path: + return path.with_suffix(".metadata.json") + + +def load_document_metadata(path: Path) -> dict[str, Any]: + metadata_path = metadata_path_for(path) + if not metadata_path.exists(): + return {} + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"Invalid metadata object in {metadata_path}.") + return payload + + +def load_pdf(path: Path) -> list[Document]: + reader = PdfReader(str(path)) + docs: list[Document] = [] + document_metadata = load_document_metadata(path) + for page_index, page in enumerate(reader.pages, start=1): + text = clean_pdf_text(page.extract_text() or "") + if not text: + continue + docs.append( + Document( + page_content=text, + metadata={ + "source_type": "document", + "source": str(path), + "filename": path.name, + "page": page_index, + "document_title": path.stem, + **document_metadata, + }, + ) + ) + return docs + + +def stable_id(doc: Document, chunk_index: int) -> str: + source = doc.metadata.get("source", "") + page = doc.metadata.get("page", "") + digest = hashlib.sha1(f"{source}|{page}|{chunk_index}|{doc.page_content}".encode("utf-8")).hexdigest() + return f"document:{Path(str(source)).stem}:p{page}:{digest[:16]}" + + +def main() -> None: + load_dotenv() + args = parse_args() + + paths = iter_document_paths(args.documents_dir, args.files) + if not paths: + raise RuntimeError("No PDF documents found to index.") + + if args.reset_collection: + client = chromadb.PersistentClient(path=args.persist_dir) + try: + client.delete_collection(args.collection) + print(f"Deleted existing Chroma collection: {args.collection}") + except Exception: + print(f"Chroma collection did not exist yet: {args.collection}") + + embeddings = HFTextEmbeddings() + vectorstore = Chroma( + collection_name=args.collection, + embedding_function=embeddings, + persist_directory=args.persist_dir, + ) + splitter = RecursiveCharacterTextSplitter( + chunk_size=args.chunk_size, + chunk_overlap=args.chunk_overlap, + ) + + total_pages = 0 + total_chunks = 0 + for path in paths: + docs = load_pdf(path) + splits = splitter.split_documents(docs) + ids = [stable_id(doc, index) for index, doc in enumerate(splits)] + try: + vectorstore.delete(ids=ids) + except Exception: + pass + vectorstore.add_documents(splits, ids=ids) + total_pages += len(docs) + total_chunks += len(splits) + print(f"Indexed {path}: {len(docs)} pages, {len(splits)} chunks") + + print(f"Done. Added {total_chunks} chunks from {total_pages} pages into {args.persist_dir} / {args.collection}.") + + +if __name__ == "__main__": + main() diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..66769c20ed766f19295c989d58cdb88c94129ad3 --- /dev/null +++ b/main.py @@ -0,0 +1,379 @@ +"""FastAPI entry point for the conversational agent.""" + +import io +import json +import os +import sys +import traceback +from pathlib import Path +from urllib.parse import parse_qs + +from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, StreamingResponse +from pydantic import BaseModel, Field +from starlette.concurrency import run_in_threadpool + +from chat_history import ( + add_message, + create_conversation, + ensure_conversation_owner, + format_history, + list_conversations, + list_messages, + list_users, +) +from hf_qwen_client import ( + DEFAULT_MODEL, + MODEL_ALIASES, + generate_response, + generate_response_stream, +) +from rag_context import build_context_prompt + + +def _configure_utf8_stream(stream): + if stream is None: + return stream + try: + stream.reconfigure(encoding="utf-8", errors="backslashreplace") + return stream + except (AttributeError, ValueError, OSError): + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return io.TextIOWrapper(buffer, encoding="utf-8", errors="backslashreplace") + return stream + + +sys.stdout = _configure_utf8_stream(sys.stdout) +sys.stderr = _configure_utf8_stream(sys.stderr) +os.environ["PYTHONIOENCODING"] = "utf-8" + +app = FastAPI(title="Agent API") +FRONTEND_PATH = Path(__file__).parent / "static" / "chat.html" + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.middleware("http") +async def print_incoming_request(request, call_next): + print( + { + "method": request.method, + "url": str(request.url), + "content_type": request.headers.get("content-type"), + "content_length": request.headers.get("content-length"), + }, + flush=True, + ) + return await call_next(request) + + +class ChatRequest(BaseModel): + text: str = Field(..., min_length=1) + user_id: int = Field(..., ge=1) + conversation_id: int | None = Field(default=None, ge=1) + model: str = DEFAULT_MODEL + context_k: int = Field(default=4, ge=1, le=24) + + +class ChatResponse(BaseModel): + model: str + response: str + conversation_id: int + + +def generate_agent_response( + text: str, + model: str = DEFAULT_MODEL, + context_k: int = 4, + conversation_history: str = "", +) -> ChatResponse: + selected_model = MODEL_ALIASES.get(model, model) + prompt = build_context_prompt( + text, + k=context_k, + conversation_history=conversation_history, + ) + response = generate_response(prompt, model=selected_model) + return ChatResponse(model=selected_model, response=response, conversation_id=0) + + +@app.get("/") +def read_root(): + return FileResponse(FRONTEND_PATH) + + +@app.get("/ui", include_in_schema=False) +def chat_ui(): + return FileResponse(FRONTEND_PATH) + + +@app.get("/health") +def health_check(): + return {"status": "ok"} + + +@app.get("/chat/users") +def chat_users(): + return list_users() + + +@app.get("/chat/conversations") +def chat_conversations(user_id: int): + return list_conversations(user_id) + + +@app.get("/chat/conversations/{conversation_id}/messages") +def chat_messages(conversation_id: int, user_id: int): + try: + return list_messages(conversation_id, user_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +def _chat(request: ChatRequest) -> ChatResponse: + try: + if request.conversation_id is None: + conversation = create_conversation(request.user_id, request.text.strip()[:80]) + conversation_id = conversation["id"] + else: + conversation_id = request.conversation_id + ensure_conversation_owner(conversation_id, request.user_id) + + history = list_messages(conversation_id, request.user_id, limit=12) + add_message(conversation_id, "user", request.text) + + selected_model = MODEL_ALIASES.get(request.model, request.model) + prompt = build_context_prompt( + request.text, + k=request.context_k, + conversation_history=format_history(history), + ) + response = generate_response(prompt, model=selected_model) + add_message( + conversation_id, + "assistant", + response, + {"model": selected_model, "context_k": request.context_k}, + ) + return ChatResponse( + model=selected_model, + response=response, + conversation_id=conversation_id, + ) + except Exception: + traceback.print_exc() + raise + + +async def _read_chat_request(http_request: Request) -> ChatRequest: + content_type = http_request.headers.get("content-type", "").lower() + raw_body = await http_request.body() + stripped_body = raw_body.lstrip() + if "application/json" in content_type or stripped_body.startswith(b"{"): + try: + payload = json.loads(raw_body) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise HTTPException(status_code=422, detail="Invalid JSON body") from exc + elif "application/x-www-form-urlencoded" in content_type: + values = parse_qs( + raw_body.decode("utf-8"), + keep_blank_values=True, + ) + payload = {key: items[-1] for key, items in values.items()} + else: + raise HTTPException(status_code=415, detail="Use JSON or form-urlencoded") + + if set(payload) == {"data"}: + try: + nested_payload = json.loads(payload["data"]) + if isinstance(nested_payload, dict): + payload = nested_payload + except (TypeError, json.JSONDecodeError): + pass + + aliases = { + "message": "text", + "prompt": "text", + "query": "text", + "userId": "user_id", + "conversationId": "conversation_id", + "contextK": "context_k", + } + for source, target in aliases.items(): + if target not in payload and source in payload: + payload[target] = payload.pop(source) + + for optional_field in ("conversation_id", "model", "context_k"): + value = payload.get(optional_field) + if isinstance(value, str) and value.strip().lower() in { + "", + "none", + "null", + "undefined", + }: + payload.pop(optional_field) + + try: + return ChatRequest(**payload) + except Exception as exc: + print( + { + "chat_validation_error": str(exc), + "received_field_count": len(payload), + "recognized_fields": sorted( + key + for key in payload + if key + in { + "text", + "user_id", + "conversation_id", + "model", + "context_k", + } + ), + }, + flush=True, + ) + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@app.post("/chat", response_model=ChatResponse) +async def chat(http_request: Request): + request = await _read_chat_request(http_request) + return await run_in_threadpool(_chat, request) + + +@app.post("/chat/stream") +def chat_stream(request: ChatRequest): + """Entrega eventos NDJSON: metadata, delta, done o error.""" + def event_stream(): + try: + if request.conversation_id is None: + conversation = create_conversation( + request.user_id, request.text.strip()[:80] + ) + conversation_id = conversation["id"] + else: + conversation_id = request.conversation_id + ensure_conversation_owner(conversation_id, request.user_id) + + history = list_messages(conversation_id, request.user_id, limit=12) + add_message(conversation_id, "user", request.text) + selected_model = MODEL_ALIASES.get(request.model, request.model) + + yield _ndjson_event( + "metadata", + conversation_id=conversation_id, + model=selected_model, + ) + yield _ndjson_event("status", text="Buscando contexto ASTM...") + prompt = build_context_prompt( + request.text, + k=request.context_k, + conversation_history=format_history(history), + ) + + yield _ndjson_event("status", text="Generando respuesta...") + print( + f"Streaming generation started | conversation={conversation_id}", + flush=True, + ) + response_parts = [] + for delta in generate_response_stream(prompt, model=selected_model): + if not response_parts: + print( + f"First streamed token | conversation={conversation_id}", + flush=True, + ) + response_parts.append(delta) + yield _ndjson_event("delta", text=delta) + + response = "".join(response_parts).strip() + add_message( + conversation_id, + "assistant", + response, + {"model": selected_model, "context_k": request.context_k}, + ) + print( + f"Streaming generation completed | conversation={conversation_id} " + f"| chars={len(response)}", + flush=True, + ) + yield _ndjson_event("done", response=response) + except Exception as exc: + traceback.print_exc() + yield _ndjson_event("error", detail=str(exc)) + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache, no-transform", + "Content-Encoding": "identity", + "X-Accel-Buffering": "no", + }, + ) + + +def _ndjson_event(event_type: str, **payload) -> str: + return json.dumps({"type": event_type, **payload}, ensure_ascii=False) + "\n" + + +@app.websocket("/ws/chat") +async def websocket_chat(websocket: WebSocket): + await websocket.accept() + try: + while True: + data = await websocket.receive_text() + if not data.strip(): + await websocket.send_json({"error": "Message text is required."}) + continue + try: + result = await run_in_threadpool(generate_agent_response, data) + await websocket.send_json(result.model_dump()) + except Exception as exc: + traceback.print_exc() + await websocket.send_json({"error": f"Hugging Face inference failed: {exc}"}) + except WebSocketDisconnect: + print("Client disconnected from WS", flush=True) + +import os +from fastapi import Depends, Header +from typing import Any, Dict +from report_agent import generate_agentic_report + +INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "alberti-internal-secret") + +async def verify_token(x_service_token: str = Header(...)): + if x_service_token != INTERNAL_API_KEY: + raise HTTPException(status_code=403, detail="Invalid internal service token") + return x_service_token + +class AgentRequest(BaseModel): + report_id: int + muestra_id: int + user_id: int + raw_data: Dict[str, Any] + +@app.post("/generate-report") +async def generate_report(req: AgentRequest, token: str = Depends(verify_token)): + try: + enriched_data = generate_agentic_report( + report_id=req.report_id, + muestra_id=req.muestra_id, + user_id=req.user_id, + raw_data=req.raw_data + ) + return enriched_data + except Exception as e: + raise HTTPException(status_code=500, detail=f"Agent analysis failed: {str(e)}") diff --git a/push_ollama_model_to_hf.py b/push_ollama_model_to_hf.py new file mode 100644 index 0000000000000000000000000000000000000000..54415f7007f494217052237b1a3ff2b7203a32cf --- /dev/null +++ b/push_ollama_model_to_hf.py @@ -0,0 +1,156 @@ +"""Descarga un modelo Ollama, exporta su GGUF, lo sube a HF y lo elimina localmente.""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +from pathlib import Path + +from huggingface_hub import HfApi +from huggingface_hub.errors import RepositoryNotFoundError + + +DEFAULT_MODEL = "qwen3:4b-instruct" +DEFAULT_REPO = "AlbertiTechnology/qwen3-4b-instruct-gguf" +MAX_MODEL_BYTES = 3 * 1024**3 + + +def find_ollama() -> str: + executable = shutil.which("ollama") + if executable: + return executable + + candidates = [ + Path(os.getenv("LOCALAPPDATA", "")) / "Programs" / "Ollama" / "ollama.exe", + Path(os.getenv("LOCALAPPDATA", "")) / "Ollama" / "ollama.exe", + Path(os.getenv("ProgramFiles", "")) / "Ollama" / "ollama.exe", + ] + for candidate in candidates: + if candidate.is_file(): + return str(candidate) + + raise RuntimeError( + "Ollama no esta instalado. Instale Ollama, cierre y abra la terminal, " + "y vuelva a ejecutar este script." + ) + + +def run_ollama(*args: str, capture: bool = False) -> str: + executable = find_ollama() + result = subprocess.run( + [executable, *args], + check=True, + text=True, + capture_output=capture, + encoding="utf-8", + errors="replace", + ) + return result.stdout if capture else "" + + +def model_blob_from_modelfile(modelfile: str) -> Path: + match = re.search(r"^FROM\s+(.+?)\s*$", modelfile, flags=re.MULTILINE) + if not match: + raise RuntimeError("No se encontro la capa GGUF en 'ollama show --modelfile'.") + + raw_path = match.group(1).strip().strip('"') + blob_path = Path(raw_path) + if not blob_path.is_file(): + raise FileNotFoundError(f"Ollama informo un blob inexistente: {blob_path}") + return blob_path + + +def exported_modelfile(original: str) -> str: + return re.sub( + r"^FROM\s+.+?$", + "FROM ./model.gguf", + original, + count=1, + flags=re.MULTILINE, + ) + + +def upload_model(model: str, repo_id: str, token: str) -> None: + print(f"Descargando {model} con Ollama...", flush=True) + run_ollama("pull", model) + modelfile = run_ollama("show", "--modelfile", model, capture=True) + blob_path = model_blob_from_modelfile(modelfile) + blob_size = blob_path.stat().st_size + if blob_size > MAX_MODEL_BYTES: + raise RuntimeError( + f"El GGUF ocupa {blob_size / 1024**3:.2f} GiB y supera el limite de 3 GiB." + ) + + readme = "\n".join( + [ + "---", + "library_name: llama.cpp", + "tags:", + "- gguf", + "- ollama", + "- qwen3", + "---", + "", + f"# {model}", + "", + "Modelo GGUF exportado desde Ollama para inferencia local.", + ] + ) + + api = HfApi(token=token) + try: + api.repo_info(repo_id=repo_id, repo_type="model") + print(f"Repositorio existente encontrado: {repo_id}", flush=True) + except RepositoryNotFoundError: + api.create_repo(repo_id=repo_id, repo_type="model") + print( + f"Subiendo directamente el blob de {blob_size / 1024**3:.2f} GiB; " + "no se creara una copia local...", + flush=True, + ) + api.upload_file( + path_or_fileobj=blob_path, + path_in_repo="model.gguf", + repo_id=repo_id, + repo_type="model", + commit_message=f"Upload {model} GGUF from Ollama", + ) + api.upload_file( + path_or_fileobj=exported_modelfile(modelfile).encode("utf-8"), + path_in_repo="Modelfile", + repo_id=repo_id, + repo_type="model", + ) + api.upload_file( + path_or_fileobj=readme.encode("utf-8"), + path_in_repo="README.md", + repo_id=repo_id, + repo_type="model", + ) + + print(f"Subida completada: https://huggingface.co/{repo_id}", flush=True) + print(f"Eliminando {model} del almacenamiento local de Ollama...", flush=True) + run_ollama("rm", model) + print("Modelo local y archivos temporales eliminados.", flush=True) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--model", default=DEFAULT_MODEL) + parser.add_argument("--repo", default=DEFAULT_REPO) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + token = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACEHUB_API_TOKEN") + if not token: + raise RuntimeError("Defina HF_TOKEN con permiso de escritura antes de ejecutar.") + upload_model(args.model, args.repo, token) + + +if __name__ == "__main__": + main() diff --git a/rag_context.py b/rag_context.py new file mode 100644 index 0000000000000000000000000000000000000000..1b11f59f90cbd9d47c23bf2230ff841c196c46c5 --- /dev/null +++ b/rag_context.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import os +import numpy as np +from dotenv import load_dotenv +from langchain_community.vectorstores import Chroma + +from hf_text_embeddings import HFTextEmbeddings + + +DOCUMENT_PERSIST_DIR = "./chroma_db_docs" +DOCUMENT_COLLECTION = "document_context" + +load_dotenv() + + +def _metadata_as_text(metadata: dict) -> str: + searchable_fields = ( + "document_title", + "document_type", + "document_class", + "keywords", + "filename", + ) + return " | ".join( + str(metadata[field]).strip() + for field in searchable_fields + if metadata.get(field) + ) + + +def _combine_filters(first: dict | None, second: dict) -> dict: + if not first: + return second + return {"$and": [first, second]} + + +def _classify_document_filter( + vectorstore: Chroma, + embeddings: HFTextEmbeddings, + query: str, + base_filter: dict | None = None, +) -> tuple[dict | None, dict | None, float | None]: + get_kwargs = {"include": ["metadatas"]} + if base_filter: + get_kwargs["where"] = base_filter + stored = vectorstore.get(**get_kwargs) + + candidates: dict[str, dict] = {} + for metadata in stored.get("metadatas") or []: + if not metadata: + continue + source = str(metadata.get("source") or "") + if source and _metadata_as_text(metadata): + candidates.setdefault(source, metadata) + + if not candidates: + return base_filter, None, None + + sources = list(candidates) + metadata_texts = [_metadata_as_text(candidates[source]) for source in sources] + query_vector = np.asarray(embeddings.embed_query(query), dtype=np.float32) + metadata_vectors = np.asarray( + embeddings.embed_documents(metadata_texts), + dtype=np.float32, + ) + query_norm = np.linalg.norm(query_vector) + metadata_norms = np.linalg.norm(metadata_vectors, axis=1) + denominators = metadata_norms * query_norm + scores = np.divide( + metadata_vectors @ query_vector, + denominators, + out=np.zeros(len(metadata_vectors), dtype=np.float32), + where=denominators != 0, + ) + best_index = int(np.argmax(scores)) + best_source = sources[best_index] + best_metadata = candidates[best_source] + return ( + _combine_filters(base_filter, {"source": {"$eq": best_source}}), + best_metadata, + float(scores[best_index]), + ) + + +def retrieve_document_context( + query: str, + k: int = 4, + filter_metadata: dict | None = None, +) -> str: + try: + embeddings = HFTextEmbeddings() + vectorstore = Chroma( + collection_name=os.getenv("DOCUMENT_CHROMA_COLLECTION", DOCUMENT_COLLECTION), + embedding_function=embeddings, + persist_directory=os.getenv("DOCUMENT_CHROMA_DIR", DOCUMENT_PERSIST_DIR), + ) + selected_filter, selected_metadata, metadata_score = _classify_document_filter( + vectorstore, + embeddings, + query, + filter_metadata, + ) + if selected_metadata is not None: + print( + "Metadata classifier | " + f"score={metadata_score:.4f} | " + f"document={selected_metadata.get('document_title')} | " + f"type={selected_metadata.get('document_type')}", + flush=True, + ) + + # Evita el query HNSW de Chroma, que puede bloquearse en algunos + # contenedores. La coleccion ASTM es pequena, por lo que un ranking + # coseno directo sobre los embeddings persistidos es rapido y estable. + get_kwargs = {"include": ["documents", "metadatas", "embeddings"]} + if selected_filter: + get_kwargs["where"] = selected_filter + stored = vectorstore.get(**get_kwargs) + documents = stored.get("documents") or [] + metadatas = stored.get("metadatas") or [] + stored_embeddings = stored.get("embeddings") + if not documents or stored_embeddings is None: + print("Document Chroma returned no searchable chunks.", flush=True) + return "" + + query_vector = np.asarray(embeddings.embed_query(query), dtype=np.float32) + chunk_vectors = np.asarray(stored_embeddings, dtype=np.float32) + query_norm = np.linalg.norm(query_vector) + chunk_norms = np.linalg.norm(chunk_vectors, axis=1) + denominators = chunk_norms * query_norm + similarities = np.divide( + chunk_vectors @ query_vector, + denominators, + out=np.zeros(len(chunk_vectors), dtype=np.float32), + where=denominators != 0, + ) + top_indices = np.argsort(similarities)[::-1][: min(k, len(documents))] + blocks = [] + for rank, index in enumerate(top_indices, start=1): + metadata = metadatas[index] if index < len(metadatas) else {} + print( + f"Document rank {rank} | similarity={similarities[index]:.4f} | " + f"document={metadata.get('document_title')}", + flush=True, + ) + blocks.append( + f"[Documento {rank}] metadata={metadata}\n{documents[index]}" + ) + return "\n\n".join(blocks) + except Exception as exc: + print(f"Document Chroma retrieval skipped: {exc}", flush=True) + return "" + + +def build_context_prompt( + user_text: str, + k: int = 4, + conversation_history: str = "", +) -> str: + document_context = retrieve_document_context(user_text, k=k) + + context_parts = [] + if document_context: + context_parts.append("Contexto normativo/documental recuperado de documentos/Chroma:\n" + document_context) + + if not context_parts: + return user_text + + context = "\n\n".join(context_parts) + return f"""Sos el agente del proyecto Alberti/Metalurgia. +Usa como fuente principal el contexto técnico y normativo recuperado desde los documentos indexados en Chroma. +Cuando respondas sobre datos del proyecto, prioriza los registros recuperados y menciona información relacionada si ayudan pero no divulgues ID o datos propios de los registros. +Si el contexto recuperado no alcanza para responder con precision, dilo claramente y no inventes datos exactos. + +{context} + +Historial reciente de esta conversacion: +{conversation_history or "Sin mensajes anteriores."} + +Pregunta del usuario: +{user_text}""" diff --git a/report_agent.py b/report_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..e4d111fcf1b9a1e4f0cc3bfe9e7138860be9db45 --- /dev/null +++ b/report_agent.py @@ -0,0 +1,102 @@ +import json +from pydantic import BaseModel, Field +from typing import List, Dict, Any, Optional +from hf_qwen_client import generate_response +from rag_context import retrieve_document_context +from chat_history import list_conversations, list_messages, format_history + +class AnalisisIA(BaseModel): + redaccion_tecnica: str = Field(..., description="Análisis técnico detallado basado en las micrografías y contexto de la muestra. Máximo 3 párrafos.") + resumen_ejecutivo: str = Field(..., description="Resumen conciso y al grano para el cliente final sobre los resultados.") + conformidad_norma: bool = Field(..., description="¿Cumple la muestra con los estándares ASTM E112 en base a los datos proporcionados?") + observaciones: str = Field(..., description="Observaciones adicionales relevantes.") + analisis_regiones: Dict[str, str] = Field( + default_factory=dict, + description="Breve análisis o comentario de no más de 1 párrafo para cada región identificada en la muestra, usando el nombre de la región como clave." + ) + conclusion: str = Field(..., description="Conclusión final y de cierre del reporte. Debe ser un párrafo contundente.") + +def get_chat_context(user_id: int) -> str: + try: + conversations = list_conversations(user_id) + if not conversations: + return "No hay conversaciones previas del usuario." + + # Tomamos la ultima conversacion del usuario + latest_conv_id = conversations[0]["id"] + messages = list_messages(latest_conv_id, user_id, limit=20) + history = format_history(messages) + return history + except Exception as e: + print(f"Error fetching chat history: {e}") + return "No se pudo recuperar el historial de chat." + +def generate_agentic_report(report_id: int, muestra_id: int, user_id: int, raw_data: dict) -> dict: + + chat_context = get_chat_context(user_id) + + # RAG: Retrieve ASTM document context + # Create a search query based on the data + material = raw_data.get('muestra', {}).get('material', 'metal') + rag_query = f"norma ASTM E112 tamaño de grano {material}" + astm_context = retrieve_document_context(rag_query, k=3) + + system_prompt = f"""Eres un experto metalurgista especializado en la norma ASTM E112. +Tu tarea es analizar los datos de mediciones de tamaño de grano de una muestra y generar conclusiones técnicas de alto valor. + +CONTEXTO NORMATIVO (ASTM): +{astm_context} + +CONTEXTO ADICIONAL DEL CLIENTE (Chat reciente): +{chat_context} + +Debes proporcionar un JSON válido con el análisis de la muestra que respete estrictamente el esquema solicitado. +Claves requeridas: "redaccion_tecnica", "resumen_ejecutivo", "conformidad_norma" (boolean), "observaciones", "analisis_regiones" (diccionario de region -> texto), "conclusion". +Mantén la redacción técnica precisa pero limitada a 3 párrafos como máximo. +""" + + import copy + clean_data = copy.deepcopy(raw_data) + + # Remove heavy arrays and URLs to avoid exceeding context limits on free API + if 'material' in clean_data: + clean_data['material'].pop('muestra_image_url', None) + clean_data['material'].pop('quality_hist_path', None) + if 'region_data' in clean_data['material']: + for region_name, rdata in clean_data['material']['region_data'].items(): + rdata.pop('imagen', None) + rdata.pop('hist_path', None) + for micro in rdata.get('micrografias', []): + micro.pop('imagen', None) + micro.pop('measure_imagen', None) + micro.pop('distribution_um', None) + + user_prompt = f"Genera el análisis técnico en JSON puro (sin Markdown) para la siguiente muestra:\n{json.dumps(clean_data, ensure_ascii=False)}" + + try: + content = generate_response( + f"{system_prompt}\n\n{user_prompt}", + max_new_tokens=1500, + ) + content = content.strip() + if content.startswith("```json"): + content = content[7:-3].strip() + elif content.startswith("```"): + content = content[3:-3].strip() + + analisis = json.loads(content) + validated_analisis = AnalisisIA(**analisis) + + raw_data["analisis_ia"] = validated_analisis.model_dump() + return raw_data + + except Exception as e: + print(f"Error calling LLM: {e}") + raw_data["analisis_ia"] = { + "redaccion_tecnica": "No se pudo generar el análisis automático.", + "resumen_ejecutivo": "Error en el agente de IA.", + "conformidad_norma": False, + "observaciones": str(e), + "conclusion": "No se pudo generar la conclusión." + } + return raw_data diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..c5686ac72dc651263be5eba5f068a38982580c2b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,19 @@ +langchain==0.3.7 +langchain-community==0.3.7 +langchain-text-splitters==0.3.2 +chromadb==0.6.3 +fastapi==0.115.6 +huggingface_hub==0.30.2 +pypdf==5.1.0 +pydantic==2.9.2 +psycopg2-binary==2.9.10 +SQLAlchemy==2.0.35 +unstructured==0.16.6 +unstructured-client==0.27.0 +python-dotenv==1.0.1 +requests==2.32.3 +scikit-learn==1.5.2 +uvicorn[standard]==0.34.0 +websockets +openai +llama-cpp-python @ https://github.com/abetlen/llama-cpp-python/releases/download/v0.3.34/llama_cpp_python-0.3.34-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl diff --git a/start_hf.py b/start_hf.py new file mode 100644 index 0000000000000000000000000000000000000000..ab75ed2a793c14399541924b4cc25270284fac35 --- /dev/null +++ b/start_hf.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + +import chromadb + + +ROOT = Path(__file__).resolve().parent +CHROMA_PATH = ROOT / "chroma_db_docs" +CHROMA_COLLECTION = "document_context" +MODEL_PATH = ROOT / "MODELS" / "qwen3-4b-instruct-gguf" + + +def log(stage: str, message: str) -> None: + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + print(f"[{timestamp}] [BOOT:{stage}] {message}", flush=True) + + +def require_environment() -> None: + missing = [name for name in ("DATABASE_URL",) if not os.getenv(name)] + if missing: + raise RuntimeError(f"Missing required Space secrets: {', '.join(missing)}") + log("ENV", "Required secrets are present (values are not printed).") + + +def validate_document_chroma() -> None: + database_path = CHROMA_PATH / "chroma.sqlite3" + if not database_path.is_file(): + raise FileNotFoundError(f"Generated Chroma database was not found: {database_path}") + + client = chromadb.PersistentClient(path=str(CHROMA_PATH)) + collection = client.get_collection(CHROMA_COLLECTION) + item_count = collection.count() + if item_count == 0: + raise RuntimeError(f"Generated Chroma collection is empty: {CHROMA_COLLECTION}") + log("CHROMA", f"Using build-generated {CHROMA_COLLECTION} collection ({item_count} chunks).") + + +def validate_local_model() -> None: + required_files = ("model.gguf",) + missing = [name for name in required_files if not (MODEL_PATH / name).is_file()] + if missing: + raise FileNotFoundError( + f"Local Qwen model is incomplete at {MODEL_PATH}; missing: {', '.join(missing)}" + ) + log("MODEL", f"Local Qwen model is available at {MODEL_PATH}.") + + +def start_api() -> None: + port = os.getenv("PORT", "7860") + log("API", f"Starting Agent API on 0.0.0.0:{port}...") + os.execv( + sys.executable, + [ + sys.executable, + "-m", + "uvicorn", + "main:app", + "--host", + "0.0.0.0", + "--port", + port, + "--log-level", + "info", + "--access-log", + ], + ) + + +def main() -> None: + log("START", "Starting Hugging Face Space initialization.") + require_environment() + validate_document_chroma() + validate_local_model() + start_api() + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + log("ERROR", f"Startup aborted: {exc}") + raise diff --git a/static/chat.html b/static/chat.html new file mode 100644 index 0000000000000000000000000000000000000000..ac39897500a168bf4e497acc290eb06ed4677ae9 --- /dev/null +++ b/static/chat.html @@ -0,0 +1,178 @@ + + + + + + Agente de Metalurgia + + + +
+
+

Agente de Metalurgia

+

Conversaciones persistentes con contexto de ASTM E112

+
+
+ + + +
+
+
+ + +
+
+ + +