JairoDanielMT commited on
Commit
b6154b2
·
1 Parent(s): 88937c2

Base Docker Space with Python backend

Browse files
.dockerignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ python_backend/.venv
2
+ __pycache__
3
+ .pytest_cache
4
+ .env
5
+ .git
.gitattributes DELETED
@@ -1,35 +0,0 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+ python_backend/.env
5
+ python_backend/.env.*
6
+ !python_backend/.env.example
7
+ __pycache__/
8
+ .pytest_cache/
9
+ .venv/
10
+ data/
Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.14-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1
4
+ ENV PYTHONUNBUFFERED=1
5
+
6
+ WORKDIR /app
7
+
8
+ RUN apt-get update \
9
+ && apt-get install -y --no-install-recommends ffmpeg build-essential \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ COPY python_backend /app/python_backend
13
+
14
+ RUN python -m pip install --upgrade pip setuptools wheel \
15
+ && pip install -e /app/python_backend
16
+
17
+ COPY . /app
18
+
19
+ RUN mkdir -p /data
20
+
21
+ ENV PORT=7860
22
+ ENV SQLITE_PATH=/data/inventario.sqlite
23
+
24
+ EXPOSE 7860
25
+
26
+ WORKDIR /app/python_backend
27
+
28
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -7,4 +7,83 @@ sdk: docker
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  pinned: false
8
  ---
9
 
10
+ # Bot Fam
11
+
12
+ Space Docker para inventario del hogar con `FastAPI`, `SQLite`, `Telegram`, `Claude Haiku` y `Whisper tiny` local.
13
+
14
+ ## Stack
15
+
16
+ - `FastAPI` asíncrono como backend operativo.
17
+ - `SQLite` como base principal del servicio.
18
+ - `faster-whisper` con modelo `tiny` dentro del contenedor.
19
+ - `Telegram` como canal principal para registrar, consumir y consultar.
20
+ - `Claude Haiku` para estructurar texto, buscar registros y armar diagramas.
21
+ - `Google Sheets + Apps Script` solo como respaldo y sincronización.
22
+
23
+ ## Estructura
24
+
25
+ - `python_backend/`: backend principal en Python.
26
+ - `google-apps-script/`: código que va en Apps Script dentro de Google Sheets.
27
+ - `docs/`: arquitectura.
28
+ - `Dockerfile`: imagen lista para Hugging Face Spaces.
29
+
30
+ ## Variables de entorno
31
+
32
+ Archivos plantilla:
33
+
34
+ - `python_backend/.env.example`
35
+ - `python_backend/.env.huggingface.example`
36
+
37
+ En Hugging Face Spaces no subes `.env`. Debes copiar esos valores a `Settings > Variables and secrets`.
38
+
39
+ ## Variables clave
40
+
41
+ - `APP_BASE_URL`: URL final del Space, por ejemplo `https://JairoDanielMT-bot-fam.hf.space`
42
+ - `SQLITE_PATH`: usar `/data/inventario.sqlite` si tienes almacenamiento persistente.
43
+ - `WHISPER_MODEL`: por defecto `tiny`.
44
+ - `WHISPER_DEVICE`: `cpu`.
45
+ - `WHISPER_COMPUTE_TYPE`: `int8`.
46
+ - `REMINDER_HOUR` y `REMINDER_MINUTE`: horario del aviso diario.
47
+ - `EXPIRY_WARNING_DAYS`: ventana de alerta de vencimiento.
48
+
49
+ ## Flujo principal
50
+
51
+ 1. Telegram recibe texto o audio.
52
+ 2. Si llega audio, el contenedor lo transcribe localmente con Whisper tiny.
53
+ 3. Claude Haiku convierte texto libre a datos estructurados o responde consultas.
54
+ 4. SQLite guarda productos, movimientos y usuarios.
55
+ 5. Google Sheets se actualiza como respaldo.
56
+ 6. El scheduler diario envía alertas de vencimiento por Telegram.
57
+
58
+ ## Comandos de Telegram
59
+
60
+ - `/registrar leche gloria 6 unidades a 4.2 vence 2026-04-10 ingreso 2026-03-26 producido 2026-03-01`
61
+ - `/consumir leche gloria 2 unidades desayuno`
62
+ - `/buscar que productos vencen este mes`
63
+ - `/diagrama arma un flowchart por fechas de caducidad`
64
+ - `/vencimientos`
65
+ - `/stock leche`
66
+
67
+ ## Webhook de Telegram
68
+
69
+ Cuando el Space ya esté arriba y `APP_BASE_URL` apunte a la URL final:
70
+
71
+ ```bash
72
+ curl -X POST https://TU-SPACE.hf.space/telegram/set-webhook
73
+ ```
74
+
75
+ Ese endpoint registra automáticamente:
76
+
77
+ - `https://TU-SPACE.hf.space/telegram/webhook`
78
+
79
+ ## Sincronización con Google Sheets
80
+
81
+ - Al iniciar, el backend intenta sincronizar desde Sheets hacia SQLite.
82
+ - Cada alta y cada consumo replica a Sheets.
83
+ - Existen endpoints manuales:
84
+ - `POST /api/sync/from-sheets`
85
+ - `POST /api/sync/to-sheets`
86
+
87
+ ## Nota de compatibilidad
88
+
89
+ El contenedor usa `python:3.14-slim`. Si Hugging Face Spaces o `faster-whisper` mostraran incompatibilidad en build, el ajuste práctico inmediato es bajar a `python:3.13-slim`.
docs/arquitectura.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Arquitectura propuesta
2
+
3
+ ## Componentes
4
+
5
+ 1. `Python Backend`
6
+ - `FastAPI` asíncrono como backend principal.
7
+ - Atiende webhook de Telegram.
8
+ - Ejecuta transcripción local con `faster-whisper tiny`.
9
+ - Usa Claude Haiku para estructurar texto, responder preguntas y generar Mermaid.
10
+ - Ejecuta recordatorios diarios.
11
+
12
+ 2. `SQLite`
13
+ - Base operativa principal.
14
+ - Guarda productos, movimientos y usuarios de Telegram.
15
+ - Responde consultas de stock y vencimiento.
16
+
17
+ 3. `Google Sheets + Google Apps Script`
18
+ - Respaldo editable por negocio.
19
+ - Expone web service para altas, consumos, listados y snapshot.
20
+ - Mantiene hojas `Productos` y `Movimientos`.
21
+
22
+ 4. `Telegram`
23
+ - Canal operativo principal.
24
+ - Registra ingresos, consumos, stock y recordatorios.
25
+
26
+ ## Flujos
27
+
28
+ ### Registro de producto
29
+
30
+ 1. Usuario envía texto o audio por Telegram o usa la web.
31
+ 2. Si es audio, Whisper tiny lo transcribe dentro del contenedor.
32
+ 3. Claude Haiku convierte el texto a JSON estructurado.
33
+ 4. FastAPI valida y guarda en SQLite.
34
+ 5. El backend replica a Google Sheets como respaldo.
35
+
36
+ ### Consumo
37
+
38
+ 1. Usuario envía `/consumir`.
39
+ 2. Claude Haiku extrae producto, cantidad y unidad.
40
+ 3. SQLite descuenta stock y registra movimiento.
41
+ 4. Google Sheets recibe la réplica del consumo.
42
+
43
+ ### Recordatorio diario
44
+
45
+ 1. Un scheduler asíncrono corre cada día.
46
+ 2. Busca productos con stock positivo próximos a vencer.
47
+ 3. Envía el resumen a los usuarios de Telegram registrados.
48
+
49
+ ### Sincronización
50
+
51
+ 1. Al iniciar, el backend intenta traer snapshot desde Google Sheets.
52
+ 2. Existen endpoints para sincronizar manualmente desde y hacia Sheets.
53
+
54
+ ## Diagrama
55
+
56
+ ```mermaid
57
+ flowchart LR
58
+ Usuario[Usuario Web o Telegram] --> API[FastAPI Python]
59
+ Telegram[Telegram Bot] --> API
60
+ API --> SQLite[(SQLite)]
61
+ API --> Whisper[Whisper Tiny Local]
62
+ API --> Claude[Claude Haiku API]
63
+ API --> GAS[Google Apps Script]
64
+ GAS --> Sheets[Google Sheets]
65
+ ```
google-apps-script/Code.gs ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const SHEET_NAME = 'Productos';
2
+ const MOVEMENTS_SHEET_NAME = 'Movimientos';
3
+ const HEADERS = [
4
+ 'ID',
5
+ 'Producto',
6
+ 'Precio',
7
+ 'Cantidad',
8
+ 'Unidad',
9
+ 'FechaCaducidad',
10
+ 'FechaIngreso',
11
+ 'FechaProduccion',
12
+ 'Notas',
13
+ 'Fuente',
14
+ 'StockActual',
15
+ 'ConsumidoTotal',
16
+ 'CreadoEn',
17
+ 'ActualizadoEn'
18
+ ];
19
+ const MOVEMENT_HEADERS = [
20
+ 'ID',
21
+ 'ProductID',
22
+ 'Producto',
23
+ 'Tipo',
24
+ 'Cantidad',
25
+ 'Unidad',
26
+ 'Notas',
27
+ 'Fuente',
28
+ 'CreadoEn'
29
+ ];
30
+
31
+ function doGet(e) {
32
+ return handleRequest_({
33
+ method: 'GET',
34
+ params: e.parameter || {}
35
+ });
36
+ }
37
+
38
+ function doPost(e) {
39
+ const body = e.postData && e.postData.contents ? JSON.parse(e.postData.contents) : {};
40
+ return handleRequest_({
41
+ method: 'POST',
42
+ params: body
43
+ });
44
+ }
45
+
46
+ function handleRequest_(request) {
47
+ try {
48
+ const params = request.params || {};
49
+ validateToken_(params.token);
50
+
51
+ const action = params.action;
52
+ if (action === 'addRecord') return jsonResponse_(addRecord_(params.record || {}));
53
+ if (action === 'listRecords') return jsonResponse_(listRecords_(params.query || ''));
54
+ if (action === 'consumeProduct') return jsonResponse_(consumeProduct_(params.consumption || {}));
55
+ if (action === 'listMovements') return jsonResponse_(listMovements_());
56
+ if (action === 'replaceSnapshot') {
57
+ return jsonResponse_(replaceSnapshot_(params.records || [], params.movements || []));
58
+ }
59
+
60
+ return jsonResponse_({ ok: false, error: 'Accion no soportada.' });
61
+ } catch (error) {
62
+ return jsonResponse_({ ok: false, error: error.message });
63
+ }
64
+ }
65
+
66
+ function getSheet_() {
67
+ const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
68
+ let sheet = spreadsheet.getSheetByName(SHEET_NAME);
69
+
70
+ if (!sheet) sheet = spreadsheet.insertSheet(SHEET_NAME);
71
+ if (sheet.getLastRow() === 0) {
72
+ sheet.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]);
73
+ sheet.setFrozenRows(1);
74
+ }
75
+
76
+ return sheet;
77
+ }
78
+
79
+ function getMovementSheet_() {
80
+ const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
81
+ let sheet = spreadsheet.getSheetByName(MOVEMENTS_SHEET_NAME);
82
+
83
+ if (!sheet) sheet = spreadsheet.insertSheet(MOVEMENTS_SHEET_NAME);
84
+ if (sheet.getLastRow() === 0) {
85
+ sheet.getRange(1, 1, 1, MOVEMENT_HEADERS.length).setValues([MOVEMENT_HEADERS]);
86
+ sheet.setFrozenRows(1);
87
+ }
88
+
89
+ return sheet;
90
+ }
91
+
92
+ function validateToken_(token) {
93
+ const scriptToken = PropertiesService.getScriptProperties().getProperty('API_TOKEN');
94
+ if (!scriptToken) throw new Error('Falta API_TOKEN en Script Properties.');
95
+ if (token !== scriptToken) throw new Error('Token invalido.');
96
+ }
97
+
98
+ function addRecord_(record) {
99
+ const sheet = getSheet_();
100
+ const movementSheet = getMovementSheet_();
101
+ const createdAt = new Date().toISOString();
102
+ const id = record.id || Utilities.getUuid();
103
+
104
+ sheet.appendRow([
105
+ id,
106
+ record.producto || '',
107
+ Number(record.precio || 0),
108
+ Number(record.cantidad || 0),
109
+ record.unidad || 'unidad',
110
+ record.fechaCaducidad || '',
111
+ record.fechaIngreso || '',
112
+ record.fechaProduccion || '',
113
+ record.notas || '',
114
+ record.fuente || 'web',
115
+ Number(record.stockActual != null ? record.stockActual : record.cantidad || 0),
116
+ Number(record.consumidoTotal || 0),
117
+ record.createdAt || createdAt,
118
+ record.updatedAt || createdAt
119
+ ]);
120
+
121
+ movementSheet.appendRow([
122
+ Utilities.getUuid(),
123
+ id,
124
+ record.producto || '',
125
+ 'ingreso',
126
+ Number(record.cantidad || 0),
127
+ record.unidad || 'unidad',
128
+ record.notas || '',
129
+ record.fuente || 'web',
130
+ createdAt
131
+ ]);
132
+
133
+ return { ok: true, id: id, createdAt: createdAt };
134
+ }
135
+
136
+ function listRecords_(query) {
137
+ const sheet = getSheet_();
138
+ const values = sheet.getDataRange().getValues();
139
+ if (values.length <= 1) return { ok: true, records: [] };
140
+
141
+ const headers = values[0];
142
+ const rows = values.slice(1).map(function(row) {
143
+ return toRecord_(headers, row);
144
+ });
145
+
146
+ const normalizedQuery = String(query || '').toLowerCase().trim();
147
+ const records = normalizedQuery
148
+ ? rows.filter(function(record) {
149
+ return JSON.stringify(record).toLowerCase().indexOf(normalizedQuery) >= 0;
150
+ })
151
+ : rows;
152
+
153
+ return { ok: true, records: records };
154
+ }
155
+
156
+ function toRecord_(headers, row) {
157
+ const result = {};
158
+ headers.forEach(function(header, index) {
159
+ result[header] = row[index];
160
+ });
161
+
162
+ return {
163
+ id: result.ID || '',
164
+ producto: result.Producto || '',
165
+ precio: result.Precio || 0,
166
+ cantidad: result.Cantidad || 0,
167
+ unidad: result.Unidad || 'unidad',
168
+ fechaCaducidad: formatDateValue_(result.FechaCaducidad),
169
+ fechaIngreso: formatDateValue_(result.FechaIngreso),
170
+ fechaProduccion: formatDateValue_(result.FechaProduccion),
171
+ notas: result.Notas || '',
172
+ fuente: result.Fuente || '',
173
+ stockActual: result.StockActual || 0,
174
+ consumidoTotal: result.ConsumidoTotal || 0,
175
+ createdAt: formatDateValue_(result.CreadoEn),
176
+ updatedAt: formatDateValue_(result.ActualizadoEn)
177
+ };
178
+ }
179
+
180
+ function consumeProduct_(consumption) {
181
+ const sheet = getSheet_();
182
+ const movementSheet = getMovementSheet_();
183
+ const values = sheet.getDataRange().getValues();
184
+ if (values.length <= 1) throw new Error('No hay productos registrados.');
185
+
186
+ const headers = values[0];
187
+ const rows = values.slice(1);
188
+ const targetProductId = consumption.productId || '';
189
+ let rowIndex = -1;
190
+
191
+ for (var i = 0; i < rows.length; i++) {
192
+ var record = toRecord_(headers, rows[i]);
193
+ if (targetProductId) {
194
+ if (record.id === targetProductId) {
195
+ rowIndex = i + 2;
196
+ break;
197
+ }
198
+ } else if (
199
+ String(record.producto).toLowerCase() === String(consumption.producto || '').toLowerCase() &&
200
+ Number(record.stockActual || 0) > 0
201
+ ) {
202
+ rowIndex = i + 2;
203
+ break;
204
+ }
205
+ }
206
+
207
+ if (rowIndex === -1) throw new Error('No se encontro el producto para consumir.');
208
+
209
+ const recordValues = sheet.getRange(rowIndex, 1, 1, headers.length).getValues()[0];
210
+ const record = toRecord_(headers, recordValues);
211
+ const quantity = Number(consumption.cantidad || 0);
212
+
213
+ if (record.unidad !== consumption.unidad) throw new Error('La unidad no coincide.');
214
+ if (Number(record.stockActual) < quantity) throw new Error('Stock insuficiente en Google Sheets.');
215
+
216
+ const newStock = Number(record.stockActual) - quantity;
217
+ const newConsumed = Number(record.consumidoTotal || 0) + quantity;
218
+ const updatedAt = new Date().toISOString();
219
+ const columns = headerIndexMap_(headers);
220
+
221
+ sheet.getRange(rowIndex, columns.StockActual).setValue(newStock);
222
+ sheet.getRange(rowIndex, columns.ConsumidoTotal).setValue(newConsumed);
223
+ sheet.getRange(rowIndex, columns.ActualizadoEn).setValue(updatedAt);
224
+
225
+ movementSheet.appendRow([
226
+ Utilities.getUuid(),
227
+ record.id,
228
+ record.producto,
229
+ 'consumo',
230
+ quantity,
231
+ record.unidad,
232
+ consumption.notas || '',
233
+ consumption.fuente || 'telegram-consumo',
234
+ updatedAt
235
+ ]);
236
+
237
+ return {
238
+ ok: true,
239
+ id: record.id,
240
+ stockActual: newStock,
241
+ consumidoTotal: newConsumed,
242
+ updatedAt: updatedAt
243
+ };
244
+ }
245
+
246
+ function listMovements_() {
247
+ const sheet = getMovementSheet_();
248
+ const values = sheet.getDataRange().getValues();
249
+ if (values.length <= 1) return { ok: true, movements: [] };
250
+
251
+ const headers = values[0];
252
+ const movements = values.slice(1).map(function(row) {
253
+ const result = {};
254
+ headers.forEach(function(header, index) {
255
+ result[header] = row[index];
256
+ });
257
+
258
+ return {
259
+ id: result.ID || '',
260
+ productId: result.ProductID || '',
261
+ producto: result.Producto || '',
262
+ tipo: result.Tipo || '',
263
+ cantidad: result.Cantidad || 0,
264
+ unidad: result.Unidad || 'unidad',
265
+ notas: result.Notas || '',
266
+ fuente: result.Fuente || '',
267
+ createdAt: formatDateValue_(result.CreadoEn)
268
+ };
269
+ });
270
+
271
+ return { ok: true, movements: movements };
272
+ }
273
+
274
+ function replaceSnapshot_(records, movements) {
275
+ const productSheet = getSheet_();
276
+ const movementSheet = getMovementSheet_();
277
+
278
+ productSheet.clearContents();
279
+ movementSheet.clearContents();
280
+
281
+ productSheet.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]);
282
+ movementSheet.getRange(1, 1, 1, MOVEMENT_HEADERS.length).setValues([MOVEMENT_HEADERS]);
283
+
284
+ if (records.length) {
285
+ const productRows = records.map(function(record) {
286
+ return [
287
+ record.id || Utilities.getUuid(),
288
+ record.producto || '',
289
+ Number(record.precio || 0),
290
+ Number(record.cantidad || 0),
291
+ record.unidad || 'unidad',
292
+ record.fechaCaducidad || '',
293
+ record.fechaIngreso || '',
294
+ record.fechaProduccion || '',
295
+ record.notas || '',
296
+ record.fuente || 'web',
297
+ Number(record.stockActual || 0),
298
+ Number(record.consumidoTotal || 0),
299
+ record.createdAt || '',
300
+ record.updatedAt || record.createdAt || ''
301
+ ];
302
+ });
303
+ productSheet.getRange(2, 1, productRows.length, HEADERS.length).setValues(productRows);
304
+ }
305
+
306
+ if (movements.length) {
307
+ const movementRows = movements.map(function(movement) {
308
+ return [
309
+ movement.id || Utilities.getUuid(),
310
+ movement.productId || '',
311
+ movement.producto || '',
312
+ movement.tipo || '',
313
+ Number(movement.cantidad || 0),
314
+ movement.unidad || 'unidad',
315
+ movement.notas || '',
316
+ movement.fuente || '',
317
+ movement.createdAt || ''
318
+ ];
319
+ });
320
+ movementSheet.getRange(2, 1, movementRows.length, MOVEMENT_HEADERS.length).setValues(movementRows);
321
+ }
322
+
323
+ productSheet.setFrozenRows(1);
324
+ movementSheet.setFrozenRows(1);
325
+
326
+ return { ok: true, records: records.length, movements: movements.length };
327
+ }
328
+
329
+ function headerIndexMap_(headers) {
330
+ const result = {};
331
+ headers.forEach(function(header, index) {
332
+ result[header] = index + 1;
333
+ });
334
+ return result;
335
+ }
336
+
337
+ function formatDateValue_(value) {
338
+ if (Object.prototype.toString.call(value) === '[object Date]' && !isNaN(value)) {
339
+ return Utilities.formatDate(value, Session.getScriptTimeZone(), 'yyyy-MM-dd');
340
+ }
341
+ return value || '';
342
+ }
343
+
344
+ function jsonResponse_(payload) {
345
+ return ContentService
346
+ .createTextOutput(JSON.stringify(payload))
347
+ .setMimeType(ContentService.MimeType.JSON);
348
+ }
python_backend/.env.example ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ PORT=7860
2
+ APP_BASE_URL=https://tu-space-o-dominio
3
+ TIMEZONE=America/Lima
4
+
5
+ ANTHROPIC_API_KEY=tu_api_key_anthropic
6
+ ANTHROPIC_MODEL=claude-3-5-haiku-latest
7
+
8
+ GOOGLE_SCRIPT_URL=https://script.google.com/macros/s/TU_DEPLOYMENT_ID/exec
9
+ GOOGLE_SCRIPT_TOKEN=token-compartido-seguro
10
+
11
+ SQLITE_PATH=/data/inventario.sqlite
12
+ WHISPER_MODEL=tiny
13
+ WHISPER_COMPUTE_TYPE=int8
14
+ WHISPER_DEVICE=cpu
15
+
16
+ TELEGRAM_BOT_TOKEN=token-del-bot
17
+ TELEGRAM_WEBHOOK_SECRET=secret-opcional
18
+ REMINDER_HOUR=8
19
+ REMINDER_MINUTE=0
20
+ EXPIRY_WARNING_DAYS=7
python_backend/app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
python_backend/app/config.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+
3
+
4
+ class Settings(BaseSettings):
5
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
6
+
7
+ port: int = 7860
8
+ app_base_url: str = ""
9
+ timezone: str = "America/Lima"
10
+
11
+ anthropic_api_key: str = ""
12
+ anthropic_model: str = "claude-3-5-haiku-latest"
13
+
14
+ google_script_url: str = ""
15
+ google_script_token: str = ""
16
+
17
+ sqlite_path: str = "/data/inventario.sqlite"
18
+ whisper_model: str = "tiny"
19
+ whisper_compute_type: str = "int8"
20
+ whisper_device: str = "cpu"
21
+
22
+ telegram_bot_token: str = ""
23
+ telegram_webhook_secret: str = ""
24
+ reminder_hour: int = 8
25
+ reminder_minute: int = 0
26
+ expiry_warning_days: int = 7
27
+
28
+
29
+ settings = Settings()
python_backend/app/database.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
6
+
7
+ from .config import settings
8
+
9
+
10
+ def _sqlite_url() -> str:
11
+ db_path = Path(settings.sqlite_path)
12
+ db_path.parent.mkdir(parents=True, exist_ok=True)
13
+ return f"sqlite+aiosqlite:///{db_path}"
14
+
15
+
16
+ engine = create_async_engine(_sqlite_url(), future=True)
17
+ SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
18
+
19
+
20
+ async def get_session() -> AsyncSession:
21
+ async with SessionLocal() as session:
22
+ yield session
python_backend/app/main.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from contextlib import asynccontextmanager, suppress
5
+ from pathlib import Path
6
+
7
+ from fastapi import Depends, FastAPI, Header, HTTPException, Request
8
+ from fastapi.responses import FileResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ from .config import settings
13
+ from .database import SessionLocal, engine, get_session
14
+ from .models import Base
15
+ from .schemas import ConsumptionCreate, DiagramRequest, ProductCreate, SearchRequest, TextExtractionRequest
16
+ from .services.ai import answer_question, build_mermaid, extract_consumption, extract_product
17
+ from .services.inventory import (
18
+ consume_product,
19
+ create_product,
20
+ get_expiring_products,
21
+ list_products,
22
+ register_telegram_user,
23
+ sync_from_sheets,
24
+ sync_to_sheets,
25
+ )
26
+ from .services.reminders import reminder_loop
27
+ from .services.telegram import download_file, get_file_url, send_message, set_webhook
28
+ from .services.whisper_local import transcribe_audio_bytes
29
+
30
+
31
+ @asynccontextmanager
32
+ async def lifespan(_app: FastAPI):
33
+ async with engine.begin() as conn:
34
+ await conn.run_sync(Base.metadata.create_all)
35
+
36
+ try:
37
+ async with SessionLocal() as session:
38
+ await sync_from_sheets(session)
39
+ except Exception as exc:
40
+ print(f"Sync inicial desde Sheets omitido: {exc}")
41
+
42
+ reminder_task = asyncio.create_task(reminder_loop(SessionLocal))
43
+ try:
44
+ yield
45
+ finally:
46
+ reminder_task.cancel()
47
+ with suppress(asyncio.CancelledError):
48
+ await reminder_task
49
+
50
+
51
+ app = FastAPI(lifespan=lifespan)
52
+ STATIC_DIR = Path(__file__).parent / "static"
53
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
54
+
55
+
56
+ @app.get("/health")
57
+ async def health() -> dict:
58
+ return {"ok": True}
59
+
60
+
61
+ @app.get("/")
62
+ async def index() -> FileResponse:
63
+ return FileResponse(STATIC_DIR / "index.html")
64
+
65
+
66
+ @app.get("/api/products")
67
+ async def api_list_products(query: str = "", session: AsyncSession = Depends(get_session)) -> dict:
68
+ return {"ok": True, "records": await list_products(session, query)}
69
+
70
+
71
+ @app.post("/api/products")
72
+ async def api_create_product(payload: ProductCreate, session: AsyncSession = Depends(get_session)) -> dict:
73
+ return {"ok": True, "record": await create_product(session, payload.model_dump())}
74
+
75
+
76
+ @app.post("/api/consume")
77
+ async def api_consume(payload: ConsumptionCreate, session: AsyncSession = Depends(get_session)) -> dict:
78
+ try:
79
+ return {"ok": True, "record": await consume_product(session, payload.model_dump())}
80
+ except ValueError as exc:
81
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
82
+
83
+
84
+ @app.post("/api/extract-product")
85
+ async def api_extract_product(payload: TextExtractionRequest) -> dict:
86
+ return {"ok": True, "product": await extract_product(payload.text, "web")}
87
+
88
+
89
+ @app.post("/api/search")
90
+ async def api_search(payload: SearchRequest, session: AsyncSession = Depends(get_session)) -> dict:
91
+ records = await list_products(session, "")
92
+ return {"ok": True, "answer": await answer_question(payload.question, records)}
93
+
94
+
95
+ @app.post("/api/diagram")
96
+ async def api_diagram(payload: DiagramRequest, session: AsyncSession = Depends(get_session)) -> dict:
97
+ records = await list_products(session, "")
98
+ return {"ok": True, "mermaid": await build_mermaid(payload.instruction, records)}
99
+
100
+
101
+ @app.get("/api/expiring")
102
+ async def api_expiring(days: int | None = None, session: AsyncSession = Depends(get_session)) -> dict:
103
+ return {"ok": True, "records": await get_expiring_products(session, days or settings.expiry_warning_days)}
104
+
105
+
106
+ @app.post("/api/sync/from-sheets")
107
+ async def api_sync_from_sheets(session: AsyncSession = Depends(get_session)) -> dict:
108
+ return {"ok": True, "result": await sync_from_sheets(session)}
109
+
110
+
111
+ @app.post("/api/sync/to-sheets")
112
+ async def api_sync_to_sheets(session: AsyncSession = Depends(get_session)) -> dict:
113
+ return {"ok": True, "result": await sync_to_sheets(session)}
114
+
115
+
116
+ @app.post("/telegram/set-webhook")
117
+ async def api_set_webhook() -> dict:
118
+ return await set_webhook()
119
+
120
+
121
+ @app.post("/telegram/webhook")
122
+ async def telegram_webhook(
123
+ request: Request,
124
+ session: AsyncSession = Depends(get_session),
125
+ x_telegram_bot_api_secret_token: str | None = Header(default=None),
126
+ ) -> dict:
127
+ if settings.telegram_webhook_secret and x_telegram_bot_api_secret_token != settings.telegram_webhook_secret:
128
+ raise HTTPException(status_code=401, detail="Unauthorized")
129
+
130
+ update = await request.json()
131
+ message = update.get("message") or update.get("edited_message")
132
+ if not message or not message.get("chat", {}).get("id"):
133
+ return {"ok": True}
134
+
135
+ chat_id = message["chat"]["id"]
136
+ await register_telegram_user(
137
+ session,
138
+ str(chat_id),
139
+ message.get("from", {}).get("username", ""),
140
+ message.get("from", {}).get("first_name", ""),
141
+ message.get("from", {}).get("last_name", ""),
142
+ )
143
+
144
+ try:
145
+ if message.get("voice") or message.get("audio"):
146
+ audio = message.get("voice") or message.get("audio")
147
+ file_url = await get_file_url(audio["file_id"])
148
+ audio_bytes = await download_file(file_url)
149
+ transcript = await transcribe_audio_bytes(audio_bytes, ".ogg")
150
+ product = await extract_product(transcript, "telegram-audio")
151
+ record = await create_product(session, product)
152
+ await send_message(
153
+ chat_id,
154
+ f"Registro guardado desde audio.\nProducto: {record['producto']}\nCantidad: {record['cantidad']} {record['unidad']}\nCaducidad: {record['fechaCaducidad']}",
155
+ )
156
+ return {"ok": True}
157
+
158
+ text = str(message.get("text", "")).strip()
159
+ if not text:
160
+ await send_message(chat_id, "Envia texto o audio para registrar o consultar productos.")
161
+ return {"ok": True}
162
+
163
+ if text.startswith("/start"):
164
+ await send_message(
165
+ chat_id,
166
+ "Comandos:\n/registrar <texto libre>\n/consumir <texto libre>\n/buscar <pregunta>\n/diagrama <instruccion>\n/vencimientos\n/stock <producto>",
167
+ )
168
+ return {"ok": True}
169
+
170
+ if text.startswith("/buscar "):
171
+ records = await list_products(session, "")
172
+ answer = await answer_question(text.replace("/buscar", "", 1).strip(), records)
173
+ await send_message(chat_id, answer)
174
+ return {"ok": True}
175
+
176
+ if text.startswith("/diagrama "):
177
+ records = await list_products(session, "")
178
+ mermaid = await build_mermaid(text.replace("/diagrama", "", 1).strip(), records)
179
+ await send_message(chat_id, mermaid)
180
+ return {"ok": True}
181
+
182
+ if text.startswith("/vencimientos"):
183
+ records = await get_expiring_products(session, settings.expiry_warning_days)
184
+ if not records:
185
+ await send_message(chat_id, "No hay productos proximos a vencer.")
186
+ else:
187
+ lines = [f"- {r['producto']}: vence {r['fechaCaducidad']}, stock {r['stockActual']} {r['unidad']}" for r in records]
188
+ await send_message(chat_id, "Productos por vencer:\n" + "\n".join(lines))
189
+ return {"ok": True}
190
+
191
+ if text.startswith("/stock "):
192
+ records = await list_products(session, text.replace("/stock", "", 1).strip())
193
+ if not records:
194
+ await send_message(chat_id, "No encontre registros.")
195
+ else:
196
+ lines = [f"- {r['producto']}: stock {r['stockActual']}/{r['cantidad']} {r['unidad']}, vence {r['fechaCaducidad']}" for r in records[:10]]
197
+ await send_message(chat_id, "Stock encontrado:\n" + "\n".join(lines))
198
+ return {"ok": True}
199
+
200
+ if text.startswith("/consumir "):
201
+ payload = await extract_consumption(text.replace("/consumir", "", 1).strip(), "telegram-consumo")
202
+ result = await consume_product(session, payload)
203
+ await send_message(
204
+ chat_id,
205
+ f"Consumo registrado.\nProducto: {result['producto']}\nConsumido: {result['consumedNow']} {result['unidad']}\nStock restante: {result['stockActual']} {result['unidad']}",
206
+ )
207
+ return {"ok": True}
208
+
209
+ raw_text = text.replace("/registrar", "", 1).strip() if text.startswith("/registrar ") else text
210
+ payload = await extract_product(raw_text, "telegram-texto")
211
+ record = await create_product(session, payload)
212
+ await send_message(
213
+ chat_id,
214
+ f"Registro guardado.\nProducto: {record['producto']}\nCantidad: {record['cantidad']} {record['unidad']}\nIngreso: {record['fechaIngreso']}",
215
+ )
216
+ except ValueError as exc:
217
+ await send_message(chat_id, str(exc))
218
+ except Exception as exc:
219
+ await send_message(chat_id, f"Hubo un error: {exc}")
220
+
221
+ return {"ok": True}
python_backend/app/models.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import Float, Integer, String, Text
4
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
5
+
6
+
7
+ class Base(DeclarativeBase):
8
+ pass
9
+
10
+
11
+ class Product(Base):
12
+ __tablename__ = "products"
13
+
14
+ id: Mapped[str] = mapped_column(String, primary_key=True)
15
+ producto: Mapped[str] = mapped_column(String, index=True)
16
+ precio: Mapped[float] = mapped_column(Float)
17
+ cantidad: Mapped[float] = mapped_column(Float)
18
+ unidad: Mapped[str] = mapped_column(String)
19
+ fecha_caducidad: Mapped[str] = mapped_column(String, index=True)
20
+ fecha_ingreso: Mapped[str] = mapped_column(String)
21
+ fecha_produccion: Mapped[str] = mapped_column(String)
22
+ notas: Mapped[str] = mapped_column(Text, default="")
23
+ fuente: Mapped[str] = mapped_column(String, default="web")
24
+ stock_actual: Mapped[float] = mapped_column(Float)
25
+ consumido_total: Mapped[float] = mapped_column(Float, default=0)
26
+ created_at: Mapped[str] = mapped_column(String)
27
+ updated_at: Mapped[str] = mapped_column(String)
28
+
29
+
30
+ class Movement(Base):
31
+ __tablename__ = "movements"
32
+
33
+ id: Mapped[str] = mapped_column(String, primary_key=True)
34
+ product_id: Mapped[str] = mapped_column(String, index=True)
35
+ producto: Mapped[str] = mapped_column(String, index=True)
36
+ tipo: Mapped[str] = mapped_column(String)
37
+ cantidad: Mapped[float] = mapped_column(Float)
38
+ unidad: Mapped[str] = mapped_column(String)
39
+ notas: Mapped[str] = mapped_column(Text, default="")
40
+ fuente: Mapped[str] = mapped_column(String, default="")
41
+ created_at: Mapped[str] = mapped_column(String)
42
+
43
+
44
+ class TelegramUser(Base):
45
+ __tablename__ = "telegram_users"
46
+
47
+ chat_id: Mapped[str] = mapped_column(String, primary_key=True)
48
+ username: Mapped[str] = mapped_column(String, default="")
49
+ first_name: Mapped[str] = mapped_column(String, default="")
50
+ last_name: Mapped[str] = mapped_column(String, default="")
51
+ is_active: Mapped[int] = mapped_column(Integer, default=1)
52
+ updated_at: Mapped[str] = mapped_column(String)
python_backend/app/schemas.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class ProductCreate(BaseModel):
7
+ producto: str
8
+ precio: float = Field(ge=0)
9
+ cantidad: float = Field(gt=0)
10
+ unidad: str = "unidad"
11
+ fechaCaducidad: str
12
+ fechaIngreso: str
13
+ fechaProduccion: str
14
+ notas: str = ""
15
+ fuente: str = "web"
16
+
17
+
18
+ class ConsumptionCreate(BaseModel):
19
+ producto: str
20
+ cantidad: float = Field(gt=0)
21
+ unidad: str = "unidad"
22
+ notas: str = ""
23
+ fuente: str = "telegram-consumo"
24
+
25
+
26
+ class SearchRequest(BaseModel):
27
+ question: str
28
+
29
+
30
+ class DiagramRequest(BaseModel):
31
+ instruction: str
32
+
33
+
34
+ class TextExtractionRequest(BaseModel):
35
+ text: str
python_backend/app/services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
python_backend/app/services/ai.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from ..config import settings
10
+
11
+
12
+ def extract_json(text: str) -> dict[str, Any]:
13
+ fenced = re.search(r"```json\s*(.*?)```", text, re.DOTALL | re.IGNORECASE)
14
+ if fenced:
15
+ return json.loads(fenced.group(1))
16
+
17
+ inline = re.search(r"\{.*\}", text, re.DOTALL)
18
+ if inline:
19
+ return json.loads(inline.group(0))
20
+
21
+ raise ValueError("No se encontro JSON valido en la respuesta de Claude.")
22
+
23
+
24
+ async def call_claude(system: str, user_content: str, max_tokens: int = 1200) -> str:
25
+ async with httpx.AsyncClient(timeout=90) as client:
26
+ response = await client.post(
27
+ "https://api.anthropic.com/v1/messages",
28
+ headers={
29
+ "content-type": "application/json",
30
+ "x-api-key": settings.anthropic_api_key,
31
+ "anthropic-version": "2023-06-01",
32
+ },
33
+ json={
34
+ "model": settings.anthropic_model,
35
+ "max_tokens": max_tokens,
36
+ "system": system,
37
+ "messages": [{"role": "user", "content": user_content}],
38
+ },
39
+ )
40
+ response.raise_for_status()
41
+ payload = response.json()
42
+ return "\n".join(part["text"] for part in payload.get("content", []) if part.get("type") == "text")
43
+
44
+
45
+ async def extract_product(raw_text: str, source: str) -> dict[str, Any]:
46
+ system = f"""
47
+ Eres un extractor de inventario domestico.
48
+ Devuelve solo JSON exacto.
49
+ Esquema:
50
+ {{
51
+ "producto": "string",
52
+ "precio": 0,
53
+ "cantidad": 0,
54
+ "unidad": "unidad",
55
+ "fechaCaducidad": "YYYY-MM-DD",
56
+ "fechaIngreso": "YYYY-MM-DD",
57
+ "fechaProduccion": "YYYY-MM-DD",
58
+ "notas": "string",
59
+ "fuente": "{source}"
60
+ }}
61
+ Si falta algo, usa 0 o cadena vacia.
62
+ """
63
+ text = await call_claude(system, f"Texto:\n{raw_text}")
64
+ return extract_json(text)
65
+
66
+
67
+ async def extract_consumption(raw_text: str, source: str) -> dict[str, Any]:
68
+ system = f"""
69
+ Eres un extractor de consumo de inventario.
70
+ Devuelve solo JSON exacto.
71
+ Esquema:
72
+ {{
73
+ "producto": "string",
74
+ "cantidad": 0,
75
+ "unidad": "unidad",
76
+ "notas": "string",
77
+ "fuente": "{source}"
78
+ }}
79
+ """
80
+ text = await call_claude(system, f"Texto:\n{raw_text}")
81
+ return extract_json(text)
82
+
83
+
84
+ def _records_to_context(records: list[dict[str, Any]]) -> str:
85
+ return "\n".join(
86
+ f"{index + 1}. producto={r.get('producto')}; precio={r.get('precio')}; cantidad={r.get('cantidad')}; unidad={r.get('unidad')}; stockActual={r.get('stockActual')}; fechaCaducidad={r.get('fechaCaducidad')}; consumidoTotal={r.get('consumidoTotal')}"
87
+ for index, r in enumerate(records)
88
+ )
89
+
90
+
91
+ async def answer_question(question: str, records: list[dict[str, Any]]) -> str:
92
+ system = """
93
+ Eres un asistente de inventario domestico.
94
+ Responde solo con informacion sustentada por los registros.
95
+ Si los datos no alcanzan, dilo claramente.
96
+ """
97
+ return await call_claude(system, f"Pregunta:\n{question}\n\nRegistros:\n{_records_to_context(records)}")
98
+
99
+
100
+ async def build_mermaid(instruction: str, records: list[dict[str, Any]]) -> str:
101
+ system = """
102
+ Genera solo codigo Mermaid valido.
103
+ No uses markdown.
104
+ """
105
+ return await call_claude(
106
+ system,
107
+ f"Instruccion:\n{instruction}\n\nRegistros:\n{_records_to_context(records)}",
108
+ )
python_backend/app/services/inventory.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import date, datetime
5
+
6
+ from sqlalchemy import delete, select
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from ..models import Movement, Product, TelegramUser
10
+ from . import sheets_backup
11
+
12
+
13
+ def now_iso() -> str:
14
+ return datetime.utcnow().replace(microsecond=0).isoformat()
15
+
16
+
17
+ def product_to_dict(product: Product) -> dict:
18
+ return {
19
+ "id": product.id,
20
+ "producto": product.producto,
21
+ "precio": product.precio,
22
+ "cantidad": product.cantidad,
23
+ "unidad": product.unidad,
24
+ "fechaCaducidad": product.fecha_caducidad,
25
+ "fechaIngreso": product.fecha_ingreso,
26
+ "fechaProduccion": product.fecha_produccion,
27
+ "notas": product.notas,
28
+ "fuente": product.fuente,
29
+ "stockActual": product.stock_actual,
30
+ "consumidoTotal": product.consumido_total,
31
+ "createdAt": product.created_at,
32
+ "updatedAt": product.updated_at,
33
+ }
34
+
35
+
36
+ def movement_to_dict(movement: Movement) -> dict:
37
+ return {
38
+ "id": movement.id,
39
+ "productId": movement.product_id,
40
+ "producto": movement.producto,
41
+ "tipo": movement.tipo,
42
+ "cantidad": movement.cantidad,
43
+ "unidad": movement.unidad,
44
+ "notas": movement.notas,
45
+ "fuente": movement.fuente,
46
+ "createdAt": movement.created_at,
47
+ }
48
+
49
+
50
+ async def register_telegram_user(session: AsyncSession, chat_id: str, username: str = "", first_name: str = "", last_name: str = "") -> None:
51
+ user = await session.get(TelegramUser, str(chat_id))
52
+ if user is None:
53
+ user = TelegramUser(
54
+ chat_id=str(chat_id),
55
+ username=username or "",
56
+ first_name=first_name or "",
57
+ last_name=last_name or "",
58
+ is_active=1,
59
+ updated_at=now_iso(),
60
+ )
61
+ session.add(user)
62
+ else:
63
+ user.username = username or ""
64
+ user.first_name = first_name or ""
65
+ user.last_name = last_name or ""
66
+ user.is_active = 1
67
+ user.updated_at = now_iso()
68
+ await session.commit()
69
+
70
+
71
+ async def list_active_users(session: AsyncSession) -> list[TelegramUser]:
72
+ result = await session.execute(select(TelegramUser).where(TelegramUser.is_active == 1))
73
+ return list(result.scalars().all())
74
+
75
+
76
+ async def create_product(session: AsyncSession, payload: dict) -> dict:
77
+ timestamp = now_iso()
78
+ product = Product(
79
+ id=str(uuid.uuid4()),
80
+ producto=payload["producto"],
81
+ precio=float(payload["precio"]),
82
+ cantidad=float(payload["cantidad"]),
83
+ unidad=payload["unidad"],
84
+ fecha_caducidad=payload["fechaCaducidad"],
85
+ fecha_ingreso=payload["fechaIngreso"],
86
+ fecha_produccion=payload["fechaProduccion"],
87
+ notas=payload.get("notas", ""),
88
+ fuente=payload.get("fuente", "web"),
89
+ stock_actual=float(payload["cantidad"]),
90
+ consumido_total=0,
91
+ created_at=timestamp,
92
+ updated_at=timestamp,
93
+ )
94
+ movement = Movement(
95
+ id=str(uuid.uuid4()),
96
+ product_id=product.id,
97
+ producto=product.producto,
98
+ tipo="ingreso",
99
+ cantidad=product.cantidad,
100
+ unidad=product.unidad,
101
+ notas=product.notas,
102
+ fuente=product.fuente,
103
+ created_at=timestamp,
104
+ )
105
+ session.add(product)
106
+ session.add(movement)
107
+ await session.commit()
108
+ await sheets_backup.add_product(product_to_dict(product))
109
+ return product_to_dict(product)
110
+
111
+
112
+ async def list_products(session: AsyncSession, query: str = "") -> list[dict]:
113
+ result = await session.execute(select(Product).order_by(Product.fecha_caducidad.asc(), Product.producto.asc()))
114
+ products = list(result.scalars().all())
115
+ if query:
116
+ needle = query.lower().strip()
117
+ products = [p for p in products if needle in p.producto.lower() or needle in p.notas.lower()]
118
+ return [product_to_dict(product) for product in products]
119
+
120
+
121
+ async def list_movements_local(session: AsyncSession) -> list[dict]:
122
+ result = await session.execute(select(Movement).order_by(Movement.created_at.desc()))
123
+ return [movement_to_dict(item) for item in result.scalars().all()]
124
+
125
+
126
+ async def consume_product(session: AsyncSession, payload: dict) -> dict:
127
+ result = await session.execute(
128
+ select(Product)
129
+ .where(Product.producto.ilike(payload["producto"]), Product.stock_actual > 0)
130
+ .order_by(Product.fecha_caducidad.asc(), Product.created_at.asc())
131
+ .limit(1)
132
+ )
133
+ product = result.scalar_one_or_none()
134
+ if product is None:
135
+ raise ValueError(f'No existe stock disponible para "{payload["producto"]}".')
136
+ if product.unidad.lower() != payload["unidad"].lower():
137
+ raise ValueError(f"La unidad esperada para {product.producto} es {product.unidad}.")
138
+ if product.stock_actual < payload["cantidad"]:
139
+ raise ValueError(f"Stock insuficiente de {product.producto}.")
140
+
141
+ product.stock_actual -= float(payload["cantidad"])
142
+ product.consumido_total += float(payload["cantidad"])
143
+ product.updated_at = now_iso()
144
+
145
+ movement = Movement(
146
+ id=str(uuid.uuid4()),
147
+ product_id=product.id,
148
+ producto=product.producto,
149
+ tipo="consumo",
150
+ cantidad=float(payload["cantidad"]),
151
+ unidad=product.unidad,
152
+ notas=payload.get("notas", ""),
153
+ fuente=payload.get("fuente", "telegram-consumo"),
154
+ created_at=product.updated_at,
155
+ )
156
+ session.add(movement)
157
+ await session.commit()
158
+
159
+ response = product_to_dict(product) | {"consumedNow": float(payload["cantidad"])}
160
+ await sheets_backup.consume_product(
161
+ {
162
+ "productId": product.id,
163
+ "producto": product.producto,
164
+ "cantidad": float(payload["cantidad"]),
165
+ "unidad": product.unidad,
166
+ "notas": payload.get("notas", ""),
167
+ "fuente": payload.get("fuente", "telegram-consumo"),
168
+ }
169
+ )
170
+ return response
171
+
172
+
173
+ async def get_expiring_products(session: AsyncSession, days_ahead: int) -> list[dict]:
174
+ products = await list_products(session)
175
+ today = date.today()
176
+ expiring = []
177
+ for item in products:
178
+ if item["stockActual"] <= 0 or not item["fechaCaducidad"]:
179
+ continue
180
+ expiry = date.fromisoformat(item["fechaCaducidad"])
181
+ diff = (expiry - today).days
182
+ if 0 <= diff <= days_ahead:
183
+ expiring.append(item)
184
+ return expiring
185
+
186
+
187
+ async def sync_from_sheets(session: AsyncSession) -> dict:
188
+ products_response, movements_response = await sheets_backup.list_products(), await sheets_backup.list_movements()
189
+ records = products_response.get("records", [])
190
+ movements = movements_response.get("movements", [])
191
+
192
+ await session.execute(delete(Product))
193
+ await session.execute(delete(Movement))
194
+
195
+ for item in records:
196
+ session.add(
197
+ Product(
198
+ id=item["id"],
199
+ producto=item["producto"],
200
+ precio=float(item.get("precio", 0)),
201
+ cantidad=float(item.get("cantidad", 0)),
202
+ unidad=item.get("unidad", "unidad"),
203
+ fecha_caducidad=item.get("fechaCaducidad", ""),
204
+ fecha_ingreso=item.get("fechaIngreso", ""),
205
+ fecha_produccion=item.get("fechaProduccion", ""),
206
+ notas=item.get("notas", ""),
207
+ fuente=item.get("fuente", "web"),
208
+ stock_actual=float(item.get("stockActual", item.get("cantidad", 0))),
209
+ consumido_total=float(item.get("consumidoTotal", 0)),
210
+ created_at=item.get("createdAt", now_iso()),
211
+ updated_at=item.get("updatedAt", item.get("createdAt", now_iso())),
212
+ )
213
+ )
214
+
215
+ for item in movements:
216
+ session.add(
217
+ Movement(
218
+ id=item["id"],
219
+ product_id=item.get("productId", ""),
220
+ producto=item["producto"],
221
+ tipo=item["tipo"],
222
+ cantidad=float(item.get("cantidad", 0)),
223
+ unidad=item.get("unidad", "unidad"),
224
+ notas=item.get("notas", ""),
225
+ fuente=item.get("fuente", ""),
226
+ created_at=item.get("createdAt", now_iso()),
227
+ )
228
+ )
229
+
230
+ await session.commit()
231
+ return {"products": len(records), "movements": len(movements)}
232
+
233
+
234
+ async def sync_to_sheets(session: AsyncSession) -> dict:
235
+ records = await list_products(session)
236
+ movements = await list_movements_local(session)
237
+ await sheets_backup.replace_snapshot(records, movements)
238
+ return {"products": len(records), "movements": len(movements)}
python_backend/app/services/reminders.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from datetime import datetime, timedelta
5
+
6
+ from sqlalchemy.ext.asyncio import async_sessionmaker
7
+
8
+ from ..config import settings
9
+ from .inventory import get_expiring_products, list_active_users
10
+ from .telegram import send_message
11
+
12
+
13
+ def seconds_until_next_run() -> float:
14
+ now = datetime.now()
15
+ target = now.replace(hour=settings.reminder_hour, minute=settings.reminder_minute, second=0, microsecond=0)
16
+ if target <= now:
17
+ target = target + timedelta(days=1)
18
+ return (target - now).total_seconds()
19
+
20
+
21
+ def render_reminder(records: list[dict]) -> str:
22
+ if not records:
23
+ return f"Recordatorio diario: no hay productos por vencer en los proximos {settings.expiry_warning_days} dias."
24
+ lines = [
25
+ f"- {item['producto']}: vence {item['fechaCaducidad']}, stock {item['stockActual']} {item['unidad']}"
26
+ for item in records
27
+ ]
28
+ return "Recordatorio diario de vencimientos:\n" + "\n".join(lines)
29
+
30
+
31
+ async def run_daily_reminders(session_factory: async_sessionmaker) -> None:
32
+ async with session_factory() as session:
33
+ users = await list_active_users(session)
34
+ if not users:
35
+ return
36
+ records = await get_expiring_products(session, settings.expiry_warning_days)
37
+ message = render_reminder(records)
38
+ for user in users:
39
+ try:
40
+ await send_message(user.chat_id, message)
41
+ except Exception as exc:
42
+ print(f"No se pudo enviar recordatorio a {user.chat_id}: {exc}")
43
+
44
+
45
+ async def reminder_loop(session_factory: async_sessionmaker) -> None:
46
+ while True:
47
+ await asyncio.sleep(seconds_until_next_run())
48
+ await run_daily_reminders(session_factory)
python_backend/app/services/sheets_backup.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from ..config import settings
8
+
9
+
10
+ async def _call(action: str, payload: dict[str, Any] | None = None, method: str = "POST") -> dict[str, Any]:
11
+ payload = payload or {}
12
+ async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
13
+ if method == "GET":
14
+ params = {"action": action, "token": settings.google_script_token, **payload}
15
+ response = await client.get(settings.google_script_url, params=params)
16
+ else:
17
+ response = await client.post(
18
+ settings.google_script_url,
19
+ json={"action": action, "token": settings.google_script_token, **payload},
20
+ )
21
+ response.raise_for_status()
22
+ return response.json()
23
+
24
+
25
+ async def add_product(record: dict[str, Any]) -> dict[str, Any]:
26
+ return await _call("addRecord", {"record": record})
27
+
28
+
29
+ async def consume_product(consumption: dict[str, Any]) -> dict[str, Any]:
30
+ return await _call("consumeProduct", {"consumption": consumption})
31
+
32
+
33
+ async def list_products() -> dict[str, Any]:
34
+ return await _call("listRecords", {"query": ""}, method="GET")
35
+
36
+
37
+ async def list_movements() -> dict[str, Any]:
38
+ return await _call("listMovements", method="GET")
39
+
40
+
41
+ async def replace_snapshot(records: list[dict[str, Any]], movements: list[dict[str, Any]]) -> dict[str, Any]:
42
+ return await _call("replaceSnapshot", {"records": records, "movements": movements})
python_backend/app/services/telegram.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ from ..config import settings
6
+
7
+
8
+ def _api_url(path: str) -> str:
9
+ return f"https://api.telegram.org/bot{settings.telegram_bot_token}/{path}"
10
+
11
+
12
+ async def send_message(chat_id: str | int, text: str) -> None:
13
+ async with httpx.AsyncClient(timeout=30) as client:
14
+ response = await client.post(_api_url("sendMessage"), json={"chat_id": chat_id, "text": text})
15
+ response.raise_for_status()
16
+
17
+
18
+ async def get_file_url(file_id: str) -> str:
19
+ async with httpx.AsyncClient(timeout=30) as client:
20
+ response = await client.get(_api_url("getFile"), params={"file_id": file_id})
21
+ response.raise_for_status()
22
+ file_path = response.json()["result"]["file_path"]
23
+ return f"https://api.telegram.org/file/bot{settings.telegram_bot_token}/{file_path}"
24
+
25
+
26
+ async def download_file(file_url: str) -> bytes:
27
+ async with httpx.AsyncClient(timeout=120) as client:
28
+ response = await client.get(file_url)
29
+ response.raise_for_status()
30
+ return response.content
31
+
32
+
33
+ async def set_webhook() -> dict:
34
+ async with httpx.AsyncClient(timeout=30) as client:
35
+ response = await client.post(
36
+ _api_url("setWebhook"),
37
+ json={
38
+ "url": f"{settings.app_base_url}/telegram/webhook",
39
+ "secret_token": settings.telegram_webhook_secret or None,
40
+ },
41
+ )
42
+ response.raise_for_status()
43
+ return response.json()
python_backend/app/services/whisper_local.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ from faster_whisper import WhisperModel
8
+
9
+ from ..config import settings
10
+
11
+ _model: WhisperModel | None = None
12
+
13
+
14
+ def _get_model() -> WhisperModel:
15
+ global _model
16
+ if _model is None:
17
+ _model = WhisperModel(
18
+ settings.whisper_model,
19
+ device=settings.whisper_device,
20
+ compute_type=settings.whisper_compute_type,
21
+ )
22
+ return _model
23
+
24
+
25
+ def _transcribe_file(file_path: str) -> str:
26
+ model = _get_model()
27
+ segments, _info = model.transcribe(file_path, beam_size=1, vad_filter=True)
28
+ return " ".join(segment.text.strip() for segment in segments).strip()
29
+
30
+
31
+ async def transcribe_audio_bytes(audio_bytes: bytes, suffix: str = ".ogg") -> str:
32
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
33
+ temp_file.write(audio_bytes)
34
+ temp_path = temp_file.name
35
+
36
+ try:
37
+ return await asyncio.to_thread(_transcribe_file, temp_path)
38
+ finally:
39
+ Path(temp_path).unlink(missing_ok=True)
python_backend/app/static/app.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ async function postJson(url, body) {
2
+ const response = await fetch(url, {
3
+ method: "POST",
4
+ headers: { "Content-Type": "application/json" },
5
+ body: JSON.stringify(body)
6
+ });
7
+ return response.json();
8
+ }
9
+
10
+ function bind(formId, resultId, endpoint, extra = {}) {
11
+ const form = document.getElementById(formId);
12
+ const result = document.getElementById(resultId);
13
+ form.addEventListener("submit", async (event) => {
14
+ event.preventDefault();
15
+ result.textContent = "Procesando...";
16
+ const payload = Object.fromEntries(new FormData(form).entries());
17
+ const data = await postJson(endpoint, { ...payload, ...extra });
18
+ result.textContent = JSON.stringify(data, null, 2);
19
+ });
20
+ }
21
+
22
+ bind("product-form", "product-result", "/api/products", { fuente: "web" });
23
+ bind("consume-form", "consume-result", "/api/consume", { fuente: "web-consumo" });
python_backend/app/static/index.html ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Inventario Hogar</title>
7
+ <link rel="stylesheet" href="/static/styles.css" />
8
+ <script type="module" src="/static/app.js" defer></script>
9
+ </head>
10
+ <body>
11
+ <main class="layout">
12
+ <section class="hero">
13
+ <div>
14
+ <p class="eyebrow">Python Async + Whisper Local</p>
15
+ <h1>Inventario del hogar</h1>
16
+ <p class="lead">SQLite es la base principal. Google Sheets es respaldo. Telegram es el canal operativo.</p>
17
+ </div>
18
+ </section>
19
+ <section class="grid">
20
+ <article class="panel">
21
+ <h2>Registrar producto</h2>
22
+ <form id="product-form">
23
+ <label>Producto<input name="producto" required /></label>
24
+ <label>Precio<input name="precio" type="number" min="0" step="0.01" required /></label>
25
+ <label>Cantidad<input name="cantidad" type="number" min="0.01" step="0.01" required /></label>
26
+ <label>Unidad<input name="unidad" value="unidad" required /></label>
27
+ <label>Fecha caducidad<input name="fechaCaducidad" type="date" required /></label>
28
+ <label>Fecha ingreso<input name="fechaIngreso" type="date" required /></label>
29
+ <label>Fecha produccion<input name="fechaProduccion" type="date" required /></label>
30
+ <label>Notas<textarea name="notas" rows="3"></textarea></label>
31
+ <button type="submit">Guardar</button>
32
+ </form>
33
+ <pre id="product-result" class="result"></pre>
34
+ </article>
35
+ <article class="panel">
36
+ <h2>Consumir producto</h2>
37
+ <form id="consume-form">
38
+ <label>Producto<input name="producto" required /></label>
39
+ <label>Cantidad<input name="cantidad" type="number" min="0.01" step="0.01" required /></label>
40
+ <label>Unidad<input name="unidad" value="unidad" required /></label>
41
+ <label>Notas<textarea name="notas" rows="3"></textarea></label>
42
+ <button type="submit">Descontar</button>
43
+ </form>
44
+ <pre id="consume-result" class="result"></pre>
45
+ </article>
46
+ </section>
47
+ </main>
48
+ </body>
49
+ </html>
python_backend/app/static/styles.css ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ margin: 0;
3
+ font-family: "IBM Plex Sans", sans-serif;
4
+ background: linear-gradient(180deg, #f8f4ee 0%, #efe4d4 100%);
5
+ color: #2f241d;
6
+ }
7
+
8
+ .layout {
9
+ max-width: 1100px;
10
+ margin: 0 auto;
11
+ padding: 40px 20px 80px;
12
+ }
13
+
14
+ .hero {
15
+ margin-bottom: 24px;
16
+ }
17
+
18
+ .hero h1 {
19
+ font-family: Georgia, serif;
20
+ font-size: clamp(2.6rem, 4vw, 4.5rem);
21
+ margin: 0 0 10px;
22
+ }
23
+
24
+ .grid {
25
+ display: grid;
26
+ gap: 24px;
27
+ grid-template-columns: repeat(2, minmax(0, 1fr));
28
+ }
29
+
30
+ .panel {
31
+ background: rgba(255, 250, 242, 0.88);
32
+ border: 1px solid rgba(47, 36, 29, 0.14);
33
+ border-radius: 22px;
34
+ padding: 22px;
35
+ }
36
+
37
+ form {
38
+ display: grid;
39
+ gap: 12px;
40
+ }
41
+
42
+ label {
43
+ display: grid;
44
+ gap: 6px;
45
+ }
46
+
47
+ input, textarea, button {
48
+ font: inherit;
49
+ }
50
+
51
+ input, textarea {
52
+ border: 1px solid rgba(47, 36, 29, 0.18);
53
+ border-radius: 14px;
54
+ padding: 12px;
55
+ }
56
+
57
+ button {
58
+ border: 0;
59
+ border-radius: 999px;
60
+ padding: 12px 18px;
61
+ background: #1f6a52;
62
+ color: white;
63
+ font-weight: 700;
64
+ }
65
+
66
+ .result {
67
+ min-height: 80px;
68
+ background: #f9f4ec;
69
+ border: 1px solid rgba(47, 36, 29, 0.12);
70
+ border-radius: 16px;
71
+ padding: 14px;
72
+ overflow: auto;
73
+ }
74
+
75
+ @media (max-width: 900px) {
76
+ .grid {
77
+ grid-template-columns: 1fr;
78
+ }
79
+ }
python_backend/pyproject.toml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "registro-productos-hogar"
3
+ version = "0.1.0"
4
+ description = "Inventario domestico con Telegram, Claude, SQLite, Whisper local y respaldo en Google Sheets."
5
+ requires-python = ">=3.14"
6
+ dependencies = [
7
+ "aiosqlite>=0.21.0",
8
+ "fastapi>=0.116.0",
9
+ "faster-whisper>=1.2.0",
10
+ "httpx>=0.28.1",
11
+ "pydantic-settings>=2.10.1",
12
+ "sqlalchemy>=2.0.43",
13
+ "uvicorn[standard]>=0.35.0"
14
+ ]
15
+
16
+ [build-system]
17
+ requires = ["setuptools>=80"]
18
+ build-backend = "setuptools.build_meta"