File size: 9,235 Bytes
11840ab
db69bb3
6998f76
49cd656
 
11840ab
db69bb3
11840ab
773b423
db69bb3
49cd656
6998f76
30b2755
11840ab
 
 
 
 
 
 
 
49cd656
 
 
 
 
 
 
 
 
 
 
11840ab
 
49cd656
 
 
 
 
 
 
 
 
11840ab
49cd656
 
11840ab
49cd656
 
 
11840ab
 
 
49cd656
 
11840ab
30b2755
 
49cd656
 
 
 
 
 
 
 
 
 
11840ab
49cd656
11840ab
49cd656
 
 
 
11840ab
 
49cd656
11840ab
 
 
 
 
 
 
49cd656
 
 
11840ab
49cd656
db69bb3
49cd656
 
 
11840ab
 
 
 
 
 
49cd656
 
 
 
 
 
11840ab
 
 
 
 
 
 
49cd656
db69bb3
11840ab
 
49cd656
 
 
 
 
 
 
11840ab
 
 
 
 
 
49cd656
11840ab
49cd656
 
 
11840ab
49cd656
11840ab
 
 
 
 
 
49cd656
11840ab
49cd656
db69bb3
11840ab
49cd656
 
 
 
 
 
 
 
 
 
30b2755
49cd656
11840ab
 
 
 
db69bb3
11840ab
 
49cd656
11840ab
49cd656
11840ab
49cd656
 
 
11840ab
 
 
 
 
 
6998f76
8e233d4
49cd656
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11840ab
 
49cd656
 
 
 
 
 
 
 
 
11840ab
 
49cd656
 
 
 
 
 
 
 
 
11840ab
 
49cd656
 
 
 
11840ab
30b2755
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
# app.py
import os
import gradio as gr

# LangChain + Google Gemini
from langchain_google_genai import ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_community.document_loaders import PyPDFLoader, TextLoader, Docx2txtLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate


# -----------------------
# Utilidades
# -----------------------
def get_api_key():
    # Lee secretos en runtime para evitar "faltan" claves si el env se inyectó después del import
    return os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY")


def _get_path(file_obj):
    if file_obj is None:
        return None
    if hasattr(file_obj, "name"):
        return file_obj.name
    if isinstance(file_obj, str):
        return file_obj
    if isinstance(file_obj, dict) and "name" in file_obj:
        return file_obj["name"]
    return None


def _load_docs(path):
    ext = (path or "").lower().split(".")[-1]
    if ext == "pdf":
        return PyPDFLoader(path).load()
    if ext == "docx":
        return Docx2txtLoader(path).load()
    if ext == "txt":
        return TextLoader(path, encoding="utf-8").load()
    raise ValueError(f"Formato no soportado: .{ext} (usa PDF, DOCX o TXT)")


def _split(docs):
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=800, chunk_overlap=120, separators=["\n\n", "\n", ". ", " ", ""]
    )
    return splitter.split_documents(docs)


def build_chain(retriever, system_message, temperature, api_key):
    if retriever is None:
        raise RuntimeError("Primero sube e indexa un documento.")
    llm = ChatGoogleGenerativeAI(
        model="gemini-2.0-flash",
        temperature=float(temperature),
        google_api_key=api_key,
    )
    template = (
        f"{system_message}\n\n"
        "Responde SOLO usando el CONTEXTO recuperado. "
        "Si no está en el contexto, di explícitamente que no aparece.\n\n"
        "CONTEXTO:\n{{context}}\n\n"
        "PREGUNTA:\n{{question}}\n\n"
        "RESPUESTA:"
    )
    prompt = PromptTemplate(template=template, input_variables=["context", "question"])
    qa = RetrievalQA.from_chain_type(
        llm=llm,
        retriever=retriever,
        chain_type="stuff",
        return_source_documents=True,
        chain_type_kwargs={"prompt": prompt},
    )
    return qa


# -----------------------
# Callbacks (sin anotaciones de tipos para evitar schema issues)
# -----------------------
def on_upload(file, system_message, temperature, emb_state, retr_state, qa_state, last_sys_state, last_temp_state):
    api_key = get_api_key()
    if not api_key:
        return "❌ Falta configurar GOOGLE_API_KEY o GEMINI_API_KEY en Settings → Secrets.", None, None, None, None, None

    path = _get_path(file)
    if not path:
        return "No se ha subido ningún archivo.", emb_state, retr_state, qa_state, last_sys_state, last_temp_state

    try:
        docs = _load_docs(path)
        splits = _split(docs)

        # Embeddings (crear si no hay)
        emb = emb_state
        if emb is None:
            emb = GoogleGenerativeAIEmbeddings(model="text-embedding-004", google_api_key=api_key)

        # Vectorstore + retriever
        vs = FAISS.from_documents(splits, emb)
        retriever = vs.as_retriever(search_kwargs={"k": 4})

        first = splits[0].page_content if splits else ""
        preview = first[:500].replace("\n", " ") + ("..." if len(first) > 500 else "")

        # Resetear QA y marcas
        qa = None
        last_sys = None
        last_temp = None

        msg = f"✅ Documento indexado. Chunks: {len(splits)}\n\nPreview:\n{preview}"
        return msg, emb, retriever, qa, last_sys, last_temp

    except Exception as e:
        return f"❌ Error al procesar el archivo: {e}", emb_state, retr_state, qa_state, last_sys_state, last_temp_state


def on_user_send(message, history):
    if not message:
        return "", history
    history = (history or []) + [{"role": "user", "content": message}]
    return "", history


def on_bot_reply(history, system_message, max_tokens, temperature, emb_state, retr_state, qa_state, last_sys_state, last_temp_state):
    api_key = get_api_key()
    if not api_key:
        return (history or []) + [{"role": "assistant", "content": "❌ Falta GOOGLE_API_KEY o GEMINI_API_KEY."}], qa_state, last_sys_state, last_temp_state

    if not history:
        return history, qa_state, last_sys_state, last_temp_state

    last = history[-1]
    if last.get("role") != "user":
        return history, qa_state, last_sys_state, last_temp_state

    retriever = retr_state
    if retriever is None:
        return history + [{"role": "assistant", "content": "Primero sube e indexa un documento."}], qa_state, last_sys_state, last_temp_state

    # (Re)construir chain si cambió sistema o temperatura o no existe
    need_build = qa_state is None or last_sys_state != system_message or last_temp_state != float(temperature)
    if need_build:
        qa_state = build_chain(retriever, system_message, float(temperature), api_key)

    try:
        res = qa_state.invoke({"query": last["content"]})
        answer = res.get("result", "") or "No se obtuvo respuesta."
        sources = res.get("source_documents", [])
        if sources:
            lines = []
            for i, d in enumerate(sources, 1):
                src = d.metadata.get("source", "doc")
                page = d.metadata.get("page", "?")
                preview = (d.page_content or "")[:140].replace("\n", " ")
                lines.append(f"{i}. {src} (pág. {page}) → {preview} ...")
            answer += "\n\n📚 Fuentes:\n" + "\n".join(lines)

        history = history + [{"role": "assistant", "content": answer}]
        last_sys_state = system_message
        last_temp_state = float(temperature)
        return history, qa_state, last_sys_state, last_temp_state

    except Exception as e:
        return history + [{"role": "assistant", "content": f"❌ Error: {e}"}], qa_state, last_sys_state, last_temp_state


# -----------------------
# UI
# -----------------------
with gr.Blocks(title="Chat RAG con LangChain + Gemini") as demo:
    gr.Markdown("# 🤖 Chat RAG con LangChain + Gemini (Google)\nSube un documento (PDF, DOCX, TXT) y hazle preguntas.")

    # Estados atómicos (evitamos dicts)
    emb_state = gr.State(None)       # GoogleGenerativeAIEmbeddings
    retr_state = gr.State(None)      # retriever
    qa_state = gr.State(None)        # RetrievalQA
    last_sys_state = gr.State(None)  # str
    last_temp_state = gr.State(None) # float

    with gr.Row():
        with gr.Column(scale=1):
            gr.Markdown("### 📄 Subir e indexar documento")
            file_upload = gr.File(label="Selecciona un archivo", file_types=[".pdf", ".docx", ".txt"])
            file_status = gr.Textbox(label="Estado del archivo", interactive=False, max_lines=10)

            gr.Markdown("### ⚙️ Configuración")
            system_message = gr.Textbox(
                value=(
                    "Eres un asistente que responde preguntas basándose EXCLUSIVAMENTE en el documento proporcionado. "
                    "Si la información no aparece en el contexto recuperado, indícalo explícitamente."
                ),
                label="Mensaje del sistema",
                max_lines=4
            )
            max_tokens = gr.Slider(50, 2048, value=512, step=50, label="Máximo de tokens (informativo)")
            temperature = gr.Slider(0.0, 1.0, value=0.2, step=0.1, label="Temperatura")

        with gr.Column(scale=2):
            gr.Markdown("### 💬 Chat")
            chatbot = gr.Chatbot(label="Conversación", height=520, type="messages")
            msg = gr.Textbox(label="Tu pregunta", placeholder="Escribe tu pregunta sobre el documento...")

            with gr.Row():
                submit_btn = gr.Button("Enviar", variant="primary")
                clear_btn = gr.Button("Limpiar chat")

    # Eventos
    file_upload.change(
        fn=on_upload,
        inputs=[file_upload, system_message, temperature, emb_state, retr_state, qa_state, last_sys_state, last_temp_state],
        outputs=[file_status, emb_state, retr_state, qa_state, last_sys_state, last_temp_state],
    )

    msg.submit(
        fn=on_user_send,
        inputs=[msg, chatbot],
        outputs=[msg, chatbot],
        queue=False
    ).then(
        fn=on_bot_reply,
        inputs=[chatbot, system_message, max_tokens, temperature, emb_state, retr_state, qa_state, last_sys_state, last_temp_state],
        outputs=[chatbot, qa_state, last_sys_state, last_temp_state],
    )

    submit_btn.click(
        fn=on_user_send,
        inputs=[msg, chatbot],
        outputs=[msg, chatbot],
        queue=False
    ).then(
        fn=on_bot_reply,
        inputs=[chatbot, system_message, max_tokens, temperature, emb_state, retr_state, qa_state, last_sys_state, last_temp_state],
        outputs=[chatbot, qa_state, last_sys_state, last_temp_state],
    )

    clear_btn.click(lambda: [], None, chatbot, queue=False)

# Lanzar (evitamos API explorer)
demo.queue().launch(show_api=False)