{"id":"cs-1782350591167-2pyjo1k6","status":"done","createdAt":1782350591167,"finishedAt":1782350751047,"meta":{"projectName":"ALimpiezas","projectPath":"C:\\xampp\\htdocs\\ALimpiezas","messagePreview":"Ahora si pero hay que rehacer la generacion de ia para lectura y comprension correcta y capturar todos los datos utiles ","phaseId":null},"events":[{"event":"status","data":{"text":"Modo IDE / sesión — completo (80 herramientas, uso selectivo)"},"at":1782350591171},{"event":"status","data":{"text":"Agente"},"at":1782350591172},{"event":"status","data":{"text":"Modo revisión: snapshot local por archivo"},"at":1782350591172},{"event":"status","data":{"text":"Contexto del IDE cargado"},"at":1782350591172},{"event":"agent","data":{"agentId":"agent-dca1bfaa-c872-4cb9-af7e-79fa80fc0a18","projectPath":"C:\\xampp\\htdocs\\ALimpiezas"},"at":1782350591172},{"event":"changes","data":{"sessionId":"cs-1782350591171-ndpzgs","pending":0},"at":1782350591172},{"event":"status","data":{"text":"Procesando…"},"at":1782350591227},{"event":"text","data":{"chunk":"Rehago la captura con IA fiscal fiable y la UI móvil de confirmación. Reviso los archivos ya identificados y la documentación del servicio en 192.168.1.16.\n"},"at":1782350751047},{"event":"tool_activity","data":{"activity":{"id":"tool_276cb037-c968-4b7d-8ad4-5750317559e","name":"glob","status":"completed","summary":"glob: ","detail":"","at":"2026-06-25T01:23:13.871Z"}},"at":1782350593871},{"event":"tool_activity","data":{"activity":{"id":"tool_cd0f1bef-d773-439f-b5a7-45f1c722d58","name":"glob","status":"running","summary":"glob: ","detail":"","at":"2026-06-25T01:23:14.106Z"}},"at":1782350594106},{"event":"tool_activity","data":{"activity":{"id":"tool_72c6e3ed-e15e-40d3-9580-9c61e1f7319","name":"glob","status":"completed","summary":"glob: ","detail":"","at":"2026-06-25T01:23:14.259Z"}},"at":1782350594259},{"event":"tool_activity","data":{"activity":{"id":"tool_cd0f1bef-d773-439f-b5a7-45f1c722d58","name":"glob","status":"completed","summary":"glob: ","detail":"","at":"2026-06-25T01:23:14.297Z"}},"at":1782350594297},{"event":"tool_activity","data":{"activity":{"id":"tool_2b60ddaf-822f-4853-9ec0-03d2c53ae12","name":"read","status":"running","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\docs\\MOBILE-BRIDGE-TEST.md","detail":"","at":"2026-06-25T01:23:15.424Z"}},"at":1782350595424},{"event":"tool_activity","data":{"activity":{"id":"tool_7cddc0a1-0b13-4f06-8cda-eddad0a0be9","name":"read","status":"running","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\docs\\MANUAL-AGENTE-IA.md","detail":"","at":"2026-06-25T01:23:15.424Z"}},"at":1782350595424},{"event":"tool_activity","data":{"activity":{"id":"tool_c3ae6782-b434-4c4f-89d3-10be04509a8","name":"read","status":"running","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\docs\\MANUAL-USUARIO.md","detail":"","at":"2026-06-25T01:23:15.426Z"}},"at":1782350595426},{"event":"tool_activity","data":{"activity":{"id":"tool_b6f77088-b4ee-412a-b1bf-90de90a69e8","name":"read","status":"running","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php","detail":"","at":"2026-06-25T01:23:15.443Z"}},"at":1782350595443},{"event":"tool_activity","data":{"activity":{"id":"tool_2b60ddaf-822f-4853-9ec0-03d2c53ae12","name":"read","status":"completed","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\docs\\MOBILE-BRIDGE-TEST.md","detail":"","at":"2026-06-25T01:23:15.537Z"}},"at":1782350595537},{"event":"tool_activity","data":{"activity":{"id":"tool_7cddc0a1-0b13-4f06-8cda-eddad0a0be9","name":"read","status":"completed","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\docs\\MANUAL-AGENTE-IA.md","detail":"","at":"2026-06-25T01:23:15.546Z"}},"at":1782350595546},{"event":"tool_activity","data":{"activity":{"id":"tool_c3ae6782-b434-4c4f-89d3-10be04509a8","name":"read","status":"completed","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\docs\\MANUAL-USUARIO.md","detail":"","at":"2026-06-25T01:23:15.548Z"}},"at":1782350595548},{"event":"tool_activity","data":{"activity":{"id":"tool_b6f77088-b4ee-412a-b1bf-90de90a69e8","name":"read","status":"completed","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php","detail":"","at":"2026-06-25T01:23:15.580Z"}},"at":1782350595580},{"event":"tool_activity","data":{"activity":{"id":"tool_81c56221-3d1a-4042-b64d-4c3c0dc8509","name":"read","status":"running","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\ai-service\\main.py","detail":"","at":"2026-06-25T01:23:17.387Z"}},"at":1782350597387},{"event":"tool_activity","data":{"activity":{"id":"tool_15b464d9-55ca-486f-877f-7258d384d92","name":"read","status":"running","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\MobileConfig.php","detail":"","at":"2026-06-25T01:23:17.387Z"}},"at":1782350597387},{"event":"tool_activity","data":{"activity":{"id":"tool_bdd51be4-9de4-4cb4-b832-ee2201f1af6","name":"read","status":"running","summary":"read: …ampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\ExpenseInvoiceCreator.php","detail":"","at":"2026-06-25T01:23:17.390Z"}},"at":1782350597390},{"event":"tool_activity","data":{"activity":{"id":"tool_7e994d38-76e9-4c08-8ec5-a2f7889bfc0","name":"read","status":"running","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Table\\ai_expense_queue.xml","detail":"","at":"2026-06-25T01:23:17.390Z"}},"at":1782350597390},{"event":"tool_activity","data":{"activity":{"id":"tool_15b464d9-55ca-486f-877f-7258d384d92","name":"read","status":"completed","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\MobileConfig.php","detail":"","at":"2026-06-25T01:23:17.496Z"}},"at":1782350597496},{"event":"tool_activity","data":{"activity":{"id":"tool_bdd51be4-9de4-4cb4-b832-ee2201f1af6","name":"read","status":"completed","summary":"read: …ampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\ExpenseInvoiceCreator.php","detail":"","at":"2026-06-25T01:23:17.500Z"}},"at":1782350597500},{"event":"tool_activity","data":{"activity":{"id":"tool_7e994d38-76e9-4c08-8ec5-a2f7889bfc0","name":"read","status":"completed","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Table\\ai_expense_queue.xml","detail":"","at":"2026-06-25T01:23:17.501Z"}},"at":1782350597501},{"event":"tool_activity","data":{"activity":{"id":"tool_81c56221-3d1a-4042-b64d-4c3c0dc8509","name":"read","status":"completed","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\ai-service\\main.py","detail":"","at":"2026-06-25T01:23:17.543Z"}},"at":1782350597543},{"event":"tool_activity","data":{"activity":{"id":"tool_8be0da05-68bf-4198-a777-b05772d05d8","name":"read","status":"running","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Assets\\JS\\mobile.js","detail":"","at":"2026-06-25T01:23:20.080Z"}},"at":1782350600080},{"event":"tool_activity","data":{"activity":{"id":"tool_fd4c0044-2c8d-403c-af8f-b8d5defd8c0","name":"read","status":"running","summary":"read: …ampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\View\\MobileExpenses.html.twig","detail":"","at":"2026-06-25T01:23:20.161Z"}},"at":1782350600161},{"event":"tool_activity","data":{"activity":{"id":"tool_8be0da05-68bf-4198-a777-b05772d05d8","name":"read","status":"completed","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Assets\\JS\\mobile.js","detail":"","at":"2026-06-25T01:23:20.194Z"}},"at":1782350600194},{"event":"tool_activity","data":{"activity":{"id":"tool_fd4c0044-2c8d-403c-af8f-b8d5defd8c0","name":"read","status":"completed","summary":"read: …ampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\View\\MobileExpenses.html.twig","detail":"","at":"2026-06-25T01:23:20.263Z"}},"at":1782350600263},{"event":"tool_activity","data":{"activity":{"id":"tool_67436aca-7d28-4e4b-8cce-54ad2a58754","name":"read","status":"completed","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Assets\\CSS\\mobile.css","detail":"","at":"2026-06-25T01:23:20.439Z"}},"at":1782350600439},{"event":"tool_activity","data":{"activity":{"id":"tool_c1d45ee8-0ef5-477d-b282-49ae2f5856a","name":"glob","status":"running","summary":"glob: ","detail":"","at":"2026-06-25T01:23:22.026Z"}},"at":1782350602026},{"event":"tool_activity","data":{"activity":{"id":"tool_b6f6da0f-4ed7-4921-89e1-5ccfdbfef0b","name":"read","status":"running","summary":"read: …ampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Controller\\MobileExpenses.php","detail":"","at":"2026-06-25T01:23:22.121Z"}},"at":1782350602121},{"event":"tool_activity","data":{"activity":{"id":"tool_c1d45ee8-0ef5-477d-b282-49ae2f5856a","name":"glob","status":"completed","summary":"glob: ","detail":"","at":"2026-06-25T01:23:22.170Z"}},"at":1782350602170},{"event":"tool_activity","data":{"activity":{"id":"tool_b6f6da0f-4ed7-4921-89e1-5ccfdbfef0b","name":"read","status":"completed","summary":"read: …ampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Controller\\MobileExpenses.php","detail":"","at":"2026-06-25T01:23:22.219Z"}},"at":1782350602219},{"event":"tool_activity","data":{"activity":{"id":"tool_ec1f0bdd-869a-45cb-9a81-4f8341ff348","name":"shell","status":"completed","summary":"shell","detail":"curl -s -m 8 \"http://192.168.1.16:8090/health\" 2>&1; echo \"---\"; curl -s -m 8 \"http://192.168.1.16:8090/api/agents/chat\" -X POST -H \"Content-Type: application/json\" -d \"{\\\"project_id\\\":\\\"alimpiezas\\\"…","at":"2026-06-25T01:23:23.568Z"}},"at":1782350603568},{"event":"tool_activity","data":{"activity":{"id":"tool_20c341eb-f380-45a6-a178-69af936d2a2","name":"shell","status":"running","summary":"shell","detail":"$paths = @('/api/openapi.json','/api/docs','/api/health','/api/supervisor/health','/api/v1/chat/completions','/api/ollama/v1/chat/completions','/api/ollama/chat','/api/rag/query','/api/projects','/ap…","at":"2026-06-25T01:23:25.365Z"}},"at":1782350605365},{"event":"tool_activity","data":{"activity":{"id":"tool_3ff05f91-6331-4fc9-b77e-18479cea6d9","name":"grep","status":"running","summary":"grep: C:\\xampp\\htdocs\\ALimpiezas","detail":"","at":"2026-06-25T01:23:26.312Z"}},"at":1782350606312},{"event":"tool_activity","data":{"activity":{"id":"tool_20c341eb-f380-45a6-a178-69af936d2a2","name":"shell","status":"completed","summary":"shell","detail":"$paths = @('/api/openapi.json','/api/docs','/api/health','/api/supervisor/health','/api/v1/chat/completions','/api/ollama/v1/chat/completions','/api/ollama/chat','/api/rag/query','/api/projects','/ap…","at":"2026-06-25T01:23:26.436Z"}},"at":1782350606436},{"event":"tool_activity","data":{"activity":{"id":"tool_3ff05f91-6331-4fc9-b77e-18479cea6d9","name":"grep","status":"completed","summary":"grep: C:\\xampp\\htdocs\\ALimpiezas","detail":"","at":"2026-06-25T01:23:26.552Z"}},"at":1782350606552},{"event":"tool_activity","data":{"activity":{"id":"tool_4b98ef88-ad0b-45a4-ae49-cdd2705f6d3","name":"read","status":"completed","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\tools\\_lan_openapi.json","detail":"","at":"2026-06-25T01:23:29.289Z"}},"at":1782350609289},{"event":"tool_activity","data":{"activity":{"id":"tool_92229f31-1520-4bf2-979a-9428dc3bfbc","name":"shell","status":"completed","summary":"shell","detail":"curl -s -m 10 \"http://192.168.1.16:8090/openapi.json\" 2>&1 | head -c 8000","at":"2026-06-25T01:23:30.546Z"}},"at":1782350610546},{"event":"tool_activity","data":{"activity":{"id":"tool_088fab19-d7ed-4ac6-8ccb-5ed80661e9d","name":"shell","status":"running","summary":"shell","detail":"curl -s -m 10 \"http://192.168.1.16:8090/health\"; echo \"\"; curl -s -m 10 \"http://192.168.1.16:8090/api/projects\" 2>&1; echo \"\"; curl -s -m 15 -X POST \"http://192.168.1.16:11434/api/chat\" -H \"Content-T…","at":"2026-06-25T01:23:31.935Z"}},"at":1782350611935},{"event":"tool_activity","data":{"activity":{"id":"tool_c2b4fa08-038c-4198-8b15-57355a3ea4b","name":"grep","status":"completed","summary":"grep: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile","detail":"","at":"2026-06-25T01:23:32.263Z"}},"at":1782350612263},{"event":"tool_activity","data":{"activity":{"id":"tool_088fab19-d7ed-4ac6-8ccb-5ed80661e9d","name":"shell","status":"completed","summary":"shell","detail":"curl -s -m 10 \"http://192.168.1.16:8090/health\"; echo \"\"; curl -s -m 10 \"http://192.168.1.16:8090/api/projects\" 2>&1; echo \"\"; curl -s -m 15 -X POST \"http://192.168.1.16:11434/api/chat\" -H \"Content-T…","at":"2026-06-25T01:24:02.091Z"}},"at":1782350642091},{"event":"tool_activity","data":{"activity":{"id":"tool_bc493934-6c01-4e67-83ac-fec5dc661d9","name":"read","status":"completed","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\Dinamic\\Lib\\FiscalNumberValidator.php","detail":"","at":"2026-06-25T01:24:21.216Z"}},"at":1782350661216},{"event":"tool_activity","data":{"activity":{"id":"tool_98b484e8-dad4-40ee-9a3f-7cad983e9b0","name":"glob","status":"completed","summary":"glob: ","detail":"","at":"2026-06-25T01:24:22.466Z"}},"at":1782350662466},{"event":"tool_activity","data":{"activity":{"id":"tool_cdcc1977-24b5-44c1-9c41-c63cc794ea0","name":"read","status":"completed","summary":"read: C:\\Users\\Paterna\\.cursor\\projects\\C-xampp-htdocs-ALimpiezas\\terminals\\214579.txt","detail":"","at":"2026-06-25T01:25:05.785Z"}},"at":1782350705785},{"event":"tool_activity","data":{"activity":{"id":"tool_75dd12e4-8109-4f0a-b43c-fe549c57453","name":"grep","status":"running","summary":"grep: C:\\xampp\\htdocs\\ALimpiezas\\Core\\Lib\\FiscalNumberValidator.php","detail":"","at":"2026-06-25T01:25:05.816Z"}},"at":1782350705817},{"event":"tool_activity","data":{"activity":{"id":"tool_935fc86d-d416-4aa9-a1bf-aea1ccefe52","name":"read","status":"running","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Assets\\sw.js","detail":"","at":"2026-06-25T01:25:05.947Z"}},"at":1782350705947},{"event":"tool_activity","data":{"activity":{"id":"tool_75dd12e4-8109-4f0a-b43c-fe549c57453","name":"grep","status":"completed","summary":"grep: C:\\xampp\\htdocs\\ALimpiezas\\Core\\Lib\\FiscalNumberValidator.php","detail":"","at":"2026-06-25T01:25:05.954Z"}},"at":1782350705954},{"event":"tool_activity","data":{"activity":{"id":"tool_935fc86d-d416-4aa9-a1bf-aea1ccefe52","name":"read","status":"completed","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Assets\\sw.js","detail":"","at":"2026-06-25T01:25:06.049Z"}},"at":1782350706049},{"event":"tool_activity","data":{"activity":{"id":"tool_352816cb-049b-40a6-849e-1c8bbe54b22","name":"updateTodos","status":"completed","summary":"updateTodos","detail":"{\"todos\":[{\"content\":\"Rehacer ai-service: OCR + Ollama LAN + validación fiscal ES\",\"status\":\"inProgress\"},{\"content\":\"A…","at":"2026-06-25T01:25:11.637Z"}},"at":1782350711637},{"event":"tool_activity","data":{"activity":{"id":"tool_1e08dfff-3413-438f-94be-64975177787","name":"read","status":"running","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Assets\\CSS\\mobile.css","detail":"","at":"2026-06-25T01:25:11.643Z"}},"at":1782350711643},{"event":"tool_activity","data":{"activity":{"id":"tool_7c2144dd-c92e-4528-b19e-350808e7c16","name":"read","status":"running","summary":"read: …ampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\View\\MobileSettings.html.twig","detail":"","at":"2026-06-25T01:25:11.737Z"}},"at":1782350711737},{"event":"tool_activity","data":{"activity":{"id":"tool_1e08dfff-3413-438f-94be-64975177787","name":"read","status":"completed","summary":"read: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Assets\\CSS\\mobile.css","detail":"","at":"2026-06-25T01:25:11.745Z"}},"at":1782350711745},{"event":"text","data":{"chunk":"Implementando extracción fiscal con Ollama en 192.168.1.16, validación española y UI móvil de confirmación.\n"},"at":1782350751048},{"event":"tool_activity","data":{"activity":{"id":"tool_7c2144dd-c92e-4528-b19e-350808e7c16","name":"read","status":"completed","summary":"read: …ampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\View\\MobileSettings.html.twig","detail":"","at":"2026-06-25T01:25:11.839Z"}},"at":1782350711839},{"event":"tool_activity","data":{"activity":{"id":"tool_8749149d-292f-440b-8a5f-baa1512e76c","name":"edit","status":"running","summary":"edit: C:\\xampp\\htdocs\\ALimpiezas\\ai-service\\main.py","detail":"","at":"2026-06-25T01:25:17.317Z"}},"at":1782350717317},{"event":"text","data":{"chunk":"Reescribiendo el microservicio con extracción fiscal e integración Ollama.\n"},"at":1782350751048},{"event":"tool_activity","data":{"activity":{"id":"tool_8749149d-292f-440b-8a5f-baa1512e76c","name":"edit","status":"completed","summary":"edit: C:\\xampp\\htdocs\\ALimpiezas\\ai-service\\main.py","detail":"","at":"2026-06-25T01:25:45.100Z"}},"at":1782350745100},{"event":"file_change","data":{"sessionId":"cs-1782350591171-ndpzgs","change":{"id":"ch-1","path":"ai-service\\main.py","type":"edit","diffString":"--- a/C:\\xampp\\htdocs\\ALimpiezas\\ai-service\\main.py\n+++ b/C:\\xampp\\htdocs\\ALimpiezas\\ai-service\\main.py\n@@ -1,12 +1,15 @@\n-\"\"\"Microservicio IA: OCR, voz e intenciones (ALimpiezas).\"\"\"\n+\"\"\"Microservicio IA: OCR, comprensión fiscal ES y Ollama (ALimpiezas).\"\"\"\n \n from __future__ import annotations\n \n import io\n+import json\n import os\n import re\n import shutil\n import tempfile\n+import urllib.error\n+import urllib.request\n from datetime import date\n from typing import Any\n \n@@ -20,7 +23,7 @@\n     text: str = \"\"\n \n \n-app = FastAPI(title=\"ALimpiezas AI Service\", version=\"0.2.0\")\n+app = FastAPI(title=\"ALimpiezas AI Service\", version=\"0.3.0\")\n \n app.add_middleware(\n     CORSMiddleware,\n@@ -66,9 +69,37 @@\n \n IMAGE_EXTENSIONS = (\".jpg\", \".jpeg\", \".png\", \".webp\", \".gif\", \".heic\", \".heif\", \".bmp\", \".tif\", \".tiff\")\n \n+DEFAULT_OLLAMA_URL = os.getenv(\"OLLAMA_URL\", \"http://192.168.1.16:11434\")\n+DEFAULT_OLLAMA_MODEL = os.getenv(\"OLLAMA_MODEL\", \"qwen2.5:7b\")\n \n-def _parse_amount(value: str) -> float | None:\n-    cleaned = value.replace(\"€\", \"\").replace(\"EUR\", \"\").strip()\n+FISCAL_JSON_SCHEMA = {\n+    \"document_kind\": \"expense_received|issued_invoice|simplified_ticket|proforma|unknown\",\n+    \"counterparty_name\": \"string\",\n+    \"counterparty_cif\": \"string\",\n+    \"counterparty_address\": \"string\",\n+    \"invoice_number\": \"string\",\n+    \"invoice_series\": \"string\",\n+    \"invoice_date\": \"YYYY-MM-DD\",\n+    \"operation_date\": \"YYYY-MM-DD or null\",\n+    \"concept\": \"string\",\n+    \"base_amount\": \"number\",\n+    \"tax_rate\": \"21|10|4|0\",\n+    \"tax_amount\": \"number\",\n+    \"irpf_rate\": \"number or null\",\n+    \"irpf_amount\": \"number or null\",\n+    \"total_amount\": \"number\",\n+    \"currency\": \"EUR\",\n+    \"payment_method\": \"string\",\n+    \"is_rectifying\": \"boolean\",\n+    \"deductible\": \"boolean\",\n+    \"notes\": \"string\",\n+}\n+\n+\n+def _parse_amount(value: str | None) -> float | None:\n+    if not value:\n+        return None\n+    cleaned = str(value).replace(\"€\", \"\").replace(\"EUR\", \"\").replace(\" \", \"\").strip()\n     if cleaned.count(\",\") == 1 and cleaned.count(\".\") > 1:\n         cleaned = cleaned.replace(\".\", \"\")\n     cleaned = cleaned.replace(\",\", \".\")\n@@ -78,6 +109,46 @@\n         return None\n \n \n+def _normalize_date(day: str, month: str, year: str) -> str | None:\n+    year_int = int(year)\n+    if year_int < 100:\n+        year_int += 2000\n+    try:\n+        return date(year_int, int(month), int(day)).isoformat()\n+    except ValueError:\n+        return None\n+\n+\n+def _validate_spain_nif(value: str) -> bool:\n+    value = re.sub(r\"[\\s.-]\", \"\", value.upper())\n+    if not value:\n+        return False\n+\n+    if re.fullmatch(r\"\\d{8}[A-HJ-NP-TV-Z]\", value):\n+        letters = \"TRWAGMYFPDXBNJZSQVHLCKE\"\n+        return value[8] == letters[int(value[:8]) % 23]\n+\n+    if re.fullmatch(r\"[XYZ]\\d{7}[A-HJ-NP-TV-Z]\", value):\n+        prefix = {\"X\": \"0\", \"Y\": \"1\", \"Z\": \"2\"}[value[0]]\n+        letters = \"TRWAGMYFPDXBNJZSQVHLCKE\"\n+        num = prefix + value[1:8]\n+        return value[8] == letters[int(num) % 23]\n+\n+    if re.fullmatch(r\"[A-HJ-NP-SUVW]\\d{7}[0-9A-J]\", value):\n+        body, control = value[:8], value[8]\n+        digits = sum(int(ch) for ch in body[1:] if ch.isdigit())\n+        letter_map = \"JABCDEFGHI\"\n+        if body[0].isdigit():\n+            expected = str(digits % 10)\n+        elif re.fullmatch(r\"\\d{7}\", body[1:]):\n+            expected = letter_map[digits % 10]\n+        else:\n+            return False\n+        return control == expected\n+\n+    return False\n+\n+\n def _preprocess_image(image: Image.Image) -> Image.Image:\n     gray = image.convert(\"L\")\n     enhanced = ImageEnhance.Contrast(gray).enhance(2.0)\n@@ -120,76 +191,332 @@\n     return \"\", \"none\"\n \n \n+def _find_all_amounts(text: str) -> list[tuple[float, str]]:\n+    found: list[tuple[float, str]] = []\n+    patterns = [\n+        r\"(?:TOTAL|T\\.?\\s*A\\.?\\s*PAGAR|IMPORTE\\s+TOTAL|SUMA\\s+TOTAL|AMOUNT\\s+DUE)\\s*[:.]?\\s*([\\d.,]+)\\s*€?\",\n+        r\"(?:TOTAL\\s+FACTURA|TOTAL\\s+€)\\s*[:.]?\\s*([\\d.,]+)\",\n+        r\"€\\s*([\\d.,]+)\\s*(?:$|\\n)\",\n+    ]\n+    upper = text.upper()\n+    for pattern in patterns:\n+        for match in re.finditer(pattern, upper, re.I | re.M):\n+            amount = _parse_amount(match.group(1))\n+            if amount is not None and amount > 0:\n+                found.append((amount, match.group(0)[:80]))\n+    return found\n+\n+\n+def _classify_document_kind(text: str, doc_type_hint: str) -> str:\n+    upper = text.upper()\n+    if doc_type_hint == \"issued\":\n+        return \"issued_invoice\"\n+    if doc_type_hint == \"expense\":\n+        if re.search(r\"\\bTICKET|SIMPLIFICAD|TPV|RECIBO\\b\", upper):\n+            return \"simplified_ticket\"\n+        if re.search(r\"\\bFACTURA\\s+RECIBIDA|PROVEEDOR|COMPRA\\b\", upper):\n+            return \"expense_received\"\n+        if re.search(r\"\\bFACTURA\\s+EMITIDA|CLIENTE|VENTA\\b\", upper):\n+            return \"issued_invoice\"\n+        return \"expense_received\"\n+    if re.search(r\"\\bPROFORMA\\b\", upper):\n+        return \"proforma\"\n+    if re.search(r\"\\bFACTURA\\s+EMITIDA\\b\", upper):\n+        return \"issued_invoice\"\n+    if re.search(r\"\\bTICKET\\b\", upper):\n+        return \"simplified_ticket\"\n+    return \"expense_received\"\n+\n+\n def _extract_from_text(text: str, doc_type: str) -> dict[str, Any]:\n     text = text.replace(\"\\x0c\", \"\\n\")\n     upper = text.upper()\n \n-    cif_match = re.search(r\"\\b([A-HJ-NP-SUVW]\\d{7}[0-9A-J]|\\d{8}[A-HJ-NP-TV-Z])\\b\", upper)\n+    cif_match = re.search(\n+        r\"\\b(?:CIF|NIF|N\\.?I\\.?F\\.?|DNI|C\\.?I\\.?F\\.?)\\s*[:.]?\\s*([A-HJ-NP-SUVW]\\d{7}[0-9A-J]|\\d{8}[A-HJ-NP-TV-Z]|[XYZ]\\d{7}[A-HJ-NP-TV-Z])\\b\",\n+        upper,\n+    )\n+    if not cif_match:\n+        cif_match = re.search(r\"\\b([A-HJ-NP-SUVW]\\d{7}[0-9A-J]|\\d{8}[A-HJ-NP-TV-Z]|[XYZ]\\d{7}[A-HJ-NP-TV-Z])\\b\", upper)\n+\n     date_match = re.search(r\"\\b(\\d{1,2})[/.-](\\d{1,2})[/.-](\\d{2,4})\\b\", text)\n-    invoice_match = re.search(r\"(?:FACTURA|N[º°\\.]|NUM(?:ERO)?\\.?|FRA\\.?)\\s*[:#]?\\s*([A-Z0-9/-]+)\", upper)\n-    total_match = re.search(r\"(?:TOTAL|T\\.?\\s*A\\.?\\s*PAGAR|IMPORTE\\s+TOTAL)\\s*[:.]?\\s*([\\d.,]+)\", upper, re.I)\n-    base_match = re.search(r\"(?:BASE|BI|BASE\\s+IMPONIBLE)\\s*[:.]?\\s*([\\d.,]+)\", upper, re.I)\n-    iva_match = re.search(r\"(?:IVA|CUOTA\\s+IVA)\\s*[:.]?\\s*([\\d.,]+)\", upper, re.I)\n+    invoice_match = re.search(\n+        r\"(?:FACTURA|N[º°\\.]|NUM(?:ERO)?\\.?|FRA\\.?|Nº\\s*FACTURA)\\s*[:#]?\\s*([A-Z0-9/-]+)\",\n+        upper,\n+    )\n \n-    supplier_name = \"\"\n-    for line in [ln.strip() for ln in text.splitlines() if ln.strip()][:12]:\n-        if len(line) < 4 or re.search(r\"^\\d+[\\d\\s.,€]+$\", line):\n-            continue\n-        if re.search(r\"factura|ticket|cliente|fecha|total|iva|nif|cif\", line, re.I):\n-            continue\n-        supplier_name = line[:150]\n-        break\n+    total_candidates = _find_all_amounts(text)\n+    total_amount = total_candidates[-1][0] if total_candidates else None\n \n-    invoice_date = None\n-    if date_match:\n-        day, month, year = date_match.groups()\n-        year = int(year)\n-        if year < 100:\n-            year += 2000\n-        try:\n-            invoice_date = date(year, int(month), int(day)).isoformat()\n-        except ValueError:\n-            invoice_date = None\n+    base_match = re.search(\n+        r\"(?:BASE\\s+IMPONIBLE|BASE|BI|SUBTOTAL)\\s*[:.]?\\s*([\\d.,]+)\",\n+        upper,\n+        re.I,\n+    )\n+    iva_match = re.search(\n+        r\"(?:CUOTA\\s+IVA|IVA\\s*(?:21|10|4)?%?|I\\.V\\.A\\.?)\\s*[:.]?\\s*([\\d.,]+)\",\n+        upper,\n+        re.I,\n+    )\n+    irpf_match = re.search(\n+        r\"(?:IRPF|RETENCI[ÓO]N)\\s*(?:\\d+%)?\\s*[:.]?\\s*([\\d.,]+)\",\n+        upper,\n+        re.I,\n+    )\n \n-    total_amount = _parse_amount(total_match.group(1)) if total_match else None\n+    tax_rate = 21.0\n+    rate_match = re.search(r\"IVA\\s*(21|10|4)\\s*%\", upper)\n+    if rate_match:\n+        tax_rate = float(rate_match.group(1))\n+\n     base_amount = _parse_amount(base_match.group(1)) if base_match else None\n     tax_amount = _parse_amount(iva_match.group(1)) if iva_match else None\n+    irpf_amount = _parse_amount(irpf_match.group(1)) if irpf_match else None\n \n     if base_amount is None and total_amount and tax_amount:\n-        base_amount = round(total_amount - tax_amount, 2)\n+        base_amount = round(total_amount - tax_amount - (irpf_amount or 0), 2)\n     elif base_amount is None and total_amount:\n-        base_amount = round(total_amount / 1.21, 2)\n-        tax_amount = round(total_amount - base_amount, 2)\n+        base_amount = round(total_amount / (1 + tax_rate / 100), 2)\n+        tax_amount = round(total_amount - base_amount - (irpf_amount or 0), 2)\n \n-    confidence = 0.25\n+    supplier_name = \"\"\n+    for line in [ln.strip() for ln in text.splitlines() if ln.strip()][:15]:\n+        if len(line) < 4 or re.search(r\"^\\d+[\\d\\s.,€]+$\", line):\n+            continue\n+        if re.search(r\"factura|ticket|cliente|fecha|total|iva|nif|cif|base|importe\", line, re.I):\n+            continue\n+        supplier_name = line[:150]\n+        break\n+\n+    invoice_date = _normalize_date(*date_match.groups()) if date_match else None\n+    counterparty_cif = cif_match.group(1) if cif_match else \"\"\n+    document_kind = _classify_document_kind(text, doc_type)\n+\n+    confidence = 0.2\n     if len(text) > 50:\n-        confidence += 0.1\n-    if cif_match:\n-        confidence += 0.15\n+        confidence += 0.08\n+    if counterparty_cif and _validate_spain_nif(counterparty_cif):\n+        confidence += 0.18\n+    elif counterparty_cif:\n+        confidence += 0.08\n     if invoice_date:\n         confidence += 0.1\n     if total_amount:\n-        confidence += 0.2\n+        confidence += 0.22\n     if supplier_name:\n         confidence += 0.1\n     if base_amount:\n+        confidence += 0.07\n+    if invoice_match:\n         confidence += 0.05\n \n     return {\n+        \"document_kind\": document_kind,\n+        \"counterparty_name\": supplier_name,\n+        \"counterparty_cif\": counterparty_cif,\n+        \"counterparty_address\": \"\",\n         \"supplier_name\": supplier_name,\n-        \"supplier_cif\": cif_match.group(1) if cif_match else \"\",\n+        \"supplier_cif\": counterparty_cif,\n         \"invoice_number\": invoice_match.group(1) if invoice_match else \"\",\n+        \"invoice_series\": \"\",\n         \"invoice_date\": invoice_date or date.today().isoformat(),\n+        \"operation_date\": invoice_date,\n+        \"concept\": \"\",\n         \"base_amount\": base_amount,\n+        \"tax_rate\": tax_rate,\n         \"tax_amount\": tax_amount,\n+        \"irpf_rate\": None,\n+        \"irpf_amount\": irpf_amount,\n         \"total_amount\": total_amount,\n-        \"confidence\": min(confidence, 0.98),\n+        \"currency\": \"EUR\",\n+        \"payment_method\": \"\",\n+        \"is_rectifying\": bool(re.search(r\"\\bRECTIFICAT\", upper)),\n+        \"deductible\": document_kind in (\"expense_received\", \"simplified_ticket\"),\n+        \"confidence\": min(confidence, 0.92),\n         \"doc_type\": doc_type,\n-        \"notes\": \"Revisa los datos antes de confirmar.\",\n-        \"raw_text_preview\": text[:800],\n+        \"notes\": \"\",\n+        \"raw_text_preview\": text[:1200],\n     }\n \n \n+def _ollama_extract(\n+    text: str,\n+    doc_type: str,\n+    ollama_url: str,\n+    model: str,\n+) -> dict[str, Any] | None:\n+    url = ollama_url.rstrip(\"/\") + \"/api/chat\"\n+    kind_hint = \"factura recibida / gasto deducible\" if doc_type == \"expense\" else \"factura emitida / venta\"\n+\n+    system_prompt = (\n+        \"Eres un experto fiscal español (RD 1619/2012, Ley IVA, autónomos). \"\n+        \"Extrae datos del texto OCR de un documento contable español. \"\n+        \"Responde SOLO con JSON válido, sin markdown. \"\n+        \"Usa null para campos desconocidos. Importes en euros con 2 decimales. \"\n+        \"document_kind: expense_received, issued_invoice, simplified_ticket, proforma o unknown. \"\n+        \"Valida coherencia: base + IVA - IRPF ≈ total (±0.05€).\"\n+    )\n+    user_prompt = (\n+        f\"Tipo esperado: {kind_hint} (doc_type={doc_type}).\\n\"\n+        f\"Esquema JSON: {json.dumps(FISCAL_JSON_SCHEMA, ensure_ascii=False)}\\n\\n\"\n+        f\"TEXTO OCR:\\n{text[:6000]}\"\n+    )\n+\n+    payload = json.dumps({\n+        \"model\": model,\n+        \"messages\": [\n+            {\"role\": \"system\", \"content\": system_prompt},\n+            {\"role\": \"user\", \"content\": user_prompt},\n+        ],\n+        \"stream\": False,\n+        \"format\": \"json\",\n+        \"options\": {\"temperature\": 0.1},\n+    }).encode(\"utf-8\")\n+\n+    req = urllib.request.Request(\n+        url,\n+        data=payload,\n+        headers={\"Content-Type\": \"application/json\"},\n+        method=\"POST\",\n+    )\n+\n+    try:\n+        with urllib.request.urlopen(req, timeout=90) as resp:\n+            body = json.loads(resp.read().decode(\"utf-8\"))\n+    except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):\n+        return None\n+\n+    content = body.get(\"message\", {}).get(\"content\", \"\")\n+    if not content:\n+        return None\n+\n+    try:\n+        parsed = json.loads(content)\n+    except json.JSONDecodeError:\n+        match = re.search(r\"\\{.*\\}\", content, re.S)\n+        if not match:\n+            return None\n+        try:\n+            parsed = json.loads(match.group(0))\n+        except json.JSONDecodeError:\n+            return None\n+\n+    if not isinstance(parsed, dict):\n+        return None\n+\n+    return parsed\n+\n+\n+def _merge_extractions(base: dict[str, Any], llm: dict[str, Any] | None) -> dict[str, Any]:\n+    if not llm:\n+        return base\n+\n+    merged = dict(base)\n+    for key, value in llm.items():\n+        if value is None or value == \"\":\n+            continue\n+        merged[key] = value\n+\n+    name = merged.get(\"counterparty_name\") or merged.get(\"supplier_name\") or \"\"\n+    cif = merged.get(\"counterparty_cif\") or merged.get(\"supplier_cif\") or \"\"\n+    merged[\"supplier_name\"] = name\n+    merged[\"supplier_cif\"] = cif\n+    merged[\"counterparty_name\"] = name\n+    merged[\"counterparty_cif\"] = cif\n+\n+    if llm:\n+        merged[\"confidence\"] = min(float(merged.get(\"confidence\", 0.5)) + 0.12, 0.98)\n+        merged[\"ocr_engine\"] = (merged.get(\"ocr_engine\") or \"ocr\") + \"+ollama\"\n+\n+    return merged\n+\n+\n+def _validate_fiscal_data(data: dict[str, Any]) -> dict[str, Any]:\n+    errors: list[str] = []\n+    warnings: list[str] = []\n+    field_status: dict[str, str] = {}\n+\n+    required_expense = [\"counterparty_name\", \"invoice_date\", \"total_amount\"]\n+    required_issued = [\"counterparty_name\", \"invoice_number\", \"invoice_date\", \"total_amount\"]\n+\n+    doc_kind = data.get(\"document_kind\", \"expense_received\")\n+    is_issued = doc_kind == \"issued_invoice\" or data.get(\"doc_type\") == \"issued\"\n+    required = required_issued if is_issued else required_expense\n+\n+    for field in required:\n+        value = data.get(field) or data.get(field.replace(\"counterparty\", \"supplier\"))\n+        field_status[field] = \"ok\" if value not in (None, \"\", 0) else \"missing\"\n+        if field_status[field] == \"missing\":\n+            label = {\n+                \"counterparty_name\": \"Nombre emisor/receptor\",\n+                \"invoice_number\": \"Número de factura\",\n+                \"invoice_date\": \"Fecha\",\n+                \"total_amount\": \"Importe total\",\n+            }.get(field, field)\n+            errors.append(f\"Falta dato obligatorio: {label}.\")\n+\n+    cif = data.get(\"counterparty_cif\") or data.get(\"supplier_cif\") or \"\"\n+    if cif:\n+        if _validate_spain_nif(cif):\n+            field_status[\"counterparty_cif\"] = \"ok\"\n+        else:\n+            field_status[\"counterparty_cif\"] = \"review\"\n+            warnings.append(\"El NIF/CIF no supera la validación algorítmica. Revísalo.\")\n+    else:\n+        field_status[\"counterparty_cif\"] = \"review\"\n+        warnings.append(\"Sin NIF/CIF del emisor. Obligatorio en facturas completas (RD 1619/2012).\")\n+\n+    base = data.get(\"base_amount\")\n+    tax = data.get(\"tax_amount\") or 0\n+    irpf = data.get(\"irpf_amount\") or 0\n+    total = data.get(\"total_amount\")\n+\n+    if base and total:\n+        expected = round(float(base) + float(tax) - float(irpf), 2)\n+        diff = abs(expected - float(total))\n+        if diff <= 0.05:\n+            field_status[\"total_amount\"] = \"ok\"\n+        elif diff <= 0.5:\n+            field_status[\"total_amount\"] = \"review\"\n+            warnings.append(f\"Total ({total}€) no cuadra exactamente con base+IVA-IRPF ({expected}€).\")\n+        else:\n+            field_status[\"total_amount\"] = \"review\"\n+            warnings.append(f\"Incoherencia fiscal: base ({base}€) + IVA ({tax}€) - IRPF ({irpf}€) ≠ total ({total}€).\")\n+\n+    if not total:\n+        field_status[\"total_amount\"] = \"missing\"\n+\n+    tax_rate = data.get(\"tax_rate\")\n+    if tax_rate not in (21, 10, 4, 0, 21.0, 10.0, 4.0, 0.0, None):\n+        warnings.append(f\"Tipo de IVA inusual ({tax_rate}%). Verifica si aplica 21%, 10% o 4%.\")\n+\n+    if doc_kind == \"simplified_ticket\":\n+        warnings.append(\"Ticket simplificado: conserva el original; algunos datos pueden no ser exigibles.\")\n+\n+    if data.get(\"is_rectifying\"):\n+        warnings.append(\"Factura rectificativa detectada. Verifica referencia a la factura original.\")\n+\n+    compliance = 1.0\n+    compliance -= 0.25 * sum(1 for s in field_status.values() if s == \"missing\")\n+    compliance -= 0.1 * sum(1 for s in field_status.values() if s == \"review\")\n+    compliance = max(0.0, min(compliance, 1.0))\n+\n+    data[\"validation_errors\"] = errors\n+    data[\"validation_warnings\"] = warnings\n+    data[\"field_status\"] = field_status\n+    data[\"fiscal_compliance\"] = round(compliance, 2)\n+    data[\"warnings\"] = warnings\n+\n+    if errors:\n+        data[\"notes\"] = \"Revisa los campos marcados antes de confirmar. \" + (data.get(\"notes\") or \"\")\n+    elif warnings:\n+        data[\"notes\"] = \"Datos extraídos. Comprueba coherencia fiscal antes de confirmar.\"\n+    else:\n+        data[\"notes\"] = \"Datos fiscales coherentes. Confirma para registrar en contabilidad.\"\n+\n+    return data\n+\n+\n def _detect_document_kind(filename: str, content_type: str) -> str:\n     lower = (filename or \"\").lower()\n     ct = (content_type or \"\").lower()\n@@ -290,10 +617,12 @@\n     return {\n         \"status\": \"ok\",\n         \"service\": \"alimpiezas-ai\",\n+        \"version\": \"0.3.0\",\n         \"tesseract\": HAS_TESSERACT,\n         \"pymupdf\": HAS_PYMUPDF,\n         \"heif\": HAS_HEIF,\n         \"whisper\": bool(os.getenv(\"OPENAI_API_KEY\")),\n+        \"ollama_default\": DEFAULT_OLLAMA_URL,\n     }\n \n \n@@ -301,6 +630,9 @@\n async def extract_invoice(\n     file: UploadFile = File(...),\n     doc_type: str = Form(default=\"expense\"),\n+    mode: str = Form(default=\"ocr\"),\n+    ollama_url: str = Form(default=\"\"),\n+    ollama_model: str = Form(default=\"\"),\n ) -> dict[str, Any]:\n     filename = file.filename or \"documento\"\n     content_type = file.content_type or \"\"\n@@ -328,6 +660,19 @@\n \n     result = _extract_from_text(text, doc_type)\n     result[\"ocr_engine\"] = engine\n+\n+    if mode == \"ai\":\n+        llm_url = (ollama_url or DEFAULT_OLLAMA_URL).strip()\n+        llm_model = (ollama_model or DEFAULT_OLLAMA_MODEL).strip()\n+        llm_data = _ollama_extract(text, doc_type, llm_url, llm_model)\n+        result = _merge_extractions(result, llm_data)\n+        if llm_data is None:\n+            result.setdefault(\"validation_warnings\", []).append(\n+                \"Ollama no respondió; se usaron solo reglas OCR. Comprueba la red LAN (192.168.1.16:11434).\"\n+            )\n+\n+    result = _validate_fiscal_data(result)\n+    result[\"source_file\"] = filename\n     return result\n \n ","diffHtml":"<div class=\"diff-colored\"><div class=\"diff-line diff-meta\">--- a/C:\\xampp\\htdocs\\ALimpiezas\\ai-service\\main.py</div><div class=\"diff-line diff-meta\">+++ b/C:\\xampp\\htdocs\\ALimpiezas\\ai-service\\main.py</div><div class=\"diff-line diff-hunk\">@@ -1,12 +1,15 @@</div><div class=\"diff-line diff-del\">-\"\"\"Microservicio IA: OCR, voz e intenciones (ALimpiezas).\"\"\"</div><div class=\"diff-line diff-add\">+\"\"\"Microservicio IA: OCR, comprensión fiscal ES y Ollama (ALimpiezas).\"\"\"</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-ctx\"> from __future__ import annotations</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-ctx\"> import io</div><div class=\"diff-line diff-add\">+import json</div><div class=\"diff-line diff-ctx\"> import os</div><div class=\"diff-line diff-ctx\"> import re</div><div class=\"diff-line diff-ctx\"> import shutil</div><div class=\"diff-line diff-ctx\"> import tempfile</div><div class=\"diff-line diff-add\">+import urllib.error</div><div class=\"diff-line diff-add\">+import urllib.request</div><div class=\"diff-line diff-ctx\"> from datetime import date</div><div class=\"diff-line diff-ctx\"> from typing import Any</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-hunk\">@@ -20,7 +23,7 @@</div><div class=\"diff-line diff-ctx\">     text: str = \"\"</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-del\">-app = FastAPI(title=\"ALimpiezas AI Service\", version=\"0.2.0\")</div><div class=\"diff-line diff-add\">+app = FastAPI(title=\"ALimpiezas AI Service\", version=\"0.3.0\")</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-ctx\"> app.add_middleware(</div><div class=\"diff-line diff-ctx\">     CORSMiddleware,</div><div class=\"diff-line diff-hunk\">@@ -66,9 +69,37 @@</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-ctx\"> IMAGE_EXTENSIONS = (\".jpg\", \".jpeg\", \".png\", \".webp\", \".gif\", \".heic\", \".heif\", \".bmp\", \".tif\", \".tiff\")</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-add\">+DEFAULT_OLLAMA_URL = os.getenv(\"OLLAMA_URL\", \"http://192.168.1.16:11434\")</div><div class=\"diff-line diff-add\">+DEFAULT_OLLAMA_MODEL = os.getenv(\"OLLAMA_MODEL\", \"qwen2.5:7b\")</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-del\">-def _parse_amount(value: str) -&gt; float | None:</div><div class=\"diff-line diff-del\">-    cleaned = value.replace(\"€\", \"\").replace(\"EUR\", \"\").strip()</div><div class=\"diff-line diff-add\">+FISCAL_JSON_SCHEMA = {</div><div class=\"diff-line diff-add\">+    \"document_kind\": \"expense_received|issued_invoice|simplified_ticket|proforma|unknown\",</div><div class=\"diff-line diff-add\">+    \"counterparty_name\": \"string\",</div><div class=\"diff-line diff-add\">+    \"counterparty_cif\": \"string\",</div><div class=\"diff-line diff-add\">+    \"counterparty_address\": \"string\",</div><div class=\"diff-line diff-add\">+    \"invoice_number\": \"string\",</div><div class=\"diff-line diff-add\">+    \"invoice_series\": \"string\",</div><div class=\"diff-line diff-add\">+    \"invoice_date\": \"YYYY-MM-DD\",</div><div class=\"diff-line diff-add\">+    \"operation_date\": \"YYYY-MM-DD or null\",</div><div class=\"diff-line diff-add\">+    \"concept\": \"string\",</div><div class=\"diff-line diff-add\">+    \"base_amount\": \"number\",</div><div class=\"diff-line diff-add\">+    \"tax_rate\": \"21|10|4|0\",</div><div class=\"diff-line diff-add\">+    \"tax_amount\": \"number\",</div><div class=\"diff-line diff-add\">+    \"irpf_rate\": \"number or null\",</div><div class=\"diff-line diff-add\">+    \"irpf_amount\": \"number or null\",</div><div class=\"diff-line diff-add\">+    \"total_amount\": \"number\",</div><div class=\"diff-line diff-add\">+    \"currency\": \"EUR\",</div><div class=\"diff-line diff-add\">+    \"payment_method\": \"string\",</div><div class=\"diff-line diff-add\">+    \"is_rectifying\": \"boolean\",</div><div class=\"diff-line diff-add\">+    \"deductible\": \"boolean\",</div><div class=\"diff-line diff-add\">+    \"notes\": \"string\",</div><div class=\"diff-line diff-add\">+}</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+def _parse_amount(value: str | None) -&gt; float | None:</div><div class=\"diff-line diff-add\">+    if not value:</div><div class=\"diff-line diff-add\">+        return None</div><div class=\"diff-line diff-add\">+    cleaned = str(value).replace(\"€\", \"\").replace(\"EUR\", \"\").replace(\" \", \"\").strip()</div><div class=\"diff-line diff-ctx\">     if cleaned.count(\",\") == 1 and cleaned.count(\".\") &gt; 1:</div><div class=\"diff-line diff-ctx\">         cleaned = cleaned.replace(\".\", \"\")</div><div class=\"diff-line diff-ctx\">     cleaned = cleaned.replace(\",\", \".\")</div><div class=\"diff-line diff-hunk\">@@ -78,6 +109,46 @@</div><div class=\"diff-line diff-ctx\">         return None</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-add\">+def _normalize_date(day: str, month: str, year: str) -&gt; str | None:</div><div class=\"diff-line diff-add\">+    year_int = int(year)</div><div class=\"diff-line diff-add\">+    if year_int &lt; 100:</div><div class=\"diff-line diff-add\">+        year_int += 2000</div><div class=\"diff-line diff-add\">+    try:</div><div class=\"diff-line diff-add\">+        return date(year_int, int(month), int(day)).isoformat()</div><div class=\"diff-line diff-add\">+    except ValueError:</div><div class=\"diff-line diff-add\">+        return None</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+def _validate_spain_nif(value: str) -&gt; bool:</div><div class=\"diff-line diff-add\">+    value = re.sub(r\"[\\s.-]\", \"\", value.upper())</div><div class=\"diff-line diff-add\">+    if not value:</div><div class=\"diff-line diff-add\">+        return False</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    if re.fullmatch(r\"\\d{8}[A-HJ-NP-TV-Z]\", value):</div><div class=\"diff-line diff-add\">+        letters = \"TRWAGMYFPDXBNJZSQVHLCKE\"</div><div class=\"diff-line diff-add\">+        return value[8] == letters[int(value[:8]) % 23]</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    if re.fullmatch(r\"[XYZ]\\d{7}[A-HJ-NP-TV-Z]\", value):</div><div class=\"diff-line diff-add\">+        prefix = {\"X\": \"0\", \"Y\": \"1\", \"Z\": \"2\"}[value[0]]</div><div class=\"diff-line diff-add\">+        letters = \"TRWAGMYFPDXBNJZSQVHLCKE\"</div><div class=\"diff-line diff-add\">+        num = prefix + value[1:8]</div><div class=\"diff-line diff-add\">+        return value[8] == letters[int(num) % 23]</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    if re.fullmatch(r\"[A-HJ-NP-SUVW]\\d{7}[0-9A-J]\", value):</div><div class=\"diff-line diff-add\">+        body, control = value[:8], value[8]</div><div class=\"diff-line diff-add\">+        digits = sum(int(ch) for ch in body[1:] if ch.isdigit())</div><div class=\"diff-line diff-add\">+        letter_map = \"JABCDEFGHI\"</div><div class=\"diff-line diff-add\">+        if body[0].isdigit():</div><div class=\"diff-line diff-add\">+            expected = str(digits % 10)</div><div class=\"diff-line diff-add\">+        elif re.fullmatch(r\"\\d{7}\", body[1:]):</div><div class=\"diff-line diff-add\">+            expected = letter_map[digits % 10]</div><div class=\"diff-line diff-add\">+        else:</div><div class=\"diff-line diff-add\">+            return False</div><div class=\"diff-line diff-add\">+        return control == expected</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    return False</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-ctx\"> def _preprocess_image(image: Image.Image) -&gt; Image.Image:</div><div class=\"diff-line diff-ctx\">     gray = image.convert(\"L\")</div><div class=\"diff-line diff-ctx\">     enhanced = ImageEnhance.Contrast(gray).enhance(2.0)</div><div class=\"diff-line diff-hunk\">@@ -120,76 +191,332 @@</div><div class=\"diff-line diff-ctx\">     return \"\", \"none\"</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-add\">+def _find_all_amounts(text: str) -&gt; list[tuple[float, str]]:</div><div class=\"diff-line diff-add\">+    found: list[tuple[float, str]] = []</div><div class=\"diff-line diff-add\">+    patterns = [</div><div class=\"diff-line diff-add\">+        r\"(?:TOTAL|T\\.?\\s*A\\.?\\s*PAGAR|IMPORTE\\s+TOTAL|SUMA\\s+TOTAL|AMOUNT\\s+DUE)\\s*[:.]?\\s*([\\d.,]+)\\s*€?\",</div><div class=\"diff-line diff-add\">+        r\"(?:TOTAL\\s+FACTURA|TOTAL\\s+€)\\s*[:.]?\\s*([\\d.,]+)\",</div><div class=\"diff-line diff-add\">+        r\"€\\s*([\\d.,]+)\\s*(?:$|\\n)\",</div><div class=\"diff-line diff-add\">+    ]</div><div class=\"diff-line diff-add\">+    upper = text.upper()</div><div class=\"diff-line diff-add\">+    for pattern in patterns:</div><div class=\"diff-line diff-add\">+        for match in re.finditer(pattern, upper, re.I | re.M):</div><div class=\"diff-line diff-add\">+            amount = _parse_amount(match.group(1))</div><div class=\"diff-line diff-add\">+            if amount is not None and amount &gt; 0:</div><div class=\"diff-line diff-add\">+                found.append((amount, match.group(0)[:80]))</div><div class=\"diff-line diff-add\">+    return found</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+def _classify_document_kind(text: str, doc_type_hint: str) -&gt; str:</div><div class=\"diff-line diff-add\">+    upper = text.upper()</div><div class=\"diff-line diff-add\">+    if doc_type_hint == \"issued\":</div><div class=\"diff-line diff-add\">+        return \"issued_invoice\"</div><div class=\"diff-line diff-add\">+    if doc_type_hint == \"expense\":</div><div class=\"diff-line diff-add\">+        if re.search(r\"\\bTICKET|SIMPLIFICAD|TPV|RECIBO\\b\", upper):</div><div class=\"diff-line diff-add\">+            return \"simplified_ticket\"</div><div class=\"diff-line diff-add\">+        if re.search(r\"\\bFACTURA\\s+RECIBIDA|PROVEEDOR|COMPRA\\b\", upper):</div><div class=\"diff-line diff-add\">+            return \"expense_received\"</div><div class=\"diff-line diff-add\">+        if re.search(r\"\\bFACTURA\\s+EMITIDA|CLIENTE|VENTA\\b\", upper):</div><div class=\"diff-line diff-add\">+            return \"issued_invoice\"</div><div class=\"diff-line diff-add\">+        return \"expense_received\"</div><div class=\"diff-line diff-add\">+    if re.search(r\"\\bPROFORMA\\b\", upper):</div><div class=\"diff-line diff-add\">+        return \"proforma\"</div><div class=\"diff-line diff-add\">+    if re.search(r\"\\bFACTURA\\s+EMITIDA\\b\", upper):</div><div class=\"diff-line diff-add\">+        return \"issued_invoice\"</div><div class=\"diff-line diff-add\">+    if re.search(r\"\\bTICKET\\b\", upper):</div><div class=\"diff-line diff-add\">+        return \"simplified_ticket\"</div><div class=\"diff-line diff-add\">+    return \"expense_received\"</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-ctx\"> def _extract_from_text(text: str, doc_type: str) -&gt; dict[str, Any]:</div><div class=\"diff-line diff-ctx\">     text = text.replace(\"\\x0c\", \"\\n\")</div><div class=\"diff-line diff-ctx\">     upper = text.upper()</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-del\">-    cif_match = re.search(r\"\\b([A-HJ-NP-SUVW]\\d{7}[0-9A-J]|\\d{8}[A-HJ-NP-TV-Z])\\b\", upper)</div><div class=\"diff-line diff-add\">+    cif_match = re.search(</div><div class=\"diff-line diff-add\">+        r\"\\b(?:CIF|NIF|N\\.?I\\.?F\\.?|DNI|C\\.?I\\.?F\\.?)\\s*[:.]?\\s*([A-HJ-NP-SUVW]\\d{7}[0-9A-J]|\\d{8}[A-HJ-NP-TV-Z]|[XYZ]\\d{7}[A-HJ-NP-TV-Z])\\b\",</div><div class=\"diff-line diff-add\">+        upper,</div><div class=\"diff-line diff-add\">+    )</div><div class=\"diff-line diff-add\">+    if not cif_match:</div><div class=\"diff-line diff-add\">+        cif_match = re.search(r\"\\b([A-HJ-NP-SUVW]\\d{7}[0-9A-J]|\\d{8}[A-HJ-NP-TV-Z]|[XYZ]\\d{7}[A-HJ-NP-TV-Z])\\b\", upper)</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-ctx\">     date_match = re.search(r\"\\b(\\d{1,2})[/.-](\\d{1,2})[/.-](\\d{2,4})\\b\", text)</div><div class=\"diff-line diff-del\">-    invoice_match = re.search(r\"(?:FACTURA|N[º°\\.]|NUM(?:ERO)?\\.?|FRA\\.?)\\s*[:#]?\\s*([A-Z0-9/-]+)\", upper)</div><div class=\"diff-line diff-del\">-    total_match = re.search(r\"(?:TOTAL|T\\.?\\s*A\\.?\\s*PAGAR|IMPORTE\\s+TOTAL)\\s*[:.]?\\s*([\\d.,]+)\", upper, re.I)</div><div class=\"diff-line diff-del\">-    base_match = re.search(r\"(?:BASE|BI|BASE\\s+IMPONIBLE)\\s*[:.]?\\s*([\\d.,]+)\", upper, re.I)</div><div class=\"diff-line diff-del\">-    iva_match = re.search(r\"(?:IVA|CUOTA\\s+IVA)\\s*[:.]?\\s*([\\d.,]+)\", upper, re.I)</div><div class=\"diff-line diff-add\">+    invoice_match = re.search(</div><div class=\"diff-line diff-add\">+        r\"(?:FACTURA|N[º°\\.]|NUM(?:ERO)?\\.?|FRA\\.?|Nº\\s*FACTURA)\\s*[:#]?\\s*([A-Z0-9/-]+)\",</div><div class=\"diff-line diff-add\">+        upper,</div><div class=\"diff-line diff-add\">+    )</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-del\">-    supplier_name = \"\"</div><div class=\"diff-line diff-del\">-    for line in [ln.strip() for ln in text.splitlines() if ln.strip()][:12]:</div><div class=\"diff-line diff-del\">-        if len(line) &lt; 4 or re.search(r\"^\\d+[\\d\\s.,€]+$\", line):</div><div class=\"diff-line diff-del\">-            continue</div><div class=\"diff-line diff-del\">-        if re.search(r\"factura|ticket|cliente|fecha|total|iva|nif|cif\", line, re.I):</div><div class=\"diff-line diff-del\">-            continue</div><div class=\"diff-line diff-del\">-        supplier_name = line[:150]</div><div class=\"diff-line diff-del\">-        break</div><div class=\"diff-line diff-add\">+    total_candidates = _find_all_amounts(text)</div><div class=\"diff-line diff-add\">+    total_amount = total_candidates[-1][0] if total_candidates else None</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-del\">-    invoice_date = None</div><div class=\"diff-line diff-del\">-    if date_match:</div><div class=\"diff-line diff-del\">-        day, month, year = date_match.groups()</div><div class=\"diff-line diff-del\">-        year = int(year)</div><div class=\"diff-line diff-del\">-        if year &lt; 100:</div><div class=\"diff-line diff-del\">-            year += 2000</div><div class=\"diff-line diff-del\">-        try:</div><div class=\"diff-line diff-del\">-            invoice_date = date(year, int(month), int(day)).isoformat()</div><div class=\"diff-line diff-del\">-        except ValueError:</div><div class=\"diff-line diff-del\">-            invoice_date = None</div><div class=\"diff-line diff-add\">+    base_match = re.search(</div><div class=\"diff-line diff-add\">+        r\"(?:BASE\\s+IMPONIBLE|BASE|BI|SUBTOTAL)\\s*[:.]?\\s*([\\d.,]+)\",</div><div class=\"diff-line diff-add\">+        upper,</div><div class=\"diff-line diff-add\">+        re.I,</div><div class=\"diff-line diff-add\">+    )</div><div class=\"diff-line diff-add\">+    iva_match = re.search(</div><div class=\"diff-line diff-add\">+        r\"(?:CUOTA\\s+IVA|IVA\\s*(?:21|10|4)?%?|I\\.V\\.A\\.?)\\s*[:.]?\\s*([\\d.,]+)\",</div><div class=\"diff-line diff-add\">+        upper,</div><div class=\"diff-line diff-add\">+        re.I,</div><div class=\"diff-line diff-add\">+    )</div><div class=\"diff-line diff-add\">+    irpf_match = re.search(</div><div class=\"diff-line diff-add\">+        r\"(?:IRPF|RETENCI[ÓO]N)\\s*(?:\\d+%)?\\s*[:.]?\\s*([\\d.,]+)\",</div><div class=\"diff-line diff-add\">+        upper,</div><div class=\"diff-line diff-add\">+        re.I,</div><div class=\"diff-line diff-add\">+    )</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-del\">-    total_amount = _parse_amount(total_match.group(1)) if total_match else None</div><div class=\"diff-line diff-add\">+    tax_rate = 21.0</div><div class=\"diff-line diff-add\">+    rate_match = re.search(r\"IVA\\s*(21|10|4)\\s*%\", upper)</div><div class=\"diff-line diff-add\">+    if rate_match:</div><div class=\"diff-line diff-add\">+        tax_rate = float(rate_match.group(1))</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-ctx\">     base_amount = _parse_amount(base_match.group(1)) if base_match else None</div><div class=\"diff-line diff-ctx\">     tax_amount = _parse_amount(iva_match.group(1)) if iva_match else None</div><div class=\"diff-line diff-add\">+    irpf_amount = _parse_amount(irpf_match.group(1)) if irpf_match else None</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-ctx\">     if base_amount is None and total_amount and tax_amount:</div><div class=\"diff-line diff-del\">-        base_amount = round(total_amount - tax_amount, 2)</div><div class=\"diff-line diff-add\">+        base_amount = round(total_amount - tax_amount - (irpf_amount or 0), 2)</div><div class=\"diff-line diff-ctx\">     elif base_amount is None and total_amount:</div><div class=\"diff-line diff-del\">-        base_amount = round(total_amount / 1.21, 2)</div><div class=\"diff-line diff-del\">-        tax_amount = round(total_amount - base_amount, 2)</div><div class=\"diff-line diff-add\">+        base_amount = round(total_amount / (1 + tax_rate / 100), 2)</div><div class=\"diff-line diff-add\">+        tax_amount = round(total_amount - base_amount - (irpf_amount or 0), 2)</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-del\">-    confidence = 0.25</div><div class=\"diff-line diff-add\">+    supplier_name = \"\"</div><div class=\"diff-line diff-add\">+    for line in [ln.strip() for ln in text.splitlines() if ln.strip()][:15]:</div><div class=\"diff-line diff-add\">+        if len(line) &lt; 4 or re.search(r\"^\\d+[\\d\\s.,€]+$\", line):</div><div class=\"diff-line diff-add\">+            continue</div><div class=\"diff-line diff-add\">+        if re.search(r\"factura|ticket|cliente|fecha|total|iva|nif|cif|base|importe\", line, re.I):</div><div class=\"diff-line diff-add\">+            continue</div><div class=\"diff-line diff-add\">+        supplier_name = line[:150]</div><div class=\"diff-line diff-add\">+        break</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    invoice_date = _normalize_date(*date_match.groups()) if date_match else None</div><div class=\"diff-line diff-add\">+    counterparty_cif = cif_match.group(1) if cif_match else \"\"</div><div class=\"diff-line diff-add\">+    document_kind = _classify_document_kind(text, doc_type)</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    confidence = 0.2</div><div class=\"diff-line diff-ctx\">     if len(text) &gt; 50:</div><div class=\"diff-line diff-del\">-        confidence += 0.1</div><div class=\"diff-line diff-del\">-    if cif_match:</div><div class=\"diff-line diff-del\">-        confidence += 0.15</div><div class=\"diff-line diff-add\">+        confidence += 0.08</div><div class=\"diff-line diff-add\">+    if counterparty_cif and _validate_spain_nif(counterparty_cif):</div><div class=\"diff-line diff-add\">+        confidence += 0.18</div><div class=\"diff-line diff-add\">+    elif counterparty_cif:</div><div class=\"diff-line diff-add\">+        confidence += 0.08</div><div class=\"diff-line diff-ctx\">     if invoice_date:</div><div class=\"diff-line diff-ctx\">         confidence += 0.1</div><div class=\"diff-line diff-ctx\">     if total_amount:</div><div class=\"diff-line diff-del\">-        confidence += 0.2</div><div class=\"diff-line diff-add\">+        confidence += 0.22</div><div class=\"diff-line diff-ctx\">     if supplier_name:</div><div class=\"diff-line diff-ctx\">         confidence += 0.1</div><div class=\"diff-line diff-ctx\">     if base_amount:</div><div class=\"diff-line diff-add\">+        confidence += 0.07</div><div class=\"diff-line diff-add\">+    if invoice_match:</div><div class=\"diff-line diff-ctx\">         confidence += 0.05</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-ctx\">     return {</div><div class=\"diff-line diff-add\">+        \"document_kind\": document_kind,</div><div class=\"diff-line diff-add\">+        \"counterparty_name\": supplier_name,</div><div class=\"diff-line diff-add\">+        \"counterparty_cif\": counterparty_cif,</div><div class=\"diff-line diff-add\">+        \"counterparty_address\": \"\",</div><div class=\"diff-line diff-ctx\">         \"supplier_name\": supplier_name,</div><div class=\"diff-line diff-del\">-        \"supplier_cif\": cif_match.group(1) if cif_match else \"\",</div><div class=\"diff-line diff-add\">+        \"supplier_cif\": counterparty_cif,</div><div class=\"diff-line diff-ctx\">         \"invoice_number\": invoice_match.group(1) if invoice_match else \"\",</div><div class=\"diff-line diff-add\">+        \"invoice_series\": \"\",</div><div class=\"diff-line diff-ctx\">         \"invoice_date\": invoice_date or date.today().isoformat(),</div><div class=\"diff-line diff-add\">+        \"operation_date\": invoice_date,</div><div class=\"diff-line diff-add\">+        \"concept\": \"\",</div><div class=\"diff-line diff-ctx\">         \"base_amount\": base_amount,</div><div class=\"diff-line diff-add\">+        \"tax_rate\": tax_rate,</div><div class=\"diff-line diff-ctx\">         \"tax_amount\": tax_amount,</div><div class=\"diff-line diff-add\">+        \"irpf_rate\": None,</div><div class=\"diff-line diff-add\">+        \"irpf_amount\": irpf_amount,</div><div class=\"diff-line diff-ctx\">         \"total_amount\": total_amount,</div><div class=\"diff-line diff-del\">-        \"confidence\": min(confidence, 0.98),</div><div class=\"diff-line diff-add\">+        \"currency\": \"EUR\",</div><div class=\"diff-line diff-add\">+        \"payment_method\": \"\",</div><div class=\"diff-line diff-add\">+        \"is_rectifying\": bool(re.search(r\"\\bRECTIFICAT\", upper)),</div><div class=\"diff-line diff-add\">+        \"deductible\": document_kind in (\"expense_received\", \"simplified_ticket\"),</div><div class=\"diff-line diff-add\">+        \"confidence\": min(confidence, 0.92),</div><div class=\"diff-line diff-ctx\">         \"doc_type\": doc_type,</div><div class=\"diff-line diff-del\">-        \"notes\": \"Revisa los datos antes de confirmar.\",</div><div class=\"diff-line diff-del\">-        \"raw_text_preview\": text[:800],</div><div class=\"diff-line diff-add\">+        \"notes\": \"\",</div><div class=\"diff-line diff-add\">+        \"raw_text_preview\": text[:1200],</div><div class=\"diff-line diff-ctx\">     }</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-add\">+def _ollama_extract(</div><div class=\"diff-line diff-add\">+    text: str,</div><div class=\"diff-line diff-add\">+    doc_type: str,</div><div class=\"diff-line diff-add\">+    ollama_url: str,</div><div class=\"diff-line diff-add\">+    model: str,</div><div class=\"diff-line diff-add\">+) -&gt; dict[str, Any] | None:</div><div class=\"diff-line diff-add\">+    url = ollama_url.rstrip(\"/\") + \"/api/chat\"</div><div class=\"diff-line diff-add\">+    kind_hint = \"factura recibida / gasto deducible\" if doc_type == \"expense\" else \"factura emitida / venta\"</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    system_prompt = (</div><div class=\"diff-line diff-add\">+        \"Eres un experto fiscal español (RD 1619/2012, Ley IVA, autónomos). \"</div><div class=\"diff-line diff-add\">+        \"Extrae datos del texto OCR de un documento contable español. \"</div><div class=\"diff-line diff-add\">+        \"Responde SOLO con JSON válido, sin markdown. \"</div><div class=\"diff-line diff-add\">+        \"Usa null para campos desconocidos. Importes en euros con 2 decimales. \"</div><div class=\"diff-line diff-add\">+        \"document_kind: expense_received, issued_invoice, simplified_ticket, proforma o unknown. \"</div><div class=\"diff-line diff-add\">+        \"Valida coherencia: base + IVA - IRPF ≈ total (±0.05€).\"</div><div class=\"diff-line diff-add\">+    )</div><div class=\"diff-line diff-add\">+    user_prompt = (</div><div class=\"diff-line diff-add\">+        f\"Tipo esperado: {kind_hint} (doc_type={doc_type}).\\n\"</div><div class=\"diff-line diff-add\">+        f\"Esquema JSON: {json.dumps(FISCAL_JSON_SCHEMA, ensure_ascii=False)}\\n\\n\"</div><div class=\"diff-line diff-add\">+        f\"TEXTO OCR:\\n{text[:6000]}\"</div><div class=\"diff-line diff-add\">+    )</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    payload = json.dumps({</div><div class=\"diff-line diff-add\">+        \"model\": model,</div><div class=\"diff-line diff-add\">+        \"messages\": [</div><div class=\"diff-line diff-add\">+            {\"role\": \"system\", \"content\": system_prompt},</div><div class=\"diff-line diff-add\">+            {\"role\": \"user\", \"content\": user_prompt},</div><div class=\"diff-line diff-add\">+        ],</div><div class=\"diff-line diff-add\">+        \"stream\": False,</div><div class=\"diff-line diff-add\">+        \"format\": \"json\",</div><div class=\"diff-line diff-add\">+        \"options\": {\"temperature\": 0.1},</div><div class=\"diff-line diff-add\">+    }).encode(\"utf-8\")</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    req = urllib.request.Request(</div><div class=\"diff-line diff-add\">+        url,</div><div class=\"diff-line diff-add\">+        data=payload,</div><div class=\"diff-line diff-add\">+        headers={\"Content-Type\": \"application/json\"},</div><div class=\"diff-line diff-add\">+        method=\"POST\",</div><div class=\"diff-line diff-add\">+    )</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    try:</div><div class=\"diff-line diff-add\">+        with urllib.request.urlopen(req, timeout=90) as resp:</div><div class=\"diff-line diff-add\">+            body = json.loads(resp.read().decode(\"utf-8\"))</div><div class=\"diff-line diff-add\">+    except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):</div><div class=\"diff-line diff-add\">+        return None</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    content = body.get(\"message\", {}).get(\"content\", \"\")</div><div class=\"diff-line diff-add\">+    if not content:</div><div class=\"diff-line diff-add\">+        return None</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    try:</div><div class=\"diff-line diff-add\">+        parsed = json.loads(content)</div><div class=\"diff-line diff-add\">+    except json.JSONDecodeError:</div><div class=\"diff-line diff-add\">+        match = re.search(r\"\\{.*\\}\", content, re.S)</div><div class=\"diff-line diff-add\">+        if not match:</div><div class=\"diff-line diff-add\">+            return None</div><div class=\"diff-line diff-add\">+        try:</div><div class=\"diff-line diff-add\">+            parsed = json.loads(match.group(0))</div><div class=\"diff-line diff-add\">+        except json.JSONDecodeError:</div><div class=\"diff-line diff-add\">+            return None</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    if not isinstance(parsed, dict):</div><div class=\"diff-line diff-add\">+        return None</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    return parsed</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+def _merge_extractions(base: dict[str, Any], llm: dict[str, Any] | None) -&gt; dict[str, Any]:</div><div class=\"diff-line diff-add\">+    if not llm:</div><div class=\"diff-line diff-add\">+        return base</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    merged = dict(base)</div><div class=\"diff-line diff-add\">+    for key, value in llm.items():</div><div class=\"diff-line diff-add\">+        if value is None or value == \"\":</div><div class=\"diff-line diff-add\">+            continue</div><div class=\"diff-line diff-add\">+        merged[key] = value</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    name = merged.get(\"counterparty_name\") or merged.get(\"supplier_name\") or \"\"</div><div class=\"diff-line diff-add\">+    cif = merged.get(\"counterparty_cif\") or merged.get(\"supplier_cif\") or \"\"</div><div class=\"diff-line diff-add\">+    merged[\"supplier_name\"] = name</div><div class=\"diff-line diff-add\">+    merged[\"supplier_cif\"] = cif</div><div class=\"diff-line diff-add\">+    merged[\"counterparty_name\"] = name</div><div class=\"diff-line diff-add\">+    merged[\"counterparty_cif\"] = cif</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    if llm:</div><div class=\"diff-line diff-add\">+        merged[\"confidence\"] = min(float(merged.get(\"confidence\", 0.5)) + 0.12, 0.98)</div><div class=\"diff-line diff-add\">+        merged[\"ocr_engine\"] = (merged.get(\"ocr_engine\") or \"ocr\") + \"+ollama\"</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    return merged</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+def _validate_fiscal_data(data: dict[str, Any]) -&gt; dict[str, Any]:</div><div class=\"diff-line diff-add\">+    errors: list[str] = []</div><div class=\"diff-line diff-add\">+    warnings: list[str] = []</div><div class=\"diff-line diff-add\">+    field_status: dict[str, str] = {}</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    required_expense = [\"counterparty_name\", \"invoice_date\", \"total_amount\"]</div><div class=\"diff-line diff-add\">+    required_issued = [\"counterparty_name\", \"invoice_number\", \"invoice_date\", \"total_amount\"]</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    doc_kind = data.get(\"document_kind\", \"expense_received\")</div><div class=\"diff-line diff-add\">+    is_issued = doc_kind == \"issued_invoice\" or data.get(\"doc_type\") == \"issued\"</div><div class=\"diff-line diff-add\">+    required = required_issued if is_issued else required_expense</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    for field in required:</div><div class=\"diff-line diff-add\">+        value = data.get(field) or data.get(field.replace(\"counterparty\", \"supplier\"))</div><div class=\"diff-line diff-add\">+        field_status[field] = \"ok\" if value not in (None, \"\", 0) else \"missing\"</div><div class=\"diff-line diff-add\">+        if field_status[field] == \"missing\":</div><div class=\"diff-line diff-add\">+            label = {</div><div class=\"diff-line diff-add\">+                \"counterparty_name\": \"Nombre emisor/receptor\",</div><div class=\"diff-line diff-add\">+                \"invoice_number\": \"Número de factura\",</div><div class=\"diff-line diff-add\">+                \"invoice_date\": \"Fecha\",</div><div class=\"diff-line diff-add\">+                \"total_amount\": \"Importe total\",</div><div class=\"diff-line diff-add\">+            }.get(field, field)</div><div class=\"diff-line diff-add\">+            errors.append(f\"Falta dato obligatorio: {label}.\")</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    cif = data.get(\"counterparty_cif\") or data.get(\"supplier_cif\") or \"\"</div><div class=\"diff-line diff-add\">+    if cif:</div><div class=\"diff-line diff-add\">+        if _validate_spain_nif(cif):</div><div class=\"diff-line diff-add\">+            field_status[\"counterparty_cif\"] = \"ok\"</div><div class=\"diff-line diff-add\">+        else:</div><div class=\"diff-line diff-add\">+            field_status[\"counterparty_cif\"] = \"review\"</div><div class=\"diff-line diff-add\">+            warnings.append(\"El NIF/CIF no supera la validación algorítmica. Revísalo.\")</div><div class=\"diff-line diff-add\">+    else:</div><div class=\"diff-line diff-add\">+        field_status[\"counterparty_cif\"] = \"review\"</div><div class=\"diff-line diff-add\">+        warnings.append(\"Sin NIF/CIF del emisor. Obligatorio en facturas completas (RD 1619/2012).\")</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    base = data.get(\"base_amount\")</div><div class=\"diff-line diff-add\">+    tax = data.get(\"tax_amount\") or 0</div><div class=\"diff-line diff-add\">+    irpf = data.get(\"irpf_amount\") or 0</div><div class=\"diff-line diff-add\">+    total = data.get(\"total_amount\")</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    if base and total:</div><div class=\"diff-line diff-add\">+        expected = round(float(base) + float(tax) - float(irpf), 2)</div><div class=\"diff-line diff-add\">+        diff = abs(expected - float(total))</div><div class=\"diff-line diff-add\">+        if diff &lt;= 0.05:</div><div class=\"diff-line diff-add\">+            field_status[\"total_amount\"] = \"ok\"</div><div class=\"diff-line diff-add\">+        elif diff &lt;= 0.5:</div><div class=\"diff-line diff-add\">+            field_status[\"total_amount\"] = \"review\"</div><div class=\"diff-line diff-add\">+            warnings.append(f\"Total ({total}€) no cuadra exactamente con base+IVA-IRPF ({expected}€).\")</div><div class=\"diff-line diff-add\">+        else:</div><div class=\"diff-line diff-add\">+            field_status[\"total_amount\"] = \"review\"</div><div class=\"diff-line diff-add\">+            warnings.append(f\"Incoherencia fiscal: base ({base}€) + IVA ({tax}€) - IRPF ({irpf}€) ≠ total ({total}€).\")</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    if not total:</div><div class=\"diff-line diff-add\">+        field_status[\"total_amount\"] = \"missing\"</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    tax_rate = data.get(\"tax_rate\")</div><div class=\"diff-line diff-add\">+    if tax_rate not in (21, 10, 4, 0, 21.0, 10.0, 4.0, 0.0, None):</div><div class=\"diff-line diff-add\">+        warnings.append(f\"Tipo de IVA inusual ({tax_rate}%). Verifica si aplica 21%, 10% o 4%.\")</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    if doc_kind == \"simplified_ticket\":</div><div class=\"diff-line diff-add\">+        warnings.append(\"Ticket simplificado: conserva el original; algunos datos pueden no ser exigibles.\")</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    if data.get(\"is_rectifying\"):</div><div class=\"diff-line diff-add\">+        warnings.append(\"Factura rectificativa detectada. Verifica referencia a la factura original.\")</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    compliance = 1.0</div><div class=\"diff-line diff-add\">+    compliance -= 0.25 * sum(1 for s in field_status.values() if s == \"missing\")</div><div class=\"diff-line diff-add\">+    compliance -= 0.1 * sum(1 for s in field_status.values() if s == \"review\")</div><div class=\"diff-line diff-add\">+    compliance = max(0.0, min(compliance, 1.0))</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    data[\"validation_errors\"] = errors</div><div class=\"diff-line diff-add\">+    data[\"validation_warnings\"] = warnings</div><div class=\"diff-line diff-add\">+    data[\"field_status\"] = field_status</div><div class=\"diff-line diff-add\">+    data[\"fiscal_compliance\"] = round(compliance, 2)</div><div class=\"diff-line diff-add\">+    data[\"warnings\"] = warnings</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    if errors:</div><div class=\"diff-line diff-add\">+        data[\"notes\"] = \"Revisa los campos marcados antes de confirmar. \" + (data.get(\"notes\") or \"\")</div><div class=\"diff-line diff-add\">+    elif warnings:</div><div class=\"diff-line diff-add\">+        data[\"notes\"] = \"Datos extraídos. Comprueba coherencia fiscal antes de confirmar.\"</div><div class=\"diff-line diff-add\">+    else:</div><div class=\"diff-line diff-add\">+        data[\"notes\"] = \"Datos fiscales coherentes. Confirma para registrar en contabilidad.\"</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    return data</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-ctx\"> def _detect_document_kind(filename: str, content_type: str) -&gt; str:</div><div class=\"diff-line diff-ctx\">     lower = (filename or \"\").lower()</div><div class=\"diff-line diff-ctx\">     ct = (content_type or \"\").lower()</div><div class=\"diff-line diff-hunk\">@@ -290,10 +617,12 @@</div><div class=\"diff-line diff-ctx\">     return {</div><div class=\"diff-line diff-ctx\">         \"status\": \"ok\",</div><div class=\"diff-line diff-ctx\">         \"service\": \"alimpiezas-ai\",</div><div class=\"diff-line diff-add\">+        \"version\": \"0.3.0\",</div><div class=\"diff-line diff-ctx\">         \"tesseract\": HAS_TESSERACT,</div><div class=\"diff-line diff-ctx\">         \"pymupdf\": HAS_PYMUPDF,</div><div class=\"diff-line diff-ctx\">         \"heif\": HAS_HEIF,</div><div class=\"diff-line diff-ctx\">         \"whisper\": bool(os.getenv(\"OPENAI_API_KEY\")),</div><div class=\"diff-line diff-add\">+        \"ollama_default\": DEFAULT_OLLAMA_URL,</div><div class=\"diff-line diff-ctx\">     }</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-hunk\">@@ -301,6 +630,9 @@</div><div class=\"diff-line diff-ctx\"> async def extract_invoice(</div><div class=\"diff-line diff-ctx\">     file: UploadFile = File(...),</div><div class=\"diff-line diff-ctx\">     doc_type: str = Form(default=\"expense\"),</div><div class=\"diff-line diff-add\">+    mode: str = Form(default=\"ocr\"),</div><div class=\"diff-line diff-add\">+    ollama_url: str = Form(default=\"\"),</div><div class=\"diff-line diff-add\">+    ollama_model: str = Form(default=\"\"),</div><div class=\"diff-line diff-ctx\"> ) -&gt; dict[str, Any]:</div><div class=\"diff-line diff-ctx\">     filename = file.filename or \"documento\"</div><div class=\"diff-line diff-ctx\">     content_type = file.content_type or \"\"</div><div class=\"diff-line diff-hunk\">@@ -328,6 +660,19 @@</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-ctx\">     result = _extract_from_text(text, doc_type)</div><div class=\"diff-line diff-ctx\">     result[\"ocr_engine\"] = engine</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    if mode == \"ai\":</div><div class=\"diff-line diff-add\">+        llm_url = (ollama_url or DEFAULT_OLLAMA_URL).strip()</div><div class=\"diff-line diff-add\">+        llm_model = (ollama_model or DEFAULT_OLLAMA_MODEL).strip()</div><div class=\"diff-line diff-add\">+        llm_data = _ollama_extract(text, doc_type, llm_url, llm_model)</div><div class=\"diff-line diff-add\">+        result = _merge_extractions(result, llm_data)</div><div class=\"diff-line diff-add\">+        if llm_data is None:</div><div class=\"diff-line diff-add\">+            result.setdefault(\"validation_warnings\", []).append(</div><div class=\"diff-line diff-add\">+                \"Ollama no respondió; se usaron solo reglas OCR. Comprueba la red LAN (192.168.1.16:11434).\"</div><div class=\"diff-line diff-add\">+            )</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    result = _validate_fiscal_data(result)</div><div class=\"diff-line diff-add\">+    result[\"source_file\"] = filename</div><div class=\"diff-line diff-ctx\">     return result</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-ctx\"> </div></div>","linesAdded":385,"linesRemoved":40,"status":"pending"},"pending":1},"at":1782350745101},{"event":"tool_activity","data":{"activity":{"id":"tool_5a5ef5f2-245a-4b59-80b5-110324f2747","name":"edit","status":"running","summary":"edit: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\MobileConfig.php","detail":"","at":"2026-06-25T01:25:47.541Z"}},"at":1782350747541},{"event":"tool_activity","data":{"activity":{"id":"tool_9d2b2fcd-1c28-4491-b62f-43124c14d62","name":"edit","status":"running","summary":"edit: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php","detail":"","at":"2026-06-25T01:25:47.632Z"}},"at":1782350747632},{"event":"tool_activity","data":{"activity":{"id":"tool_5a5ef5f2-245a-4b59-80b5-110324f2747","name":"edit","status":"completed","summary":"edit: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\MobileConfig.php","detail":"","at":"2026-06-25T01:25:47.730Z"}},"at":1782350747730},{"event":"file_change","data":{"sessionId":"cs-1782350591171-ndpzgs","change":{"id":"ch-2","path":"Plugins\\ALimpiezasMobile\\Lib\\MobileConfig.php","type":"edit","diffString":"--- a/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\MobileConfig.php\n+++ b/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\MobileConfig.php\n@@ -37,4 +37,9 @@\n     {\n         return rtrim(Tools::settings('alimpiezas', 'ollama_url', 'http://192.168.1.16:11434'), '/');\n     }\n+\n+    public static function ollamaModel(): string\n+    {\n+        return Tools::settings('alimpiezas', 'ollama_model', 'qwen2.5:7b');\n+    }\n }","diffHtml":"<div class=\"diff-colored\"><div class=\"diff-line diff-meta\">--- a/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\MobileConfig.php</div><div class=\"diff-line diff-meta\">+++ b/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\MobileConfig.php</div><div class=\"diff-line diff-hunk\">@@ -37,4 +37,9 @@</div><div class=\"diff-line diff-ctx\">     {</div><div class=\"diff-line diff-ctx\">         return rtrim(Tools::settings('alimpiezas', 'ollama_url', 'http://192.168.1.16:11434'), '/');</div><div class=\"diff-line diff-ctx\">     }</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-add\">+    public static function ollamaModel(): string</div><div class=\"diff-line diff-add\">+    {</div><div class=\"diff-line diff-add\">+        return Tools::settings('alimpiezas', 'ollama_model', 'qwen2.5:7b');</div><div class=\"diff-line diff-add\">+    }</div><div class=\"diff-line diff-ctx\"> }</div></div>","linesAdded":5,"linesRemoved":0,"status":"pending"},"pending":2},"at":1782350747730},{"event":"tool_activity","data":{"activity":{"id":"tool_9d2b2fcd-1c28-4491-b62f-43124c14d62","name":"edit","status":"completed","summary":"edit: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php","detail":"","at":"2026-06-25T01:25:48.212Z"}},"at":1782350748212},{"event":"file_change","data":{"sessionId":"cs-1782350591171-ndpzgs","change":{"id":"ch-3","path":"Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php","type":"edit","diffString":"--- a/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php\n+++ b/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php\n@@ -4,6 +4,7 @@\n \n use FacturaScripts\\Core\\Tools;\n use FacturaScripts\\Core\\UploadedFile;\n+use FacturaScripts\\Dinamic\\Lib\\FiscalNumberValidator;\n use FacturaScripts\\Dinamic\\Model\\AttachedFile;\n use FacturaScripts\\Plugins\\ALimpiezasMobile\\Model\\AiExpenseQueue;\n ","diffHtml":"<div class=\"diff-colored\"><div class=\"diff-line diff-meta\">--- a/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php</div><div class=\"diff-line diff-meta\">+++ b/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php</div><div class=\"diff-line diff-hunk\">@@ -4,6 +4,7 @@</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-ctx\"> use FacturaScripts\\Core\\Tools;</div><div class=\"diff-line diff-ctx\"> use FacturaScripts\\Core\\UploadedFile;</div><div class=\"diff-line diff-add\">+use FacturaScripts\\Dinamic\\Lib\\FiscalNumberValidator;</div><div class=\"diff-line diff-ctx\"> use FacturaScripts\\Dinamic\\Model\\AttachedFile;</div><div class=\"diff-line diff-ctx\"> use FacturaScripts\\Plugins\\ALimpiezasMobile\\Model\\AiExpenseQueue;</div><div class=\"diff-line diff-ctx\"> </div></div>","linesAdded":1,"linesRemoved":0,"status":"pending"},"pending":3},"at":1782350748212},{"event":"tool_activity","data":{"activity":{"id":"tool_d31dd9f1-fa4a-4b39-9b42-00808a09a1b","name":"edit","status":"completed","summary":"edit: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php","detail":"","at":"2026-06-25T01:25:48.750Z"}},"at":1782350748750},{"event":"file_change","data":{"sessionId":"cs-1782350591171-ndpzgs","change":{"id":"ch-4","path":"Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php","type":"edit","diffString":"--- a/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php\n+++ b/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php\n@@ -28,6 +28,8 @@\n             ? self::callAiService($attached->getFullPath(), $docType, MobileConfig::expenseMode())\n             : self::manualExtract($docType);\n \n+        $extracted = self::enrichFiscalValidation($extracted);\n+\n         if (!empty($extracted['_error'])) {\n             return [\n                 'ok' => false,","diffHtml":"<div class=\"diff-colored\"><div class=\"diff-line diff-meta\">--- a/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php</div><div class=\"diff-line diff-meta\">+++ b/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php</div><div class=\"diff-line diff-hunk\">@@ -28,6 +28,8 @@</div><div class=\"diff-line diff-ctx\">             ? self::callAiService($attached-&gt;getFullPath(), $docType, MobileConfig::expenseMode())</div><div class=\"diff-line diff-ctx\">             : self::manualExtract($docType);</div><div class=\"diff-line diff-ctx\"> </div><div class=\"diff-line diff-add\">+        $extracted = self::enrichFiscalValidation($extracted);</div><div class=\"diff-line diff-add\">+</div><div class=\"diff-line diff-ctx\">         if (!empty($extracted['_error'])) {</div><div class=\"diff-line diff-ctx\">             return [</div><div class=\"diff-line diff-ctx\">                 'ok' =&gt; false,</div></div>","linesAdded":2,"linesRemoved":0,"status":"pending"},"pending":4},"at":1782350748750},{"event":"tool_activity","data":{"activity":{"id":"tool_4a07589e-2ffb-4456-b657-0a0ff67e5df","name":"edit","status":"running","summary":"edit: C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php","detail":"","at":"2026-06-25T01:25:51.030Z"}},"at":1782350751030},{"event":"status","data":{"text":"Límite de 80 herramientas alcanzado — finalizando con lo obtenido."},"at":1782350751030},{"event":"status","data":{"text":"Turno añadido al transcript del IDE (reabrir chat para verlo)"},"at":1782350751041},{"event":"done","data":{"ok":true,"bridgeSessionId":"bs-1782350751039-3i5gwm","agentId":"agent-dca1bfaa-c872-4cb9-af7e-79fa80fc0a18","runId":"run-8ac13eb3-dde8-46e6-87a6-8fce005e2a15","status":"cancelled","result":"","changeSessionId":"cs-1782350591171-ndpzgs","pendingChanges":4,"changes":[{"id":"ch-1","callId":"tool_8749149d-292f-440b-8a5f-baa1512e76c","path":"ai-service\\main.py","type":"edit","diffString":"--- a/C:\\xampp\\htdocs\\ALimpiezas\\ai-service\\main.py\n+++ b/C:\\xampp\\htdocs\\ALimpiezas\\ai-service\\main.py\n@@ -1,12 +1,15 @@\n-\"\"\"Microservicio IA: OCR, voz e intenciones (ALimpiezas).\"\"\"\n+\"\"\"Microservicio IA: OCR, comprensión fiscal ES y Ollama (ALimpiezas).\"\"\"\n \n from __future__ import annotations\n \n import io\n+import json\n import os\n import re\n import shutil\n import tempfile\n+import urllib.error\n+import urllib.request\n from datetime import date\n from typing import Any\n \n@@ -20,7 +23,7 @@\n     text: str = \"\"\n \n \n-app = FastAPI(title=\"ALimpiezas AI Service\", version=\"0.2.0\")\n+app = FastAPI(title=\"ALimpiezas AI Service\", version=\"0.3.0\")\n \n app.add_middleware(\n     CORSMiddleware,\n@@ -66,9 +69,37 @@\n \n IMAGE_EXTENSIONS = (\".jpg\", \".jpeg\", \".png\", \".webp\", \".gif\", \".heic\", \".heif\", \".bmp\", \".tif\", \".tiff\")\n \n+DEFAULT_OLLAMA_URL = os.getenv(\"OLLAMA_URL\", \"http://192.168.1.16:11434\")\n+DEFAULT_OLLAMA_MODEL = os.getenv(\"OLLAMA_MODEL\", \"qwen2.5:7b\")\n \n-def _parse_amount(value: str) -> float | None:\n-    cleaned = value.replace(\"€\", \"\").replace(\"EUR\", \"\").strip()\n+FISCAL_JSON_SCHEMA = {\n+    \"document_kind\": \"expense_received|issued_invoice|simplified_ticket|proforma|unknown\",\n+    \"counterparty_name\": \"string\",\n+    \"counterparty_cif\": \"string\",\n+    \"counterparty_address\": \"string\",\n+    \"invoice_number\": \"string\",\n+    \"invoice_series\": \"string\",\n+    \"invoice_date\": \"YYYY-MM-DD\",\n+    \"operation_date\": \"YYYY-MM-DD or null\",\n+    \"concept\": \"string\",\n+    \"base_amount\": \"number\",\n+    \"tax_rate\": \"21|10|4|0\",\n+    \"tax_amount\": \"number\",\n+    \"irpf_rate\": \"number or null\",\n+    \"irpf_amount\": \"number or null\",\n+    \"total_amount\": \"number\",\n+    \"currency\": \"EUR\",\n+    \"payment_method\": \"string\",\n+    \"is_rectifying\": \"boolean\",\n+    \"deductible\": \"boolean\",\n+    \"notes\": \"string\",\n+}\n+\n+\n+def _parse_amount(value: str | None) -> float | None:\n+    if not value:\n+        return None\n+    cleaned = str(value).replace(\"€\", \"\").replace(\"EUR\", \"\").replace(\" \", \"\").strip()\n     if cleaned.count(\",\") == 1 and cleaned.count(\".\") > 1:\n         cleaned = cleaned.replace(\".\", \"\")\n     cleaned = cleaned.replace(\",\", \".\")\n@@ -78,6 +109,46 @@\n         return None\n \n \n+def _normalize_date(day: str, month: str, year: str) -> str | None:\n+    year_int = int(year)\n+    if year_int < 100:\n+        year_int += 2000\n+    try:\n+        return date(year_int, int(month), int(day)).isoformat()\n+    except ValueError:\n+        return None\n+\n+\n+def _validate_spain_nif(value: str) -> bool:\n+    value = re.sub(r\"[\\s.-]\", \"\", value.upper())\n+    if not value:\n+        return False\n+\n+    if re.fullmatch(r\"\\d{8}[A-HJ-NP-TV-Z]\", value):\n+        letters = \"TRWAGMYFPDXBNJZSQVHLCKE\"\n+        return value[8] == letters[int(value[:8]) % 23]\n+\n+    if re.fullmatch(r\"[XYZ]\\d{7}[A-HJ-NP-TV-Z]\", value):\n+        prefix = {\"X\": \"0\", \"Y\": \"1\", \"Z\": \"2\"}[value[0]]\n+        letters = \"TRWAGMYFPDXBNJZSQVHLCKE\"\n+        num = prefix + value[1:8]\n+        return value[8] == letters[int(num) % 23]\n+\n+    if re.fullmatch(r\"[A-HJ-NP-SUVW]\\d{7}[0-9A-J]\", value):\n+        body, control = value[:8], value[8]\n+        digits = sum(int(ch) for ch in body[1:] if ch.isdigit())\n+        letter_map = \"JABCDEFGHI\"\n+        if body[0].isdigit():\n+            expected = str(digits % 10)\n+        elif re.fullmatch(r\"\\d{7}\", body[1:]):\n+            expected = letter_map[digits % 10]\n+        else:\n+            return False\n+        return control == expected\n+\n+    return False\n+\n+\n def _preprocess_image(image: Image.Image) -> Image.Image:\n     gray = image.convert(\"L\")\n     enhanced = ImageEnhance.Contrast(gray).enhance(2.0)\n@@ -120,76 +191,332 @@\n     return \"\", \"none\"\n \n \n+def _find_all_amounts(text: str) -> list[tuple[float, str]]:\n+    found: list[tuple[float, str]] = []\n+    patterns = [\n+        r\"(?:TOTAL|T\\.?\\s*A\\.?\\s*PAGAR|IMPORTE\\s+TOTAL|SUMA\\s+TOTAL|AMOUNT\\s+DUE)\\s*[:.]?\\s*([\\d.,]+)\\s*€?\",\n+        r\"(?:TOTAL\\s+FACTURA|TOTAL\\s+€)\\s*[:.]?\\s*([\\d.,]+)\",\n+        r\"€\\s*([\\d.,]+)\\s*(?:$|\\n)\",\n+    ]\n+    upper = text.upper()\n+    for pattern in patterns:\n+        for match in re.finditer(pattern, upper, re.I | re.M):\n+            amount = _parse_amount(match.group(1))\n+            if amount is not None and amount > 0:\n+                found.append((amount, match.group(0)[:80]))\n+    return found\n+\n+\n+def _classify_document_kind(text: str, doc_type_hint: str) -> str:\n+    upper = text.upper()\n+    if doc_type_hint == \"issued\":\n+        return \"issued_invoice\"\n+    if doc_type_hint == \"expense\":\n+        if re.search(r\"\\bTICKET|SIMPLIFICAD|TPV|RECIBO\\b\", upper):\n+            return \"simplified_ticket\"\n+        if re.search(r\"\\bFACTURA\\s+RECIBIDA|PROVEEDOR|COMPRA\\b\", upper):\n+            return \"expense_received\"\n+        if re.search(r\"\\bFACTURA\\s+EMITIDA|CLIENTE|VENTA\\b\", upper):\n+            return \"issued_invoice\"\n+        return \"expense_received\"\n+    if re.search(r\"\\bPROFORMA\\b\", upper):\n+        return \"proforma\"\n+    if re.search(r\"\\bFACTURA\\s+EMITIDA\\b\", upper):\n+        return \"issued_invoice\"\n+    if re.search(r\"\\bTICKET\\b\", upper):\n+        return \"simplified_ticket\"\n+    return \"expense_received\"\n+\n+\n def _extract_from_text(text: str, doc_type: str) -> dict[str, Any]:\n     text = text.replace(\"\\x0c\", \"\\n\")\n     upper = text.upper()\n \n-    cif_match = re.search(r\"\\b([A-HJ-NP-SUVW]\\d{7}[0-9A-J]|\\d{8}[A-HJ-NP-TV-Z])\\b\", upper)\n+    cif_match = re.search(\n+        r\"\\b(?:CIF|NIF|N\\.?I\\.?F\\.?|DNI|C\\.?I\\.?F\\.?)\\s*[:.]?\\s*([A-HJ-NP-SUVW]\\d{7}[0-9A-J]|\\d{8}[A-HJ-NP-TV-Z]|[XYZ]\\d{7}[A-HJ-NP-TV-Z])\\b\",\n+        upper,\n+    )\n+    if not cif_match:\n+        cif_match = re.search(r\"\\b([A-HJ-NP-SUVW]\\d{7}[0-9A-J]|\\d{8}[A-HJ-NP-TV-Z]|[XYZ]\\d{7}[A-HJ-NP-TV-Z])\\b\", upper)\n+\n     date_match = re.search(r\"\\b(\\d{1,2})[/.-](\\d{1,2})[/.-](\\d{2,4})\\b\", text)\n-    invoice_match = re.search(r\"(?:FACTURA|N[º°\\.]|NUM(?:ERO)?\\.?|FRA\\.?)\\s*[:#]?\\s*([A-Z0-9/-]+)\", upper)\n-    total_match = re.search(r\"(?:TOTAL|T\\.?\\s*A\\.?\\s*PAGAR|IMPORTE\\s+TOTAL)\\s*[:.]?\\s*([\\d.,]+)\", upper, re.I)\n-    base_match = re.search(r\"(?:BASE|BI|BASE\\s+IMPONIBLE)\\s*[:.]?\\s*([\\d.,]+)\", upper, re.I)\n-    iva_match = re.search(r\"(?:IVA|CUOTA\\s+IVA)\\s*[:.]?\\s*([\\d.,]+)\", upper, re.I)\n+    invoice_match = re.search(\n+        r\"(?:FACTURA|N[º°\\.]|NUM(?:ERO)?\\.?|FRA\\.?|Nº\\s*FACTURA)\\s*[:#]?\\s*([A-Z0-9/-]+)\",\n+        upper,\n+    )\n \n-    supplier_name = \"\"\n-    for line in [ln.strip() for ln in text.splitlines() if ln.strip()][:12]:\n-        if len(line) < 4 or re.search(r\"^\\d+[\\d\\s.,€]+$\", line):\n-            continue\n-        if re.search(r\"factura|ticket|cliente|fecha|total|iva|nif|cif\", line, re.I):\n-            continue\n-        supplier_name = line[:150]\n-        break\n+    total_candidates = _find_all_amounts(text)\n+    total_amount = total_candidates[-1][0] if total_candidates else None\n \n-    invoice_date = None\n-    if date_match:\n-        day, month, year = date_match.groups()\n-        year = int(year)\n-        if year < 100:\n-            year += 2000\n-        try:\n-            invoice_date = date(year, int(month), int(day)).isoformat()\n-        except ValueError:\n-            invoice_date = None\n+    base_match = re.search(\n+        r\"(?:BASE\\s+IMPONIBLE|BASE|BI|SUBTOTAL)\\s*[:.]?\\s*([\\d.,]+)\",\n+        upper,\n+        re.I,\n+    )\n+    iva_match = re.search(\n+        r\"(?:CUOTA\\s+IVA|IVA\\s*(?:21|10|4)?%?|I\\.V\\.A\\.?)\\s*[:.]?\\s*([\\d.,]+)\",\n+        upper,\n+        re.I,\n+    )\n+    irpf_match = re.search(\n+        r\"(?:IRPF|RETENCI[ÓO]N)\\s*(?:\\d+%)?\\s*[:.]?\\s*([\\d.,]+)\",\n+        upper,\n+        re.I,\n+    )\n \n-    total_amount = _parse_amount(total_match.group(1)) if total_match else None\n+    tax_rate = 21.0\n+    rate_match = re.search(r\"IVA\\s*(21|10|4)\\s*%\", upper)\n+    if rate_match:\n+        tax_rate = float(rate_match.group(1))\n+\n     base_amount = _parse_amount(base_match.group(1)) if base_match else None\n     tax_amount = _parse_amount(iva_match.group(1)) if iva_match else None\n+    irpf_amount = _parse_amount(irpf_match.group(1)) if irpf_match else None\n \n     if base_amount is None and total_amount and tax_amount:\n-        base_amount = round(total_amount - tax_amount, 2)\n+        base_amount = round(total_amount - tax_amount - (irpf_amount or 0), 2)\n     elif base_amount is None and total_amount:\n-        base_amount = round(total_amount / 1.21, 2)\n-        tax_amount = round(total_amount - base_amount, 2)\n+        base_amount = round(total_amount / (1 + tax_rate / 100), 2)\n+        tax_amount = round(total_amount - base_amount - (irpf_amount or 0), 2)\n \n-    confidence = 0.25\n+    supplier_name = \"\"\n+    for line in [ln.strip() for ln in text.splitlines() if ln.strip()][:15]:\n+        if len(line) < 4 or re.search(r\"^\\d+[\\d\\s.,€]+$\", line):\n+            continue\n+        if re.search(r\"factura|ticket|cliente|fecha|total|iva|nif|cif|base|importe\", line, re.I):\n+            continue\n+        supplier_name = line[:150]\n+        break\n+\n+    invoice_date = _normalize_date(*date_match.groups()) if date_match else None\n+    counterparty_cif = cif_match.group(1) if cif_match else \"\"\n+    document_kind = _classify_document_kind(text, doc_type)\n+\n+    confidence = 0.2\n     if len(text) > 50:\n-        confidence += 0.1\n-    if cif_match:\n-        confidence += 0.15\n+        confidence += 0.08\n+    if counterparty_cif and _validate_spain_nif(counterparty_cif):\n+        confidence += 0.18\n+    elif counterparty_cif:\n+        confidence += 0.08\n     if invoice_date:\n         confidence += 0.1\n     if total_amount:\n-        confidence += 0.2\n+        confidence += 0.22\n     if supplier_name:\n         confidence += 0.1\n     if base_amount:\n+        confidence += 0.07\n+    if invoice_match:\n         confidence += 0.05\n \n     return {\n+        \"document_kind\": document_kind,\n+        \"counterparty_name\": supplier_name,\n+        \"counterparty_cif\": counterparty_cif,\n+        \"counterparty_address\": \"\",\n         \"supplier_name\": supplier_name,\n-        \"supplier_cif\": cif_match.group(1) if cif_match else \"\",\n+        \"supplier_cif\": counterparty_cif,\n         \"invoice_number\": invoice_match.group(1) if invoice_match else \"\",\n+        \"invoice_series\": \"\",\n         \"invoice_date\": invoice_date or date.today().isoformat(),\n+        \"operation_date\": invoice_date,\n+        \"concept\": \"\",\n         \"base_amount\": base_amount,\n+        \"tax_rate\": tax_rate,\n         \"tax_amount\": tax_amount,\n+        \"irpf_rate\": None,\n+        \"irpf_amount\": irpf_amount,\n         \"total_amount\": total_amount,\n-        \"confidence\": min(confidence, 0.98),\n+        \"currency\": \"EUR\",\n+        \"payment_method\": \"\",\n+        \"is_rectifying\": bool(re.search(r\"\\bRECTIFICAT\", upper)),\n+        \"deductible\": document_kind in (\"expense_received\", \"simplified_ticket\"),\n+        \"confidence\": min(confidence, 0.92),\n         \"doc_type\": doc_type,\n-        \"notes\": \"Revisa los datos antes de confirmar.\",\n-        \"raw_text_preview\": text[:800],\n+        \"notes\": \"\",\n+        \"raw_text_preview\": text[:1200],\n     }\n \n \n+def _ollama_extract(\n+    text: str,\n+    doc_type: str,\n+    ollama_url: str,\n+    model: str,\n+) -> dict[str, Any] | None:\n+    url = ollama_url.rstrip(\"/\") + \"/api/chat\"\n+    kind_hint = \"factura recibida / gasto deducible\" if doc_type == \"expense\" else \"factura emitida / venta\"\n+\n+    system_prompt = (\n+        \"Eres un experto fiscal español (RD 1619/2012, Ley IVA, autónomos). \"\n+        \"Extrae datos del texto OCR de un documento contable español. \"\n+        \"Responde SOLO con JSON válido, sin markdown. \"\n+        \"Usa null para campos desconocidos. Importes en euros con 2 decimales. \"\n+        \"document_kind: expense_received, issued_invoice, simplified_ticket, proforma o unknown. \"\n+        \"Valida coherencia: base + IVA - IRPF ≈ total (±0.05€).\"\n+    )\n+    user_prompt = (\n+        f\"Tipo esperado: {kind_hint} (doc_type={doc_type}).\\n\"\n+        f\"Esquema JSON: {json.dumps(FISCAL_JSON_SCHEMA, ensure_ascii=False)}\\n\\n\"\n+        f\"TEXTO OCR:\\n{text[:6000]}\"\n+    )\n+\n+    payload = json.dumps({\n+        \"model\": model,\n+        \"messages\": [\n+            {\"role\": \"system\", \"content\": system_prompt},\n+            {\"role\": \"user\", \"content\": user_prompt},\n+        ],\n+        \"stream\": False,\n+        \"format\": \"json\",\n+        \"options\": {\"temperature\": 0.1},\n+    }).encode(\"utf-8\")\n+\n+    req = urllib.request.Request(\n+        url,\n+        data=payload,\n+        headers={\"Content-Type\": \"application/json\"},\n+        method=\"POST\",\n+    )\n+\n+    try:\n+        with urllib.request.urlopen(req, timeout=90) as resp:\n+            body = json.loads(resp.read().decode(\"utf-8\"))\n+    except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):\n+        return None\n+\n+    content = body.get(\"message\", {}).get(\"content\", \"\")\n+    if not content:\n+        return None\n+\n+    try:\n+        parsed = json.loads(content)\n+    except json.JSONDecodeError:\n+        match = re.search(r\"\\{.*\\}\", content, re.S)\n+        if not match:\n+            return None\n+        try:\n+            parsed = json.loads(match.group(0))\n+        except json.JSONDecodeError:\n+            return None\n+\n+    if not isinstance(parsed, dict):\n+        return None\n+\n+    return parsed\n+\n+\n+def _merge_extractions(base: dict[str, Any], llm: dict[str, Any] | None) -> dict[str, Any]:\n+    if not llm:\n+        return base\n+\n+    merged = dict(base)\n+    for key, value in llm.items():\n+        if value is None or value == \"\":\n+            continue\n+        merged[key] = value\n+\n+    name = merged.get(\"counterparty_name\") or merged.get(\"supplier_name\") or \"\"\n+    cif = merged.get(\"counterparty_cif\") or merged.get(\"supplier_cif\") or \"\"\n+    merged[\"supplier_name\"] = name\n+    merged[\"supplier_cif\"] = cif\n+    merged[\"counterparty_name\"] = name\n+    merged[\"counterparty_cif\"] = cif\n+\n+    if llm:\n+        merged[\"confidence\"] = min(float(merged.get(\"confidence\", 0.5)) + 0.12, 0.98)\n+        merged[\"ocr_engine\"] = (merged.get(\"ocr_engine\") or \"ocr\") + \"+ollama\"\n+\n+    return merged\n+\n+\n+def _validate_fiscal_data(data: dict[str, Any]) -> dict[str, Any]:\n+    errors: list[str] = []\n+    warnings: list[str] = []\n+    field_status: dict[str, str] = {}\n+\n+    required_expense = [\"counterparty_name\", \"invoice_date\", \"total_amount\"]\n+    required_issued = [\"counterparty_name\", \"invoice_number\", \"invoice_date\", \"total_amount\"]\n+\n+    doc_kind = data.get(\"document_kind\", \"expense_received\")\n+    is_issued = doc_kind == \"issued_invoice\" or data.get(\"doc_type\") == \"issued\"\n+    required = required_issued if is_issued else required_expense\n+\n+    for field in required:\n+        value = data.get(field) or data.get(field.replace(\"counterparty\", \"supplier\"))\n+        field_status[field] = \"ok\" if value not in (None, \"\", 0) else \"missing\"\n+        if field_status[field] == \"missing\":\n+            label = {\n+                \"counterparty_name\": \"Nombre emisor/receptor\",\n+                \"invoice_number\": \"Número de factura\",\n+                \"invoice_date\": \"Fecha\",\n+                \"total_amount\": \"Importe total\",\n+            }.get(field, field)\n+            errors.append(f\"Falta dato obligatorio: {label}.\")\n+\n+    cif = data.get(\"counterparty_cif\") or data.get(\"supplier_cif\") or \"\"\n+    if cif:\n+        if _validate_spain_nif(cif):\n+            field_status[\"counterparty_cif\"] = \"ok\"\n+        else:\n+            field_status[\"counterparty_cif\"] = \"review\"\n+            warnings.append(\"El NIF/CIF no supera la validación algorítmica. Revísalo.\")\n+    else:\n+        field_status[\"counterparty_cif\"] = \"review\"\n+        warnings.append(\"Sin NIF/CIF del emisor. Obligatorio en facturas completas (RD 1619/2012).\")\n+\n+    base = data.get(\"base_amount\")\n+    tax = data.get(\"tax_amount\") or 0\n+    irpf = data.get(\"irpf_amount\") or 0\n+    total = data.get(\"total_amount\")\n+\n+    if base and total:\n+        expected = round(float(base) + float(tax) - float(irpf), 2)\n+        diff = abs(expected - float(total))\n+        if diff <= 0.05:\n+            field_status[\"total_amount\"] = \"ok\"\n+        elif diff <= 0.5:\n+            field_status[\"total_amount\"] = \"review\"\n+            warnings.append(f\"Total ({total}€) no cuadra exactamente con base+IVA-IRPF ({expected}€).\")\n+        else:\n+            field_status[\"total_amount\"] = \"review\"\n+            warnings.append(f\"Incoherencia fiscal: base ({base}€) + IVA ({tax}€) - IRPF ({irpf}€) ≠ total ({total}€).\")\n+\n+    if not total:\n+        field_status[\"total_amount\"] = \"missing\"\n+\n+    tax_rate = data.get(\"tax_rate\")\n+    if tax_rate not in (21, 10, 4, 0, 21.0, 10.0, 4.0, 0.0, None):\n+        warnings.append(f\"Tipo de IVA inusual ({tax_rate}%). Verifica si aplica 21%, 10% o 4%.\")\n+\n+    if doc_kind == \"simplified_ticket\":\n+        warnings.append(\"Ticket simplificado: conserva el original; algunos datos pueden no ser exigibles.\")\n+\n+    if data.get(\"is_rectifying\"):\n+        warnings.append(\"Factura rectificativa detectada. Verifica referencia a la factura original.\")\n+\n+    compliance = 1.0\n+    compliance -= 0.25 * sum(1 for s in field_status.values() if s == \"missing\")\n+    compliance -= 0.1 * sum(1 for s in field_status.values() if s == \"review\")\n+    compliance = max(0.0, min(compliance, 1.0))\n+\n+    data[\"validation_errors\"] = errors\n+    data[\"validation_warnings\"] = warnings\n+    data[\"field_status\"] = field_status\n+    data[\"fiscal_compliance\"] = round(compliance, 2)\n+    data[\"warnings\"] = warnings\n+\n+    if errors:\n+        data[\"notes\"] = \"Revisa los campos marcados antes de confirmar. \" + (data.get(\"notes\") or \"\")\n+    elif warnings:\n+        data[\"notes\"] = \"Datos extraídos. Comprueba coherencia fiscal antes de confirmar.\"\n+    else:\n+        data[\"notes\"] = \"Datos fiscales coherentes. Confirma para registrar en contabilidad.\"\n+\n+    return data\n+\n+\n def _detect_document_kind(filename: str, content_type: str) -> str:\n     lower = (filename or \"\").lower()\n     ct = (content_type or \"\").lower()\n@@ -290,10 +617,12 @@\n     return {\n         \"status\": \"ok\",\n         \"service\": \"alimpiezas-ai\",\n+        \"version\": \"0.3.0\",\n         \"tesseract\": HAS_TESSERACT,\n         \"pymupdf\": HAS_PYMUPDF,\n         \"heif\": HAS_HEIF,\n         \"whisper\": bool(os.getenv(\"OPENAI_API_KEY\")),\n+        \"ollama_default\": DEFAULT_OLLAMA_URL,\n     }\n \n \n@@ -301,6 +630,9 @@\n async def extract_invoice(\n     file: UploadFile = File(...),\n     doc_type: str = Form(default=\"expense\"),\n+    mode: str = Form(default=\"ocr\"),\n+    ollama_url: str = Form(default=\"\"),\n+    ollama_model: str = Form(default=\"\"),\n ) -> dict[str, Any]:\n     filename = file.filename or \"documento\"\n     content_type = file.content_type or \"\"\n@@ -328,6 +660,19 @@\n \n     result = _extract_from_text(text, doc_type)\n     result[\"ocr_engine\"] = engine\n+\n+    if mode == \"ai\":\n+        llm_url = (ollama_url or DEFAULT_OLLAMA_URL).strip()\n+        llm_model = (ollama_model or DEFAULT_OLLAMA_MODEL).strip()\n+        llm_data = _ollama_extract(text, doc_type, llm_url, llm_model)\n+        result = _merge_extractions(result, llm_data)\n+        if llm_data is None:\n+            result.setdefault(\"validation_warnings\", []).append(\n+                \"Ollama no respondió; se usaron solo reglas OCR. Comprueba la red LAN (192.168.1.16:11434).\"\n+            )\n+\n+    result = _validate_fiscal_data(result)\n+    result[\"source_file\"] = filename\n     return result\n \n ","linesAdded":385,"linesRemoved":40,"status":"pending"},{"id":"ch-2","callId":"tool_5a5ef5f2-245a-4b59-80b5-110324f2747","path":"Plugins\\ALimpiezasMobile\\Lib\\MobileConfig.php","type":"edit","diffString":"--- a/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\MobileConfig.php\n+++ b/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\MobileConfig.php\n@@ -37,4 +37,9 @@\n     {\n         return rtrim(Tools::settings('alimpiezas', 'ollama_url', 'http://192.168.1.16:11434'), '/');\n     }\n+\n+    public static function ollamaModel(): string\n+    {\n+        return Tools::settings('alimpiezas', 'ollama_model', 'qwen2.5:7b');\n+    }\n }","linesAdded":5,"linesRemoved":0,"status":"pending"},{"id":"ch-3","callId":"tool_9d2b2fcd-1c28-4491-b62f-43124c14d62","path":"Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php","type":"edit","diffString":"--- a/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php\n+++ b/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php\n@@ -4,6 +4,7 @@\n \n use FacturaScripts\\Core\\Tools;\n use FacturaScripts\\Core\\UploadedFile;\n+use FacturaScripts\\Dinamic\\Lib\\FiscalNumberValidator;\n use FacturaScripts\\Dinamic\\Model\\AttachedFile;\n use FacturaScripts\\Plugins\\ALimpiezasMobile\\Model\\AiExpenseQueue;\n ","linesAdded":1,"linesRemoved":0,"status":"pending"},{"id":"ch-4","callId":"tool_d31dd9f1-fa4a-4b39-9b42-00808a09a1b","path":"Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php","type":"edit","diffString":"--- a/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php\n+++ b/C:\\xampp\\htdocs\\ALimpiezas\\Plugins\\ALimpiezasMobile\\Lib\\AiExpenseProcessor.php\n@@ -28,6 +28,8 @@\n             ? self::callAiService($attached->getFullPath(), $docType, MobileConfig::expenseMode())\n             : self::manualExtract($docType);\n \n+        $extracted = self::enrichFiscalValidation($extracted);\n+\n         if (!empty($extracted['_error'])) {\n             return [\n                 'ok' => false,","linesAdded":2,"linesRemoved":0,"status":"pending"}],"transcriptPath":"C:\\Users\\Paterna\\.cursor\\projects\\c-xampp-htdocs-ALimpiezas\\agent-transcripts\\736ac9a4-90f0-4491-8940-56ec149befda\\736ac9a4-90f0-4491-8940-56ec149befda.jsonl","notifyTitle":"Agente terminado","notifyBody":"Rehago la captura con IA fiscal fiable y la UI móvil de confirmación. Reviso los archivos ya identificados y la document"},"at":1782350751044}]}
