josesalazar2025 commited on
Commit
d90e9a6
·
1 Parent(s): 06f94a3

Add Spanish explanatory comments across JS modules and PHP proxy

Browse files
Files changed (12) hide show
  1. README.md +1 -33
  2. SKILL.md +0 -205
  3. USO_DE_IA.md +7 -2
  4. api/conexion.php +2 -4
  5. api/hf_proxy.php +4 -3
  6. js/analisis.js +6 -0
  7. js/auth.js +4 -1
  8. js/ia.js +10 -8
  9. js/main.js +6 -0
  10. js/papers.js +3 -0
  11. js/pdf-parser.js +12 -11
  12. js/ui.js +8 -1
README.md CHANGED
@@ -164,38 +164,6 @@ O bien con el servidor integrado de PHP desde la raiz del proyecto:
164
  php -S localhost:8000
165
  ```
166
 
167
- ### 5. Despliegue con Docker (HuggingFace Spaces)
168
-
169
- El proyecto incluye un `Dockerfile` basado en `php:8.2-apache` configurado para ejecutarse en HuggingFace Spaces en el puerto `7860`.
170
-
171
- **Construir y ejecutar localmente:**
172
-
173
- ```bash
174
- docker build -t morphos .
175
- docker run -p 7860:7860 morphos
176
- ```
177
-
178
- Abrir en el navegador: `http://localhost:7860`
179
-
180
- **Variables de entorno en Docker:**
181
-
182
- La API key de HuggingFace debe estar en `api/.env` antes de construir la imagen:
183
-
184
- ```text
185
- HF_API_KEY=tu_clave_de_huggingface
186
- ```
187
-
188
- > En HuggingFace Spaces también puede configurarse como secreto desde *Settings → Repository secrets* para no exponerla en el repositorio.
189
-
190
- **Notas del entorno Docker:**
191
-
192
- * Apache escucha en el puerto `7860` (configurado vía `sed` sobre `ports.conf` y `000-default.conf`)
193
- * El script `docker-entrypoint.sh` inicializa la base de datos SQLite en `/var/www/html/data/morphos.db` al arrancar el contenedor
194
- * Los datos escritos en SQLite durante la ejecución **no persisten** entre reinicios del contenedor a menos que se monte un volumen externo
195
- * Si el Space queda en estado de error por un proceso Apache colgado, usar **Factory reboot** desde el menú del Space en HuggingFace
196
-
197
- ---
198
-
199
  ## Backend de IA
200
 
201
  El modelo de IA se configura desde la propia interfaz. La seleccion se guarda en `localStorage`.
@@ -259,7 +227,7 @@ La gravedad se calcula como la desviacion relativa al ancho del rango de referen
259
  ## Retos
260
 
261
  * Por la diversidad de unidades de medición que utilizan los diferentes fabricantes de equipos de laboratorio se incorporó una detección de unidades para su conversión y normalización
262
- * El modelado del output de la I.A requirió muchísimas iteraciones de formateo del prompt y harness
263
  * Inicialmente quería usar proveedores de inferencia gratuita de medGemma (como featherless AI) pero fallaban continuamente, por eso decidí optar por hostear al modelo en Zero GPU de HF con la subscripción pro para la prueba de concepto
264
  * Incluir las librería de parseo de pdf y las fuentes en el directorio del proyecto con la intención de reducir dependencias externas estaba generando problemas con las métricas de velocidad de lighthouse que no lograba solucionar. Claude planteó implementación de caché en htacesss y pre carga de las fuentes, lo cual llevó la puntuación de 60 a 90/100 sin mayores cambios estructurales
265
  * Lograr una interfaz limpia y entendible requirío de muchos intentos hasta lograr un flujo de trabajo intuitivo y accsesible con la mínima friccion posible para los usuarios
 
164
  php -S localhost:8000
165
  ```
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  ## Backend de IA
168
 
169
  El modelo de IA se configura desde la propia interfaz. La seleccion se guarda en `localStorage`.
 
227
  ## Retos
228
 
229
  * Por la diversidad de unidades de medición que utilizan los diferentes fabricantes de equipos de laboratorio se incorporó una detección de unidades para su conversión y normalización
230
+ * El modelado del output de la I.A requirió muchísimas iteraciones de formateo del prompt y harness para evitar alucinaciones o que envíara su proceso de pensamiento, aún requiere de mucho trabajo extra de refinamiento
231
  * Inicialmente quería usar proveedores de inferencia gratuita de medGemma (como featherless AI) pero fallaban continuamente, por eso decidí optar por hostear al modelo en Zero GPU de HF con la subscripción pro para la prueba de concepto
232
  * Incluir las librería de parseo de pdf y las fuentes en el directorio del proyecto con la intención de reducir dependencias externas estaba generando problemas con las métricas de velocidad de lighthouse que no lograba solucionar. Claude planteó implementación de caché en htacesss y pre carga de las fuentes, lo cual llevó la puntuación de 60 a 90/100 sin mayores cambios estructurales
233
  * Lograr una interfaz limpia y entendible requirío de muchos intentos hasta lograr un flujo de trabajo intuitivo y accsesible con la mínima friccion posible para los usuarios
SKILL.md DELETED
@@ -1,205 +0,0 @@
1
- ---
2
- name: hf-cli
3
- description: "Hugging Face Hub CLI (`hf`) for downloading, uploading, and managing models, datasets, spaces, buckets, repos, papers, jobs, and more on the Hugging Face Hub. Use when: handling authentication; managing local cache; managing Hugging Face Buckets; running or scheduling jobs on Hugging Face infrastructure; managing Hugging Face repos; discussions and pull requests; browsing models, datasets and spaces; reading, searching, or browsing academic papers; managing collections; querying datasets; configuring spaces; setting up webhooks; or deploying and managing HF Inference Endpoints. Make sure to use this skill whenever the user mentions 'hf', 'huggingface', 'Hugging Face', 'huggingface-cli', or 'hugging face cli', or wants to do anything related to the Hugging Face ecosystem and to AI and ML in general. Also use for cloud storage needs like training checkpoints, data pipelines, or agent traces. Use even if the user doesn't explicitly ask for a CLI command. Replaces the deprecated `huggingface-cli`."
4
- ---
5
-
6
- Install: `curl -LsSf https://hf.co/cli/install.sh | bash -s`.
7
-
8
- The Hugging Face Hub CLI tool `hf` is available. IMPORTANT: The `hf` command replaces the deprecated `huggingface-cli` command.
9
-
10
- Use `hf --help` to view available functions. Note that auth commands are now all under `hf auth` e.g. `hf auth whoami`.
11
-
12
- Generated with `huggingface_hub v1.13.0`. Run `hf skills add --force` to regenerate.
13
-
14
- ## Commands
15
-
16
- - `hf download REPO_ID` — Download files from the Hub. `[--type CHOICE --revision TEXT --include TEXT --exclude TEXT --cache-dir TEXT --local-dir TEXT --force-download --dry-run --max-workers INTEGER --format CHOICE]`
17
- - `hf env` — Print information about the environment. `[--format CHOICE]`
18
- - `hf sync` — Sync files between local directory and a bucket. `[--delete --ignore-times --ignore-sizes --plan TEXT --apply TEXT --dry-run --include TEXT --exclude TEXT --filter-from TEXT --existing --ignore-existing --verbose --format CHOICE]`
19
- - `hf update` — Update the `hf` CLI to the latest version. `[--format CHOICE]`
20
- - `hf upload REPO_ID` — Upload a file or a folder to the Hub. Recommended for single-commit uploads. `[--type CHOICE --revision TEXT --private --include TEXT --exclude TEXT --delete TEXT --commit-message TEXT --commit-description TEXT --create-pr --every FLOAT --format CHOICE]`
21
- - `hf upload-large-folder REPO_ID LOCAL_PATH` — Upload a large folder to the Hub. Recommended for resumable uploads. `[--type CHOICE --revision TEXT --private --include TEXT --exclude TEXT --num-workers INTEGER --no-report --no-bars --format CHOICE]`
22
- - `hf version` — Print information about the hf version. `[--format CHOICE]`
23
-
24
- ### `hf auth` — Manage authentication (login, logout, etc.).
25
-
26
- - `hf auth list` — List all stored access tokens. `[--format CHOICE]`
27
- - `hf auth login` — Login using a token from huggingface.co/settings/tokens. `[--add-to-git-credential --force --format CHOICE]`
28
- - `hf auth logout` — Logout from a specific token. `[--token-name TEXT --format CHOICE]`
29
- - `hf auth switch` — Switch between access tokens. `[--token-name TEXT --add-to-git-credential --format CHOICE]`
30
- - `hf auth token` — Print the current access token to stdout. `[--format CHOICE]`
31
- - `hf auth whoami` — Find out which huggingface.co account you are logged in as. `[--format CHOICE]`
32
-
33
- ### `hf buckets` — Commands to interact with buckets.
34
-
35
- - `hf buckets cp SRC` — Copy files to or from buckets. `[--format CHOICE]`
36
- - `hf buckets create BUCKET_ID` — Create a new bucket. `[--private --exist-ok --format CHOICE]`
37
- - `hf buckets delete BUCKET_ID` — Delete a bucket. `[--yes --missing-ok --format CHOICE]`
38
- - `hf buckets info BUCKET_ID` — Get info about a bucket. `[--format CHOICE]`
39
- - `hf buckets list` — List buckets or files in a bucket. `[--human-readable --tree --recursive --search TEXT --format CHOICE]`
40
- - `hf buckets move FROM_ID TO_ID` — Move (rename) a bucket to a new name or namespace. `[--format CHOICE]`
41
- - `hf buckets remove ARGUMENT` — Remove files from a bucket. `[--recursive --yes --dry-run --include TEXT --exclude TEXT --format CHOICE]`
42
- - `hf buckets sync` — Sync files between local directory and a bucket. `[--delete --ignore-times --ignore-sizes --plan TEXT --apply TEXT --dry-run --include TEXT --exclude TEXT --filter-from TEXT --existing --ignore-existing --verbose --format CHOICE]`
43
-
44
- ### `hf cache` — Manage local cache directory.
45
-
46
- - `hf cache list` — List cached repositories or revisions. `[--cache-dir TEXT --revisions --filter TEXT --sort CHOICE --limit INTEGER --format CHOICE]`
47
- - `hf cache prune` — Remove detached revisions from the cache. `[--cache-dir TEXT --yes --dry-run --format CHOICE]`
48
- - `hf cache rm TARGETS` — Remove cached repositories or revisions. `[--cache-dir TEXT --yes --dry-run --format CHOICE]`
49
- - `hf cache verify REPO_ID` — Verify checksums for a single repo revision from cache or a local directory. `[--type CHOICE --revision TEXT --cache-dir TEXT --local-dir TEXT --fail-on-missing-files --fail-on-extra-files --format CHOICE]`
50
-
51
- ### `hf collections` — Interact with collections on the Hub.
52
-
53
- - `hf collections add-item COLLECTION_SLUG ITEM_ID ITEM_TYPE` — Add an item to a collection. `[--note TEXT --exists-ok --format CHOICE]`
54
- - `hf collections create TITLE` — Create a new collection on the Hub. `[--namespace TEXT --description TEXT --private --exists-ok --format CHOICE]`
55
- - `hf collections delete COLLECTION_SLUG` — Delete a collection from the Hub. `[--missing-ok --format CHOICE]`
56
- - `hf collections delete-item COLLECTION_SLUG ITEM_OBJECT_ID` — Delete an item from a collection. `[--missing-ok --format CHOICE]`
57
- - `hf collections info COLLECTION_SLUG` — Get info about a collection on the Hub. `[--format CHOICE]`
58
- - `hf collections list` — List collections on the Hub. `[--owner TEXT --item TEXT --sort CHOICE --limit INTEGER --format CHOICE]`
59
- - `hf collections update COLLECTION_SLUG` — Update a collection's metadata on the Hub. `[--title TEXT --description TEXT --position INTEGER --private --theme TEXT --format CHOICE]`
60
- - `hf collections update-item COLLECTION_SLUG ITEM_OBJECT_ID` — Update an item in a collection. `[--note TEXT --position INTEGER --format CHOICE]`
61
-
62
- ### `hf datasets` — Interact with datasets on the Hub.
63
-
64
- - `hf datasets card DATASET_ID` — Get the dataset card (README) for a dataset on the Hub. `[--metadata --text --format CHOICE]`
65
- - `hf datasets info DATASET_ID` — Get info about a dataset on the Hub. `[--revision TEXT --expand TEXT --format CHOICE]`
66
- - `hf datasets leaderboard DATASET_ID` — List model scores from a dataset leaderboard. This command helps find the best models for a task or compare models by benchmark scores. `[--limit INTEGER --format CHOICE]`
67
- - `hf datasets list` — List datasets on the Hub, or files in a dataset repo. `[--search TEXT --author TEXT --filter TEXT --sort CHOICE --limit INTEGER --expand TEXT --human-readable --tree --recursive --revision TEXT --format CHOICE]`
68
- - `hf datasets parquet DATASET_ID` — List parquet file URLs available for a dataset. `[--subset TEXT --split TEXT --format CHOICE]`
69
- - `hf datasets sql SQL` — Execute a raw SQL query with DuckDB against dataset parquet URLs. `[--format CHOICE]`
70
-
71
- ### `hf discussions` — Manage discussions and pull requests on the Hub.
72
-
73
- - `hf discussions close REPO_ID NUM` — Close a discussion or pull request. `[--comment TEXT --yes --type CHOICE --format CHOICE]`
74
- - `hf discussions comment REPO_ID NUM` — Comment on a discussion or pull request. `[--body TEXT --body-file PATH --type CHOICE --format CHOICE]`
75
- - `hf discussions create REPO_ID --title TEXT` — Create a new discussion or pull request on a repo. `[--body TEXT --body-file PATH --pull-request --type CHOICE --format CHOICE]`
76
- - `hf discussions diff REPO_ID NUM` — Show the diff of a pull request. `[--type CHOICE --format CHOICE]`
77
- - `hf discussions info REPO_ID NUM` — Get info about a discussion or pull request. `[--type CHOICE --format CHOICE]`
78
- - `hf discussions list REPO_ID` — List discussions and pull requests on a repo. `[--status CHOICE --kind CHOICE --author TEXT --limit INTEGER --type CHOICE --format CHOICE]`
79
- - `hf discussions merge REPO_ID NUM` — Merge a pull request. `[--comment TEXT --yes --type CHOICE --format CHOICE]`
80
- - `hf discussions rename REPO_ID NUM NEW_TITLE` — Rename a discussion or pull request. `[--type CHOICE --format CHOICE]`
81
- - `hf discussions reopen REPO_ID NUM` — Reopen a closed discussion or pull request. `[--comment TEXT --yes --type CHOICE --format CHOICE]`
82
-
83
- ### `hf endpoints` — Manage Hugging Face Inference Endpoints.
84
-
85
- - `hf endpoints catalog deploy --repo TEXT` — Deploy an Inference Endpoint from the Model Catalog. `[--name TEXT --accelerator TEXT --namespace TEXT --format CHOICE]`
86
- - `hf endpoints catalog list` — List available Catalog models. `[--format CHOICE]`
87
- - `hf endpoints delete NAME` — Delete an Inference Endpoint permanently. `[--namespace TEXT --yes --format CHOICE]`
88
- - `hf endpoints deploy NAME --repo TEXT --framework TEXT --accelerator TEXT --instance-size TEXT --instance-type TEXT --region TEXT --vendor TEXT` — Deploy an Inference Endpoint from a Hub repository. `[--namespace TEXT --task TEXT --min-replica INTEGER --max-replica INTEGER --scale-to-zero-timeout INTEGER --scaling-metric CHOICE --scaling-threshold FLOAT --format CHOICE]`
89
- - `hf endpoints describe NAME` — Get information about an existing endpoint. `[--namespace TEXT --format CHOICE]`
90
- - `hf endpoints list` — Lists all Inference Endpoints for the given namespace. `[--namespace TEXT --format CHOICE]`
91
- - `hf endpoints pause NAME` — Pause an Inference Endpoint. `[--namespace TEXT --format CHOICE]`
92
- - `hf endpoints resume NAME` — Resume an Inference Endpoint. `[--namespace TEXT --fail-if-already-running --format CHOICE]`
93
- - `hf endpoints scale-to-zero NAME` — Scale an Inference Endpoint to zero. `[--namespace TEXT --format CHOICE]`
94
- - `hf endpoints update NAME` — Update an existing endpoint. `[--namespace TEXT --repo TEXT --accelerator TEXT --instance-size TEXT --instance-type TEXT --framework TEXT --revision TEXT --task TEXT --min-replica INTEGER --max-replica INTEGER --scale-to-zero-timeout INTEGER --scaling-metric CHOICE --scaling-threshold FLOAT --format CHOICE]`
95
-
96
- ### `hf extensions` — Manage hf CLI extensions.
97
-
98
- - `hf extensions exec NAME` — Execute an installed extension.
99
- - `hf extensions install REPO_ID` — Install an extension from a public GitHub repository. `[--force --format CHOICE]`
100
- - `hf extensions list` — List installed extension commands. `[--format CHOICE]`
101
- - `hf extensions remove NAME` — Remove an installed extension. `[--format CHOICE]`
102
- - `hf extensions search` — Search extensions available on GitHub (tagged with 'hf-extension' topic). `[--format CHOICE]`
103
-
104
- ### `hf jobs` — Run and manage Jobs on the Hub.
105
-
106
- - `hf jobs cancel JOB_ID` — Cancel a Job `[--namespace TEXT --format CHOICE]`
107
- - `hf jobs hardware` — List available hardware options for Jobs `[--format CHOICE]`
108
- - `hf jobs inspect JOB_IDS` — Display detailed information on one or more Jobs `[--namespace TEXT --format CHOICE]`
109
- - `hf jobs logs JOB_ID` — Fetch the logs of a Job. `[--follow --tail INTEGER --namespace TEXT --format CHOICE]`
110
- - `hf jobs ps` — List Jobs. `[--all --namespace TEXT --filter TEXT --format TEXT --quiet]`
111
- - `hf jobs run IMAGE COMMAND` — Run a Job. `[--env TEXT --secrets TEXT --label TEXT --volume TEXT --env-file TEXT --secrets-file TEXT --flavor CHOICE --timeout TEXT --detach --namespace TEXT]`
112
- - `hf jobs scheduled delete SCHEDULED_JOB_ID` — Delete a scheduled Job. `[--namespace TEXT --format CHOICE]`
113
- - `hf jobs scheduled inspect SCHEDULED_JOB_IDS` — Display detailed information on one or more scheduled Jobs `[--namespace TEXT --format CHOICE]`
114
- - `hf jobs scheduled ps` — List scheduled Jobs `[--all --namespace TEXT --filter TEXT --format TEXT --quiet]`
115
- - `hf jobs scheduled resume SCHEDULED_JOB_ID` — Resume (unpause) a scheduled Job. `[--namespace TEXT --format CHOICE]`
116
- - `hf jobs scheduled run SCHEDULE IMAGE COMMAND` — Schedule a Job. `[--suspend --concurrency --env TEXT --secrets TEXT --label TEXT --volume TEXT --env-file TEXT --secrets-file TEXT --flavor CHOICE --timeout TEXT --namespace TEXT]`
117
- - `hf jobs scheduled suspend SCHEDULED_JOB_ID` — Suspend (pause) a scheduled Job. `[--namespace TEXT --format CHOICE]`
118
- - `hf jobs scheduled uv run SCHEDULE SCRIPT` — Run a UV script (local file or URL) on HF infrastructure `[--suspend --concurrency --image TEXT --flavor CHOICE --env TEXT --secrets TEXT --label TEXT --volume TEXT --env-file TEXT --secrets-file TEXT --timeout TEXT --namespace TEXT --with TEXT --python TEXT]`
119
- - `hf jobs stats` — Fetch the resource usage statistics and metrics of Jobs `[--namespace TEXT --format CHOICE]`
120
- - `hf jobs uv run SCRIPT` — Run a UV script (local file or URL) on HF infrastructure `[--image TEXT --flavor CHOICE --env TEXT --secrets TEXT --label TEXT --volume TEXT --env-file TEXT --secrets-file TEXT --timeout TEXT --detach --namespace TEXT --with TEXT --python TEXT]`
121
-
122
- ### `hf models` — Interact with models on the Hub.
123
-
124
- - `hf models card MODEL_ID` — Get the model card (README) for a model on the Hub. `[--metadata --text --format CHOICE]`
125
- - `hf models info MODEL_ID` — Get info about a model on the Hub. `[--revision TEXT --expand TEXT --format CHOICE]`
126
- - `hf models list` — List models on the Hub, or files in a model repo. `[--search TEXT --author TEXT --filter TEXT --num-parameters TEXT --sort CHOICE --limit INTEGER --expand TEXT --human-readable --tree --recursive --revision TEXT --format CHOICE]`
127
-
128
- ### `hf papers` — Interact with papers on the Hub.
129
-
130
- - `hf papers info PAPER_ID` — Get info about a paper on the Hub. `[--format CHOICE]`
131
- - `hf papers list` — List daily papers on the Hub. `[--date TEXT --week TEXT --month TEXT --submitter TEXT --sort CHOICE --limit INTEGER --format CHOICE]`
132
- - `hf papers read PAPER_ID` — Read a paper as markdown. `[--format CHOICE]`
133
- - `hf papers search QUERY` — Search papers on the Hub. `[--limit INTEGER --format CHOICE]`
134
-
135
- ### `hf repos` — Manage repos on the Hub.
136
-
137
- - `hf repos branch create REPO_ID BRANCH` — Create a new branch for a repo on the Hub. `[--revision TEXT --type CHOICE --exist-ok --format CHOICE]`
138
- - `hf repos branch delete REPO_ID BRANCH` — Delete a branch from a repo on the Hub. `[--type CHOICE --format CHOICE]`
139
- - `hf repos create REPO_ID` — Create a new repo on the Hub. `[--type CHOICE --space-sdk TEXT --private --public --protected --exist-ok --resource-group-id TEXT --flavor CHOICE --storage CHOICE --sleep-time INTEGER --secrets TEXT --secrets-file TEXT --env TEXT --env-file TEXT --volume TEXT --format CHOICE]`
140
- - `hf repos delete REPO_ID` — Delete a repo from the Hub. This is an irreversible operation. `[--type CHOICE --missing-ok --yes --format CHOICE]`
141
- - `hf repos delete-files REPO_ID PATTERNS` — Delete files from a repo on the Hub. `[--type CHOICE --revision TEXT --commit-message TEXT --commit-description TEXT --create-pr --format CHOICE]`
142
- - `hf repos duplicate FROM_ID` — Duplicate a repo on the Hub (model, dataset, or Space). `[--type CHOICE --private --public --protected --exist-ok --flavor CHOICE --storage CHOICE --sleep-time INTEGER --secrets TEXT --secrets-file TEXT --env TEXT --env-file TEXT --volume TEXT --format CHOICE]`
143
- - `hf repos move FROM_ID TO_ID` — Move a repository from a namespace to another namespace. `[--type CHOICE --format CHOICE]`
144
- - `hf repos settings REPO_ID` — Update the settings of a repository. `[--gated CHOICE --private --public --protected --type CHOICE --format CHOICE]`
145
- - `hf repos tag create REPO_ID TAG` — Create a tag for a repo. `[--message TEXT --revision TEXT --type CHOICE --format CHOICE]`
146
- - `hf repos tag delete REPO_ID TAG` — Delete a tag for a repo. `[--yes --type CHOICE --format CHOICE]`
147
- - `hf repos tag list REPO_ID` — List tags for a repo. `[--type CHOICE --format CHOICE]`
148
-
149
- ### `hf skills` — Manage skills for AI assistants.
150
-
151
- - `hf skills add` — Download a Hugging Face skill and install it for an AI assistant. `[--claude --global --dest PATH --force --format CHOICE]`
152
- - `hf skills preview` — Print the generated `hf-cli` SKILL.md to stdout. `[--format CHOICE]`
153
- - `hf skills upgrade` — Upgrade installed Hugging Face marketplace skills. `[--claude --global --dest PATH --format CHOICE]`
154
-
155
- ### `hf spaces` — Interact with spaces on the Hub.
156
-
157
- - `hf spaces card SPACE_ID` — Get the Space card (README) for a Space on the Hub. `[--metadata --text --format CHOICE]`
158
- - `hf spaces dev-mode SPACE_ID` — Enable or disable dev mode on a Space. `[--stop --format CHOICE]`
159
- - `hf spaces hardware` — List available hardware options for Spaces. `[--format CHOICE]`
160
- - `hf spaces hot-reload SPACE_ID` — Hot-reload any Python file of a Space without a full rebuild + restart. `[--local-file PATH --skip-checks --skip-summary --format CHOICE]`
161
- - `hf spaces info SPACE_ID` — Get info about a space on the Hub. `[--revision TEXT --expand TEXT --format CHOICE]`
162
- - `hf spaces list` — List spaces on the Hub, or files in a space repo. `[--search TEXT --author TEXT --filter TEXT --sort CHOICE --limit INTEGER --expand TEXT --human-readable --tree --recursive --revision TEXT --format CHOICE]`
163
- - `hf spaces logs SPACE_ID` — Fetch the run or build logs of a Space. `[--build --follow --tail INTEGER --format CHOICE]`
164
- - `hf spaces pause SPACE_ID` — Pause a Space. `[--format CHOICE]`
165
- - `hf spaces restart SPACE_ID` — Restart a Space. `[--factory-reboot --format CHOICE]`
166
- - `hf spaces search QUERY` — Search spaces on the Hub using semantic search. `[--filter TEXT --sdk TEXT --include-non-running --description --limit INTEGER --format CHOICE]`
167
- - `hf spaces settings SPACE_ID` — Update the settings of a Space. `[--sleep-time INTEGER --hardware CHOICE --format CHOICE]`
168
- - `hf spaces volumes delete SPACE_ID` — Remove all volumes from a Space. `[--yes --format CHOICE]`
169
- - `hf spaces volumes list SPACE_ID` — List volumes mounted in a Space. `[--format CHOICE]`
170
- - `hf spaces volumes set SPACE_ID` — Set (replace) volumes for a Space. `[--volume TEXT --format CHOICE]`
171
-
172
- ### `hf webhooks` — Manage webhooks on the Hub.
173
-
174
- - `hf webhooks create --watch TEXT` — Create a new webhook. `[--url TEXT --job-id TEXT --domain CHOICE --secret TEXT --format CHOICE]`
175
- - `hf webhooks delete WEBHOOK_ID` — Delete a webhook permanently. `[--yes --format CHOICE]`
176
- - `hf webhooks disable WEBHOOK_ID` — Disable an active webhook. `[--format CHOICE]`
177
- - `hf webhooks enable WEBHOOK_ID` — Enable a disabled webhook. `[--format CHOICE]`
178
- - `hf webhooks info WEBHOOK_ID` — Show full details for a single webhook. `[--format CHOICE]`
179
- - `hf webhooks list` — List all webhooks for the current user. `[--format CHOICE]`
180
- - `hf webhooks update WEBHOOK_ID` — Update an existing webhook. Only provided options are changed. `[--url TEXT --watch TEXT --domain CHOICE --secret TEXT --format CHOICE]`
181
-
182
- ## Common options
183
-
184
- - `--format` — Output format: `--format json` (or `--json`) or `--format table` (default).
185
- - `-q / --quiet` — Print only IDs (one per line).
186
- - `--revision` — Git revision id which can be a branch name, a tag, or a commit hash.
187
- - `--token` — Use a User Access Token. Prefer setting `HF_TOKEN` env var instead of passing `--token`.
188
- - `--type` — The type of repository (model, dataset, or space).
189
-
190
- ## Mounting repos as local filesystems
191
-
192
- To mount Hub repositories or buckets as local filesystems — no download, no copy, no waiting — use `hf-mount`. Files are fetched on demand. GitHub: https://github.com/huggingface/hf-mount
193
-
194
- Install: `curl -fsSL https://raw.githubusercontent.com/huggingface/hf-mount/main/install.sh | sh`
195
-
196
- Some command examples:
197
- - `hf-mount start repo openai-community/gpt2 /tmp/gpt2` — mount a repo (read-only)
198
- - `hf-mount start --hf-token $HF_TOKEN bucket myuser/my-bucket /tmp/data` — mount a bucket (read-write)
199
- - `hf-mount status` / `hf-mount stop /tmp/data` — list or unmount
200
-
201
- ## Tips
202
-
203
- - Use `hf <command> --help` for full options, descriptions, usage, and real-world examples
204
- - Authenticate with `HF_TOKEN` env var (recommended) or with `--token`
205
- - Update the CLI with `hf update` (uses the correct command for the detected install method)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
USO_DE_IA.md CHANGED
@@ -1,11 +1,12 @@
1
  # Uso de inteligencia artificial en el desarrollo de Morphos
 
2
  ## Proyecto final — Curso de Desarrollo Web 2026
3
 
4
  ---
5
 
6
  ## Modelo utilizado
7
 
8
- Se utilizó exclusivamente Claude Code (Sonnet 4.6) de Anthropic como asistente de desarrollo a lo largo de todo el proyecto.
9
 
10
  ---
11
 
@@ -21,11 +22,13 @@ El modelo actuó como ejecutor de decisiones ya tomadas, no como tomador de deci
21
 
22
  Para el motor de detección de patrones (`analisis.js`) y los datos de referencia (`valores_referencia.json`, `alteraciones.json`), se proporcionó al modelo literatura especializada en patología clínica veterinaria como contexto de generación.
23
 
24
- Los rangos de referencia por especie, los ajustes por edad, raza y sexo, las descripciones clínicas de cada alteración, y la lógica de clasificación de gravedad fueron **validados gracias a mi formación y experiencia profesional en Medicina Veterinaria**, antes de ser incorporados al código. El modelo fue un medio para estructurar y codificar ese conocimiento, no la fuente del mismo.
25
 
26
  Textos de referencia utilizados:
 
27
  - Thrall, *Veterinary Hematology and Clinical Chemistry*, 3.ª ed. 2022
28
  - Weiss, — *Schalm's Veterinary Hematology*, 7.ª ed. 2022
 
29
  ---
30
 
31
  ## Afinamiento del prompt y control de salidas del modelo de IA
@@ -58,9 +61,11 @@ Durante las auditorías de rendimiento con Lighthouse se identificó que la carg
58
  Al concluir el desarrollo se realizó una auditoría asistida por IA con los siguientes objetivos:
59
 
60
  **Código muerto**
 
61
  - Identificación y eliminación de exportaciones sin consumidores y código que ya no era necesario o era experimental
62
 
63
  **Seguridad**
 
64
  - Protección de archivos sensibles (`.env`, `setup.php`) mediante `.htaccess`
65
  - Sanitización de datos externos de APIs con `textContent` en lugar de `innerHTML`, eliminando el riesgo de XSS
66
  - Validación de URLs externas antes de usarlas como atributos `href`
 
1
  # Uso de inteligencia artificial en el desarrollo de Morphos
2
+
3
  ## Proyecto final — Curso de Desarrollo Web 2026
4
 
5
  ---
6
 
7
  ## Modelo utilizado
8
 
9
+ Se utilizó Claude Code (Sonnet 4.6) de Anthropic como asistente de desarrollo a lo largo de todo el proyecto y para el despliegue, docker y debug del comportamiento del modelo de IA se utilizó Kimi 2.6 con Open Code CLI.
10
 
11
  ---
12
 
 
22
 
23
  Para el motor de detección de patrones (`analisis.js`) y los datos de referencia (`valores_referencia.json`, `alteraciones.json`), se proporcionó al modelo literatura especializada en patología clínica veterinaria como contexto de generación.
24
 
25
+ Los rangos de referencia por especie, los ajustes por edad, raza y sexo, las descripciones clínicas de cada alteración, y la lógica de clasificación de gravedad fueron **validados gracias a mi formación y experiencia profesional en Medicina Veterinaria**, antes de ser incorporados al código. El modelo fue un medio para estructurar y codificar ese conocimiento, no la fuente del mismo. Se han realizado múltiples pruebas con resultados de laboratorio reales y citologías de pacientes facilitados por otros veterinarios ejercientes.
26
 
27
  Textos de referencia utilizados:
28
+
29
  - Thrall, *Veterinary Hematology and Clinical Chemistry*, 3.ª ed. 2022
30
  - Weiss, — *Schalm's Veterinary Hematology*, 7.ª ed. 2022
31
+
32
  ---
33
 
34
  ## Afinamiento del prompt y control de salidas del modelo de IA
 
61
  Al concluir el desarrollo se realizó una auditoría asistida por IA con los siguientes objetivos:
62
 
63
  **Código muerto**
64
+
65
  - Identificación y eliminación de exportaciones sin consumidores y código que ya no era necesario o era experimental
66
 
67
  **Seguridad**
68
+
69
  - Protección de archivos sensibles (`.env`, `setup.php`) mediante `.htaccess`
70
  - Sanitización de datos externos de APIs con `textContent` en lugar de `innerHTML`, eliminando el riesgo de XSS
71
  - Validación de URLs externas antes de usarlas como atributos `href`
api/conexion.php CHANGED
@@ -1,6 +1,6 @@
1
  <?php
2
 
3
- // Try MySQL first (local XAMPP), fallback to SQLite (Docker / HF Spaces)
4
  $dbHost = '127.0.0.1';
5
  $dbUsuario = 'root';
6
  $dbClave = '';
@@ -16,7 +16,6 @@ if (file_exists(__DIR__ . '/.env')) {
16
 
17
  $conexion = null;
18
 
19
- // Attempt MySQL only if not explicitly forced to SQLite
20
  $useSqlite = getenv('DB_FORCE_SQLITE') === '1';
21
 
22
  if (!$useSqlite) {
@@ -28,13 +27,12 @@ if (!$useSqlite) {
28
  }
29
  }
30
 
31
- // Fallback to SQLite
32
  if (!$conexion) {
33
  try {
34
  $conexion = new PDO("sqlite:$dbPath");
35
  $conexion->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
36
 
37
- // Ensure users table exists (idempotent)
38
  $conexion->exec("CREATE TABLE IF NOT EXISTS usuarios (
39
  id INTEGER PRIMARY KEY AUTOINCREMENT,
40
  nombre TEXT NOT NULL,
 
1
  <?php
2
 
3
+ // Primero intenta conectar con MySQL (XAMPP), fallback a SQLite (Docker / HF Spaces)
4
  $dbHost = '127.0.0.1';
5
  $dbUsuario = 'root';
6
  $dbClave = '';
 
16
 
17
  $conexion = null;
18
 
 
19
  $useSqlite = getenv('DB_FORCE_SQLITE') === '1';
20
 
21
  if (!$useSqlite) {
 
27
  }
28
  }
29
 
30
+ // Fallback de SQLite
31
  if (!$conexion) {
32
  try {
33
  $conexion = new PDO("sqlite:$dbPath");
34
  $conexion->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
35
 
 
36
  $conexion->exec("CREATE TABLE IF NOT EXISTS usuarios (
37
  id INTEGER PRIMARY KEY AUTOINCREMENT,
38
  nombre TEXT NOT NULL,
api/hf_proxy.php CHANGED
@@ -9,6 +9,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
9
 
10
  $hfKey = $_ENV['HF_API_KEY'] ?? $_SERVER['HF_API_KEY'] ?? getenv('HF_API_KEY') ?? '';
11
 
 
12
  if (!$hfKey && file_exists(__DIR__ . '/.env')) {
13
  foreach (file(__DIR__ . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
14
  if (str_starts_with($line, 'HF_API_KEY=')) { $hfKey = trim(substr($line, 11)); break; }
@@ -30,8 +31,6 @@ function hf_get(string $url, array $headers, ?string $post = null): array {
30
  return [curl_exec($ch), curl_getinfo($ch, CURLINFO_HTTP_CODE)];
31
  }
32
 
33
- // Upload a data URL to the Gradio /upload endpoint and return a FileData object,
34
- // or fall back to the raw data URL format if the upload fails.
35
  function uploadImagen(string $space, string $hfKey, string $dataUrl): ?array {
36
  if (!preg_match('/^data:(image\/[\w+]+);base64,(.+)$/s', $dataUrl, $m)) return null;
37
  $mimeType = $m[1];
@@ -39,6 +38,7 @@ function uploadImagen(string $space, string $hfKey, string $dataUrl): ?array {
39
  $binary = base64_decode($m[2]);
40
  if ($binary === false) return null;
41
 
 
42
  $boundary = bin2hex(random_bytes(16));
43
  $body = "--$boundary\r\n"
44
  . "Content-Disposition: form-data; name=\"files\"; filename=\"image.$ext\"\r\n"
@@ -61,8 +61,8 @@ function uploadImagen(string $space, string $hfKey, string $dataUrl): ?array {
61
  $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
62
  curl_close($ch);
63
 
 
64
  if ($code >= 400 || !$result) {
65
- // Fall back: pass as data URL directly (older Gradio versions accept this)
66
  return ['url' => $dataUrl, 'orig_name' => "image.$ext", 'mime_type' => $mimeType];
67
  }
68
 
@@ -98,6 +98,7 @@ if (!$eventId) { http_response_code(502); echo json_encode(['error' => 'No se ob
98
  [$stream] = hf_get("$SPACE/call/analyze/$eventId", ["Authorization: Bearer $hfKey"]);
99
 
100
  $result = $error = null; $lastEvent = '';
 
101
  foreach (explode("\n", $stream) as $raw) {
102
  $line = rtrim($raw, "\r");
103
  if (str_starts_with($line, 'event:')) $lastEvent = trim(substr($line, 6));
 
9
 
10
  $hfKey = $_ENV['HF_API_KEY'] ?? $_SERVER['HF_API_KEY'] ?? getenv('HF_API_KEY') ?? '';
11
 
12
+ // Fallback a archivo .env si la variable de entorno no esta definida
13
  if (!$hfKey && file_exists(__DIR__ . '/.env')) {
14
  foreach (file(__DIR__ . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
15
  if (str_starts_with($line, 'HF_API_KEY=')) { $hfKey = trim(substr($line, 11)); break; }
 
31
  return [curl_exec($ch), curl_getinfo($ch, CURLINFO_HTTP_CODE)];
32
  }
33
 
 
 
34
  function uploadImagen(string $space, string $hfKey, string $dataUrl): ?array {
35
  if (!preg_match('/^data:(image\/[\w+]+);base64,(.+)$/s', $dataUrl, $m)) return null;
36
  $mimeType = $m[1];
 
38
  $binary = base64_decode($m[2]);
39
  if ($binary === false) return null;
40
 
41
+ // Construye manualmente el cuerpo multipart para enviar la imagen al upload endpoint de Gradio
42
  $boundary = bin2hex(random_bytes(16));
43
  $body = "--$boundary\r\n"
44
  . "Content-Disposition: form-data; name=\"files\"; filename=\"image.$ext\"\r\n"
 
61
  $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
62
  curl_close($ch);
63
 
64
+ // Si el upload falla, devuelve la imagen inline para que el modelo la procese igualmente
65
  if ($code >= 400 || !$result) {
 
66
  return ['url' => $dataUrl, 'orig_name' => "image.$ext", 'mime_type' => $mimeType];
67
  }
68
 
 
98
  [$stream] = hf_get("$SPACE/call/analyze/$eventId", ["Authorization: Bearer $hfKey"]);
99
 
100
  $result = $error = null; $lastEvent = '';
101
+ // Parsea el stream SSE de Gradio buscando el evento 'complete' o 'process_completed'
102
  foreach (explode("\n", $stream) as $raw) {
103
  $line = rtrim($raw, "\r");
104
  if (str_starts_with($line, 'event:')) $lastEvent = trim(substr($line, 6));
js/analisis.js CHANGED
@@ -6,6 +6,7 @@
6
  const UMBRALES_GRAVEDAD = { leve: 0.5, moderado: 1.5 };
7
 
8
  const clasificarGravedad = (valor, ref) => {
 
9
  const rango = ref.superior - ref.inferior;
10
  const desviacion = valor > ref.superior
11
  ? (valor - ref.superior) / rango
@@ -98,6 +99,7 @@ const ajustarReferencias = (refsEspecie, paciente) => {
98
  const ajRaza = obtenerAjustesRaza(paciente.raza, paciente.especie);
99
  const ajSexo = AJUSTES_SEXO[paciente.especie]?.[paciente.sexo] ?? {};
100
 
 
101
  return Object.entries(refsEspecie).reduce((acc, [clave, ref]) => {
102
  const factorEdad = ajEdad[clave] ?? {};
103
  const factorRaza = ajRaza[clave] ?? {};
@@ -133,6 +135,7 @@ const detectarPatrones = (hallazgos, especie, alt) => {
133
  // Serie roja
134
 
135
  if (esBajo('hct') || esBajo('hgb') || esBajo('rbc')) {
 
136
  const tipoPorVcm = !presente('vcm') ? '' :
137
  esBajo('vcm') ? 'microcítica' :
138
  esAlto('vcm') ? 'macrocítica' : 'normocítica';
@@ -160,6 +163,7 @@ const detectarPatrones = (hallazgos, especie, alt) => {
160
  // Serie blanca
161
 
162
  if (esAlto('wbc')) {
 
163
  const neutrofilia = esAlto('neutro');
164
  const linfocitosis = esAlto('linfo');
165
 
@@ -329,6 +333,7 @@ const detectarPatrones = (hallazgos, especie, alt) => {
329
  const valSodio = valor('sodio');
330
  const valPotasio = valor('potasio');
331
 
 
332
  if (valSodio !== null && valPotasio !== null && valPotasio > 0) {
333
  const ratioNaK = valSodio / valPotasio;
334
  if (ratioNaK < 27) agregar({
@@ -461,6 +466,7 @@ export const analizarResultados = (valoresInput, paciente, referencias, alteraci
461
  const refsEspecie = referencias[paciente.especie];
462
  if (!refsEspecie) return { hallazgos: [], patrones: [] };
463
 
 
464
  const refsAjustadas = ajustarReferencias(refsEspecie, paciente);
465
  const hallazgos = [];
466
 
 
6
  const UMBRALES_GRAVEDAD = { leve: 0.5, moderado: 1.5 };
7
 
8
  const clasificarGravedad = (valor, ref) => {
9
+ // Mide cuantos anchos de rango de referencia se desvia el valor
10
  const rango = ref.superior - ref.inferior;
11
  const desviacion = valor > ref.superior
12
  ? (valor - ref.superior) / rango
 
99
  const ajRaza = obtenerAjustesRaza(paciente.raza, paciente.especie);
100
  const ajSexo = AJUSTES_SEXO[paciente.especie]?.[paciente.sexo] ?? {};
101
 
102
+ // Multiplica los limites inferiores y superiores por los factores de edad, raza y sexo
103
  return Object.entries(refsEspecie).reduce((acc, [clave, ref]) => {
104
  const factorEdad = ajEdad[clave] ?? {};
105
  const factorRaza = ajRaza[clave] ?? {};
 
135
  // Serie roja
136
 
137
  if (esBajo('hct') || esBajo('hgb') || esBajo('rbc')) {
138
+ // Clasifica el tipo de anemia segun el VCM para sugerir la etiologia mas probable
139
  const tipoPorVcm = !presente('vcm') ? '' :
140
  esBajo('vcm') ? 'microcítica' :
141
  esAlto('vcm') ? 'macrocítica' : 'normocítica';
 
163
  // Serie blanca
164
 
165
  if (esAlto('wbc')) {
166
+ // Diferencia leucocitosis neutrofilica de linfocitica; si no hay diferencial, informa generico
167
  const neutrofilia = esAlto('neutro');
168
  const linfocitosis = esAlto('linfo');
169
 
 
333
  const valSodio = valor('sodio');
334
  const valPotasio = valor('potasio');
335
 
336
+ // Ratio Na/K < 27 es sugestivo de hipoadrenocorticismo; la gravedad aumenta a menor ratio
337
  if (valSodio !== null && valPotasio !== null && valPotasio > 0) {
338
  const ratioNaK = valSodio / valPotasio;
339
  if (ratioNaK < 27) agregar({
 
466
  const refsEspecie = referencias[paciente.especie];
467
  if (!refsEspecie) return { hallazgos: [], patrones: [] };
468
 
469
+ // Ajusta los rangos segun edad, raza y sexo antes de comparar
470
  const refsAjustadas = ajustarReferencias(refsEspecie, paciente);
471
  const hallazgos = [];
472
 
js/auth.js CHANGED
@@ -103,6 +103,7 @@ function limpiarCampo(input) {
103
  }
104
 
105
  function activarValidacionCampo(input, reglaDeFalso) {
 
106
  let tocado = false;
107
  input.addEventListener('blur', () => { tocado = true; marcarCampo(input, !reglaDeFalso()); });
108
  input.addEventListener('input', () => { if (tocado) marcarCampo(input, !reglaDeFalso()); });
@@ -127,7 +128,7 @@ function inicializarValidacionRegistro() {
127
  activarValidacionCampo(email, () => !esEmailValido(email.value));
128
  activarValidacionCampo(password, () => password.value.length < 6);
129
 
130
- // password2 depende del valor de password, se re-evalúa en ambos
131
  let tocadoP2 = false;
132
  const validarP2 = () => password.value === password2.value && password2.value.length > 0;
133
  password2.addEventListener('blur', () => { tocadoP2 = true; marcarCampo(password2, validarP2()); });
@@ -173,6 +174,7 @@ formLogin.addEventListener('submit', async e => {
173
  estadoAuth = true;
174
  actualizarBtnUsuario(datos.nombre);
175
  cerrarModal();
 
176
  accionPendiente?.();
177
  accionPendiente = null;
178
  });
@@ -216,6 +218,7 @@ formRegistro.addEventListener('submit', async e => {
216
  estadoAuth = true;
217
  actualizarBtnUsuario(datos.nombre);
218
  cerrarModal();
 
219
  accionPendiente?.();
220
  accionPendiente = null;
221
  });
 
103
  }
104
 
105
  function activarValidacionCampo(input, reglaDeFalso) {
106
+ // Solo marca despues del primer blur para no agobiar al usuario mientras escribe
107
  let tocado = false;
108
  input.addEventListener('blur', () => { tocado = true; marcarCampo(input, !reglaDeFalso()); });
109
  input.addEventListener('input', () => { if (tocado) marcarCampo(input, !reglaDeFalso()); });
 
128
  activarValidacionCampo(email, () => !esEmailValido(email.value));
129
  activarValidacionCampo(password, () => password.value.length < 6);
130
 
131
+ // password2 depende del valor de password, se re-evalua en ambos campos para dar feedback inmediato
132
  let tocadoP2 = false;
133
  const validarP2 = () => password.value === password2.value && password2.value.length > 0;
134
  password2.addEventListener('blur', () => { tocadoP2 = true; marcarCampo(password2, validarP2()); });
 
174
  estadoAuth = true;
175
  actualizarBtnUsuario(datos.nombre);
176
  cerrarModal();
177
+ // Ejecuta la accion que el usuario intento hacer antes de loguearse (ej. analisis IA)
178
  accionPendiente?.();
179
  accionPendiente = null;
180
  });
 
218
  estadoAuth = true;
219
  actualizarBtnUsuario(datos.nombre);
220
  cerrarModal();
221
+ // Ejecuta la accion que el usuario intento hacer antes de registrarse (ej. analisis IA)
222
  accionPendiente?.();
223
  accionPendiente = null;
224
  });
js/ia.js CHANGED
@@ -74,6 +74,7 @@ function construirPrompt(obtenerDatosPaciente, obtenerValoresFormulario, getUlti
74
  ? hallazgos.map(h => ` ${h.nombre}: ${h.valor} ${h.unidad || ''} (${h.direccion} · ${h.gravedad})`).join('\n')
75
  : ' Todos los valores normales';
76
 
 
77
  return `Eres médico veterinario especialista en patología clínica.
78
 
79
  Paciente: ${paciente.especie || 'desconocido'}, ${paciente.raza || 'raza desconocida'}, ${edadTexto}, ${paciente.sexo || 'sexo desconocido'}
@@ -105,6 +106,7 @@ Proporciona una interpretación clínica breve (6-8 oraciones) destacando los ha
105
  }
106
 
107
  function limpiarRespuesta(text) {
 
108
  if (text.includes('<start_of_turn>model')) {
109
  text = text.split('<start_of_turn>model').pop();
110
  }
@@ -115,7 +117,7 @@ function limpiarRespuesta(text) {
115
  // Formato normal: <unused94>pensamiento<unused95>respuesta
116
  text = text.split('<unused95>').pop();
117
  } else if (text.includes('<unused94>')) {
118
- // El modelo agotó tokens en el razonamiento mostrar el pensamiento como respuesta
119
  text = text.split('<unused94>').slice(1).join('').trim();
120
  }
121
  text = text.replace(/<unused\d+>/g, '');
@@ -127,7 +129,7 @@ function limpiarRespuesta(text) {
127
  // Quitar bloques de razonamiento / thinking process
128
  text = text.replace(/^thought\s*\n?/i, '');
129
 
130
- // Detectar si el modelo solo generó razonamiento en inglés sin respuesta clínica
131
  const tieneRazonamiento = /Here'?s a thinking process|Understand the Role|Analyze the Request|Review the Lab Results|Synthesize Findings|Formulate Clinical Interpretation/i.test(text);
132
  const tieneEspanol = /[áéíóúñÁÉÍÓÚÑ]{2,}/.test(text) || /\b(paciente|hallazgos|interpretación|recomendaciones|análisis|resultados|clínica|diagnóstico|evaluación|hepatopatía|nefropatía|anemia|leucocitosis|neutrofilia|linfopenia|hiperglucemia|hipoglucemia|pancreatitis|hepatitis|cirrosis|insuficiencia)\b/i.test(text);
133
  if (tieneRazonamiento && !tieneEspanol) {
@@ -136,7 +138,7 @@ function limpiarRespuesta(text) {
136
 
137
  // Si hay razonamiento mezclado con español, intentar extraer solo la respuesta
138
  if (tieneRazonamiento) {
139
- // Buscar la primera línea que parezca español clínico
140
  const lineas = text.split('\n');
141
  let inicioRespuesta = -1;
142
  for (let i = 0; i < lineas.length; i++) {
@@ -157,10 +159,10 @@ function limpiarRespuesta(text) {
157
  text = text.replace(/\\[a-zA-Z]+(\{[^}]*\})?/g, '');
158
  text = text.replace(/\$[^$]*\$/g, '');
159
 
160
- // Colapsar líneas vacías múltiples
161
  text = text.replace(/\n{3,}/g, '\n\n').trim();
162
 
163
- // Cortar al primer párrafo que se repite (loop del modelo)
164
  const parrafos = text.split(/\n\n+/);
165
  const vistos = new Set();
166
  const sinRepetidos = [];
@@ -203,6 +205,7 @@ async function _llamarOllama(salidaEl, obtenerDatosPaciente, obtenerValoresFormu
203
  const prompt = construirPrompt(obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias);
204
  const imagenes = [...imagenesDataUrl.filter(Boolean), ...capturasMicroscopio];
205
 
 
206
  const contenido = [];
207
  for (const img of imagenes) {
208
  if (typeof img === 'string' && img.startsWith('data:image/'))
@@ -244,16 +247,16 @@ async function _llamarOllama(salidaEl, obtenerDatosPaciente, obtenerValoresFormu
244
  async function _llamarSpace(salidaEl, obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias) {
245
  let prompt = construirPrompt(obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias);
246
 
247
- // HACK: Prepend <unused95> to force medGemma to skip thinking phase and output response directly
248
  if (!prompt.includes('<unused95>')) {
249
  prompt = '<unused95>' + prompt;
250
  }
251
 
 
252
  const imagenes = [...imagenesDataUrl.filter(Boolean), ...capturasMicroscopio]
253
  .filter(img => typeof img === 'string' && /^data:image\/(jpeg|png|gif|webp);base64,/.test(img))
254
  .slice(0, 4);
255
 
256
- // DEBUG: Log exact payload for desktop vs mobile comparison
257
  console.log('=== MORPHOS AI REQUEST ===');
258
  console.log('Images count:', imagenes.length);
259
  console.log('Prompt length:', prompt.length);
@@ -269,7 +272,6 @@ async function _llamarSpace(salidaEl, obtenerDatosPaciente, obtenerValoresFormul
269
 
270
  const data = await res.json();
271
 
272
- // DEBUG: Log raw response
273
  console.log('=== MORPHOS AI RESPONSE ===');
274
  console.log('Status:', res.status);
275
  console.log('Raw text preview:', (data.text ?? 'NO TEXT').substring(0, 300));
 
74
  ? hallazgos.map(h => ` ${h.nombre}: ${h.valor} ${h.unidad || ''} (${h.direccion} · ${h.gravedad})`).join('\n')
75
  : ' Todos los valores normales';
76
 
77
+ // Prompt enfocado en citologia cuando hay imagenes adjuntas
78
  return `Eres médico veterinario especialista en patología clínica.
79
 
80
  Paciente: ${paciente.especie || 'desconocido'}, ${paciente.raza || 'raza desconocida'}, ${edadTexto}, ${paciente.sexo || 'sexo desconocido'}
 
106
  }
107
 
108
  function limpiarRespuesta(text) {
109
+ // Elimina tokens especiales del modelo medGemma y otros artefactos de generacion
110
  if (text.includes('<start_of_turn>model')) {
111
  text = text.split('<start_of_turn>model').pop();
112
  }
 
117
  // Formato normal: <unused94>pensamiento<unused95>respuesta
118
  text = text.split('<unused95>').pop();
119
  } else if (text.includes('<unused94>')) {
120
+ // El modelo agoto tokens en el razonamiento; muestra el pensamiento como respuesta
121
  text = text.split('<unused94>').slice(1).join('').trim();
122
  }
123
  text = text.replace(/<unused\d+>/g, '');
 
129
  // Quitar bloques de razonamiento / thinking process
130
  text = text.replace(/^thought\s*\n?/i, '');
131
 
132
+ // Detectar si el modelo solo genero razonamiento en ingles sin respuesta clinica
133
  const tieneRazonamiento = /Here'?s a thinking process|Understand the Role|Analyze the Request|Review the Lab Results|Synthesize Findings|Formulate Clinical Interpretation/i.test(text);
134
  const tieneEspanol = /[áéíóúñÁÉÍÓÚÑ]{2,}/.test(text) || /\b(paciente|hallazgos|interpretación|recomendaciones|análisis|resultados|clínica|diagnóstico|evaluación|hepatopatía|nefropatía|anemia|leucocitosis|neutrofilia|linfopenia|hiperglucemia|hipoglucemia|pancreatitis|hepatitis|cirrosis|insuficiencia)\b/i.test(text);
135
  if (tieneRazonamiento && !tieneEspanol) {
 
138
 
139
  // Si hay razonamiento mezclado con español, intentar extraer solo la respuesta
140
  if (tieneRazonamiento) {
141
+ // Buscar la primera linea que parezca español clinico
142
  const lineas = text.split('\n');
143
  let inicioRespuesta = -1;
144
  for (let i = 0; i < lineas.length; i++) {
 
159
  text = text.replace(/\\[a-zA-Z]+(\{[^}]*\})?/g, '');
160
  text = text.replace(/\$[^$]*\$/g, '');
161
 
162
+ // Colapsar lineas vacias multiples
163
  text = text.replace(/\n{3,}/g, '\n\n').trim();
164
 
165
+ // Cortar al primer parrafo que se repite (loop del modelo)
166
  const parrafos = text.split(/\n\n+/);
167
  const vistos = new Set();
168
  const sinRepetidos = [];
 
205
  const prompt = construirPrompt(obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias);
206
  const imagenes = [...imagenesDataUrl.filter(Boolean), ...capturasMicroscopio];
207
 
208
+ // Construye el payload compatible con OpenAI vision: imagenes primero, luego el texto
209
  const contenido = [];
210
  for (const img of imagenes) {
211
  if (typeof img === 'string' && img.startsWith('data:image/'))
 
247
  async function _llamarSpace(salidaEl, obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias) {
248
  let prompt = construirPrompt(obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias);
249
 
250
+ // El modelo medGemma espera el token <unused95> al inicio del prompt para modo respuesta directa
251
  if (!prompt.includes('<unused95>')) {
252
  prompt = '<unused95>' + prompt;
253
  }
254
 
255
+ // Filtra y limita a 4 imagenes por restriccion del backend de HuggingFace
256
  const imagenes = [...imagenesDataUrl.filter(Boolean), ...capturasMicroscopio]
257
  .filter(img => typeof img === 'string' && /^data:image\/(jpeg|png|gif|webp);base64,/.test(img))
258
  .slice(0, 4);
259
 
 
260
  console.log('=== MORPHOS AI REQUEST ===');
261
  console.log('Images count:', imagenes.length);
262
  console.log('Prompt length:', prompt.length);
 
272
 
273
  const data = await res.json();
274
 
 
275
  console.log('=== MORPHOS AI RESPONSE ===');
276
  console.log('Status:', res.status);
277
  console.log('Raw text preview:', (data.text ?? 'NO TEXT').substring(0, 300));
js/main.js CHANGED
@@ -56,6 +56,7 @@ const obtenerDatosPaciente = () => {
56
  const especieCruda = document.getElementById('pt-especie').value;
57
  const valorEdad = document.getElementById('pt-edad').value;
58
  const edadUnidad = document.getElementById('pt-edad-unidad').value;
 
59
  const edadMeses = valorEdad === '' ? null
60
  : edadUnidad === 'meses' ? parseFloat(valorEdad)
61
  : parseFloat(valorEdad) * 12;
@@ -124,6 +125,7 @@ const renderizarPatrones = (patrones) => {
124
  const evaluar = () => {
125
  const paciente = obtenerDatosPaciente();
126
 
 
127
  if (!paciente.especie || !referencias[paciente.especie]) {
128
  actualizarClasesInputs([]);
129
  renderizarPatrones([]);
@@ -131,6 +133,7 @@ const evaluar = () => {
131
  }
132
 
133
  const valores = obtenerValoresFormulario();
 
134
  const { hallazgos, patrones } = analizarResultados(valores, paciente, referencias, alteraciones);
135
  ultimoAnalisis = { hallazgos, patrones };
136
 
@@ -142,6 +145,7 @@ const evaluar = () => {
142
 
143
  document.addEventListener('input', e => {
144
  if (e.target.type !== 'number') return;
 
145
  if (e.target.value < 0) e.target.value = 0;
146
  if (e.target.value.replace('.', '').length > 4) e.target.value = e.target.value.slice(0, 4);
147
  e.target.classList.toggle('max-chars', e.target.value.replace('.', '').length >= 4);
@@ -164,6 +168,7 @@ document.addEventListener('click', e => {
164
  if (!btn) return;
165
  const panel = document.getElementById(`panel-${btn.dataset.panel}`);
166
  if (!panel) return;
 
167
  panel.querySelectorAll('input[type="number"], input[type="text"], input[type="url"], select').forEach(el => {
168
  if (el.tagName === 'SELECT') {
169
  el.selectedIndex = 0;
@@ -181,6 +186,7 @@ document.addEventListener('click', e => {
181
  });
182
 
183
  document.querySelector('.boton-analizar').addEventListener('click', async () => {
 
184
  const autenticado = await verificarAuth();
185
  if (!autenticado) {
186
  abrirModalAuth(() => {
 
56
  const especieCruda = document.getElementById('pt-especie').value;
57
  const valorEdad = document.getElementById('pt-edad').value;
58
  const edadUnidad = document.getElementById('pt-edad-unidad').value;
59
+ // Normaliza la edad siempre a meses para que analisis.js pueda aplicar ajustes por cachorro/adulto/senior
60
  const edadMeses = valorEdad === '' ? null
61
  : edadUnidad === 'meses' ? parseFloat(valorEdad)
62
  : parseFloat(valorEdad) * 12;
 
125
  const evaluar = () => {
126
  const paciente = obtenerDatosPaciente();
127
 
128
+ // Si no hay especie o aun no cargaron las referencias, limpia la UI para evitar falsos positivos
129
  if (!paciente.especie || !referencias[paciente.especie]) {
130
  actualizarClasesInputs([]);
131
  renderizarPatrones([]);
 
133
  }
134
 
135
  const valores = obtenerValoresFormulario();
136
+ // Delega en analisis.js la comparacion contra rangos de referencia y la deteccion de patrones clinicos
137
  const { hallazgos, patrones } = analizarResultados(valores, paciente, referencias, alteraciones);
138
  ultimoAnalisis = { hallazgos, patrones };
139
 
 
145
 
146
  document.addEventListener('input', e => {
147
  if (e.target.type !== 'number') return;
148
+ // Impide valores negativos y limita a 4 digitos significativos para mantener la UI limpia
149
  if (e.target.value < 0) e.target.value = 0;
150
  if (e.target.value.replace('.', '').length > 4) e.target.value = e.target.value.slice(0, 4);
151
  e.target.classList.toggle('max-chars', e.target.value.replace('.', '').length >= 4);
 
168
  if (!btn) return;
169
  const panel = document.getElementById(`panel-${btn.dataset.panel}`);
170
  if (!panel) return;
171
+ // Recorre todos los campos editables del panel y los resetea, incluyendo indicadores visuales de estado
172
  panel.querySelectorAll('input[type="number"], input[type="text"], input[type="url"], select').forEach(el => {
173
  if (el.tagName === 'SELECT') {
174
  el.selectedIndex = 0;
 
186
  });
187
 
188
  document.querySelector('.boton-analizar').addEventListener('click', async () => {
189
+ // Si el usuario no esta logueado, abre el modal de auth y encola la llamada a IA como callback
190
  const autenticado = await verificarAuth();
191
  if (!autenticado) {
192
  abrirModalAuth(() => {
js/papers.js CHANGED
@@ -60,6 +60,7 @@ const traducirPatron = (nombre) => {
60
 
61
  const construirQuery = (patrones) => {
62
  if (!patrones || patrones.length === 0) return 'veterinary clinical laboratory diagnosis canine feline';
 
63
  const terminos = [...new Set(patrones.map(p => traducirPatron(p.nombre)))].slice(0, 3);
64
  return `${terminos.join(' ')} canine OR feline veterinary`;
65
  };
@@ -139,6 +140,7 @@ const renderizarPaginacion = () => {
139
  return;
140
  }
141
 
 
142
  const inicio = Math.max(0, paginaActual - 2);
143
  const fin = Math.min(totalPaginas, inicio + 5);
144
 
@@ -211,6 +213,7 @@ export const abrirModalPapers = async (patrones) => {
211
  const etiquetaConsulta = document.getElementById('papers-consulta');
212
  if (etiquetaConsulta) etiquetaConsulta.textContent = `"${nuevaConsulta}"`;
213
 
 
214
  if (nuevaConsulta === consultaActual && todosLosPapers.length > 0) {
215
  renderizarPaginaActual();
216
  return;
 
60
 
61
  const construirQuery = (patrones) => {
62
  if (!patrones || patrones.length === 0) return 'veterinary clinical laboratory diagnosis canine feline';
63
+ // Limita a 3 terminos para mantener la query enfocada y evitar resultados irrelevantes
64
  const terminos = [...new Set(patrones.map(p => traducirPatron(p.nombre)))].slice(0, 3);
65
  return `${terminos.join(' ')} canine OR feline veterinary`;
66
  };
 
140
  return;
141
  }
142
 
143
+ // Ventana deslizante de maximo 5 botones centrada en la pagina actual
144
  const inicio = Math.max(0, paginaActual - 2);
145
  const fin = Math.min(totalPaginas, inicio + 5);
146
 
 
213
  const etiquetaConsulta = document.getElementById('papers-consulta');
214
  if (etiquetaConsulta) etiquetaConsulta.textContent = `"${nuevaConsulta}"`;
215
 
216
+ // Reutiliza resultados si la consulta no cambio desde la ultima vez
217
  if (nuevaConsulta === consultaActual && todosLosPapers.length > 0) {
218
  renderizarPaginaActual();
219
  return;
js/pdf-parser.js CHANGED
@@ -10,7 +10,7 @@ const DEFS_ANALITOS = [
10
  { campo: 'hgb', re: /\b(?:hemoglobin[ao]?\w*|hgb|hb)\b(?!a\d)/i },
11
  { campo: 'hct', re: /\b(?:hematocrit[oo]?\w*|hct|pcv)\b/i },
12
  { campo: 'vcm', re: /\b(?:v\.?c\.?m\.?|m\.?c\.?v\.?|vol(?:umen)?\s+corp\w*)\b/i },
13
- // CHCM debe ir antes que HCM para evitar que MCH coincida con MCHC
14
  { campo: 'chcm', re: /\b(?:c\.?h\.?c\.?m\.?|m\.?c\.?h\.?c\.?|concentr\w+\s+hem\w+\s+corp\w*)\b/i },
15
  { campo: 'hcm', re: /\b(?:h\.?c\.?m\.?|m\.?c\.?h\.?)(?![cC]\.?)\b/i },
16
  { campo: 'rdw', re: /\b(?:r\.?d\.?w\.?(?:-cv)?|anch\w+\s+distrib\w+)\b/i },
@@ -201,6 +201,7 @@ function aplicarConversion(campo, claveConv, value, cadenaUnidad) {
201
  const key = claveConv || campo;
202
  const reglas = CONVERSIONES_UNIDADES[key];
203
  if (!reglas) return value;
 
204
  for (const regla of reglas) {
205
  if (regla.re.test(cadenaUnidad)) {
206
  const f = regla.factor;
@@ -211,8 +212,8 @@ function aplicarConversion(campo, claveConv, value, cadenaUnidad) {
211
  return value;
212
  }
213
 
214
- // Retorna { num, unit } donde unit es la cadena de ~50 caracteres tras el valor numérico.
215
- // Las reglas de conversión evalúan su regex contra esta cadena.
216
  function extraerValorYUnidad(contexto) {
217
  const m = contexto.match(/[<>≤≥]?\s*(\d+(?:[.,]\d+)?)([\s\S]*)/);
218
  if (!m) return { num: null, unit: '' };
@@ -244,7 +245,7 @@ function parsearTextoLab(textoCrudo) {
244
  resultados[def.campo] = aplicarConversion(def.campo, def.claveConv, num, unit);
245
  }
246
 
247
- // Derivar % desde conteos absolutos si el % no se encontró directamente y se conoce el WBC
248
  if (resultados.wbc && resultados.wbc > 0) {
249
  for (const f of ['neutro', 'linfo', 'mono', 'eosino', 'baso']) {
250
  if (resultados[f] === undefined && resultados[`${f}_abs`] !== undefined) {
@@ -254,8 +255,8 @@ function parsearTextoLab(textoCrudo) {
254
  }
255
  }
256
 
257
- // Derivar % de reticulocitos desde el conteo absoluto y RBC si no se encontró directamente
258
- // reti_abs (x10³/μL) ÷ (rbc (x10⁶/μL) × 10) = reti%
259
  if (resultados.rbc && resultados.rbc > 0 && resultados.reti === undefined && resultados.reti_abs !== undefined) {
260
  const pct = resultados.reti_abs / (resultados.rbc * 10);
261
  if (pct >= 0 && pct <= 20) resultados.reti = Math.round(pct * 100) / 100;
@@ -311,7 +312,7 @@ function inferEspecie(raza) {
311
  function parsearTextoPaciente(textoCrudo) {
312
  const p = {};
313
 
314
- // Especie "Especie: Canino" / "Species: Dog" / "Tipo: Felino"
315
  const coincEsp = textoCrudo.match(/\b(?:especies?|species|tipo(?:\s+de)?\s+animal)\s*:?\s{0,4}([A-Za-záéíóúÁÉÍÓÚñÑ]{3,20})/i);
316
  if (coincEsp) {
317
  const v = coincEsp[1].toLowerCase();
@@ -319,11 +320,10 @@ function parsearTextoPaciente(textoCrudo) {
319
  else if (/fel[io]|gat[ao]|cat/.test(v)) p.especie = 'Felino';
320
  }
321
 
322
- // Raza "Raza: Labrador Retriever" / "Breed: Mixed"
323
  const coincRaza = textoCrudo.match(/\b(?:raza|breed|race|cruce)\s*:?\s{0,4}([^\n\r;:]{2,60})/i);
324
  if (coincRaza) {
325
  const crudo = coincRaza[1];
326
- // Detener en la siguiente palabra clave de etiqueta o 2+ espacios consecutivos (diseño tabular)
327
  const indiceParo = crudo.search(SIGUIENTE_ETIQUETA);
328
  const limpiado = (indiceParo > 0 ? crudo.slice(0, indiceParo) : crudo)
329
  .split(/\s{2,}/)[0]
@@ -331,9 +331,10 @@ function parsearTextoPaciente(textoCrudo) {
331
  if (limpiado.length >= 2) p.raza = limpiado.length > 40 ? limpiado.slice(0, 40).trim() : limpiado;
332
  }
333
 
 
334
  if (!p.especie && p.raza) p.especie = inferEspecie(p.raza);
335
 
336
- // Sexo — "Sexo: Macho" / "Sex: F" / "Género: Hembra Esterilizada"
337
  const coincSex = textoCrudo.match(/\b(?:sexo|sex[ou]?|g[eé]nero|gender)\s*:?\s{0,4}([^\n\r;:]{1,30})/i);
338
  if (coincSex) {
339
  const v = coincSex[1].trim();
@@ -341,7 +342,7 @@ function parsearTextoPaciente(textoCrudo) {
341
  else if (/\b(?:hembra|female|esterilizada?|spayed)\b/i.test(v) || /^[fh]\.?\s*$/i.test(v)) p.sexo = 'Hembra';
342
  }
343
 
344
- // Edad — "Edad: 5 años" / "Age: 6 months" / "Edad: 2.5 años"
345
  const coincEdad = textoCrudo.match(/\b(?:edad|age)\s*:?\s{0,4}(\d+(?:[.,]\d+)?)\s*(a[ñn]os?|years?|yr?s?|meses?|months?)\b/i);
346
  if (coincEdad) {
347
  p.edad = parseFloat(coincEdad[1].replace(',', '.'));
 
10
  { campo: 'hgb', re: /\b(?:hemoglobin[ao]?\w*|hgb|hb)\b(?!a\d)/i },
11
  { campo: 'hct', re: /\b(?:hematocrit[oo]?\w*|hct|pcv)\b/i },
12
  { campo: 'vcm', re: /\b(?:v\.?c\.?m\.?|m\.?c\.?v\.?|vol(?:umen)?\s+corp\w*)\b/i },
13
+ // CHCM debe ir antes que HCM para evitar que MCH coincida con MCHC al usar lookahead negativo
14
  { campo: 'chcm', re: /\b(?:c\.?h\.?c\.?m\.?|m\.?c\.?h\.?c\.?|concentr\w+\s+hem\w+\s+corp\w*)\b/i },
15
  { campo: 'hcm', re: /\b(?:h\.?c\.?m\.?|m\.?c\.?h\.?)(?![cC]\.?)\b/i },
16
  { campo: 'rdw', re: /\b(?:r\.?d\.?w\.?(?:-cv)?|anch\w+\s+distrib\w+)\b/i },
 
201
  const key = claveConv || campo;
202
  const reglas = CONVERSIONES_UNIDADES[key];
203
  if (!reglas) return value;
204
+ // Aplica la primera regla cuya regex coincida con la cadena de unidad detectada
205
  for (const regla of reglas) {
206
  if (regla.re.test(cadenaUnidad)) {
207
  const f = regla.factor;
 
212
  return value;
213
  }
214
 
215
+ // Retorna { num, unit } donde unit es la cadena de ~50 caracteres tras el valor numerico.
216
+ // Las reglas de conversion evaluan su regex contra esta cadena para decidir el factor.
217
  function extraerValorYUnidad(contexto) {
218
  const m = contexto.match(/[<>≤≥]?\s*(\d+(?:[.,]\d+)?)([\s\S]*)/);
219
  if (!m) return { num: null, unit: '' };
 
245
  resultados[def.campo] = aplicarConversion(def.campo, def.claveConv, num, unit);
246
  }
247
 
248
+ // Derivar % desde conteos absolutos si el % no se encontro directamente y se conoce el WBC
249
  if (resultados.wbc && resultados.wbc > 0) {
250
  for (const f of ['neutro', 'linfo', 'mono', 'eosino', 'baso']) {
251
  if (resultados[f] === undefined && resultados[`${f}_abs`] !== undefined) {
 
255
  }
256
  }
257
 
258
+ // Derivar % de reticulocitos desde el conteo absoluto y RBC si no se encontro directamente
259
+ // reti_abs (x10³/uL) / (rbc (x10⁶/uL) * 10) = reti%
260
  if (resultados.rbc && resultados.rbc > 0 && resultados.reti === undefined && resultados.reti_abs !== undefined) {
261
  const pct = resultados.reti_abs / (resultados.rbc * 10);
262
  if (pct >= 0 && pct <= 20) resultados.reti = Math.round(pct * 100) / 100;
 
312
  function parsearTextoPaciente(textoCrudo) {
313
  const p = {};
314
 
315
+ // Especie: tolera variaciones como "Canino", "Dog", "Felino", "Cat"
316
  const coincEsp = textoCrudo.match(/\b(?:especies?|species|tipo(?:\s+de)?\s+animal)\s*:?\s{0,4}([A-Za-záéíóúÁÉÍÓÚñÑ]{3,20})/i);
317
  if (coincEsp) {
318
  const v = coincEsp[1].toLowerCase();
 
320
  else if (/fel[io]|gat[ao]|cat/.test(v)) p.especie = 'Felino';
321
  }
322
 
323
+ // Raza: corta en la siguiente etiqueta o doble espacio para evitar absorber campos adyacentes en tablas
324
  const coincRaza = textoCrudo.match(/\b(?:raza|breed|race|cruce)\s*:?\s{0,4}([^\n\r;:]{2,60})/i);
325
  if (coincRaza) {
326
  const crudo = coincRaza[1];
 
327
  const indiceParo = crudo.search(SIGUIENTE_ETIQUETA);
328
  const limpiado = (indiceParo > 0 ? crudo.slice(0, indiceParo) : crudo)
329
  .split(/\s{2,}/)[0]
 
331
  if (limpiado.length >= 2) p.raza = limpiado.length > 40 ? limpiado.slice(0, 40).trim() : limpiado;
332
  }
333
 
334
+ // Si no encontro especie pero si raza, infiere la especie a partir de listas de razas conocidas
335
  if (!p.especie && p.raza) p.especie = inferEspecie(p.raza);
336
 
337
+ // Sexo: soporta abreviaturas (M, F, H) y variantes como "Esterilizada"
338
  const coincSex = textoCrudo.match(/\b(?:sexo|sex[ou]?|g[eé]nero|gender)\s*:?\s{0,4}([^\n\r;:]{1,30})/i);
339
  if (coincSex) {
340
  const v = coincSex[1].trim();
 
342
  else if (/\b(?:hembra|female|esterilizada?|spayed)\b/i.test(v) || /^[fh]\.?\s*$/i.test(v)) p.sexo = 'Hembra';
343
  }
344
 
345
+ // Edad: extrae numero y unidad, normalizando comas decimales
346
  const coincEdad = textoCrudo.match(/\b(?:edad|age)\s*:?\s{0,4}(\d+(?:[.,]\d+)?)\s*(a[ñn]os?|years?|yr?s?|meses?|months?)\b/i);
347
  if (coincEdad) {
348
  p.edad = parseFloat(coincEdad[1].replace(',', '.'));
js/ui.js CHANGED
@@ -15,6 +15,7 @@ export function activarTab(targetId) {
15
  const esTabExamenes = targetId === 'examenes';
16
  const mostrarExamenes = esTabExamenes || esSubpanelExamenes;
17
 
 
18
  let idPanelActual;
19
  if (esTabExamenes) {
20
  idPanelActual = panelExamenActivo;
@@ -70,6 +71,7 @@ document.querySelector('main').addEventListener('touchstart', e => {
70
  document.querySelector('main').addEventListener('touchend', e => {
71
  const dx = e.changedTouches[0].clientX - inicioSwipeX;
72
  const dy = e.changedTouches[0].clientY - inicioSwipeY;
 
73
  if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy)) return;
74
  const indice = SWIPE_ORDER.indexOf(panelActivo);
75
  const siguiente = dx < 0 ? SWIPE_ORDER[indice + 1] : SWIPE_ORDER[indice - 1];
@@ -122,6 +124,7 @@ function inicializarFilasGrid() {
122
  if (!esGridEscritorio()) return;
123
  mainEl.style.gridTemplateRows = '1fr auto auto';
124
 
 
125
  const alturaPanel = panelFlujo.getBoundingClientRect().height;
126
  const alturaEncabezado = panelFlujo.querySelector('.panel-cabecera').getBoundingClientRect().height;
127
  if (alturaPanel > 0) filaExpandida = `${alturaPanel}px`;
@@ -188,6 +191,7 @@ function establecerSubpanelColapsado(subpanel, debeColapsar) {
188
  if (debeColapsar === subpanel.classList.contains('collapsed')) return;
189
  subpanel.classList.toggle('collapsed', debeColapsar);
190
  if (btn) btn.setAttribute('aria-expanded', String(!debeColapsar));
 
191
  if (debeColapsar) {
192
  animEl.style.height = `${animEl.offsetHeight}px`;
193
  animEl.offsetHeight;
@@ -324,6 +328,7 @@ document.querySelectorAll('.zona-imagen').forEach(zona => {
324
  reader.onload = ev => {
325
  const img = new Image();
326
  img.onload = () => {
 
327
  const MAX_PIXELES = 1024;
328
  const scale = Math.min(MAX_PIXELES / img.width, MAX_PIXELES / img.height, 1);
329
  const canvas = document.createElement('canvas');
@@ -420,6 +425,7 @@ document.querySelectorAll('.zona-imagen').forEach(zona => {
420
 
421
  async function abrirCamara() {
422
  try {
 
423
  stream = await navigator.mediaDevices.getUserMedia({
424
  video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 } }
425
  });
@@ -429,7 +435,7 @@ document.querySelectorAll('.zona-imagen').forEach(zona => {
429
  controles.hidden = false;
430
  actualizarInsignia();
431
  } catch {
432
- // Permiso denegado o cámara no disponible no se requiere fallback
433
  }
434
  }
435
 
@@ -447,6 +453,7 @@ document.querySelectorAll('.zona-imagen').forEach(zona => {
447
  e.stopPropagation();
448
  if (capturasMicroscopio.length >= MAX_CAPTURAS_MICRO) return;
449
  const canvas = document.createElement('canvas');
 
450
  const MAX_PIXELES = 1024;
451
  const scale = Math.min(MAX_PIXELES / video.videoWidth, MAX_PIXELES / video.videoHeight, 1);
452
  canvas.width = Math.round(video.videoWidth * scale);
 
15
  const esTabExamenes = targetId === 'examenes';
16
  const mostrarExamenes = esTabExamenes || esSubpanelExamenes;
17
 
18
+ // Si se clickea la tab generica "Examenes", muestra el ultimo subtab activo; si es un subtab, lo guarda
19
  let idPanelActual;
20
  if (esTabExamenes) {
21
  idPanelActual = panelExamenActivo;
 
71
  document.querySelector('main').addEventListener('touchend', e => {
72
  const dx = e.changedTouches[0].clientX - inicioSwipeX;
73
  const dy = e.changedTouches[0].clientY - inicioSwipeY;
74
+ // Ignora gestos cortos o verticales para no interferir con scroll
75
  if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy)) return;
76
  const indice = SWIPE_ORDER.indexOf(panelActivo);
77
  const siguiente = dx < 0 ? SWIPE_ORDER[indice + 1] : SWIPE_ORDER[indice - 1];
 
124
  if (!esGridEscritorio()) return;
125
  mainEl.style.gridTemplateRows = '1fr auto auto';
126
 
127
+ // Mide el panel de flujo expandido y colapsado para animar grid-template-rows con precision
128
  const alturaPanel = panelFlujo.getBoundingClientRect().height;
129
  const alturaEncabezado = panelFlujo.querySelector('.panel-cabecera').getBoundingClientRect().height;
130
  if (alturaPanel > 0) filaExpandida = `${alturaPanel}px`;
 
191
  if (debeColapsar === subpanel.classList.contains('collapsed')) return;
192
  subpanel.classList.toggle('collapsed', debeColapsar);
193
  if (btn) btn.setAttribute('aria-expanded', String(!debeColapsar));
194
+ // Forzar reflujo antes de cambiar height permite que CSS transition anime correctamente
195
  if (debeColapsar) {
196
  animEl.style.height = `${animEl.offsetHeight}px`;
197
  animEl.offsetHeight;
 
328
  reader.onload = ev => {
329
  const img = new Image();
330
  img.onload = () => {
331
+ // Reduce la imagen a max 1024px en su lado mayor para no saturar la memoria ni la API
332
  const MAX_PIXELES = 1024;
333
  const scale = Math.min(MAX_PIXELES / img.width, MAX_PIXELES / img.height, 1);
334
  const canvas = document.createElement('canvas');
 
425
 
426
  async function abrirCamara() {
427
  try {
428
+ // Preferencia por camara trasera (microscopio o movil apuntando a la muestra)
429
  stream = await navigator.mediaDevices.getUserMedia({
430
  video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 } }
431
  });
 
435
  controles.hidden = false;
436
  actualizarInsignia();
437
  } catch {
438
+ // Permiso denegado o camara no disponible; no se requiere fallback
439
  }
440
  }
441
 
 
453
  e.stopPropagation();
454
  if (capturasMicroscopio.length >= MAX_CAPTURAS_MICRO) return;
455
  const canvas = document.createElement('canvas');
456
+ // Escala el fotograma de video para mantener un tamano razonable antes de enviarlo al modelo
457
  const MAX_PIXELES = 1024;
458
  const scale = Math.min(MAX_PIXELES / video.videoWidth, MAX_PIXELES / video.videoHeight, 1);
459
  canvas.width = Math.round(video.videoWidth * scale);