danielramos commited on
Commit
cc17c6c
·
0 Parent(s):

feat: add application boilerplate with UI and backend models

Browse files
Files changed (5) hide show
  1. .gitignore +39 -0
  2. cliente/index.html +607 -0
  3. download_model.py +37 -0
  4. main.py +313 -0
  5. requirements.txt +7 -0
.gitignore ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ venv/
3
+ .venv/
4
+ __pycache__/
5
+ *.py[cod]
6
+ *$py.class
7
+ *.so
8
+ .Python
9
+ build/
10
+ develop-eggs/
11
+ dist/
12
+ downloads/
13
+ eggs/
14
+ .eggs/
15
+ lib/
16
+ lib64/
17
+ parts/
18
+ sdist/
19
+ var/
20
+ wheels/
21
+ share/python-wheels/
22
+ *.egg-info/
23
+ .installed.cfg
24
+ *.egg
25
+
26
+ # Entornos virtuales y configuraciones
27
+ .env
28
+ .env.*
29
+ env/
30
+ ENV/
31
+
32
+ # IDEs
33
+ .vscode/
34
+ .idea/
35
+ *.swp
36
+ *.swo
37
+
38
+ # Modelos pesados (se descargan via download_model.py en el Build de Render)
39
+ model/*.gguf
cliente/index.html ADDED
@@ -0,0 +1,607 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Qwen 2.5 • Intelecto Conciso</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --bg: #090d16;
13
+ --card-bg: rgba(16, 23, 42, 0.75);
14
+ --border: rgba(255, 255, 255, 0.08);
15
+ --border-focus: rgba(99, 102, 241, 0.5);
16
+ --accent: #6366f1;
17
+ --accent-glow: rgba(99, 102, 241, 0.25);
18
+ --text: #f8fafc;
19
+ --text-muted: #94a3b8;
20
+ --user-msg: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
21
+ --bot-msg: rgba(30, 41, 59, 0.7);
22
+ --optimal: #10b981;
23
+ --warning: #f59e0b;
24
+ --danger: #ef4444;
25
+ --purple: #a855f7;
26
+ }
27
+
28
+ * {
29
+ box-sizing: border-box;
30
+ margin: 0;
31
+ padding: 0;
32
+ }
33
+
34
+ body {
35
+ font-family: 'Outfit', sans-serif;
36
+ background: var(--bg);
37
+ color: var(--text);
38
+ min-height: 100vh;
39
+ display: flex;
40
+ flex-direction: column;
41
+ align-items: center;
42
+ justify-content: center;
43
+ padding: 1rem;
44
+ background-image:
45
+ radial-gradient(circle at 15% 15%, rgba(99, 102, 241, 0.12) 0%, transparent 40%),
46
+ radial-gradient(circle at 85% 85%, rgba(168, 85, 247, 0.08) 0%, transparent 40%);
47
+ }
48
+
49
+ .app-container {
50
+ width: 100%;
51
+ max-width: 860px;
52
+ height: 90vh;
53
+ background: var(--card-bg);
54
+ backdrop-filter: blur(20px);
55
+ -webkit-backdrop-filter: blur(20px);
56
+ border: 1px solid var(--border);
57
+ border-radius: 24px;
58
+ display: flex;
59
+ flex-direction: column;
60
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
61
+ overflow: hidden;
62
+ }
63
+
64
+ /* HEADER */
65
+ header {
66
+ padding: 1.1rem 1.5rem;
67
+ border-bottom: 1px solid var(--border);
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: space-between;
71
+ gap: 1rem;
72
+ background: rgba(15, 23, 42, 0.5);
73
+ }
74
+
75
+ .brand {
76
+ display: flex;
77
+ align-items: center;
78
+ gap: 0.75rem;
79
+ }
80
+
81
+ .avatar-icon {
82
+ width: 42px;
83
+ height: 42px;
84
+ border-radius: 12px;
85
+ background: linear-gradient(135deg, #10b981, #6366f1);
86
+ display: flex;
87
+ align-items: center;
88
+ justify-content: center;
89
+ font-weight: 700;
90
+ font-size: 1.2rem;
91
+ box-shadow: 0 0 20px rgba(16, 185, 129, 0.25);
92
+ }
93
+
94
+ .brand-info h1 {
95
+ font-size: 1.05rem;
96
+ font-weight: 600;
97
+ letter-spacing: -0.02em;
98
+ }
99
+
100
+ .brand-info p {
101
+ font-size: 0.78rem;
102
+ color: var(--text-muted);
103
+ }
104
+
105
+ .header-actions {
106
+ display: flex;
107
+ align-items: center;
108
+ gap: 0.6rem;
109
+ }
110
+
111
+ .btn-reset {
112
+ background: rgba(255, 255, 255, 0.05);
113
+ border: 1px solid var(--border);
114
+ color: var(--text-muted);
115
+ padding: 0.45rem 0.85rem;
116
+ border-radius: 10px;
117
+ font-size: 0.78rem;
118
+ font-weight: 500;
119
+ cursor: pointer;
120
+ display: flex;
121
+ align-items: center;
122
+ gap: 0.4rem;
123
+ transition: all 0.2s ease;
124
+ font-family: inherit;
125
+ }
126
+
127
+ .btn-reset:hover {
128
+ background: rgba(239, 68, 68, 0.15);
129
+ border-color: rgba(239, 68, 68, 0.4);
130
+ color: #fca5a5;
131
+ }
132
+
133
+ .badge-status {
134
+ display: inline-flex;
135
+ align-items: center;
136
+ gap: 0.4rem;
137
+ padding: 0.35rem 0.75rem;
138
+ border-radius: 20px;
139
+ font-size: 0.75rem;
140
+ font-weight: 600;
141
+ background: rgba(16, 185, 129, 0.12);
142
+ color: var(--optimal);
143
+ border: 1px solid rgba(16, 185, 129, 0.25);
144
+ }
145
+
146
+ .dot {
147
+ width: 7px;
148
+ height: 7px;
149
+ border-radius: 50%;
150
+ background: currentColor;
151
+ box-shadow: 0 0 8px currentColor;
152
+ }
153
+
154
+ /* TELEMETRY BAR */
155
+ .telemetry-bar {
156
+ padding: 0.65rem 1.5rem;
157
+ background: rgba(10, 15, 30, 0.8);
158
+ border-bottom: 1px solid var(--border);
159
+ display: flex;
160
+ align-items: center;
161
+ justify-content: space-between;
162
+ font-size: 0.75rem;
163
+ color: var(--text-muted);
164
+ font-family: 'JetBrains Mono', monospace;
165
+ }
166
+
167
+ .telemetry-item {
168
+ display: flex;
169
+ align-items: center;
170
+ gap: 0.5rem;
171
+ }
172
+
173
+ .progress-bar-container {
174
+ width: 140px;
175
+ height: 6px;
176
+ background: rgba(255, 255, 255, 0.1);
177
+ border-radius: 10px;
178
+ overflow: hidden;
179
+ display: inline-block;
180
+ vertical-align: middle;
181
+ }
182
+
183
+ .progress-bar-fill {
184
+ height: 100%;
185
+ width: 2%;
186
+ background: var(--optimal);
187
+ border-radius: 10px;
188
+ transition: width 0.4s ease, background-color 0.4s ease;
189
+ }
190
+
191
+ .health-pill {
192
+ padding: 0.15rem 0.5rem;
193
+ border-radius: 6px;
194
+ font-size: 0.7rem;
195
+ font-weight: 600;
196
+ text-transform: uppercase;
197
+ letter-spacing: 0.03em;
198
+ }
199
+
200
+ .health-pill.optimo {
201
+ background: rgba(16, 185, 129, 0.15);
202
+ color: #34d399;
203
+ }
204
+
205
+ .health-pill.alerta {
206
+ background: rgba(245, 158, 11, 0.2);
207
+ color: #fbbf24;
208
+ }
209
+
210
+ .health-pill.poda_aplicada {
211
+ background: rgba(168, 85, 247, 0.2);
212
+ color: #c084fc;
213
+ }
214
+
215
+ /* CHAT STREAM */
216
+ .chat-messages {
217
+ flex: 1;
218
+ overflow-y: auto;
219
+ padding: 1.5rem;
220
+ display: flex;
221
+ flex-direction: column;
222
+ gap: 1.1rem;
223
+ scroll-behavior: smooth;
224
+ }
225
+
226
+ .chat-messages::-webkit-scrollbar {
227
+ width: 6px;
228
+ }
229
+
230
+ .chat-messages::-webkit-scrollbar-thumb {
231
+ background: rgba(255, 255, 255, 0.12);
232
+ border-radius: 10px;
233
+ }
234
+
235
+ .message {
236
+ display: flex;
237
+ gap: 0.75rem;
238
+ max-width: 82%;
239
+ animation: fadeIn 0.25s ease-out;
240
+ }
241
+
242
+ @keyframes fadeIn {
243
+ from { opacity: 0; transform: translateY(6px); }
244
+ to { opacity: 1; transform: translateY(0); }
245
+ }
246
+
247
+ .message.user {
248
+ align-self: flex-end;
249
+ flex-direction: row-reverse;
250
+ }
251
+
252
+ .message.bot {
253
+ align-self: flex-start;
254
+ }
255
+
256
+ .msg-avatar {
257
+ width: 32px;
258
+ height: 32px;
259
+ border-radius: 10px;
260
+ display: flex;
261
+ align-items: center;
262
+ justify-content: center;
263
+ font-size: 0.85rem;
264
+ font-weight: 700;
265
+ flex-shrink: 0;
266
+ }
267
+
268
+ .message.user .msg-avatar {
269
+ background: rgba(255, 255, 255, 0.1);
270
+ color: #e2e8f0;
271
+ }
272
+
273
+ .message.bot .msg-avatar {
274
+ background: linear-gradient(135deg, #10b981, #6366f1);
275
+ color: white;
276
+ }
277
+
278
+ .msg-content {
279
+ padding: 0.85rem 1.15rem;
280
+ border-radius: 18px;
281
+ font-size: 0.93rem;
282
+ line-height: 1.5;
283
+ word-break: break-word;
284
+ }
285
+
286
+ .message.user .msg-content {
287
+ background: var(--user-msg);
288
+ color: white;
289
+ border-bottom-right-radius: 4px;
290
+ box-shadow: 0 4px 15px rgba(99, 102, 241, 0.2);
291
+ }
292
+
293
+ .message.bot .msg-content {
294
+ background: var(--bot-msg);
295
+ color: var(--text);
296
+ border: 1px solid var(--border);
297
+ border-bottom-left-radius: 4px;
298
+ }
299
+
300
+ /* TYPING INDICATOR */
301
+ .typing-indicator {
302
+ display: none;
303
+ align-self: flex-start;
304
+ gap: 0.4rem;
305
+ padding: 0.75rem 1.1rem;
306
+ background: var(--bot-msg);
307
+ border: 1px solid var(--border);
308
+ border-radius: 18px;
309
+ border-bottom-left-radius: 4px;
310
+ align-items: center;
311
+ }
312
+
313
+ .typing-dot {
314
+ width: 7px;
315
+ height: 7px;
316
+ background: #10b981;
317
+ border-radius: 50%;
318
+ animation: pulse 1.4s infinite ease-in-out both;
319
+ }
320
+
321
+ .typing-dot:nth-child(1) { animation-delay: -0.32s; }
322
+ .typing-dot:nth-child(2) { animation-delay: -0.16s; }
323
+
324
+ @keyframes pulse {
325
+ 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
326
+ 40% { transform: scale(1.1); opacity: 1; }
327
+ }
328
+
329
+ /* INPUT AREA */
330
+ .input-container {
331
+ padding: 1.1rem 1.5rem;
332
+ border-top: 1px solid var(--border);
333
+ background: rgba(15, 23, 42, 0.5);
334
+ }
335
+
336
+ .input-box {
337
+ display: flex;
338
+ align-items: center;
339
+ gap: 0.6rem;
340
+ background: rgba(10, 15, 30, 0.9);
341
+ border: 1px solid var(--border);
342
+ border-radius: 16px;
343
+ padding: 0.4rem 0.5rem 0.4rem 1.1rem;
344
+ transition: all 0.2s ease;
345
+ }
346
+
347
+ .input-box:focus-within {
348
+ border-color: var(--border-focus);
349
+ box-shadow: 0 0 20px var(--accent-glow);
350
+ }
351
+
352
+ .input-box input {
353
+ flex: 1;
354
+ background: transparent;
355
+ border: none;
356
+ color: var(--text);
357
+ font-size: 0.95rem;
358
+ font-family: inherit;
359
+ outline: none;
360
+ }
361
+
362
+ .input-box input::placeholder {
363
+ color: var(--text-muted);
364
+ font-size: 0.9rem;
365
+ }
366
+
367
+ .btn-send {
368
+ background: #10b981;
369
+ color: white;
370
+ border: none;
371
+ width: 38px;
372
+ height: 38px;
373
+ border-radius: 12px;
374
+ display: flex;
375
+ align-items: center;
376
+ justify-content: center;
377
+ cursor: pointer;
378
+ transition: all 0.2s ease;
379
+ flex-shrink: 0;
380
+ }
381
+
382
+ .btn-send:hover:not(:disabled) {
383
+ background: #059669;
384
+ transform: scale(1.04);
385
+ }
386
+
387
+ .btn-send:disabled {
388
+ opacity: 0.4;
389
+ cursor: not-allowed;
390
+ }
391
+
392
+ .footer-note {
393
+ text-align: center;
394
+ font-size: 0.72rem;
395
+ color: var(--text-muted);
396
+ margin-top: 0.45rem;
397
+ }
398
+ </style>
399
+ </head>
400
+ <body>
401
+
402
+ <div class="app-container">
403
+ <!-- Header -->
404
+ <header>
405
+ <div class="brand">
406
+ <div class="avatar-icon">⚡</div>
407
+ <div class="brand-info">
408
+ <h1>Qwen 2.5 • Intelecto Conciso</h1>
409
+ <p>Qwen2.5-0.5B-Instruct • Render Free (512 MB)</p>
410
+ </div>
411
+ </div>
412
+ <div class="header-actions">
413
+ <button class="btn-reset" id="resetBtn" title="Vaciar memoria y reiniciar contexto">
414
+ 🔄 Reiniciar Contexto
415
+ </button>
416
+ <div class="badge-status" id="modelStatus">
417
+ <span class="dot"></span>
418
+ <span>Online</span>
419
+ </div>
420
+ </div>
421
+ </header>
422
+
423
+ <!-- Context Telemetry & Health Bar -->
424
+ <div class="telemetry-bar">
425
+ <div class="telemetry-item">
426
+ <span>Ventana de Contexto:</span>
427
+ <div class="progress-bar-container">
428
+ <div class="progress-bar-fill" id="contextFill"></div>
429
+ </div>
430
+ <span id="tokenCounter">0 / 768 tokens (0%)</span>
431
+ </div>
432
+ <div class="telemetry-item">
433
+ <span>Salud:</span>
434
+ <span class="health-pill optimo" id="healthPill">ÓPTIMO</span>
435
+ </div>
436
+ </div>
437
+
438
+ <!-- Chat Messages Stream -->
439
+ <div class="chat-messages" id="chatMessages">
440
+ <div class="message bot">
441
+ <div class="msg-avatar">⚡</div>
442
+ <div class="msg-content">
443
+ Hola. Soy un intelecto sintético conciso y preciso en español. ¿Qué deseas saber?
444
+ </div>
445
+ </div>
446
+ <div class="typing-indicator" id="typingIndicator">
447
+ <div class="typing-dot"></div>
448
+ <div class="typing-dot"></div>
449
+ <div class="typing-dot"></div>
450
+ </div>
451
+ </div>
452
+
453
+ <!-- Input Area -->
454
+ <div class="input-container">
455
+ <form class="input-box" id="chatForm">
456
+ <input
457
+ type="text"
458
+ id="userInput"
459
+ placeholder="Escribe tu consulta directa y precisa... (Enter)"
460
+ autocomplete="off"
461
+ autofocus
462
+ />
463
+ <button type="submit" class="btn-send" id="sendBtn" title="Enviar">
464
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
465
+ <line x1="22" y1="2" x2="11" y2="13"></line>
466
+ <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
467
+ </svg>
468
+ </button>
469
+ </form>
470
+ <div class="footer-note">
471
+ Modo ultra-conciso en español activo (Qwen2.5-0.5B-Instruct, telemetría y poda adaptativa de contexto).
472
+ </div>
473
+ </div>
474
+ </div>
475
+
476
+ <script>
477
+ const chatMessages = document.getElementById('chatMessages');
478
+ const chatForm = document.getElementById('chatForm');
479
+ const userInput = document.getElementById('userInput');
480
+ const sendBtn = document.getElementById('sendBtn');
481
+ const typingIndicator = document.getElementById('typingIndicator');
482
+ const resetBtn = document.getElementById('resetBtn');
483
+ const contextFill = document.getElementById('contextFill');
484
+ const tokenCounter = document.getElementById('tokenCounter');
485
+ const healthPill = document.getElementById('healthPill');
486
+
487
+ let conversationHistory = [];
488
+
489
+ function scrollToBottom() {
490
+ chatMessages.scrollTop = chatMessages.scrollHeight;
491
+ }
492
+
493
+ function appendMessage(role, text) {
494
+ const msgDiv = document.createElement('div');
495
+ msgDiv.className = `message ${role}`;
496
+
497
+ const avatar = document.createElement('div');
498
+ avatar.className = 'msg-avatar';
499
+ avatar.textContent = role === 'user' ? '👤' : '⚡';
500
+
501
+ const content = document.createElement('div');
502
+ content.className = 'msg-content';
503
+ content.textContent = text;
504
+
505
+ msgDiv.appendChild(avatar);
506
+ msgDiv.appendChild(content);
507
+
508
+ chatMessages.insertBefore(msgDiv, typingIndicator);
509
+ scrollToBottom();
510
+ }
511
+
512
+ function updateTelemetry(stats) {
513
+ if (!stats) return;
514
+ const { prompt_tokens, completion_tokens, total_tokens, max_context, usage_percent, health_status } = stats;
515
+
516
+ tokenCounter.textContent = `${total_tokens} / ${max_context} tokens (${usage_percent.toFixed(1)}%)`;
517
+ contextFill.style.width = `${Math.min(usage_percent, 100)}%`;
518
+
519
+ healthPill.className = `health-pill ${health_status}`;
520
+ if (health_status === 'optimo') {
521
+ healthPill.textContent = 'ÓPTIMO';
522
+ contextFill.style.backgroundColor = 'var(--optimal)';
523
+ } else if (health_status === 'alerta') {
524
+ healthPill.textContent = 'ALERTA (Contexto Alto)';
525
+ contextFill.style.backgroundColor = 'var(--warning)';
526
+ } else if (health_status === 'poda_aplicada') {
527
+ healthPill.textContent = 'PODA AUTOMÁTICA';
528
+ contextFill.style.backgroundColor = 'var(--purple)';
529
+ }
530
+ }
531
+
532
+ async function sendMessage(text) {
533
+ if (!text.trim()) return;
534
+
535
+ appendMessage('user', text);
536
+ userInput.value = '';
537
+ userInput.disabled = true;
538
+ sendBtn.disabled = true;
539
+ typingIndicator.style.display = 'flex';
540
+ scrollToBottom();
541
+
542
+ try {
543
+ const response = await fetch('/chat', {
544
+ method: 'POST',
545
+ headers: { 'Content-Type': 'application/json' },
546
+ body: JSON.stringify({
547
+ message: text,
548
+ history: conversationHistory,
549
+ temperature: 0.3,
550
+ max_tokens: 80
551
+ })
552
+ });
553
+
554
+ if (!response.ok) {
555
+ throw new Error(`Error en servidor: ${response.status}`);
556
+ }
557
+
558
+ const data = await response.json();
559
+ appendMessage('bot', data.reply || 'Sin respuesta.');
560
+
561
+ if (data.updated_history) {
562
+ conversationHistory = data.updated_history;
563
+ } else {
564
+ conversationHistory.push({ role: 'user', content: text });
565
+ conversationHistory.push({ role: 'assistant', content: data.reply });
566
+ }
567
+
568
+ if (data.context_telemetry) {
569
+ updateTelemetry(data.context_telemetry);
570
+ }
571
+
572
+ } catch (err) {
573
+ console.error(err);
574
+ appendMessage('bot', '⚠️ Error de conexión: ' + err.message);
575
+ } finally {
576
+ typingIndicator.style.display = 'none';
577
+ userInput.disabled = false;
578
+ sendBtn.disabled = false;
579
+ userInput.focus();
580
+ scrollToBottom();
581
+ }
582
+ }
583
+
584
+ chatForm.addEventListener('submit', (e) => {
585
+ e.preventDefault();
586
+ const text = userInput.value.trim();
587
+ if (text) sendMessage(text);
588
+ });
589
+
590
+ resetBtn.addEventListener('click', () => {
591
+ conversationHistory = [];
592
+ chatMessages.innerHTML = '';
593
+ chatMessages.appendChild(typingIndicator);
594
+ appendMessage('bot', 'Contexto reiniciado a 0. ¿Qué deseas consultar?');
595
+ updateTelemetry({
596
+ prompt_tokens: 0,
597
+ completion_tokens: 0,
598
+ total_tokens: 0,
599
+ max_context: 768,
600
+ usage_percent: 0,
601
+ health_status: 'optimo'
602
+ });
603
+ userInput.focus();
604
+ });
605
+ </script>
606
+ </body>
607
+ </html>
download_model.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+ from huggingface_hub import hf_hub_download
4
+
5
+ # Directorio del modelo
6
+ BASE_DIR = Path(__file__).resolve().parent
7
+ MODEL_DIR = BASE_DIR / "model"
8
+ MODEL_DIR.mkdir(exist_ok=True)
9
+
10
+ # Modelo recomendado: Qwen 2.5 0.5B Instruct (Habla español fluido, afinado para chat)
11
+ REPO_ID = "bartowski/Qwen2.5-0.5B-Instruct-GGUF"
12
+ FILENAME = "Qwen2.5-0.5B-Instruct-Q4_K_M.gguf"
13
+ DESTINATION = MODEL_DIR / FILENAME
14
+
15
+ def ensure_model() -> Path:
16
+ """Verifica si el modelo existe localmente; si no, lo descarga desde Hugging Face."""
17
+ if DESTINATION.exists():
18
+ print(f"[OK] El modelo ya existe en: {DESTINATION} ({DESTINATION.stat().st_size / (1024 * 1024):.2f} MB)")
19
+ return DESTINATION
20
+
21
+ print(f"[DESCARGA] Bajando {FILENAME} desde Hugging Face ({REPO_ID})...")
22
+ print("Esto se realiza una sola vez en el Build de Render o al arrancar localmente.")
23
+
24
+ file_path = hf_hub_download(
25
+ repo_id=REPO_ID,
26
+ filename=FILENAME,
27
+ local_dir=str(MODEL_DIR),
28
+ local_dir_use_symlinks=False
29
+ )
30
+
31
+ downloaded_path = Path(file_path)
32
+ print(f"[EXITO] Modelo descargado en: {downloaded_path} ({downloaded_path.stat().st_size / (1024 * 1024):.2f} MB)")
33
+ return downloaded_path
34
+
35
+ if __name__ == "__main__":
36
+ ensure_model()
37
+
main.py ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ from contextlib import asynccontextmanager
4
+ from pathlib import Path
5
+ from typing import List, Optional, Dict, Any, Tuple
6
+
7
+ from fastapi import FastAPI, HTTPException
8
+ from fastapi.responses import FileResponse
9
+ from pydantic import BaseModel, Field
10
+
11
+ from llama_cpp import Llama
12
+ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, BaseMessage
13
+ from langgraph.graph import StateGraph, MessagesState, START, END
14
+
15
+ # Importador para asegurar descarga automática si falta el modelo
16
+ from download_model import ensure_model
17
+
18
+
19
+ # ==========================================
20
+ # 1. Configuración del Modelo
21
+ # ==========================================
22
+ BASE_DIR = Path(__file__).resolve().parent
23
+ MODEL_DIR = BASE_DIR / "model"
24
+ CLIENTE_DIR = BASE_DIR / "cliente"
25
+
26
+ def get_model_path() -> Path:
27
+ """Obtiene la ruta del modelo .gguf; si no existe, lo descarga automáticamente."""
28
+ gguf_files = list(MODEL_DIR.glob("*.gguf"))
29
+ if gguf_files:
30
+ return gguf_files[0]
31
+
32
+ print("[INFO] No se encontró modelo local. Iniciando descarga automática...")
33
+ return ensure_model()
34
+
35
+ MODEL_FILE = get_model_path()
36
+
37
+ # Configuración del LLM optimizada para Render Free (512 MB RAM)
38
+ MAX_CONTEXT_TOKENS = 768 # 768 tokens para no sobrepasar los 512 MB de RAM en Render
39
+ SAFE_PROMPT_LIMIT = 550 # Umbral de seguridad para disparar poda
40
+
41
+ llm: Optional[Llama] = None
42
+
43
+ @asynccontextmanager
44
+ async def lifespan(app: FastAPI):
45
+ global llm
46
+ print(f"[INFO] Cargando modelo en memoria: {MODEL_FILE.name}...")
47
+ llm = Llama(
48
+ model_path=str(MODEL_FILE),
49
+ n_ctx=MAX_CONTEXT_TOKENS, # Ventana de contexto optimizada
50
+ n_threads=1, # 1 hilo para la CPU compartida de Render
51
+ n_batch=128, # Batch ligero para RAM
52
+ verbose=False
53
+ )
54
+ print(f"[INFO] Modelo {MODEL_FILE.name} cargado en memoria exitosamente.")
55
+ yield
56
+ print("[INFO] Apagando servicio...")
57
+
58
+
59
+ # ==========================================
60
+ # 2. Funciones de Optimización de Contexto
61
+ # ==========================================
62
+ DEFAULT_SYSTEM_PROMPT = (
63
+ "Eres una mente sintética superinteligente, hiperprecisa y de pocas palabras. "
64
+ "Respondes con exactitud milimétrica en un máximo de 1 a 2 oraciones en español. "
65
+ "Cero relleno, sin introducciones vacías, directo a la verdad esencial."
66
+ )
67
+
68
+ def estimate_tokens(text: str) -> int:
69
+ """Estima la cantidad de tokens de un texto."""
70
+ if llm is None:
71
+ return len(text.split())
72
+ return len(llm.tokenize(text.encode("utf-8")))
73
+
74
+ def prune_history_if_needed(messages: List[Dict[str, str]]) -> Tuple[List[Dict[str, str]], bool]:
75
+ """
76
+ Poda el historial para evitar saturación de memoria y degradación del modelo.
77
+ Mantiene siempre el system_prompt y recorta las conversaciones más viejas.
78
+ """
79
+ total_tokens = sum(estimate_tokens(m.get("content", "")) for m in messages)
80
+ pruned = False
81
+
82
+ while total_tokens > SAFE_PROMPT_LIMIT and len(messages) > 3:
83
+ messages.pop(1)
84
+ pruned = True
85
+ total_tokens = sum(estimate_tokens(m.get("content", "")) for m in messages)
86
+
87
+ return messages, pruned
88
+
89
+ def clean_output(text: str) -> str:
90
+ """Limpia posibles artefactos residuales de plantillas."""
91
+ text = re.sub(r"<\|im_\w+\|>", "", text)
92
+ return text.strip()
93
+
94
+
95
+ # ==========================================
96
+ # 3. Workflow con LangGraph
97
+ # ==========================================
98
+ def llm_node(state: MessagesState) -> Dict[str, Any]:
99
+ """Nodo LangGraph que ejecuta inferencia precisa y concisa en español."""
100
+ if llm is None:
101
+ raise RuntimeError("El modelo LLM no está cargado en memoria.")
102
+
103
+ raw_messages = state["messages"]
104
+ formatted = []
105
+
106
+ for msg in raw_messages:
107
+ if isinstance(msg, dict):
108
+ formatted.append({
109
+ "role": msg.get("role", "user"),
110
+ "content": str(msg.get("content", ""))
111
+ })
112
+ elif isinstance(msg, AIMessage):
113
+ formatted.append({"role": "assistant", "content": str(msg.content)})
114
+ elif isinstance(msg, SystemMessage):
115
+ formatted.append({"role": "system", "content": str(msg.content)})
116
+ elif isinstance(msg, HumanMessage):
117
+ formatted.append({"role": "user", "content": str(msg.content)})
118
+ elif isinstance(msg, BaseMessage):
119
+ formatted.append({"role": getattr(msg, "type", "user"), "content": str(msg.content)})
120
+
121
+ # Poda preventiva de contexto para cuidar la memoria
122
+ optimized_messages, was_pruned = prune_history_if_needed(formatted)
123
+
124
+ completion = llm.create_chat_completion(
125
+ messages=optimized_messages,
126
+ max_tokens=80,
127
+ temperature=0.3,
128
+ repeat_penalty=1.18,
129
+ stop=["<|im_end|>", "<|im_start|>", "\nUser:", "User:"]
130
+ )
131
+
132
+ raw_reply = completion["choices"][0]["message"]["content"]
133
+ bot_reply = clean_output(raw_reply)
134
+ if not bot_reply:
135
+ bot_reply = "Respuesta procesada con éxito."
136
+
137
+ usage = completion.get("usage", {})
138
+
139
+ ai_msg = AIMessage(
140
+ content=bot_reply,
141
+ additional_kwargs={
142
+ "usage": usage,
143
+ "was_pruned": was_pruned,
144
+ "optimized_messages": optimized_messages
145
+ }
146
+ )
147
+ return {"messages": [ai_msg]}
148
+
149
+
150
+ graph_builder = StateGraph(MessagesState)
151
+ graph_builder.add_node("agent", llm_node)
152
+ graph_builder.add_edge(START, "agent")
153
+ graph_builder.add_edge("agent", END)
154
+ graph_app = graph_builder.compile()
155
+
156
+
157
+ # ==========================================
158
+ # 4. Esquemas Pydantic y API FastAPI
159
+ # ==========================================
160
+ app = FastAPI(
161
+ title="Qwen 2.5 Hyper-Concise Chat API",
162
+ description="API de chat superinteligente y concisa con Qwen2.5-0.5B-Instruct y LangGraph en un solo proceso. Optimizada para Render Free (512 MB RAM).",
163
+ version="3.0.0",
164
+ lifespan=lifespan
165
+ )
166
+
167
+
168
+ class ChatRequest(BaseModel):
169
+ message: str = Field(..., description="Mensaje del usuario", example="¿Qué es la entropía?")
170
+ history: Optional[List[Dict[str, str]]] = Field(
171
+ default=None,
172
+ description="Historial previo [{'role': 'user'|'assistant', 'content': '...'}]"
173
+ )
174
+ system_prompt: Optional[str] = Field(
175
+ default=DEFAULT_SYSTEM_PROMPT,
176
+ description="Prompt de sistema para guiar la precisión y concisión"
177
+ )
178
+ temperature: Optional[float] = Field(default=0.3, ge=0.0, le=1.5, description="Temperatura baja para máxima precisión")
179
+ max_tokens: Optional[int] = Field(default=80, ge=10, le=256, description="Límite para respuestas cortas")
180
+
181
+
182
+ class ContextTelemetry(BaseModel):
183
+ prompt_tokens: int
184
+ completion_tokens: int
185
+ total_tokens: int
186
+ max_context: int
187
+ usage_percent: float
188
+ health_status: str # "optimo", "alerta", "poda_aplicada"
189
+
190
+
191
+ class ChatResponse(BaseModel):
192
+ reply: str
193
+ model: str
194
+ updated_history: List[Dict[str, str]]
195
+ context_telemetry: ContextTelemetry
196
+
197
+
198
+ # ==========================================
199
+ # 5. Endpoints de la Aplicación
200
+ # ==========================================
201
+ @app.get("/", include_in_schema=False)
202
+ @app.get("/cliente", include_in_schema=False)
203
+ async def serve_chat_ui():
204
+ """Sirve la interfaz web del chat cliente/index.html."""
205
+ html_file = CLIENTE_DIR / "index.html"
206
+ if not html_file.exists():
207
+ raise HTTPException(status_code=404, detail="No se encontró cliente/index.html")
208
+ return FileResponse(html_file)
209
+
210
+
211
+ @app.get(
212
+ "/healthz",
213
+ tags=["Diagnóstico"],
214
+ summary="Verificar el estado del servicio y la memoria"
215
+ )
216
+ async def health_check():
217
+ return {
218
+ "status": "healthy",
219
+ "model_loaded": llm is not None,
220
+ "model_file": MODEL_FILE.name,
221
+ "max_context": MAX_CONTEXT_TOKENS,
222
+ "persona": "Superhumano Hiperpreciso & Conciso (Español)",
223
+ "platform": "Render Free Ready"
224
+ }
225
+
226
+
227
+ @app.post(
228
+ "/chat",
229
+ response_model=ChatResponse,
230
+ tags=["Chat & Inferencia"],
231
+ summary="Enviar mensaje al chat",
232
+ description="Procesa la conversación a través de LangGraph con Qwen2.5, monitorea el contexto y aplica poda si es necesario."
233
+ )
234
+ async def chat_endpoint(request: ChatRequest):
235
+ if llm is None:
236
+ raise HTTPException(status_code=503, detail="El modelo aún se está cargando en memoria.")
237
+
238
+ # Reconstrucción de mensajes con historial
239
+ messages = [SystemMessage(content=request.system_prompt or DEFAULT_SYSTEM_PROMPT)]
240
+ if request.history:
241
+ for item in request.history:
242
+ role = item.get("role", "user")
243
+ content = item.get("content", "")
244
+ if role == "assistant":
245
+ messages.append(AIMessage(content=content))
246
+ elif role == "user":
247
+ messages.append(HumanMessage(content=content))
248
+
249
+ # Agregar el mensaje actual del usuario
250
+ messages.append(HumanMessage(content=request.message))
251
+
252
+ # Invocar el grafo de LangGraph
253
+ output = graph_app.invoke({"messages": messages})
254
+ last_msg = output["messages"][-1]
255
+
256
+ reply_content = last_msg.content
257
+ kwargs = getattr(last_msg, "additional_kwargs", {})
258
+ usage = kwargs.get("usage", {})
259
+ was_pruned = kwargs.get("was_pruned", False)
260
+
261
+ prompt_toks = usage.get("prompt_tokens", estimate_tokens(request.message))
262
+ comp_toks = usage.get("completion_tokens", estimate_tokens(reply_content))
263
+ tot_toks = prompt_toks + comp_toks
264
+ usage_pct = (tot_toks / MAX_CONTEXT_TOKENS) * 100
265
+
266
+ # Determinar estado de salud del contexto
267
+ if was_pruned:
268
+ health_status = "poda_aplicada"
269
+ elif usage_pct >= 65.0:
270
+ health_status = "alerta"
271
+ else:
272
+ health_status = "optimo"
273
+
274
+ # Actualizar historial para el cliente
275
+ current_history = request.history or []
276
+ current_history.append({"role": "user", "content": request.message})
277
+ current_history.append({"role": "assistant", "content": reply_content})
278
+
279
+ # Si se aplicó poda en el servidor, sincronizar historial
280
+ if was_pruned and "optimized_messages" in kwargs:
281
+ current_history = [
282
+ m for m in kwargs["optimized_messages"] if m.get("role") in ("user", "assistant")
283
+ ]
284
+
285
+ telemetry = ContextTelemetry(
286
+ prompt_tokens=prompt_toks,
287
+ completion_tokens=comp_toks,
288
+ total_tokens=tot_toks,
289
+ max_context=MAX_CONTEXT_TOKENS,
290
+ usage_percent=round(usage_pct, 1),
291
+ health_status=health_status
292
+ )
293
+
294
+ return ChatResponse(
295
+ reply=reply_content,
296
+ model=MODEL_FILE.name,
297
+ updated_history=current_history,
298
+ context_telemetry=telemetry
299
+ )
300
+
301
+
302
+ # ==========================================
303
+ # 6. Arranque Local y en Render
304
+ # ==========================================
305
+ if __name__ == "__main__":
306
+ import uvicorn
307
+ port = int(os.environ.get("PORT", 8000))
308
+ print(f"\n=======================================================")
309
+ print(f"🚀 Qwen2.5 Chat API Activa en: http://localhost:{port}")
310
+ print(f"💬 Interfaz de Chat Web: http://localhost:{port}/cliente")
311
+ print(f"📖 Swagger Docs: http://localhost:{port}/docs")
312
+ print(f"=======================================================\n")
313
+ uvicorn.run("main:app", host="0.0.0.0", port=port, reload=False)
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
2
+ llama-cpp-python>=0.3.0
3
+ fastapi>=0.115.0
4
+ uvicorn[standard]>=0.30.0
5
+ langgraph>=0.2.0
6
+ langchain-core>=0.3.0
7
+ huggingface_hub>=0.24.0