File size: 16,370 Bytes
479d4dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
# ===================== app.py (BLOQUE 1/4) =====================
#  Consumertec LLM Backend (OpenAI + FastAPI)
#
#  - Frontend: HTML (dentro del Space) => static/index.html
#  - Backend: Hugging Face Space
#  - Motor: OpenAI (gpt-4.1-mini por defecto)
#
#  CARACTERÍSTICAS:
#  ✔ Usa el prompt de la sección 3 (question)
#  ✔ Usa el resumen numérico de la sección 5 (numeric_summary)
#  ✔ NO inventa cifras
#  ✔ Menciona productos explícitamente
#  ✔ Respuesta larga (configurable por MAX_NEW_TOKENS)
#  ✔ Estructura fija con títulos subrayados
# ============================================================

import os
import traceback
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pydantic import BaseModel
from openai import OpenAI
import uvicorn

# ------------------------------------------------------------
# CONFIGURACIÓN GENERAL
# ------------------------------------------------------------
MODEL_NAME = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "600"))
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")

print("===== Inicio Consumertec LLM Backend (OpenAI) =====")
print("Modelo OpenAI:", MODEL_NAME)
print("Max tokens salida:", MAX_NEW_TOKENS)
print("API Key detectada:", bool(OPENAI_API_KEY))

if not OPENAI_API_KEY:
    print("❌ ERROR: OPENAI_API_KEY no configurada en Secrets del Space.")

client = OpenAI(api_key=OPENAI_API_KEY)

# ------------------------------------------------------------
# FastAPI APP
# ------------------------------------------------------------
app = FastAPI(
    title="Explorador CSV Consumertec",
    description="Explorador CSV Consumertec (Sección 5 determinista + Sección 6 texto continuo)",
    version="2.0.0"
)

# CORS (si también sirves HTML desde /static, esto no estorba)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=False,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ------------------------------------------------------------
# SERVIR FRONTEND HTML (static/index.html)
#  - "/" y "/ui" muestran el HTML
#  - "/static/..." sirve recursos estáticos si los agregas luego
# ------------------------------------------------------------
app.mount("/static", StaticFiles(directory="static"), name="static")

@app.get("/")
def root():
    return FileResponse("static/index.html")

@app.get("/ui")
def ui():
    return FileResponse("static/index.html")

# ------------------------------------------------------------
# ESQUEMA DE ENTRADA
# ------------------------------------------------------------
class GenerateRequest(BaseModel):
    mode: str
    question: str
    numeric_summary: str | None = None
# ===================== app.py (BLOQUE 2/4) =====================
# ------------------------------------------------------------
# CONTEXTO DE DOMINIO (GENERAL)
# ------------------------------------------------------------
DOMAIN_CONTEXT = """
Eres un analista técnico senior especializado en pruebas de consumo,
detergencia, blancura y evaluación comparativa de productos en Consumertec.
""".strip()

# ------------------------------------------------------------
# CONTEXTO DE DOMINIO (CONDICIONAL) — RESPONSE
#   Úsalo SOLO cuando el modo lo requiera (p.ej. "comparaciones" o "response_rel")
# ------------------------------------------------------------
RESPONSE_CONTEXT = """
La columna RESPONSE codifica el resultado comparativo ordinal entre PRODUCT_TEST y PRODUCT_BENCH para una combinación experimental específica (por ejemplo: MODALITY, SCENARIOS, WASH, PROCESS, MONITOR, THRESHOLD, etc.). No representa una magnitud continua ni una probabilidad directa, sino una clasificación discreta del desenlace relativo del producto evaluado frente al producto de referencia.
Los valores de RESPONSE están ordenados conceptualmente desde el resultado más favorable para PRODUCT_TEST hasta el más desfavorable, con una distinción explícita entre diferencias claras y tendencias (diferencias leves o cercanas al umbral de decisión):
- 01.SUPERIOR: PRODUCT_TEST es claramente superior a PRODUCT_BENCH bajo la condición evaluada. Resultado favorable fuerte.
- 02.SUPERIOR_TREND: PRODUCT_TEST muestra una tendencia a ser superior, indicando una ventaja leve o cercana al criterio de superioridad.
- 03.PARITY: No se detecta una diferencia relevante entre PRODUCT_TEST y PRODUCT_BENCH. Equivalencia práctica.
- 04.INFERIOR_TREND: PRODUCT_TEST muestra una tendencia a ser inferior, indicando una desventaja leve o cercana al criterio de inferioridad.
- 05.INFERIOR: PRODUCT_TEST es claramente inferior a PRODUCT_BENCH. Resultado desfavorable fuerte.
Desde el punto de vista analítico, RESPONSE es una variable ordinal, no intervalar: las distancias entre categorías no son cuantitativas ni necesariamente simétricas. Por tanto, no debe tratarse como un valor numérico continuo ni promediarse directamente.
En análisis de desempeño, suele definirse un subconjunto operativo de resultados favorables agrupando {01.SUPERIOR, 02.SUPERIOR_TREND}, mientras que {04.INFERIOR_TREND, 05.INFERIOR} se interpretan como desfavorables, y 03.PARITY actúa como estado neutro. Esta definición es convencional y depende del objetivo del análisis.
La distribución de RESPONSE suele estar concentrada en PARITY; por ello, pequeñas variaciones en el conteo de resultados favorables pueden producir diferencias relativas marcadas en tasas. Los resultados extremos (01 y 05) deben interpretarse como eventos raros. Las tasas y z-scores derivados deben leerse como indicadores comparativos, no como probabilidades absolutas.
Finalmente, RESPONSE no implica causalidad por sí misma: su lectura debe contextualizarse junto con las variables experimentales asociadas, ya que estas pueden actuar como efectos de confusión que inflan o deprimen la frecuencia observada de resultados favorables o desfavorables.
""".strip()

# ------------------------------------------------------------
# CONTEXTO DE DOMINIO (COMPARACIONES / OUTPUT_MAP_COMPARISONS.csv)
# ------------------------------------------------------------
COMPARISONS_CONTEXT = """
El archivo OUTPUT_MAP_COMPARISONS.csv contiene comparaciones de desempeño entre productos, evaluadas en distintas condiciones experimentales. Cada fila compara un valor de la columna PRODUCT_TEST con un valor de la columna PRODUCT_BENCH, bajo una combinación específica de las variables MODALITY, SCENARIOS, WASH, PROCESS, MONITOR, INDEX y THRESHOLD.
La columna MODALITY indica el tipo de blanco visualizado, siendo BLUISH un blanco con tonalidad azulada y STANDARD un blanco neutro. La variable SCENARIOS representa diferentes entornos visuales en los que se realizó la evaluación. WASH indica la cantidad de lavados aplicados y PROCESS se refiere al tipo de proceso de lavado.
MONITOR describe el tipo de tela utilizada como referencia blanca, incluyendo categorías como a.1.DSPCG para blanco sucio de poliéster algodón, b.1.DSCCG para blanco sucio de algodón, c.1.PPC para blanco original de poliéster algodón, d.1.PCC para blanco original de algodón, e.1.PPP para blanco original de poliéster, f.1.DPCG para blanco percudido de poliéster algodón, g.1.DCCG para blanco percudido de algodón y h.1.DPPG para blanco percudido de poliéster.
La columna INDEX refleja la dimensión perceptual de blancura que se evalúa: 1.2.WI_STw corresponde a la blancura estándar, 2.2.WI_GEw a la blancura verdosa, 3.2.WI_VOw a la blancura violácea y 4.2.WI_LGw a la blancura luminosa.
THRESHOLD señala el umbral o nivel de sensibilidad utilizado para distinguir diferencias entre productos.
El resultado de cada comparación se encuentra en la columna RESPONSE, que muestra si PRODUCT_TEST tuvo peor, igual o mejor desempeño que PRODUCT_BENCH. Estos valores se ordenan desde el menor al mayor desempeño de la siguiente manera: 05.INFERIOR, 04.INFERIOR_TREND, 03.PARITY, 02.SUPERIOR_TREND y 01.SUPERIOR.
Este archivo está estructurado para facilitar el análisis comparativo entre productos en función de la percepción visual de blancura bajo distintos contextos experimentales.
""".strip()

# ------------------------------------------------------------
# CONTEXTO DE DOMINIO (BLANCURA / OUTPUT_WHITES_WI.csv)
# ------------------------------------------------------------
WHITENESS_CONTEXT = """
El archivo OUTPUT_WHITES_WI.csv contiene mediciones de blancura para distintos productos, especificados en la columna “PRODUCT”. Estas mediciones están representadas en cuatro columnas diferentes: “1.2.WI_STw”, que indica la percepción de blancura estándar; “2.2.WI_GEw”, que representa la percepción de blancura con tonalidad verdosa; “3.2.WI_VOw”, que refleja la percepción de blancura con tonalidad violácea; y “4.2.WI_LGw”, que corresponde a la percepción de blancura luminosa.
Los valores registrados en estas columnas dependen de las condiciones bajo las cuales se evaluaron los productos, las cuales se definen a través de las columnas “MODALITY”, “SCENARIOS”, “WASH”, “PROCESS” y “MONITOR”. La columna “REPLICA” indica las repeticiones de cada evaluación para asegurar la consistencia de los datos.
La columna “SCENARIOS” hace referencia a diferentes escenarios visuales en los que se evaluó la blancura, mientras que la columna “WASH” registra el número de lavados aplicados a los productos. La columna “PROCESS” describe los diferentes procesos de lavado utilizados en cada caso.
Finalmente, la columna “MONITOR” identifica el tipo de tela blanca utilizada como referencia para la percepción visual de la blancura, siendo esta una variable clave en el análisis, ya que influye directamente en cómo se perciben las diferencias entre productos.
Los monitores se codifican de la siguiente manera: “a.1.DSPCG” corresponde a un monitor blanco sucio de poliéster-algodón, “b.1.DSCCG” a un monitor blanco sucio de algodón, “c.1.PPC” a un monitor blanco original de poliéster-algodón, “d.1.PCC” a un monitor blanco original de algodón, “e.1.PPP” a un monitor blanco original de poliéster, “f.1.DPCG” a un monitor blanco percudido de poliéster-algodón, “g.1.DCCG” a un monitor blanco percudido de algodón, y “h.1.DPPG” a un monitor blanco percudido de poliéster.
Este conjunto de datos permite analizar el desempeño de blancura de los productos en función de múltiples combinaciones de condiciones, facilitando comparaciones entre productos y evaluaciones específicas del efecto que tienen los diferentes escenarios visuales, procesos de lavado y tipos de monitor sobre la percepción de la blancura.
""".strip()
# ===================== app.py (BLOQUE 3/4) =====================
# ------------------------------------------------------------
# MENSAJES AL MODELO
# ------------------------------------------------------------
SYSTEM_MESSAGE = f"""
{DOMAIN_CONTEXT}
REGLAS ABSOLUTAS:
- Respondes SIEMPRE en español.
- Tono técnico, analítico y sobrio.
- No utilices lenguaje comercial ni promocional.
- No inventes cifras ni porcentajes.
- Solo puedes usar números que aparezcan explícitamente en la evidencia numérica.
- No menciones CSV, columnas, archivos, tablas ni pasos de procedimiento.
- PROHIBIDO usar listas, viñetas, numeraciones o tablas. Todo debe ser texto continuo.
- Debes mencionar explícitamente los productos (códigos tipo 00_000) que aparezcan en la evidencia.
FORMATO OBLIGATORIO DE SALIDA (TEXTO PLANO):
SÍNTESIS EJECUTIVA
==================
INTERPRETACIÓN TÉCNICA
======================
ESTILO:
- Empieza con una lectura general y luego entra en productos.
- Puedes declarar superioridad, empate o resultados mixtos si la evidencia lo soporta.
""".strip()


def build_user_content(mode: str, question: str, numeric_summary: str | None) -> str:
    """
    Construye el mensaje de usuario que verá el modelo.
    Usa el resumen numérico como EVIDENCIA factual y el prompt como guía analítica.
    Además, inyecta contexto de dominio específico según el modo.
    """
    evidencia = (numeric_summary or "").strip()
    if not evidencia:
        evidencia = "No se proporcionó evidencia numérica."

    mode_norm = (mode or "").strip().lower()

    contexto_especifico = ""
    if mode_norm == "whiteness":
        contexto_especifico = f"""
CONTEXTO ESPECÍFICO DEL DATASET (BLANCURA)
{WHITENESS_CONTEXT}
""".strip()
    elif mode_norm == "comparaciones":
        contexto_especifico = f"""
CONTEXTO ESPECÍFICO DEL DATASET (COMPARACIONES)
{COMPARISONS_CONTEXT}
""".strip()
    elif mode_norm == "response_rel":
        contexto_especifico = f"""
CONTEXTO ESPECÍFICO DE LA COLUMNA RESPONSE (ORDINAL)
{RESPONSE_CONTEXT}
""".strip()

    # Inyección adicional del contexto RESPONSE cuando corresponde
    if mode_norm in ("comparaciones", "response_rel"):
        contexto_especifico = f"""
{contexto_especifico}
CONTEXTO DE RESPUESTA (RESPONSE)
{RESPONSE_CONTEXT}
""".strip()

    texto = f"""
MODO DE ANÁLISIS
{mode}
{contexto_especifico}
EVIDENCIA NUMÉRICA (BASE FACTUAL)
La siguiente evidencia numérica es verdadera.
Regla: NO la repitas completa; úsala solo como soporte (citas puntuales) para tu análisis.
{evidencia}
PROMPT DE ANÁLISIS DEL USUARIO
Desarrolla el análisis solicitado a partir de la evidencia numérica anterior:
{question}
INSTRUCCIONES FINALES:
- Menciona explícitamente los nombres/códigos de los productos que aparezcan en la evidencia.
- Indica qué productos destacan y cuáles quedan por detrás, según la evidencia.
- Usa solo cifras presentes en la evidencia (sin recalcular, extrapolar ni reimprimir tablas).
- Respeta estrictamente el formato de secciones con títulos subrayados.
"""
    return texto.strip()
# ===================== app.py (BLOQUE 4/4) =====================
# ------------------------------------------------------------
# ENDPOINTS
# ------------------------------------------------------------
@app.get("/status")
def status():
    return {
        "status": "ok",
        "message": "Backend Consumertec LLM (OpenAI) activo.",
        "model": MODEL_NAME,
        "max_new_tokens": MAX_NEW_TOKENS,
        "has_api_key": bool(OPENAI_API_KEY)
    }


@app.post("/generate_answer")
def generate_answer(payload: GenerateRequest):
    try:
        if not OPENAI_API_KEY:
            raise HTTPException(
                status_code=500,
                detail="OPENAI_API_KEY no configurada en Secrets."
            )

        user_content = build_user_content(
            payload.mode,
            payload.question,
            payload.numeric_summary
        )

        completion = client.chat.completions.create(
            model=MODEL_NAME,
            messages=[
                {"role": "system", "content": SYSTEM_MESSAGE},
                {"role": "user", "content": user_content},
            ],
            max_completion_tokens=MAX_NEW_TOKENS,
            temperature=0.65,
            top_p=0.9,
        )

        answer = (
            completion.choices[0].message.content.strip()
            if completion.choices
            and completion.choices[0].message
            and completion.choices[0].message.content
            else ""
        )

        if not answer:
            answer = (
                "SÍNTESIS EJECUTIVA\n"
                "==================\n"
                "El análisis no pudo generarse correctamente con los datos actuales.\n\n"
                "INTERPRETACIÓN TÉCNICA\n"
                "======================\n"
                "No se obtuvo contenido suficiente del modelo para redactar una interpretación técnica defendible."
            )

        return {
            "answer": answer,
            "mode": payload.mode
        }

    except HTTPException:
        raise
    except Exception as e:
        traceback.print_exc()
        raise HTTPException(
            status_code=500,
            detail=f"Error interno del backend: {e}"
        )


# ------------------------------------------------------------
# ARRANQUE LOCAL (solo pruebas, HF lo ignora)
# ------------------------------------------------------------
if __name__ == "__main__":
    uvicorn.run(
        app,
        host="0.0.0.0",
        port=7860
    )