{"repo": "ahmednour1234/bacco", "files": {".env.example": {"9a5ada4ab1ceb0de386bc1ce99c561f8869c09d7": {"t": 1785745165.6067455, "n": 2563, "h": "d7a9539a7e49cc60", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_MODEL=deepseek-chat\nDEEPSEEK_VISION_MODEL=deepseek-vl2\n\n# Product Catalog Research module (DeepSeek client + research pipeline).\n# Never commit real secret values.\nDEEPSEEK_BASE_URL=https://api.deepseek.com\nDEEPSEEK_TIMEOUT=120\nDEEPSEEK_MAX_RETRIES=3\nDEEPSEEK_RATE_LIMIT=30\nCATALOG_RESEARCH_PROVIDER=deepseek\n# Leave as \"default\" so the standard queue worker processes research jobs.\nCATALOG_RESEARCH_QUEUE=default\nCATALOG_RESEARCH_BATCH_SIZE=5\nCATALOG_RESEARCH_MANUFACTURERS_BATCH=5\nCATALOG_RESEARCH_SERIES_BATCH=10\nCATALOG_RESEARCH_VARIANTS_BATCH=20\nCATALOG_RESEARCH_MAX_CONCURRENT_JOBS=3\nCATALOG_SOURCE_REFRESH_DAYS=180\n\n# Vision API — DeepSeek does NOT support images; use a vision-capable service.\n# Option 1 (recommended, free): Google Gemini — https://aistudio.google.com/apikey\nGEMINI_API_KEY=\nGEMINI_VISION_MODEL=gemini-2.0-flash\n\n# Option 2 (free): Groq — https://console.groq.com\nGROQ_API_KEY=\nGROQ_VISION_MODEL=meta-llama/llama-4-scout-17b-16e-instruct\n\n# Option 3: Generic OpenAI-compatible endpoint (OpenAI, OpenRouter, etc.)\nVISION_API_KEY=\nVISION_BASE_URL=https://openrouter.ai/api/v1\nVISION_MODEL=google/gemini-flash-1.5-8b\n\nVITE_APP_NAME=\"${APP_NAME}\"\n\n# Reading PDF BOQs. Without a key the app falls back to OCR.space's shared\n# demo key, which is rate-limited and will fail under real use.\n# Free key: https://ocr.space/ocrapi/freekey\nOCRSPACE_API_KEY=\n"}, "7d1f333677c84a7f32e280efa33dbaee165f8efe": {"t": 1785748347.919626, "n": 2563, "h": "d7a9539a7e49cc60", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_MODEL=deepseek-chat\nDEEPSEEK_VISION_MODEL=deepseek-vl2\n\n# Product Catalog Research module (DeepSeek client + research pipeline).\n# Never commit real secret values.\nDEEPSEEK_BASE_URL=https://api.deepseek.com\nDEEPSEEK_TIMEOUT=120\nDEEPSEEK_MAX_RETRIES=3\nDEEPSEEK_RATE_LIMIT=30\nCATALOG_RESEARCH_PROVIDER=deepseek\n# Leave as \"default\" so the standard queue worker processes research jobs.\nCATALOG_RESEARCH_QUEUE=default\nCATALOG_RESEARCH_BATCH_SIZE=5\nCATALOG_RESEARCH_MANUFACTURERS_BATCH=5\nCATALOG_RESEARCH_SERIES_BATCH=10\nCATALOG_RESEARCH_VARIANTS_BATCH=20\nCATALOG_RESEARCH_MAX_CONCURRENT_JOBS=3\nCATALOG_SOURCE_REFRESH_DAYS=180\n\n# Vision API — DeepSeek does NOT support images; use a vision-capable service.\n# Option 1 (recommended, free): Google Gemini — https://aistudio.google.com/apikey\nGEMINI_API_KEY=\nGEMINI_VISION_MODEL=gemini-2.0-flash\n\n# Option 2 (free): Groq — https://console.groq.com\nGROQ_API_KEY=\nGROQ_VISION_MODEL=meta-llama/llama-4-scout-17b-16e-instruct\n\n# Option 3: Generic OpenAI-compatible endpoint (OpenAI, OpenRouter, etc.)\nVISION_API_KEY=\nVISION_BASE_URL=https://openrouter.ai/api/v1\nVISION_MODEL=google/gemini-flash-1.5-8b\n\nVITE_APP_NAME=\"${APP_NAME}\"\n\n# Reading PDF BOQs. Without a key the app falls back to OCR.space's shared\n# demo key, which is rate-limited and will fail under real use.\n# Free key: https://ocr.space/ocrapi/freekey\nOCRSPACE_API_KEY=\n"}, "380bc546449c8da0516a2cd357f65f6e6421dd90": {"t": 1785748349.023084, "n": 2497, "h": "66fd371031beb07d", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_MODEL=deepseek-chat\nDEEPSEEK_VISION_MODEL=deepseek-vl2\n\n# Product Catalog Research module (DeepSeek client + research pipeline).\n# Never commit real secret values.\nDEEPSEEK_BASE_URL=https://api.deepseek.com\nDEEPSEEK_TIMEOUT=120\nDEEPSEEK_MAX_RETRIES=3\nDEEPSEEK_RATE_LIMIT=30\nCATALOG_RESEARCH_PROVIDER=deepseek\nCATALOG_RESEARCH_QUEUE=catalog-research\nCATALOG_RESEARCH_BATCH_SIZE=5\nCATALOG_RESEARCH_MANUFACTURERS_BATCH=5\nCATALOG_RESEARCH_SERIES_BATCH=10\nCATALOG_RESEARCH_VARIANTS_BATCH=20\nCATALOG_RESEARCH_MAX_CONCURRENT_JOBS=3\nCATALOG_SOURCE_REFRESH_DAYS=180\n\n# Vision API — DeepSeek does NOT support images; use a vision-capable service.\n# Option 1 (recommended, free): Google Gemini — https://aistudio.google.com/apikey\nGEMINI_API_KEY=\nGEMINI_VISION_MODEL=gemini-2.0-flash\n\n# Option 2 (free): Groq — https://console.groq.com\nGROQ_API_KEY=\nGROQ_VISION_MODEL=meta-llama/llama-4-scout-17b-16e-instruct\n\n# Option 3: Generic OpenAI-compatible endpoint (OpenAI, OpenRouter, etc.)\nVISION_API_KEY=\nVISION_BASE_URL=https://openrouter.ai/api/v1\nVISION_MODEL=google/gemini-flash-1.5-8b\n\nVITE_APP_NAME=\"${APP_NAME}\"\n\n# Reading PDF BOQs. Without a key the app falls back to OCR.space's shared\n# demo key, which is rate-limited and will fail under real use.\n# Free key: https://ocr.space/ocrapi/freekey\nOCRSPACE_API_KEY=\n"}, "89bda6cd3370f6bba078a12282b744e0ca8f1bd7": {"t": 1785748350.0862277, "n": 1995, "h": "404d602a591e64e9", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_MODEL=deepseek-chat\nDEEPSEEK_VISION_MODEL=deepseek-vl2\n\n# Vision API — DeepSeek does NOT support images; use a vision-capable service.\n# Option 1 (recommended, free): Google Gemini — https://aistudio.google.com/apikey\nGEMINI_API_KEY=\nGEMINI_VISION_MODEL=gemini-2.0-flash\n\n# Option 2 (free): Groq — https://console.groq.com\nGROQ_API_KEY=\nGROQ_VISION_MODEL=meta-llama/llama-4-scout-17b-16e-instruct\n\n# Option 3: Generic OpenAI-compatible endpoint (OpenAI, OpenRouter, etc.)\nVISION_API_KEY=\nVISION_BASE_URL=https://openrouter.ai/api/v1\nVISION_MODEL=google/gemini-flash-1.5-8b\n\nVITE_APP_NAME=\"${APP_NAME}\"\n\n# Reading PDF BOQs. Without a key the app falls back to OCR.space's shared\n# demo key, which is rate-limited and will fail under real use.\n# Free key: https://ocr.space/ocrapi/freekey\nOCRSPACE_API_KEY=\n"}, "f01410ba86b70436dbde9e233e6611d10545c13c": {"t": 1785748352.2040315, "n": 1792, "h": "f0bad8792e745b8f", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_MODEL=deepseek-chat\nDEEPSEEK_VISION_MODEL=deepseek-vl2\n\n# Vision API — DeepSeek does NOT support images; use a vision-capable service.\n# Option 1 (recommended, free): Google Gemini — https://aistudio.google.com/apikey\nGEMINI_API_KEY=\nGEMINI_VISION_MODEL=gemini-2.0-flash\n\n# Option 2 (free): Groq — https://console.groq.com\nGROQ_API_KEY=\nGROQ_VISION_MODEL=meta-llama/llama-4-scout-17b-16e-instruct\n\n# Option 3: Generic OpenAI-compatible endpoint (OpenAI, OpenRouter, etc.)\nVISION_API_KEY=\nVISION_BASE_URL=https://openrouter.ai/api/v1\nVISION_MODEL=google/gemini-flash-1.5-8b\n\nVITE_APP_NAME=\"${APP_NAME}\"\n"}, "769498c3a1c058b48ccadcb2840e8474a079cab2": {"t": 1785748353.2742786, "n": 1757, "h": "156c833b8b94afb4", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_MODEL=deepseek-chat\n\n# Vision API — DeepSeek does NOT support images; use a vision-capable service.\n# Option 1 (recommended, free): Google Gemini — https://aistudio.google.com/apikey\nGEMINI_API_KEY=\nGEMINI_VISION_MODEL=gemini-2.0-flash\n\n# Option 2 (free): Groq — https://console.groq.com\nGROQ_API_KEY=\nGROQ_VISION_MODEL=meta-llama/llama-4-scout-17b-16e-instruct\n\n# Option 3: Generic OpenAI-compatible endpoint (OpenAI, OpenRouter, etc.)\nVISION_API_KEY=\nVISION_BASE_URL=https://openrouter.ai/api/v1\nVISION_MODEL=google/gemini-flash-1.5-8b\n\nVITE_APP_NAME=\"${APP_NAME}\"\n"}, "c9cfc223dc923400b78320c53bc3915186a077bd": {"t": 1785748354.3366458, "n": 1676, "h": "00e9d77c3bc9fed0", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_MODEL=deepseek-chat\n\n# Vision API — DeepSeek does NOT support images; use any OpenAI-compatible vision service.\n# Free: OpenRouter (https://openrouter.ai) — set VISION_BASE_URL=https://openrouter.ai/api/v1\n# and VISION_MODEL=google/gemini-flash-1.5-8b (free tier)\n# OpenAI: set VISION_BASE_URL=https://api.openai.com/v1 and VISION_MODEL=gpt-4o-mini\nVISION_API_KEY=\nVISION_BASE_URL=https://openrouter.ai/api/v1\nVISION_MODEL=google/gemini-flash-1.5-8b\n\nVITE_APP_NAME=\"${APP_NAME}\"\n"}, "b65ff6622e72f65525fe55404e124bc72c97539d": {"t": 1785748355.3790066, "n": 1275, "h": "3e3bdc7bf4a03912", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_MODEL=deepseek-chat\nDEEPSEEK_VISION_MODEL=deepseek-chat\n\nVITE_APP_NAME=\"${APP_NAME}\"\n"}, "a4318f8cc2271b05ba9016c1010c1b8f31161281": {"t": 1785748356.5027087, "n": 1191, "h": "61ca4e2987a765e3", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nVITE_APP_NAME=\"${APP_NAME}\"\n"}}, "config/services.php": {"9a5ada4ab1ceb0de386bc1ce99c561f8869c09d7": {"t": 1785745761.2134774, "n": 2841, "h": "2a2d60233a0431c9", "c": " [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 30),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'deepseek' => [\n 'key' => env('DEEPSEEK_API_KEY'),\n 'model' => env('DEEPSEEK_MODEL', 'deepseek-chat'),\n 'vision_model' => env('DEEPSEEK_VISION_MODEL', 'deepseek-vl2'),\n // Catalog research client settings (see config/catalog_research.php too).\n 'base_url' => env('DEEPSEEK_BASE_URL', 'https://api.deepseek.com'),\n 'timeout' => (int) env('DEEPSEEK_TIMEOUT', 120),\n 'max_retries' => (int) env('DEEPSEEK_MAX_RETRIES', 3),\n 'rate_limit' => (int) env('DEEPSEEK_RATE_LIMIT', 30), // requests / minute\n ],\n\n // Google Gemini — free tier at https://aistudio.google.com/apikey\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY', ''),\n 'model' => env('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),\n ],\n\n // Groq — free tier at https://console.groq.com (vision: llama-4 / llama-3.2)\n 'groq' => [\n 'key' => env('GROQ_API_KEY', ''),\n 'model' => env('GROQ_VISION_MODEL', 'meta-llama/llama-4-scout-17b-16e-instruct'),\n ],\n\n // Generic vision fallback (any OpenAI-compatible endpoint: OpenAI, OpenRouter, etc.)\n 'vision' => [\n 'key' => env('VISION_API_KEY', ''),\n 'base_url' => env('VISION_BASE_URL', 'https://openrouter.ai/api/v1'),\n 'model' => env('VISION_MODEL', 'google/gemini-flash-1.5-8b'),\n ],\n\n 'ocrspace' => [\n 'key' => env('OCRSPACE_API_KEY', 'helloworld'),\n ],\n\n];\n"}, "380bc546449c8da0516a2cd357f65f6e6421dd90": {"t": 1785748346.793319, "n": 2841, "h": "2a2d60233a0431c9", "c": " [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 30),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'deepseek' => [\n 'key' => env('DEEPSEEK_API_KEY'),\n 'model' => env('DEEPSEEK_MODEL', 'deepseek-chat'),\n 'vision_model' => env('DEEPSEEK_VISION_MODEL', 'deepseek-vl2'),\n // Catalog research client settings (see config/catalog_research.php too).\n 'base_url' => env('DEEPSEEK_BASE_URL', 'https://api.deepseek.com'),\n 'timeout' => (int) env('DEEPSEEK_TIMEOUT', 120),\n 'max_retries' => (int) env('DEEPSEEK_MAX_RETRIES', 3),\n 'rate_limit' => (int) env('DEEPSEEK_RATE_LIMIT', 30), // requests / minute\n ],\n\n // Google Gemini — free tier at https://aistudio.google.com/apikey\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY', ''),\n 'model' => env('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),\n ],\n\n // Groq — free tier at https://console.groq.com (vision: llama-4 / llama-3.2)\n 'groq' => [\n 'key' => env('GROQ_API_KEY', ''),\n 'model' => env('GROQ_VISION_MODEL', 'meta-llama/llama-4-scout-17b-16e-instruct'),\n ],\n\n // Generic vision fallback (any OpenAI-compatible endpoint: OpenAI, OpenRouter, etc.)\n 'vision' => [\n 'key' => env('VISION_API_KEY', ''),\n 'base_url' => env('VISION_BASE_URL', 'https://openrouter.ai/api/v1'),\n 'model' => env('VISION_MODEL', 'google/gemini-flash-1.5-8b'),\n ],\n\n 'ocrspace' => [\n 'key' => env('OCRSPACE_API_KEY', 'helloworld'),\n ],\n\n];\n"}, "f01410ba86b70436dbde9e233e6611d10545c13c": {"t": 1785748347.9152079, "n": 2467, "h": "6643d4ee1ef4e8c9", "c": " [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 30),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'deepseek' => [\n 'key' => env('DEEPSEEK_API_KEY'),\n 'model' => env('DEEPSEEK_MODEL', 'deepseek-chat'),\n 'vision_model' => env('DEEPSEEK_VISION_MODEL', 'deepseek-vl2'),\n ],\n\n // Google Gemini — free tier at https://aistudio.google.com/apikey\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY', ''),\n 'model' => env('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),\n ],\n\n // Groq — free tier at https://console.groq.com (vision: llama-4 / llama-3.2)\n 'groq' => [\n 'key' => env('GROQ_API_KEY', ''),\n 'model' => env('GROQ_VISION_MODEL', 'meta-llama/llama-4-scout-17b-16e-instruct'),\n ],\n\n // Generic vision fallback (any OpenAI-compatible endpoint: OpenAI, OpenRouter, etc.)\n 'vision' => [\n 'key' => env('VISION_API_KEY', ''),\n 'base_url' => env('VISION_BASE_URL', 'https://openrouter.ai/api/v1'),\n 'model' => env('VISION_MODEL', 'google/gemini-flash-1.5-8b'),\n ],\n\n 'ocrspace' => [\n 'key' => env('OCRSPACE_API_KEY', 'helloworld'),\n ],\n\n];\n"}, "769498c3a1c058b48ccadcb2840e8474a079cab2": {"t": 1785748349.1555865, "n": 2381, "h": "3c5c8d5a40f4b977", "c": " [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 30),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'deepseek' => [\n 'key' => env('DEEPSEEK_API_KEY'),\n 'model' => env('DEEPSEEK_MODEL', 'deepseek-chat'),\n ],\n\n // Google Gemini — free tier at https://aistudio.google.com/apikey\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY', ''),\n 'model' => env('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),\n ],\n\n // Groq — free tier at https://console.groq.com (vision: llama-4 / llama-3.2)\n 'groq' => [\n 'key' => env('GROQ_API_KEY', ''),\n 'model' => env('GROQ_VISION_MODEL', 'meta-llama/llama-4-scout-17b-16e-instruct'),\n ],\n\n // Generic vision fallback (any OpenAI-compatible endpoint: OpenAI, OpenRouter, etc.)\n 'vision' => [\n 'key' => env('VISION_API_KEY', ''),\n 'base_url' => env('VISION_BASE_URL', 'https://openrouter.ai/api/v1'),\n 'model' => env('VISION_MODEL', 'google/gemini-flash-1.5-8b'),\n ],\n\n 'ocrspace' => [\n 'key' => env('OCRSPACE_API_KEY', 'helloworld'),\n ],\n\n];\n"}, "49f1993d53cb7be5eb63aa3417cccb277e75daa4": {"t": 1785748351.3533325, "n": 2286, "h": "a06cf01f5b22215e", "c": " [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 30),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'deepseek' => [\n 'key' => env('DEEPSEEK_API_KEY'),\n 'model' => env('DEEPSEEK_MODEL', 'deepseek-chat'),\n ],\n\n // Google Gemini (recommended — free tier available at https://ai.google.dev)\n // Set GEMINI_API_KEY in .env and image processing will use Gemini automatically.\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY', ''),\n 'model' => env('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),\n ],\n\n // Generic vision fallback (any OpenAI-compatible endpoint: OpenAI, OpenRouter, etc.)\n // Only used when GEMINI_API_KEY is not set.\n 'vision' => [\n 'key' => env('VISION_API_KEY', ''),\n 'base_url' => env('VISION_BASE_URL', 'https://openrouter.ai/api/v1'),\n 'model' => env('VISION_MODEL', 'google/gemini-flash-1.5-8b'),\n ],\n\n 'ocrspace' => [\n 'key' => env('OCRSPACE_API_KEY', 'helloworld'),\n ],\n\n];\n"}, "c9cfc223dc923400b78320c53bc3915186a077bd": {"t": 1785748352.4341967, "n": 2181, "h": "72567b532e87f5b3", "c": " [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 30),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'deepseek' => [\n 'key' => env('DEEPSEEK_API_KEY'),\n 'model' => env('DEEPSEEK_MODEL', 'deepseek-chat'),\n ],\n\n // Vision-capable model (any OpenAI-compatible endpoint: OpenAI, OpenRouter, Gemini, etc.)\n // DeepSeek does NOT support image inputs — use a vision API here.\n // Free option: OpenRouter (https://openrouter.ai) with google/gemini-flash-1.5-8b\n // Cheap option: OpenAI gpt-4o-mini | Google Gemini Flash (https://ai.google.dev)\n 'vision' => [\n 'key' => env('VISION_API_KEY', ''),\n 'base_url' => env('VISION_BASE_URL', 'https://openrouter.ai/api/v1'),\n 'model' => env('VISION_MODEL', 'google/gemini-flash-1.5-8b'),\n ],\n\n 'ocrspace' => [\n 'key' => env('OCRSPACE_API_KEY', 'helloworld'),\n ],\n\n];\n"}, "b65ff6622e72f65525fe55404e124bc72c97539d": {"t": 1785748354.2153206, "n": 1701, "h": "7e822ca1a1601242", "c": " [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 30),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'deepseek' => [\n 'key' => env('DEEPSEEK_API_KEY'),\n 'model' => env('DEEPSEEK_MODEL', 'deepseek-chat'),\n 'vision_model' => env('DEEPSEEK_VISION_MODEL', 'deepseek-chat'),\n ],\n\n 'ocrspace' => [\n 'key' => env('OCRSPACE_API_KEY', 'helloworld'),\n ],\n\n];\n"}, "ed11fb1768acfde3b4f940ad9398269f2353fd64": {"t": 1785748355.2859316, "n": 1527, "h": "98dca374fd90c796", "c": " [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 30),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY'),\n 'model' => env('GEMINI_MODEL', 'gemini-2.5-flash'),\n ],\n\n];\n"}, "591e116059c37741acf2d7111a75250d1714307e": {"t": 1785748356.378487, "n": 1528, "h": "0062ac2bde78fa26", "c": " [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 180),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY'),\n 'model' => env('GEMINI_MODEL', 'gemini-2.5-flash'),\n ],\n\n];\n"}, "6fad901dd50024591679a24a2b8c93da95f47220": {"t": 1785748357.478145, "n": 1528, "h": "966cc75cd166c4f5", "c": " [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 180),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY'),\n 'model' => env('GEMINI_MODEL', 'gemini-2.0-flash'),\n ],\n\n];\n"}, "d18974ac98ccde45831fafe18562ddf73e6c0c6c": {"t": 1785748358.5549593, "n": 1528, "h": "0062ac2bde78fa26", "c": " [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 180),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY'),\n 'model' => env('GEMINI_MODEL', 'gemini-2.5-flash'),\n ],\n\n];\n"}, "53c8f6e7f81d8dfdc9642ff895a779f1b4cd7a67": {"t": 1785748359.634313, "n": 1461, "h": "890856ae61e5f558", "c": " [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 120),\n ],\n\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY'),\n 'model' => env('GEMINI_MODEL', 'gemini-2.5-flash'),\n ],\n\n];\n"}, "a4318f8cc2271b05ba9016c1010c1b8f31161281": {"t": 1785748360.7212021, "n": 1333, "h": "545fa668f99b2f5f", "c": " [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 120),\n ],\n\n];\n"}}, "app/Services/QuotationAiService.php": {"9a5ada4ab1ceb0de386bc1ce99c561f8869c09d7": {"t": 1785745771.025775, "n": 101270, "h": "ebfa7324f0f51e4a", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n\n if ($extracted === self::PDF_LOW_MEMORY) {\n return $this->failure('The server was low on memory while reading this PDF. Please try again in a moment, or convert it to Excel or CSV.');\n }\n\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // No text yet — smalot threw its \"$obj\" bug and pdftotext is not\n // installed. Last resort before giving up: OCR.space, a hosted\n // reader that takes the PDF and returns its text. Needs no local\n // binary and no vision key, and still feeds DeepSeek afterwards.\n $ocrText = $this->extractPdfTextViaOcrSpace($absPath);\n\n if ($ocrText !== null && mb_strlen(trim($ocrText)) > 50) {\n $ocrText = $this->sanitizeUtf8($ocrText);\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $ocrText . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // Every reader failed. Which one to blame changes the advice: a\n // missing OCR key is ours to fix and no amount of re-uploading\n // helps, so telling the user to convert the file would send them\n // chasing a problem they cannot solve.\n Log::error('QuotationAiService: every PDF reader failed.', [\n 'file' => basename($absPath),\n 'bytes' => @filesize($absPath) ?: 0,\n 'pages' => $this->lastPdfPageCount,\n 'pdftotext' => $this->pdftotextPath() !== null,\n 'ocr_key_set' => ((string) config('services.ocrspace.key', '')) !== ''\n && config('services.ocrspace.key') !== 'helloworld',\n ]);\n\n return $this->failure('Could not read this PDF. It may be a scanned image, or use a format our reader cannot open. Please convert it to Excel or CSV and upload again.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n // Try to reclaim memory a previous job left behind before deciding\n // there is not enough. Without this, a worker sitting near its limit\n // after a big job could refuse a tiny PDF for no real reason.\n gc_collect_cycles();\n\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n // A distinct sentinel, so the caller can tell \"no memory\" apart\n // from \"no text\" and show the right message instead of blaming\n // the file for being scanned.\n return self::PDF_LOW_MEMORY;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n // Class, file and line, not just the message — a bare message left\n // the actual cause of a PDF failure guessable at best.\n Log::warning('QuotationAiService: PDF parsing failed.', [\n 'exception' => $e::class,\n 'error' => $e->getMessage(),\n 'at' => $e->getFile() . ':' . $e->getLine(),\n ]);\n\n // smalot/pdfparser throws \"Invalid object reference for $obj.\" on\n // some perfectly valid PDFs — a known bug in the library, not a\n // problem with the file. Fall back to the pdftotext binary, which\n // reads those documents fine.\n return $this->extractPdfTextViaBinary($absPath);\n }\n }\n\n /**\n * Extract PDF text through the OCR.space API.\n *\n * The fallback of last resort when smalot throws and pdftotext is not on the\n * server. OCR.space is a hosted reader: the file is uploaded and its text\n * comes back, so it needs nothing installed locally. The free tier caps\n * uploads at 1 MB and 3 pages — enough for a typical BOQ, and skipped with a\n * clear log line when the file is over that, rather than a wasted call.\n */\n private function extractPdfTextViaOcrSpace(string $absPath): ?string\n {\n $apiKey = (string) config('services.ocrspace.key', '');\n\n if ($apiKey === '') {\n return null;\n }\n\n // The free tier caps the FILE at 3 MB (the 1 MB figure is the per-page\n // limit). Capping at 1 MB here turned readable BOQs away without trying.\n $size = @filesize($absPath) ?: 0;\n if ($size > 3 * 1024 * 1024) {\n Log::warning('QuotationAiService: PDF too large for the OCR fallback.', [\n 'bytes' => $size,\n 'limit' => 3 * 1024 * 1024,\n ]);\n return null;\n }\n\n // 'helloworld' is OCR.space's public demo key, and it is the config\n // default. It is rate-limited to near-nothing and starts rejecting real\n // traffic almost immediately, which surfaces to the user as the generic\n // \"could not read this PDF\" — indistinguishable from a genuinely\n // unreadable file. Say so plainly instead.\n if ($apiKey === 'helloworld') {\n Log::warning('QuotationAiService: OCR is using the shared demo key; PDF reading will fail under any real use. Set OCRSPACE_API_KEY in .env (free at https://ocr.space/ocrapi/freekey).');\n }\n\n try {\n $response = Http::timeout(120)\n ->attach('file', (string) file_get_contents($absPath), basename($absPath))\n ->post('https://api.ocr.space/parse/image', [\n 'apikey' => $apiKey,\n 'filetype' => 'PDF',\n 'language' => 'eng',\n 'isTable' => 'true', // BOQs are tabular; keep columns\n 'OCREngine' => '2',\n 'scale' => 'true',\n ]);\n\n if (! $response->successful()) {\n Log::warning('QuotationAiService: OCR.space request failed.', [\n 'status' => $response->status(),\n ]);\n return null;\n }\n\n $json = $response->json();\n\n // OCR.space reports its own errors in the body with a 200 status.\n if (($json['IsErroredOnProcessing'] ?? false) === true) {\n Log::warning('QuotationAiService: OCR.space could not process the PDF.', [\n 'message' => $json['ErrorMessage'] ?? 'unknown',\n ]);\n return null;\n }\n\n $text = '';\n foreach ($json['ParsedResults'] ?? [] as $page) {\n $text .= ($page['ParsedText'] ?? '') . \"\\n\";\n }\n\n $text = trim((string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text));\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via OCR.space.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: OCR.space fallback threw.', [\n 'error' => $e->getMessage(),\n ]);\n return null;\n }\n }\n\n /**\n * Locate the pdftotext binary.\n *\n * On shared hosting it is rarely on the system PATH, so a user-installed\n * copy — dropped in ~/bin, ~/.local/bin, or the project's vendor/bin — has\n * to be found explicitly. An escaped, quoted absolute path is returned so it\n * is safe to interpolate into the command.\n *\n * PDFTOTEXT_PATH in .env wins, for a copy installed somewhere non-standard.\n */\n private function pdftotextPath(): ?string\n {\n $configured = trim((string) env('PDFTOTEXT_PATH', ''));\n\n $candidates = array_filter([\n $configured !== '' ? $configured : null,\n ($home = getenv('HOME')) ? $home . '/bin/pdftotext' : null,\n $home ? $home . '/.local/bin/pdftotext' : null,\n base_path('vendor/bin/pdftotext'),\n '/usr/bin/pdftotext',\n '/usr/local/bin/pdftotext',\n ]);\n\n foreach ($candidates as $path) {\n if (is_file($path) && is_executable($path)) {\n return escapeshellarg($path);\n }\n }\n\n // Fall back to a bare name, in case it is on PATH after all.\n $onPath = @shell_exec('command -v pdftotext 2>/dev/null');\n\n return is_string($onPath) && trim($onPath) !== '' ? 'pdftotext' : null;\n }\n\n private function extractPdfTextViaBinary(string $absPath): ?string\n {\n // shell_exec is commonly disabled on shared hosting; bail cleanly so the\n // caller falls through to the vision path or a clear error.\n if (! function_exists('shell_exec')\n || in_array('shell_exec', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true)) {\n return null;\n }\n\n $binary = $this->pdftotextPath();\n\n if ($binary === null) {\n Log::warning('QuotationAiService: pdftotext binary not found.', [\n 'looked_in' => 'PATH, PDFTOTEXT_PATH, ~/bin, ~/.local/bin, vendor/bin',\n ]);\n return null;\n }\n\n // `-layout` keeps columns roughly aligned, which matters for a BOQ; the\n // trailing \"-\" writes to stdout instead of a file.\n $command = sprintf('%s -layout -enc UTF-8 %s - 2>/dev/null', $binary, escapeshellarg($absPath));\n\n $output = @shell_exec($command);\n\n if (! is_string($output) || trim($output) === '') {\n Log::warning('QuotationAiService: pdftotext fallback produced no text.', [\n 'path' => basename($absPath),\n ]);\n return null;\n }\n\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $output);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via pdftotext fallback.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array $row\n * @return array\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n // Clamped to the column width. The model routinely returns a whole\n // description here — \"Cocktail Unit Ice well adjacent to chopping\n // area with waste chute…\" — and a value over 100 chars made the\n // INSERT fail with SQLSTATE[22001], which silently cost the whole\n // chunk its rows and left the reuse tables empty.\n 'category' => mb_substr((string) ($raw['core_product'] ?? $raw['category'] ?? ''), 0, 100),\n 'brand' => mb_substr((string) ($specs['brand'] ?? $raw['brand'] ?? ''), 0, 100),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "89bda6cd3370f6bba078a12282b744e0ca8f1bd7": {"t": 1785748347.7905595, "n": 101270, "h": "ebfa7324f0f51e4a", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n\n if ($extracted === self::PDF_LOW_MEMORY) {\n return $this->failure('The server was low on memory while reading this PDF. Please try again in a moment, or convert it to Excel or CSV.');\n }\n\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // No text yet — smalot threw its \"$obj\" bug and pdftotext is not\n // installed. Last resort before giving up: OCR.space, a hosted\n // reader that takes the PDF and returns its text. Needs no local\n // binary and no vision key, and still feeds DeepSeek afterwards.\n $ocrText = $this->extractPdfTextViaOcrSpace($absPath);\n\n if ($ocrText !== null && mb_strlen(trim($ocrText)) > 50) {\n $ocrText = $this->sanitizeUtf8($ocrText);\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $ocrText . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // Every reader failed. Which one to blame changes the advice: a\n // missing OCR key is ours to fix and no amount of re-uploading\n // helps, so telling the user to convert the file would send them\n // chasing a problem they cannot solve.\n Log::error('QuotationAiService: every PDF reader failed.', [\n 'file' => basename($absPath),\n 'bytes' => @filesize($absPath) ?: 0,\n 'pages' => $this->lastPdfPageCount,\n 'pdftotext' => $this->pdftotextPath() !== null,\n 'ocr_key_set' => ((string) config('services.ocrspace.key', '')) !== ''\n && config('services.ocrspace.key') !== 'helloworld',\n ]);\n\n return $this->failure('Could not read this PDF. It may be a scanned image, or use a format our reader cannot open. Please convert it to Excel or CSV and upload again.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n // Try to reclaim memory a previous job left behind before deciding\n // there is not enough. Without this, a worker sitting near its limit\n // after a big job could refuse a tiny PDF for no real reason.\n gc_collect_cycles();\n\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n // A distinct sentinel, so the caller can tell \"no memory\" apart\n // from \"no text\" and show the right message instead of blaming\n // the file for being scanned.\n return self::PDF_LOW_MEMORY;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n // Class, file and line, not just the message — a bare message left\n // the actual cause of a PDF failure guessable at best.\n Log::warning('QuotationAiService: PDF parsing failed.', [\n 'exception' => $e::class,\n 'error' => $e->getMessage(),\n 'at' => $e->getFile() . ':' . $e->getLine(),\n ]);\n\n // smalot/pdfparser throws \"Invalid object reference for $obj.\" on\n // some perfectly valid PDFs — a known bug in the library, not a\n // problem with the file. Fall back to the pdftotext binary, which\n // reads those documents fine.\n return $this->extractPdfTextViaBinary($absPath);\n }\n }\n\n /**\n * Extract PDF text through the OCR.space API.\n *\n * The fallback of last resort when smalot throws and pdftotext is not on the\n * server. OCR.space is a hosted reader: the file is uploaded and its text\n * comes back, so it needs nothing installed locally. The free tier caps\n * uploads at 1 MB and 3 pages — enough for a typical BOQ, and skipped with a\n * clear log line when the file is over that, rather than a wasted call.\n */\n private function extractPdfTextViaOcrSpace(string $absPath): ?string\n {\n $apiKey = (string) config('services.ocrspace.key', '');\n\n if ($apiKey === '') {\n return null;\n }\n\n // The free tier caps the FILE at 3 MB (the 1 MB figure is the per-page\n // limit). Capping at 1 MB here turned readable BOQs away without trying.\n $size = @filesize($absPath) ?: 0;\n if ($size > 3 * 1024 * 1024) {\n Log::warning('QuotationAiService: PDF too large for the OCR fallback.', [\n 'bytes' => $size,\n 'limit' => 3 * 1024 * 1024,\n ]);\n return null;\n }\n\n // 'helloworld' is OCR.space's public demo key, and it is the config\n // default. It is rate-limited to near-nothing and starts rejecting real\n // traffic almost immediately, which surfaces to the user as the generic\n // \"could not read this PDF\" — indistinguishable from a genuinely\n // unreadable file. Say so plainly instead.\n if ($apiKey === 'helloworld') {\n Log::warning('QuotationAiService: OCR is using the shared demo key; PDF reading will fail under any real use. Set OCRSPACE_API_KEY in .env (free at https://ocr.space/ocrapi/freekey).');\n }\n\n try {\n $response = Http::timeout(120)\n ->attach('file', (string) file_get_contents($absPath), basename($absPath))\n ->post('https://api.ocr.space/parse/image', [\n 'apikey' => $apiKey,\n 'filetype' => 'PDF',\n 'language' => 'eng',\n 'isTable' => 'true', // BOQs are tabular; keep columns\n 'OCREngine' => '2',\n 'scale' => 'true',\n ]);\n\n if (! $response->successful()) {\n Log::warning('QuotationAiService: OCR.space request failed.', [\n 'status' => $response->status(),\n ]);\n return null;\n }\n\n $json = $response->json();\n\n // OCR.space reports its own errors in the body with a 200 status.\n if (($json['IsErroredOnProcessing'] ?? false) === true) {\n Log::warning('QuotationAiService: OCR.space could not process the PDF.', [\n 'message' => $json['ErrorMessage'] ?? 'unknown',\n ]);\n return null;\n }\n\n $text = '';\n foreach ($json['ParsedResults'] ?? [] as $page) {\n $text .= ($page['ParsedText'] ?? '') . \"\\n\";\n }\n\n $text = trim((string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text));\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via OCR.space.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: OCR.space fallback threw.', [\n 'error' => $e->getMessage(),\n ]);\n return null;\n }\n }\n\n /**\n * Locate the pdftotext binary.\n *\n * On shared hosting it is rarely on the system PATH, so a user-installed\n * copy — dropped in ~/bin, ~/.local/bin, or the project's vendor/bin — has\n * to be found explicitly. An escaped, quoted absolute path is returned so it\n * is safe to interpolate into the command.\n *\n * PDFTOTEXT_PATH in .env wins, for a copy installed somewhere non-standard.\n */\n private function pdftotextPath(): ?string\n {\n $configured = trim((string) env('PDFTOTEXT_PATH', ''));\n\n $candidates = array_filter([\n $configured !== '' ? $configured : null,\n ($home = getenv('HOME')) ? $home . '/bin/pdftotext' : null,\n $home ? $home . '/.local/bin/pdftotext' : null,\n base_path('vendor/bin/pdftotext'),\n '/usr/bin/pdftotext',\n '/usr/local/bin/pdftotext',\n ]);\n\n foreach ($candidates as $path) {\n if (is_file($path) && is_executable($path)) {\n return escapeshellarg($path);\n }\n }\n\n // Fall back to a bare name, in case it is on PATH after all.\n $onPath = @shell_exec('command -v pdftotext 2>/dev/null');\n\n return is_string($onPath) && trim($onPath) !== '' ? 'pdftotext' : null;\n }\n\n private function extractPdfTextViaBinary(string $absPath): ?string\n {\n // shell_exec is commonly disabled on shared hosting; bail cleanly so the\n // caller falls through to the vision path or a clear error.\n if (! function_exists('shell_exec')\n || in_array('shell_exec', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true)) {\n return null;\n }\n\n $binary = $this->pdftotextPath();\n\n if ($binary === null) {\n Log::warning('QuotationAiService: pdftotext binary not found.', [\n 'looked_in' => 'PATH, PDFTOTEXT_PATH, ~/bin, ~/.local/bin, vendor/bin',\n ]);\n return null;\n }\n\n // `-layout` keeps columns roughly aligned, which matters for a BOQ; the\n // trailing \"-\" writes to stdout instead of a file.\n $command = sprintf('%s -layout -enc UTF-8 %s - 2>/dev/null', $binary, escapeshellarg($absPath));\n\n $output = @shell_exec($command);\n\n if (! is_string($output) || trim($output) === '') {\n Log::warning('QuotationAiService: pdftotext fallback produced no text.', [\n 'path' => basename($absPath),\n ]);\n return null;\n }\n\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $output);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via pdftotext fallback.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array $row\n * @return array\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n // Clamped to the column width. The model routinely returns a whole\n // description here — \"Cocktail Unit Ice well adjacent to chopping\n // area with waste chute…\" — and a value over 100 chars made the\n // INSERT fail with SQLSTATE[22001], which silently cost the whole\n // chunk its rows and left the reuse tables empty.\n 'category' => mb_substr((string) ($raw['core_product'] ?? $raw['category'] ?? ''), 0, 100),\n 'brand' => mb_substr((string) ($specs['brand'] ?? $raw['brand'] ?? ''), 0, 100),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "31eba737716b48e69f3ed688aeaf7bc864a34c87": {"t": 1785748349.2719965, "n": 99727, "h": "e90fe0f56938737a", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n\n if ($extracted === self::PDF_LOW_MEMORY) {\n return $this->failure('The server was low on memory while reading this PDF. Please try again in a moment, or convert it to Excel or CSV.');\n }\n\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // No text yet — smalot threw its \"$obj\" bug and pdftotext is not\n // installed. Last resort before giving up: OCR.space, a hosted\n // reader that takes the PDF and returns its text. Needs no local\n // binary and no vision key, and still feeds DeepSeek afterwards.\n $ocrText = $this->extractPdfTextViaOcrSpace($absPath);\n\n if ($ocrText !== null && mb_strlen(trim($ocrText)) > 50) {\n $ocrText = $this->sanitizeUtf8($ocrText);\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $ocrText . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n return $this->failure('Could not read this PDF. It may be a scanned image, or use a format our reader cannot open. Please convert it to Excel or CSV and upload again.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n // Try to reclaim memory a previous job left behind before deciding\n // there is not enough. Without this, a worker sitting near its limit\n // after a big job could refuse a tiny PDF for no real reason.\n gc_collect_cycles();\n\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n // A distinct sentinel, so the caller can tell \"no memory\" apart\n // from \"no text\" and show the right message instead of blaming\n // the file for being scanned.\n return self::PDF_LOW_MEMORY;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n // Class, file and line, not just the message — a bare message left\n // the actual cause of a PDF failure guessable at best.\n Log::warning('QuotationAiService: PDF parsing failed.', [\n 'exception' => $e::class,\n 'error' => $e->getMessage(),\n 'at' => $e->getFile() . ':' . $e->getLine(),\n ]);\n\n // smalot/pdfparser throws \"Invalid object reference for $obj.\" on\n // some perfectly valid PDFs — a known bug in the library, not a\n // problem with the file. Fall back to the pdftotext binary, which\n // reads those documents fine.\n return $this->extractPdfTextViaBinary($absPath);\n }\n }\n\n /**\n * Extract PDF text through the OCR.space API.\n *\n * The fallback of last resort when smalot throws and pdftotext is not on the\n * server. OCR.space is a hosted reader: the file is uploaded and its text\n * comes back, so it needs nothing installed locally. The free tier caps\n * uploads at 1 MB and 3 pages — enough for a typical BOQ, and skipped with a\n * clear log line when the file is over that, rather than a wasted call.\n */\n private function extractPdfTextViaOcrSpace(string $absPath): ?string\n {\n $apiKey = (string) config('services.ocrspace.key', '');\n\n if ($apiKey === '') {\n return null;\n }\n\n // Free tier hard limit. Above it the call is rejected, so do not make it.\n $size = @filesize($absPath) ?: 0;\n if ($size > 1024 * 1024) {\n Log::warning('QuotationAiService: PDF too large for the OCR fallback.', [\n 'bytes' => $size,\n 'limit' => 1024 * 1024,\n ]);\n return null;\n }\n\n try {\n $response = Http::timeout(120)\n ->attach('file', (string) file_get_contents($absPath), basename($absPath))\n ->post('https://api.ocr.space/parse/image', [\n 'apikey' => $apiKey,\n 'filetype' => 'PDF',\n 'language' => 'eng',\n 'isTable' => 'true', // BOQs are tabular; keep columns\n 'OCREngine' => '2',\n 'scale' => 'true',\n ]);\n\n if (! $response->successful()) {\n Log::warning('QuotationAiService: OCR.space request failed.', [\n 'status' => $response->status(),\n ]);\n return null;\n }\n\n $json = $response->json();\n\n // OCR.space reports its own errors in the body with a 200 status.\n if (($json['IsErroredOnProcessing'] ?? false) === true) {\n Log::warning('QuotationAiService: OCR.space could not process the PDF.', [\n 'message' => $json['ErrorMessage'] ?? 'unknown',\n ]);\n return null;\n }\n\n $text = '';\n foreach ($json['ParsedResults'] ?? [] as $page) {\n $text .= ($page['ParsedText'] ?? '') . \"\\n\";\n }\n\n $text = trim((string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text));\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via OCR.space.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: OCR.space fallback threw.', [\n 'error' => $e->getMessage(),\n ]);\n return null;\n }\n }\n\n /**\n * Locate the pdftotext binary.\n *\n * On shared hosting it is rarely on the system PATH, so a user-installed\n * copy — dropped in ~/bin, ~/.local/bin, or the project's vendor/bin — has\n * to be found explicitly. An escaped, quoted absolute path is returned so it\n * is safe to interpolate into the command.\n *\n * PDFTOTEXT_PATH in .env wins, for a copy installed somewhere non-standard.\n */\n private function pdftotextPath(): ?string\n {\n $configured = trim((string) env('PDFTOTEXT_PATH', ''));\n\n $candidates = array_filter([\n $configured !== '' ? $configured : null,\n ($home = getenv('HOME')) ? $home . '/bin/pdftotext' : null,\n $home ? $home . '/.local/bin/pdftotext' : null,\n base_path('vendor/bin/pdftotext'),\n '/usr/bin/pdftotext',\n '/usr/local/bin/pdftotext',\n ]);\n\n foreach ($candidates as $path) {\n if (is_file($path) && is_executable($path)) {\n return escapeshellarg($path);\n }\n }\n\n // Fall back to a bare name, in case it is on PATH after all.\n $onPath = @shell_exec('command -v pdftotext 2>/dev/null');\n\n return is_string($onPath) && trim($onPath) !== '' ? 'pdftotext' : null;\n }\n\n private function extractPdfTextViaBinary(string $absPath): ?string\n {\n // shell_exec is commonly disabled on shared hosting; bail cleanly so the\n // caller falls through to the vision path or a clear error.\n if (! function_exists('shell_exec')\n || in_array('shell_exec', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true)) {\n return null;\n }\n\n $binary = $this->pdftotextPath();\n\n if ($binary === null) {\n Log::warning('QuotationAiService: pdftotext binary not found.', [\n 'looked_in' => 'PATH, PDFTOTEXT_PATH, ~/bin, ~/.local/bin, vendor/bin',\n ]);\n return null;\n }\n\n // `-layout` keeps columns roughly aligned, which matters for a BOQ; the\n // trailing \"-\" writes to stdout instead of a file.\n $command = sprintf('%s -layout -enc UTF-8 %s - 2>/dev/null', $binary, escapeshellarg($absPath));\n\n $output = @shell_exec($command);\n\n if (! is_string($output) || trim($output) === '') {\n Log::warning('QuotationAiService: pdftotext fallback produced no text.', [\n 'path' => basename($absPath),\n ]);\n return null;\n }\n\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $output);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via pdftotext fallback.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array $row\n * @return array\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n // Clamped to the column width. The model routinely returns a whole\n // description here — \"Cocktail Unit Ice well adjacent to chopping\n // area with waste chute…\" — and a value over 100 chars made the\n // INSERT fail with SQLSTATE[22001], which silently cost the whole\n // chunk its rows and left the reuse tables empty.\n 'category' => mb_substr((string) ($raw['core_product'] ?? $raw['category'] ?? ''), 0, 100),\n 'brand' => mb_substr((string) ($specs['brand'] ?? $raw['brand'] ?? ''), 0, 100),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "2105116bd66b3ba99b4e577e0957008ffcfdec2e": {"t": 1785748350.7343705, "n": 99311, "h": "af0161c408563c8f", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n\n if ($extracted === self::PDF_LOW_MEMORY) {\n return $this->failure('The server was low on memory while reading this PDF. Please try again in a moment, or convert it to Excel or CSV.');\n }\n\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // No text yet — smalot threw its \"$obj\" bug and pdftotext is not\n // installed. Last resort before giving up: OCR.space, a hosted\n // reader that takes the PDF and returns its text. Needs no local\n // binary and no vision key, and still feeds DeepSeek afterwards.\n $ocrText = $this->extractPdfTextViaOcrSpace($absPath);\n\n if ($ocrText !== null && mb_strlen(trim($ocrText)) > 50) {\n $ocrText = $this->sanitizeUtf8($ocrText);\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $ocrText . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n return $this->failure('Could not read this PDF. It may be a scanned image, or use a format our reader cannot open. Please convert it to Excel or CSV and upload again.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n // Try to reclaim memory a previous job left behind before deciding\n // there is not enough. Without this, a worker sitting near its limit\n // after a big job could refuse a tiny PDF for no real reason.\n gc_collect_cycles();\n\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n // A distinct sentinel, so the caller can tell \"no memory\" apart\n // from \"no text\" and show the right message instead of blaming\n // the file for being scanned.\n return self::PDF_LOW_MEMORY;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n // Class, file and line, not just the message — a bare message left\n // the actual cause of a PDF failure guessable at best.\n Log::warning('QuotationAiService: PDF parsing failed.', [\n 'exception' => $e::class,\n 'error' => $e->getMessage(),\n 'at' => $e->getFile() . ':' . $e->getLine(),\n ]);\n\n // smalot/pdfparser throws \"Invalid object reference for $obj.\" on\n // some perfectly valid PDFs — a known bug in the library, not a\n // problem with the file. Fall back to the pdftotext binary, which\n // reads those documents fine.\n return $this->extractPdfTextViaBinary($absPath);\n }\n }\n\n /**\n * Extract PDF text through the OCR.space API.\n *\n * The fallback of last resort when smalot throws and pdftotext is not on the\n * server. OCR.space is a hosted reader: the file is uploaded and its text\n * comes back, so it needs nothing installed locally. The free tier caps\n * uploads at 1 MB and 3 pages — enough for a typical BOQ, and skipped with a\n * clear log line when the file is over that, rather than a wasted call.\n */\n private function extractPdfTextViaOcrSpace(string $absPath): ?string\n {\n $apiKey = (string) config('services.ocrspace.key', '');\n\n if ($apiKey === '') {\n return null;\n }\n\n // Free tier hard limit. Above it the call is rejected, so do not make it.\n $size = @filesize($absPath) ?: 0;\n if ($size > 1024 * 1024) {\n Log::warning('QuotationAiService: PDF too large for the OCR fallback.', [\n 'bytes' => $size,\n 'limit' => 1024 * 1024,\n ]);\n return null;\n }\n\n try {\n $response = Http::timeout(120)\n ->attach('file', (string) file_get_contents($absPath), basename($absPath))\n ->post('https://api.ocr.space/parse/image', [\n 'apikey' => $apiKey,\n 'filetype' => 'PDF',\n 'language' => 'eng',\n 'isTable' => 'true', // BOQs are tabular; keep columns\n 'OCREngine' => '2',\n 'scale' => 'true',\n ]);\n\n if (! $response->successful()) {\n Log::warning('QuotationAiService: OCR.space request failed.', [\n 'status' => $response->status(),\n ]);\n return null;\n }\n\n $json = $response->json();\n\n // OCR.space reports its own errors in the body with a 200 status.\n if (($json['IsErroredOnProcessing'] ?? false) === true) {\n Log::warning('QuotationAiService: OCR.space could not process the PDF.', [\n 'message' => $json['ErrorMessage'] ?? 'unknown',\n ]);\n return null;\n }\n\n $text = '';\n foreach ($json['ParsedResults'] ?? [] as $page) {\n $text .= ($page['ParsedText'] ?? '') . \"\\n\";\n }\n\n $text = trim((string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text));\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via OCR.space.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: OCR.space fallback threw.', [\n 'error' => $e->getMessage(),\n ]);\n return null;\n }\n }\n\n /**\n * Locate the pdftotext binary.\n *\n * On shared hosting it is rarely on the system PATH, so a user-installed\n * copy — dropped in ~/bin, ~/.local/bin, or the project's vendor/bin — has\n * to be found explicitly. An escaped, quoted absolute path is returned so it\n * is safe to interpolate into the command.\n *\n * PDFTOTEXT_PATH in .env wins, for a copy installed somewhere non-standard.\n */\n private function pdftotextPath(): ?string\n {\n $configured = trim((string) env('PDFTOTEXT_PATH', ''));\n\n $candidates = array_filter([\n $configured !== '' ? $configured : null,\n ($home = getenv('HOME')) ? $home . '/bin/pdftotext' : null,\n $home ? $home . '/.local/bin/pdftotext' : null,\n base_path('vendor/bin/pdftotext'),\n '/usr/bin/pdftotext',\n '/usr/local/bin/pdftotext',\n ]);\n\n foreach ($candidates as $path) {\n if (is_file($path) && is_executable($path)) {\n return escapeshellarg($path);\n }\n }\n\n // Fall back to a bare name, in case it is on PATH after all.\n $onPath = @shell_exec('command -v pdftotext 2>/dev/null');\n\n return is_string($onPath) && trim($onPath) !== '' ? 'pdftotext' : null;\n }\n\n private function extractPdfTextViaBinary(string $absPath): ?string\n {\n // shell_exec is commonly disabled on shared hosting; bail cleanly so the\n // caller falls through to the vision path or a clear error.\n if (! function_exists('shell_exec')\n || in_array('shell_exec', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true)) {\n return null;\n }\n\n $binary = $this->pdftotextPath();\n\n if ($binary === null) {\n Log::warning('QuotationAiService: pdftotext binary not found.', [\n 'looked_in' => 'PATH, PDFTOTEXT_PATH, ~/bin, ~/.local/bin, vendor/bin',\n ]);\n return null;\n }\n\n // `-layout` keeps columns roughly aligned, which matters for a BOQ; the\n // trailing \"-\" writes to stdout instead of a file.\n $command = sprintf('%s -layout -enc UTF-8 %s - 2>/dev/null', $binary, escapeshellarg($absPath));\n\n $output = @shell_exec($command);\n\n if (! is_string($output) || trim($output) === '') {\n Log::warning('QuotationAiService: pdftotext fallback produced no text.', [\n 'path' => basename($absPath),\n ]);\n return null;\n }\n\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $output);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via pdftotext fallback.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array $row\n * @return array\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "53d2a8cbc1f70002ebb7c26e22ace78a5f0a004c": {"t": 1785748352.2062125, "n": 95737, "h": "7223ddf7507e700d", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n\n if ($extracted === self::PDF_LOW_MEMORY) {\n return $this->failure('The server was low on memory while reading this PDF. Please try again in a moment, or convert it to Excel or CSV.');\n }\n\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // No text — smalot threw and pdftotext was unavailable. We do not\n // route PDFs to a vision model (that path needs a Gemini key we\n // are not using), so ask for an Excel/CSV instead.\n return $this->failure('Could not read this PDF. It may be a scanned image, or use a format our reader cannot open. Please convert it to Excel or CSV and upload again.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n // Try to reclaim memory a previous job left behind before deciding\n // there is not enough. Without this, a worker sitting near its limit\n // after a big job could refuse a tiny PDF for no real reason.\n gc_collect_cycles();\n\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n // A distinct sentinel, so the caller can tell \"no memory\" apart\n // from \"no text\" and show the right message instead of blaming\n // the file for being scanned.\n return self::PDF_LOW_MEMORY;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n // Class, file and line, not just the message — a bare message left\n // the actual cause of a PDF failure guessable at best.\n Log::warning('QuotationAiService: PDF parsing failed.', [\n 'exception' => $e::class,\n 'error' => $e->getMessage(),\n 'at' => $e->getFile() . ':' . $e->getLine(),\n ]);\n\n // smalot/pdfparser throws \"Invalid object reference for $obj.\" on\n // some perfectly valid PDFs — a known bug in the library, not a\n // problem with the file. Fall back to the pdftotext binary, which\n // reads those documents fine.\n return $this->extractPdfTextViaBinary($absPath);\n }\n }\n\n /**\n * Locate the pdftotext binary.\n *\n * On shared hosting it is rarely on the system PATH, so a user-installed\n * copy — dropped in ~/bin, ~/.local/bin, or the project's vendor/bin — has\n * to be found explicitly. An escaped, quoted absolute path is returned so it\n * is safe to interpolate into the command.\n *\n * PDFTOTEXT_PATH in .env wins, for a copy installed somewhere non-standard.\n */\n private function pdftotextPath(): ?string\n {\n $configured = trim((string) env('PDFTOTEXT_PATH', ''));\n\n $candidates = array_filter([\n $configured !== '' ? $configured : null,\n ($home = getenv('HOME')) ? $home . '/bin/pdftotext' : null,\n $home ? $home . '/.local/bin/pdftotext' : null,\n base_path('vendor/bin/pdftotext'),\n '/usr/bin/pdftotext',\n '/usr/local/bin/pdftotext',\n ]);\n\n foreach ($candidates as $path) {\n if (is_file($path) && is_executable($path)) {\n return escapeshellarg($path);\n }\n }\n\n // Fall back to a bare name, in case it is on PATH after all.\n $onPath = @shell_exec('command -v pdftotext 2>/dev/null');\n\n return is_string($onPath) && trim($onPath) !== '' ? 'pdftotext' : null;\n }\n\n private function extractPdfTextViaBinary(string $absPath): ?string\n {\n // shell_exec is commonly disabled on shared hosting; bail cleanly so the\n // caller falls through to the vision path or a clear error.\n if (! function_exists('shell_exec')\n || in_array('shell_exec', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true)) {\n return null;\n }\n\n $binary = $this->pdftotextPath();\n\n if ($binary === null) {\n Log::warning('QuotationAiService: pdftotext binary not found.', [\n 'looked_in' => 'PATH, PDFTOTEXT_PATH, ~/bin, ~/.local/bin, vendor/bin',\n ]);\n return null;\n }\n\n // `-layout` keeps columns roughly aligned, which matters for a BOQ; the\n // trailing \"-\" writes to stdout instead of a file.\n $command = sprintf('%s -layout -enc UTF-8 %s - 2>/dev/null', $binary, escapeshellarg($absPath));\n\n $output = @shell_exec($command);\n\n if (! is_string($output) || trim($output) === '') {\n Log::warning('QuotationAiService: pdftotext fallback produced no text.', [\n 'path' => basename($absPath),\n ]);\n return null;\n }\n\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $output);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via pdftotext fallback.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array $row\n * @return array\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "8baa8a9d7297d411c83559c12ddd3811451c0dbe": {"t": 1785748353.6509032, "n": 97476, "h": "2f66657b490f0820", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n\n if ($extracted === self::PDF_LOW_MEMORY) {\n return $this->failure('The server was low on memory while reading this PDF. Please try again in a moment, or convert it to Excel or CSV.');\n }\n\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // No text — smalot threw and pdftotext was missing or empty.\n // Last resort: hand the PDF straight to a vision model, which\n // reads it as an image and needs no local binary at all. This is\n // what covers a server without pdftotext installed.\n if ($this->hasVisionKey()) {\n Log::info('QuotationAiService: PDF text extraction failed; trying vision.', [\n 'path' => basename($absPath),\n ]);\n\n $bytes = @file_get_contents($absPath);\n $vision = $bytes !== false\n ? $this->callDeepSeekVision($bytes, 'application/pdf', $context)\n : ['success' => false];\n\n if ($vision['success'] ?? false) {\n return $vision;\n }\n }\n\n return $this->failure('Could not read this PDF. It may be a scanned image, or use a format our reader cannot open. Please convert it to Excel or CSV and upload again.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n // Try to reclaim memory a previous job left behind before deciding\n // there is not enough. Without this, a worker sitting near its limit\n // after a big job could refuse a tiny PDF for no real reason.\n gc_collect_cycles();\n\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n // A distinct sentinel, so the caller can tell \"no memory\" apart\n // from \"no text\" and show the right message instead of blaming\n // the file for being scanned.\n return self::PDF_LOW_MEMORY;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n // Class, file and line, not just the message — a bare message left\n // the actual cause of a PDF failure guessable at best.\n Log::warning('QuotationAiService: PDF parsing failed.', [\n 'exception' => $e::class,\n 'error' => $e->getMessage(),\n 'at' => $e->getFile() . ':' . $e->getLine(),\n ]);\n\n // smalot/pdfparser throws \"Invalid object reference for $obj.\" on\n // some perfectly valid PDFs — a known bug in the library, not a\n // problem with the file. Fall back to the pdftotext binary, which\n // reads those documents fine.\n return $this->extractPdfTextViaBinary($absPath);\n }\n }\n\n /**\n * Extract PDF text with the poppler `pdftotext` binary.\n *\n * A fallback for when smalot/pdfparser throws on a document it cannot handle\n * — most often \"Invalid object reference for $obj.\" pdftotext is a separate,\n * more robust implementation, so a PDF that breaks one usually reads on the\n * other. Returns null when the binary is absent or produces nothing, so the\n * caller drops to the vision path or a clear error.\n */\n /**\n * True when at least one vision provider is configured.\n *\n * Checked before falling back to vision, so a server with no vision key\n * gets a clear \"convert to Excel\" message rather than a failed API call.\n */\n private function hasVisionKey(): bool\n {\n // Gemini only. Groq's and OpenRouter's vision endpoints expect an image\n // in the data URL, not a raw PDF, so passing a PDF to them fails or is\n // silently ignored. Gemini accepts application/pdf directly.\n return ((string) config('services.gemini.key', '')) !== '';\n }\n\n /**\n * Locate the pdftotext binary.\n *\n * On shared hosting it is rarely on the system PATH, so a user-installed\n * copy — dropped in ~/bin, ~/.local/bin, or the project's vendor/bin — has\n * to be found explicitly. An escaped, quoted absolute path is returned so it\n * is safe to interpolate into the command.\n *\n * PDFTOTEXT_PATH in .env wins, for a copy installed somewhere non-standard.\n */\n private function pdftotextPath(): ?string\n {\n $configured = trim((string) env('PDFTOTEXT_PATH', ''));\n\n $candidates = array_filter([\n $configured !== '' ? $configured : null,\n ($home = getenv('HOME')) ? $home . '/bin/pdftotext' : null,\n $home ? $home . '/.local/bin/pdftotext' : null,\n base_path('vendor/bin/pdftotext'),\n '/usr/bin/pdftotext',\n '/usr/local/bin/pdftotext',\n ]);\n\n foreach ($candidates as $path) {\n if (is_file($path) && is_executable($path)) {\n return escapeshellarg($path);\n }\n }\n\n // Fall back to a bare name, in case it is on PATH after all.\n $onPath = @shell_exec('command -v pdftotext 2>/dev/null');\n\n return is_string($onPath) && trim($onPath) !== '' ? 'pdftotext' : null;\n }\n\n private function extractPdfTextViaBinary(string $absPath): ?string\n {\n // shell_exec is commonly disabled on shared hosting; bail cleanly so the\n // caller falls through to the vision path or a clear error.\n if (! function_exists('shell_exec')\n || in_array('shell_exec', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true)) {\n return null;\n }\n\n $binary = $this->pdftotextPath();\n\n if ($binary === null) {\n Log::warning('QuotationAiService: pdftotext binary not found.', [\n 'looked_in' => 'PATH, PDFTOTEXT_PATH, ~/bin, ~/.local/bin, vendor/bin',\n ]);\n return null;\n }\n\n // `-layout` keeps columns roughly aligned, which matters for a BOQ; the\n // trailing \"-\" writes to stdout instead of a file.\n $command = sprintf('%s -layout -enc UTF-8 %s - 2>/dev/null', $binary, escapeshellarg($absPath));\n\n $output = @shell_exec($command);\n\n if (! is_string($output) || trim($output) === '') {\n Log::warning('QuotationAiService: pdftotext fallback produced no text.', [\n 'path' => basename($absPath),\n ]);\n return null;\n }\n\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $output);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via pdftotext fallback.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array $row\n * @return array\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "ca5e2a1409c26009f3d15783431a4e9be5c90092": {"t": 1785748355.111592, "n": 94360, "h": "57a4e017ceb58e5e", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n\n if ($extracted === self::PDF_LOW_MEMORY) {\n return $this->failure('The server was low on memory while reading this PDF. Please try again in a moment, or convert it to Excel or CSV.');\n }\n\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n // Try to reclaim memory a previous job left behind before deciding\n // there is not enough. Without this, a worker sitting near its limit\n // after a big job could refuse a tiny PDF for no real reason.\n gc_collect_cycles();\n\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n // A distinct sentinel, so the caller can tell \"no memory\" apart\n // from \"no text\" and show the right message instead of blaming\n // the file for being scanned.\n return self::PDF_LOW_MEMORY;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n // Class, file and line, not just the message — a bare message left\n // the actual cause of a PDF failure guessable at best.\n Log::warning('QuotationAiService: PDF parsing failed.', [\n 'exception' => $e::class,\n 'error' => $e->getMessage(),\n 'at' => $e->getFile() . ':' . $e->getLine(),\n ]);\n\n // smalot/pdfparser throws \"Invalid object reference for $obj.\" on\n // some perfectly valid PDFs — a known bug in the library, not a\n // problem with the file. Fall back to the pdftotext binary, which\n // reads those documents fine.\n return $this->extractPdfTextViaBinary($absPath);\n }\n }\n\n /**\n * Extract PDF text with the poppler `pdftotext` binary.\n *\n * A fallback for when smalot/pdfparser throws on a document it cannot handle\n * — most often \"Invalid object reference for $obj.\" pdftotext is a separate,\n * more robust implementation, so a PDF that breaks one usually reads on the\n * other. Returns null when the binary is absent or produces nothing, so the\n * caller drops to the vision path or a clear error.\n */\n private function extractPdfTextViaBinary(string $absPath): ?string\n {\n // shell_exec is commonly disabled on shared hosting; bail cleanly so the\n // caller falls through to the vision path or a clear error.\n if (! function_exists('shell_exec')\n || in_array('shell_exec', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true)) {\n return null;\n }\n\n // `-layout` keeps columns roughly aligned, which matters for a BOQ; the\n // trailing \"-\" writes to stdout instead of a file.\n $command = sprintf('pdftotext -layout -enc UTF-8 %s - 2>/dev/null', escapeshellarg($absPath));\n\n $output = @shell_exec($command);\n\n if (! is_string($output) || trim($output) === '') {\n Log::warning('QuotationAiService: pdftotext fallback produced no text.', [\n 'path' => basename($absPath),\n ]);\n return null;\n }\n\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $output);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via pdftotext fallback.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array $row\n * @return array\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "de5c9196479848453b9b2e2038f1d1e85a95011c": {"t": 1785748356.5478766, "n": 92167, "h": "3e3722325446a5c4", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n\n if ($extracted === self::PDF_LOW_MEMORY) {\n return $this->failure('The server was low on memory while reading this PDF. Please try again in a moment, or convert it to Excel or CSV.');\n }\n\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n // Try to reclaim memory a previous job left behind before deciding\n // there is not enough. Without this, a worker sitting near its limit\n // after a big job could refuse a tiny PDF for no real reason.\n gc_collect_cycles();\n\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n // A distinct sentinel, so the caller can tell \"no memory\" apart\n // from \"no text\" and show the right message instead of blaming\n // the file for being scanned.\n return self::PDF_LOW_MEMORY;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n // Class, file and line, not just the message — a bare message left\n // the actual cause of a PDF failure guessable at best.\n Log::warning('QuotationAiService: PDF parsing failed.', [\n 'exception' => $e::class,\n 'error' => $e->getMessage(),\n 'at' => $e->getFile() . ':' . $e->getLine(),\n ]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array $row\n * @return array\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "ea6c7a169184892a12edda05c08c8a5b44d31c5d": {"t": 1785748357.9901447, "n": 90992, "h": "0153db197ab21b93", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n return null;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array $row\n * @return array\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "fc70438baa6c12056bb832b607cd810344f72c3b": {"t": 1785748359.4702814, "n": 90996, "h": "7c23871a81273d01", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n return null;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array $row\n * @return array\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "a660dac8c322b56ea312fba576f45cecfac61ae4": {"t": 1785748360.9265428, "n": 88241, "h": "e5922490ac1978ee", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array $row\n * @return array\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "ffab6c52ecaf2c34acbac553b1e081a500d944a8": {"t": 1785748362.3283331, "n": 86277, "h": "7b17078a7600646a", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n return $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array $row\n * @return array\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "62ffe1c043c72fac40db0f04bef89ebd9b889aed": {"t": 1785748363.7379313, "n": 82553, "h": "68a4a986024f0c5b", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n return $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n ]);\n\n if ($dropped > 0) {\n $this->reportStage($nonSupply > 0\n ? \"Skipped {$blank} blank and {$nonSupply} non-supply rows. Sending {$kept}…\"\n : \"Removed {$blank} blank rows. Sending {$kept} rows…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array $row\n */\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "fb313038bdd6fd0ebd62127ad96cf9b969cc0a5b": {"t": 1785748365.1720457, "n": 79996, "h": "0765d7183cd1fae8", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n return $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n Log::info('QuotationAiService: stripped blank rows before sending to AI.', [\n 'kept' => $kept,\n 'dropped' => $dropped,\n ]);\n\n if ($dropped > 0) {\n $this->reportStage(\"Removed {$dropped} blank rows. Sending {$kept} rows…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array $row\n */\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "2c0e6147e0acffd0074328e34971efef7eef0115": {"t": 1785748366.6170099, "n": 79619, "h": "9c0b0544c85eaad0", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n return $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n Log::info('QuotationAiService: stripped blank rows before sending to AI.', [\n 'kept' => $kept,\n 'dropped' => $dropped,\n ]);\n\n if ($dropped > 0) {\n $this->reportStage(\"Removed {$dropped} blank rows. Sending {$kept} rows…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array $row\n */\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "af84adf334f292529f732fed3a4a1aee26b52c34": {"t": 1785748368.0307813, "n": 77633, "h": "3ce932cedc5cc4e5", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n Log::info('QuotationAiService: stripped blank rows before sending to AI.', [\n 'kept' => $kept,\n 'dropped' => $dropped,\n ]);\n\n if ($dropped > 0) {\n $this->reportStage(\"Removed {$dropped} blank rows. Sending {$kept} rows…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array $row\n */\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "eb051ce6c79d115451ba88c10f94c8302b8e14f2": {"t": 1785748369.45644, "n": 75203, "h": "e33b5f6d315bae64", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "54c919aa45efc8162f2a8fb035023e12abf6eeea": {"t": 1785748370.8722367, "n": 74329, "h": "d6908808f3282639", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "a04efeddb42344e31b8b514abedeea42174828bc": {"t": 1785748372.2594821, "n": 73093, "h": "bbb11664015c6012", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "d00ac635c7976e4b9c7f4d223c66f262576ddb12": {"t": 1785748377.943306, "n": 72132, "h": "1d7480d74de15a93", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n if (str_starts_with($line, 'Sheet: ')) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "52c63627beac204bb669af7b24c4e651869d1474": {"t": 1785748379.5048864, "n": 69284, "h": "733b86e152d6a561", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n foreach (explode(\"\\n\", $text) as $line) {\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "7be8671e8a42b6c7e31cdb9dbe3eccc731cfd9db": {"t": 1785748380.9081903, "n": 68328, "h": "5548cad491eb191c", "c": "onChunkProgress = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n foreach (explode(\"\\n\", $text) as $line) {\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "c11b0e8d3aa3e00749a0756db173c862c113e665": {"t": 1785748382.2988863, "n": 61603, "h": "c8529eead95933d3", "c": "boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "ca827c092853eeb50651f314116da97327281f1e": {"t": 1785748383.6825216, "n": 56893, "h": "bbb45898ce237ff2", "c": "boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "5757a4820f53b92f9f21a773fd1274172273958b": {"t": 1785748385.0312154, "n": 56870, "h": "4a796ac12fc9c052", "c": "boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "b89222a285363f5a3988031b5311b4c04a0f2a64": {"t": 1785748386.3781297, "n": 54684, "h": "6b8038eeaccb1e0a", "c": "boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "c45560667a217d6e69c4f825fad38b73e29b7a8b": {"t": 1785748387.7459095, "n": 53324, "h": "7798536dc9d018ab", "c": "boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "99a6f00086bef82241863c38b4a13b945729f834": {"t": 1785748389.0834527, "n": 49930, "h": "aee442e2ec251a79", "c": "boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract only purchasable physical supply products. Reject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "4899193889c08a561d2e173e1865ff601a46b0fa": {"t": 1785748390.4544926, "n": 49348, "h": "ad327072abc1cb55", "c": "boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n return $bestScore >= 3 ? $best : null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract only purchasable physical supply products. Reject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "ed91cf7f5173f67311b3f74ee5255bb04048ea45": {"t": 1785748391.7940319, "n": 48984, "h": "cf7f9cbbdb26d379", "c": "boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n $cacheKey = 'boq_analysis_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n return $bestScore >= 3 ? $best : null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract only purchasable physical supply products. Reject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "61b247e493c7cd9de56db3794e0d19d4730a6c64": {"t": 1785748393.1045017, "n": 46936, "h": "853e8a043558de4e", "c": "boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n $cacheKey = 'boq_analysis_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fallback to DeepSeek if local parse failed or found 0 items.\n $result = $this->parseSpreadsheetDirect($absPath);\n\n if (! $result['success'] || empty($result['items'])) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // Only use DeepSeek result if it actually found items\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n $result = $deepSeekResult;\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result (even with 0 items) if DeepSeek also failed\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وصف البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصف', 'البند', 'بيان'],\n 'unit' => ['الوحدة', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر الوحدة', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n return $bestScore >= 3 ? $best : null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/]/', \"\\n\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract only purchasable physical supply products. Reject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<< true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $message,\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}}, "app/Services/PriceAnalysisService.php": {"9a5ada4ab1ceb0de386bc1ce99c561f8869c09d7": {"t": 1785745788.6481616, "n": 13624, "h": "423c9d9d9020afa8", "c": "> $items\n * @return array{\n * findings: list}>,\n * ranges: array,\n * summary: array{subtotal:float, vat:float, total:float}\n * }\n */\n public function analyze(array $items): array\n {\n return [\n 'findings' => array_values($this->detectPriceInconsistencies($items)),\n 'ranges' => $this->fetchMarketRanges($items),\n 'summary' => $this->computeSummary($items),\n ];\n }\n\n // -------------------------------------------------------------------------\n // PRICE_INCONSISTENCY\n // -------------------------------------------------------------------------\n\n /**\n * Rows that describe the same item (same normalized key) but carry different\n * unit prices. Rows without a numeric price are ignored.\n *\n * @param array> $items\n * @return list}>\n */\n private function detectPriceInconsistencies(array $items): array\n {\n $groups = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $key = $this->normalizeKey($item);\n if ($key === '') {\n continue;\n }\n $groups[$key][$index] = round((float) $price, 2);\n }\n\n $findings = [];\n foreach ($groups as $priced) {\n $distinct = array_unique(array_values($priced));\n if (count($distinct) < 2) {\n continue;\n }\n\n $rows = array_keys($priced);\n $findings[] = [\n 'code' => 'PRICE_INCONSISTENCY',\n 'severity' => 'danger',\n 'message' => __('app.analysis_inconsistency_msg', [\n 'desc' => $this->label($items[$rows[0]]),\n 'min' => number_format(min($distinct), 2),\n 'max' => number_format(max($distinct), 2),\n ]),\n 'rows' => $rows,\n ];\n }\n\n return $findings;\n }\n\n // -------------------------------------------------------------------------\n // Summary\n // -------------------------------------------------------------------------\n\n /**\n * Recompute subtotal / VAT / total from non-rejected priced rows.\n *\n * @param array> $items\n * @return array{subtotal:float, vat:float, total:float}\n */\n private function computeSummary(array $items): array\n {\n $subtotal = 0.0;\n foreach ($items as $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $subtotal += (float) $price * (float) ($item['quantity'] ?? 0);\n }\n\n $vat = round($subtotal * self::VAT_RATE, 2);\n\n return [\n 'subtotal' => round($subtotal, 2),\n 'vat' => $vat,\n 'total' => round($subtotal + $vat, 2),\n ];\n }\n\n // -------------------------------------------------------------------------\n // Market range (min / avg / max) via DeepSeek\n // -------------------------------------------------------------------------\n\n /**\n * Ask DeepSeek for a realistic Saudi market unit-price range per priced item.\n *\n * @param array> $items\n * @return array Keyed by item index.\n */\n private function fetchMarketRanges(array $items): array\n {\n $indices = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n if (is_numeric($item['unit_price'] ?? null) && (float) $item['unit_price'] > 0) {\n $indices[] = $index;\n }\n }\n\n if (empty($indices)) {\n return [];\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('PriceAnalysisService: DEEPSEEK_API_KEY not configured; skipping market ranges.');\n return [];\n }\n\n // Reuse ranges already looked up for these products.\n //\n // This runs again on every approve/reject click, so a user working\n // through a quotation re-asked the AI for the same market ranges over\n // and over — and got slightly different numbers each time. The range is\n // a property of the product, not of the current price, so it is safe to\n // keep and safe to share across quotations.\n $ranges = [];\n $pending = [];\n\n foreach ($indices as $index) {\n $cached = AiCache::store()->get($this->rangeCacheKey($items[$index]));\n\n if (is_array($cached)) {\n $ranges[$index] = $cached;\n continue;\n }\n\n $pending[] = $index;\n }\n\n foreach (array_chunk($pending, self::CHUNK_SIZE) as $chunk) {\n $fetched = $this->fetchRangeChunk($items, $chunk, $apiKey);\n\n foreach ($fetched as $index => $range) {\n AiCache::store()->put(\n $this->rangeCacheKey($items[$index]),\n $range,\n now()->addDays(self::RANGE_CACHE_DAYS),\n );\n }\n\n $ranges += $fetched;\n }\n\n return $ranges;\n }\n\n /**\n * Cache key for one product's market range.\n *\n * Keyed on what the model is actually asked about — description, category,\n * brand and unit — and deliberately not on the current price, which does not\n * change what the market charges.\n */\n private function rangeCacheKey(array $item): string\n {\n $signature = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''))\n . '|' . mb_strtolower(trim((string) ($item['category'] ?? '')))\n . '|' . mb_strtolower(trim((string) ($item['brand'] ?? '')))\n . '|' . mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'price_range_' . hash('sha256', $signature);\n }\n\n /**\n * @param list $indices\n * @return array\n */\n private function fetchRangeChunk(array $items, array $indices, string $apiKey): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'For each item below, give a realistic CURRENT unit-price RANGE in SAR based on typical 2024-2026 Saudi '\n . 'supplier rates: the lowest (\"mn\"), the average (\"av\") and the highest (\"mx\") you would expect to see. '\n . 'RULES: '\n . '(1) All three must be positive numbers with mn <= av <= mx. '\n . '(2) Return ONLY a valid compact JSON array, NO whitespace/newlines between elements, no markdown, no extra keys. '\n . '(3) Each element must be exactly: {\"i\":,\"mn\":,\"av\":,\"mx\":} '\n . 'Example: [{\"i\":0,\"mn\":1200,\"av\":1500,\"mx\":1900}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $prompt]],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n Log::warning('PriceAnalysisService: DeepSeek range request failed.', ['status' => $response->status()]);\n return [];\n }\n\n return $this->parseRanges($response->json('choices.0.message.content') ?? '');\n } catch (\\Throwable $e) {\n Log::error('PriceAnalysisService: Exception fetching ranges.', ['message' => $e->getMessage()]);\n return [];\n }\n }\n\n /**\n * @return array\n */\n private function parseRanges(string $text): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $data = json_decode($text, true);\n\n if (! is_array($data)) {\n preg_match_all(\n '/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"mn\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"av\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"mx\"\\s*:\\s*([\\d.]+)\\s*\\}/',\n $text,\n $matches,\n PREG_SET_ORDER\n );\n $data = array_map(fn($m) => [\n 'i' => (int) $m[1],\n 'mn' => (float) $m[2],\n 'av' => (float) $m[3],\n 'mx' => (float) $m[4],\n ], $matches);\n }\n\n if (! is_array($data)) {\n return [];\n }\n\n $ranges = [];\n foreach ($data as $entry) {\n $idx = $entry['i'] ?? null;\n if (! is_int($idx) && ! (is_string($idx) && ctype_digit($idx))) {\n continue;\n }\n $mn = is_numeric($entry['mn'] ?? null) ? (float) $entry['mn'] : null;\n $av = is_numeric($entry['av'] ?? null) ? (float) $entry['av'] : null;\n $mx = is_numeric($entry['mx'] ?? null) ? (float) $entry['mx'] : null;\n\n if ($mn === null || $av === null || $mx === null || $mn <= 0 || $mx <= 0) {\n continue;\n }\n\n // Guard against the model returning them out of order.\n $sorted = [$mn, $av, $mx];\n sort($sorted);\n\n $ranges[(int) $idx] = [\n 'min' => round($sorted[0], 2),\n 'avg' => round($sorted[1], 2),\n 'max' => round($sorted[2], 2),\n ];\n }\n\n return $ranges;\n }\n\n // -------------------------------------------------------------------------\n // Helpers\n // -------------------------------------------------------------------------\n\n /**\n * A normalized grouping key: lower-cased, whitespace-collapsed description + unit.\n * Empty when there is no description to compare on.\n */\n private function normalizeKey(array $item): string\n {\n $desc = mb_strtolower(trim((string) ($item['description'] ?? '')));\n $desc = preg_replace('/\\s+/u', ' ', $desc);\n\n if ($desc === '') {\n return '';\n }\n\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return $desc . '|' . $unit;\n }\n\n /** Short human label for a finding message. */\n private function label(array $item): string\n {\n $desc = trim((string) ($item['description'] ?? ''));\n return $desc !== '' ? mb_substr($desc, 0, 60) : '—';\n }\n}\n"}, "06c9320bf7fd9a0d5e611ec5a69092739739349f": {"t": 1785748347.1838262, "n": 13624, "h": "423c9d9d9020afa8", "c": "> $items\n * @return array{\n * findings: list}>,\n * ranges: array,\n * summary: array{subtotal:float, vat:float, total:float}\n * }\n */\n public function analyze(array $items): array\n {\n return [\n 'findings' => array_values($this->detectPriceInconsistencies($items)),\n 'ranges' => $this->fetchMarketRanges($items),\n 'summary' => $this->computeSummary($items),\n ];\n }\n\n // -------------------------------------------------------------------------\n // PRICE_INCONSISTENCY\n // -------------------------------------------------------------------------\n\n /**\n * Rows that describe the same item (same normalized key) but carry different\n * unit prices. Rows without a numeric price are ignored.\n *\n * @param array> $items\n * @return list}>\n */\n private function detectPriceInconsistencies(array $items): array\n {\n $groups = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $key = $this->normalizeKey($item);\n if ($key === '') {\n continue;\n }\n $groups[$key][$index] = round((float) $price, 2);\n }\n\n $findings = [];\n foreach ($groups as $priced) {\n $distinct = array_unique(array_values($priced));\n if (count($distinct) < 2) {\n continue;\n }\n\n $rows = array_keys($priced);\n $findings[] = [\n 'code' => 'PRICE_INCONSISTENCY',\n 'severity' => 'danger',\n 'message' => __('app.analysis_inconsistency_msg', [\n 'desc' => $this->label($items[$rows[0]]),\n 'min' => number_format(min($distinct), 2),\n 'max' => number_format(max($distinct), 2),\n ]),\n 'rows' => $rows,\n ];\n }\n\n return $findings;\n }\n\n // -------------------------------------------------------------------------\n // Summary\n // -------------------------------------------------------------------------\n\n /**\n * Recompute subtotal / VAT / total from non-rejected priced rows.\n *\n * @param array> $items\n * @return array{subtotal:float, vat:float, total:float}\n */\n private function computeSummary(array $items): array\n {\n $subtotal = 0.0;\n foreach ($items as $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $subtotal += (float) $price * (float) ($item['quantity'] ?? 0);\n }\n\n $vat = round($subtotal * self::VAT_RATE, 2);\n\n return [\n 'subtotal' => round($subtotal, 2),\n 'vat' => $vat,\n 'total' => round($subtotal + $vat, 2),\n ];\n }\n\n // -------------------------------------------------------------------------\n // Market range (min / avg / max) via DeepSeek\n // -------------------------------------------------------------------------\n\n /**\n * Ask DeepSeek for a realistic Saudi market unit-price range per priced item.\n *\n * @param array> $items\n * @return array Keyed by item index.\n */\n private function fetchMarketRanges(array $items): array\n {\n $indices = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n if (is_numeric($item['unit_price'] ?? null) && (float) $item['unit_price'] > 0) {\n $indices[] = $index;\n }\n }\n\n if (empty($indices)) {\n return [];\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('PriceAnalysisService: DEEPSEEK_API_KEY not configured; skipping market ranges.');\n return [];\n }\n\n // Reuse ranges already looked up for these products.\n //\n // This runs again on every approve/reject click, so a user working\n // through a quotation re-asked the AI for the same market ranges over\n // and over — and got slightly different numbers each time. The range is\n // a property of the product, not of the current price, so it is safe to\n // keep and safe to share across quotations.\n $ranges = [];\n $pending = [];\n\n foreach ($indices as $index) {\n $cached = AiCache::store()->get($this->rangeCacheKey($items[$index]));\n\n if (is_array($cached)) {\n $ranges[$index] = $cached;\n continue;\n }\n\n $pending[] = $index;\n }\n\n foreach (array_chunk($pending, self::CHUNK_SIZE) as $chunk) {\n $fetched = $this->fetchRangeChunk($items, $chunk, $apiKey);\n\n foreach ($fetched as $index => $range) {\n AiCache::store()->put(\n $this->rangeCacheKey($items[$index]),\n $range,\n now()->addDays(self::RANGE_CACHE_DAYS),\n );\n }\n\n $ranges += $fetched;\n }\n\n return $ranges;\n }\n\n /**\n * Cache key for one product's market range.\n *\n * Keyed on what the model is actually asked about — description, category,\n * brand and unit — and deliberately not on the current price, which does not\n * change what the market charges.\n */\n private function rangeCacheKey(array $item): string\n {\n $signature = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''))\n . '|' . mb_strtolower(trim((string) ($item['category'] ?? '')))\n . '|' . mb_strtolower(trim((string) ($item['brand'] ?? '')))\n . '|' . mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'price_range_' . hash('sha256', $signature);\n }\n\n /**\n * @param list $indices\n * @return array\n */\n private function fetchRangeChunk(array $items, array $indices, string $apiKey): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'For each item below, give a realistic CURRENT unit-price RANGE in SAR based on typical 2024-2026 Saudi '\n . 'supplier rates: the lowest (\"mn\"), the average (\"av\") and the highest (\"mx\") you would expect to see. '\n . 'RULES: '\n . '(1) All three must be positive numbers with mn <= av <= mx. '\n . '(2) Return ONLY a valid compact JSON array, NO whitespace/newlines between elements, no markdown, no extra keys. '\n . '(3) Each element must be exactly: {\"i\":,\"mn\":,\"av\":,\"mx\":} '\n . 'Example: [{\"i\":0,\"mn\":1200,\"av\":1500,\"mx\":1900}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $prompt]],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n Log::warning('PriceAnalysisService: DeepSeek range request failed.', ['status' => $response->status()]);\n return [];\n }\n\n return $this->parseRanges($response->json('choices.0.message.content') ?? '');\n } catch (\\Throwable $e) {\n Log::error('PriceAnalysisService: Exception fetching ranges.', ['message' => $e->getMessage()]);\n return [];\n }\n }\n\n /**\n * @return array\n */\n private function parseRanges(string $text): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $data = json_decode($text, true);\n\n if (! is_array($data)) {\n preg_match_all(\n '/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"mn\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"av\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"mx\"\\s*:\\s*([\\d.]+)\\s*\\}/',\n $text,\n $matches,\n PREG_SET_ORDER\n );\n $data = array_map(fn($m) => [\n 'i' => (int) $m[1],\n 'mn' => (float) $m[2],\n 'av' => (float) $m[3],\n 'mx' => (float) $m[4],\n ], $matches);\n }\n\n if (! is_array($data)) {\n return [];\n }\n\n $ranges = [];\n foreach ($data as $entry) {\n $idx = $entry['i'] ?? null;\n if (! is_int($idx) && ! (is_string($idx) && ctype_digit($idx))) {\n continue;\n }\n $mn = is_numeric($entry['mn'] ?? null) ? (float) $entry['mn'] : null;\n $av = is_numeric($entry['av'] ?? null) ? (float) $entry['av'] : null;\n $mx = is_numeric($entry['mx'] ?? null) ? (float) $entry['mx'] : null;\n\n if ($mn === null || $av === null || $mx === null || $mn <= 0 || $mx <= 0) {\n continue;\n }\n\n // Guard against the model returning them out of order.\n $sorted = [$mn, $av, $mx];\n sort($sorted);\n\n $ranges[(int) $idx] = [\n 'min' => round($sorted[0], 2),\n 'avg' => round($sorted[1], 2),\n 'max' => round($sorted[2], 2),\n ];\n }\n\n return $ranges;\n }\n\n // -------------------------------------------------------------------------\n // Helpers\n // -------------------------------------------------------------------------\n\n /**\n * A normalized grouping key: lower-cased, whitespace-collapsed description + unit.\n * Empty when there is no description to compare on.\n */\n private function normalizeKey(array $item): string\n {\n $desc = mb_strtolower(trim((string) ($item['description'] ?? '')));\n $desc = preg_replace('/\\s+/u', ' ', $desc);\n\n if ($desc === '') {\n return '';\n }\n\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return $desc . '|' . $unit;\n }\n\n /** Short human label for a finding message. */\n private function label(array $item): string\n {\n $desc = trim((string) ($item['description'] ?? ''));\n return $desc !== '' ? mb_substr($desc, 0, 60) : '—';\n }\n}\n"}, "8fd48f258c675a8fe54dab5da10ea820e80627cc": {"t": 1785748348.3132017, "n": 13603, "h": "882a1e638af55e7e", "c": "> $items\n * @return array{\n * findings: list}>,\n * ranges: array,\n * summary: array{subtotal:float, vat:float, total:float}\n * }\n */\n public function analyze(array $items): array\n {\n return [\n 'findings' => array_values($this->detectPriceInconsistencies($items)),\n 'ranges' => $this->fetchMarketRanges($items),\n 'summary' => $this->computeSummary($items),\n ];\n }\n\n // -------------------------------------------------------------------------\n // PRICE_INCONSISTENCY\n // -------------------------------------------------------------------------\n\n /**\n * Rows that describe the same item (same normalized key) but carry different\n * unit prices. Rows without a numeric price are ignored.\n *\n * @param array> $items\n * @return list}>\n */\n private function detectPriceInconsistencies(array $items): array\n {\n $groups = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $key = $this->normalizeKey($item);\n if ($key === '') {\n continue;\n }\n $groups[$key][$index] = round((float) $price, 2);\n }\n\n $findings = [];\n foreach ($groups as $priced) {\n $distinct = array_unique(array_values($priced));\n if (count($distinct) < 2) {\n continue;\n }\n\n $rows = array_keys($priced);\n $findings[] = [\n 'code' => 'PRICE_INCONSISTENCY',\n 'severity' => 'danger',\n 'message' => __('app.analysis_inconsistency_msg', [\n 'desc' => $this->label($items[$rows[0]]),\n 'min' => number_format(min($distinct), 2),\n 'max' => number_format(max($distinct), 2),\n ]),\n 'rows' => $rows,\n ];\n }\n\n return $findings;\n }\n\n // -------------------------------------------------------------------------\n // Summary\n // -------------------------------------------------------------------------\n\n /**\n * Recompute subtotal / VAT / total from non-rejected priced rows.\n *\n * @param array> $items\n * @return array{subtotal:float, vat:float, total:float}\n */\n private function computeSummary(array $items): array\n {\n $subtotal = 0.0;\n foreach ($items as $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $subtotal += (float) $price * (float) ($item['quantity'] ?? 0);\n }\n\n $vat = round($subtotal * self::VAT_RATE, 2);\n\n return [\n 'subtotal' => round($subtotal, 2),\n 'vat' => $vat,\n 'total' => round($subtotal + $vat, 2),\n ];\n }\n\n // -------------------------------------------------------------------------\n // Market range (min / avg / max) via DeepSeek\n // -------------------------------------------------------------------------\n\n /**\n * Ask DeepSeek for a realistic Saudi market unit-price range per priced item.\n *\n * @param array> $items\n * @return array Keyed by item index.\n */\n private function fetchMarketRanges(array $items): array\n {\n $indices = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n if (is_numeric($item['unit_price'] ?? null) && (float) $item['unit_price'] > 0) {\n $indices[] = $index;\n }\n }\n\n if (empty($indices)) {\n return [];\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('PriceAnalysisService: DEEPSEEK_API_KEY not configured; skipping market ranges.');\n return [];\n }\n\n // Reuse ranges already looked up for these products.\n //\n // This runs again on every approve/reject click, so a user working\n // through a quotation re-asked the AI for the same market ranges over\n // and over — and got slightly different numbers each time. The range is\n // a property of the product, not of the current price, so it is safe to\n // keep and safe to share across quotations.\n $ranges = [];\n $pending = [];\n\n foreach ($indices as $index) {\n $cached = Cache::store('ai')->get($this->rangeCacheKey($items[$index]));\n\n if (is_array($cached)) {\n $ranges[$index] = $cached;\n continue;\n }\n\n $pending[] = $index;\n }\n\n foreach (array_chunk($pending, self::CHUNK_SIZE) as $chunk) {\n $fetched = $this->fetchRangeChunk($items, $chunk, $apiKey);\n\n foreach ($fetched as $index => $range) {\n Cache::store('ai')->put(\n $this->rangeCacheKey($items[$index]),\n $range,\n now()->addDays(self::RANGE_CACHE_DAYS),\n );\n }\n\n $ranges += $fetched;\n }\n\n return $ranges;\n }\n\n /**\n * Cache key for one product's market range.\n *\n * Keyed on what the model is actually asked about — description, category,\n * brand and unit — and deliberately not on the current price, which does not\n * change what the market charges.\n */\n private function rangeCacheKey(array $item): string\n {\n $signature = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''))\n . '|' . mb_strtolower(trim((string) ($item['category'] ?? '')))\n . '|' . mb_strtolower(trim((string) ($item['brand'] ?? '')))\n . '|' . mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'price_range_' . hash('sha256', $signature);\n }\n\n /**\n * @param list $indices\n * @return array\n */\n private function fetchRangeChunk(array $items, array $indices, string $apiKey): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'For each item below, give a realistic CURRENT unit-price RANGE in SAR based on typical 2024-2026 Saudi '\n . 'supplier rates: the lowest (\"mn\"), the average (\"av\") and the highest (\"mx\") you would expect to see. '\n . 'RULES: '\n . '(1) All three must be positive numbers with mn <= av <= mx. '\n . '(2) Return ONLY a valid compact JSON array, NO whitespace/newlines between elements, no markdown, no extra keys. '\n . '(3) Each element must be exactly: {\"i\":,\"mn\":,\"av\":,\"mx\":} '\n . 'Example: [{\"i\":0,\"mn\":1200,\"av\":1500,\"mx\":1900}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $prompt]],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n Log::warning('PriceAnalysisService: DeepSeek range request failed.', ['status' => $response->status()]);\n return [];\n }\n\n return $this->parseRanges($response->json('choices.0.message.content') ?? '');\n } catch (\\Throwable $e) {\n Log::error('PriceAnalysisService: Exception fetching ranges.', ['message' => $e->getMessage()]);\n return [];\n }\n }\n\n /**\n * @return array\n */\n private function parseRanges(string $text): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $data = json_decode($text, true);\n\n if (! is_array($data)) {\n preg_match_all(\n '/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"mn\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"av\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"mx\"\\s*:\\s*([\\d.]+)\\s*\\}/',\n $text,\n $matches,\n PREG_SET_ORDER\n );\n $data = array_map(fn($m) => [\n 'i' => (int) $m[1],\n 'mn' => (float) $m[2],\n 'av' => (float) $m[3],\n 'mx' => (float) $m[4],\n ], $matches);\n }\n\n if (! is_array($data)) {\n return [];\n }\n\n $ranges = [];\n foreach ($data as $entry) {\n $idx = $entry['i'] ?? null;\n if (! is_int($idx) && ! (is_string($idx) && ctype_digit($idx))) {\n continue;\n }\n $mn = is_numeric($entry['mn'] ?? null) ? (float) $entry['mn'] : null;\n $av = is_numeric($entry['av'] ?? null) ? (float) $entry['av'] : null;\n $mx = is_numeric($entry['mx'] ?? null) ? (float) $entry['mx'] : null;\n\n if ($mn === null || $av === null || $mx === null || $mn <= 0 || $mx <= 0) {\n continue;\n }\n\n // Guard against the model returning them out of order.\n $sorted = [$mn, $av, $mx];\n sort($sorted);\n\n $ranges[(int) $idx] = [\n 'min' => round($sorted[0], 2),\n 'avg' => round($sorted[1], 2),\n 'max' => round($sorted[2], 2),\n ];\n }\n\n return $ranges;\n }\n\n // -------------------------------------------------------------------------\n // Helpers\n // -------------------------------------------------------------------------\n\n /**\n * A normalized grouping key: lower-cased, whitespace-collapsed description + unit.\n * Empty when there is no description to compare on.\n */\n private function normalizeKey(array $item): string\n {\n $desc = mb_strtolower(trim((string) ($item['description'] ?? '')));\n $desc = preg_replace('/\\s+/u', ' ', $desc);\n\n if ($desc === '') {\n return '';\n }\n\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return $desc . '|' . $unit;\n }\n\n /** Short human label for a finding message. */\n private function label(array $item): string\n {\n $desc = trim((string) ($item['description'] ?? ''));\n return $desc !== '' ? mb_substr($desc, 0, 60) : '—';\n }\n}\n"}, "61f9b1587699bc38d19f087d7b90074121d30a70": {"t": 1785748349.4228656, "n": 13577, "h": "5e2c1d48343f7804", "c": "> $items\n * @return array{\n * findings: list}>,\n * ranges: array,\n * summary: array{subtotal:float, vat:float, total:float}\n * }\n */\n public function analyze(array $items): array\n {\n return [\n 'findings' => array_values($this->detectPriceInconsistencies($items)),\n 'ranges' => $this->fetchMarketRanges($items),\n 'summary' => $this->computeSummary($items),\n ];\n }\n\n // -------------------------------------------------------------------------\n // PRICE_INCONSISTENCY\n // -------------------------------------------------------------------------\n\n /**\n * Rows that describe the same item (same normalized key) but carry different\n * unit prices. Rows without a numeric price are ignored.\n *\n * @param array> $items\n * @return list}>\n */\n private function detectPriceInconsistencies(array $items): array\n {\n $groups = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $key = $this->normalizeKey($item);\n if ($key === '') {\n continue;\n }\n $groups[$key][$index] = round((float) $price, 2);\n }\n\n $findings = [];\n foreach ($groups as $priced) {\n $distinct = array_unique(array_values($priced));\n if (count($distinct) < 2) {\n continue;\n }\n\n $rows = array_keys($priced);\n $findings[] = [\n 'code' => 'PRICE_INCONSISTENCY',\n 'severity' => 'danger',\n 'message' => __('app.analysis_inconsistency_msg', [\n 'desc' => $this->label($items[$rows[0]]),\n 'min' => number_format(min($distinct), 2),\n 'max' => number_format(max($distinct), 2),\n ]),\n 'rows' => $rows,\n ];\n }\n\n return $findings;\n }\n\n // -------------------------------------------------------------------------\n // Summary\n // -------------------------------------------------------------------------\n\n /**\n * Recompute subtotal / VAT / total from non-rejected priced rows.\n *\n * @param array> $items\n * @return array{subtotal:float, vat:float, total:float}\n */\n private function computeSummary(array $items): array\n {\n $subtotal = 0.0;\n foreach ($items as $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $subtotal += (float) $price * (float) ($item['quantity'] ?? 0);\n }\n\n $vat = round($subtotal * self::VAT_RATE, 2);\n\n return [\n 'subtotal' => round($subtotal, 2),\n 'vat' => $vat,\n 'total' => round($subtotal + $vat, 2),\n ];\n }\n\n // -------------------------------------------------------------------------\n // Market range (min / avg / max) via DeepSeek\n // -------------------------------------------------------------------------\n\n /**\n * Ask DeepSeek for a realistic Saudi market unit-price range per priced item.\n *\n * @param array> $items\n * @return array Keyed by item index.\n */\n private function fetchMarketRanges(array $items): array\n {\n $indices = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n if (is_numeric($item['unit_price'] ?? null) && (float) $item['unit_price'] > 0) {\n $indices[] = $index;\n }\n }\n\n if (empty($indices)) {\n return [];\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('PriceAnalysisService: DEEPSEEK_API_KEY not configured; skipping market ranges.');\n return [];\n }\n\n // Reuse ranges already looked up for these products.\n //\n // This runs again on every approve/reject click, so a user working\n // through a quotation re-asked the AI for the same market ranges over\n // and over — and got slightly different numbers each time. The range is\n // a property of the product, not of the current price, so it is safe to\n // keep and safe to share across quotations.\n $ranges = [];\n $pending = [];\n\n foreach ($indices as $index) {\n $cached = Cache::get($this->rangeCacheKey($items[$index]));\n\n if (is_array($cached)) {\n $ranges[$index] = $cached;\n continue;\n }\n\n $pending[] = $index;\n }\n\n foreach (array_chunk($pending, self::CHUNK_SIZE) as $chunk) {\n $fetched = $this->fetchRangeChunk($items, $chunk, $apiKey);\n\n foreach ($fetched as $index => $range) {\n Cache::put(\n $this->rangeCacheKey($items[$index]),\n $range,\n now()->addDays(self::RANGE_CACHE_DAYS),\n );\n }\n\n $ranges += $fetched;\n }\n\n return $ranges;\n }\n\n /**\n * Cache key for one product's market range.\n *\n * Keyed on what the model is actually asked about — description, category,\n * brand and unit — and deliberately not on the current price, which does not\n * change what the market charges.\n */\n private function rangeCacheKey(array $item): string\n {\n $signature = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''))\n . '|' . mb_strtolower(trim((string) ($item['category'] ?? '')))\n . '|' . mb_strtolower(trim((string) ($item['brand'] ?? '')))\n . '|' . mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'price_range_' . hash('sha256', $signature);\n }\n\n /**\n * @param list $indices\n * @return array\n */\n private function fetchRangeChunk(array $items, array $indices, string $apiKey): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'For each item below, give a realistic CURRENT unit-price RANGE in SAR based on typical 2024-2026 Saudi '\n . 'supplier rates: the lowest (\"mn\"), the average (\"av\") and the highest (\"mx\") you would expect to see. '\n . 'RULES: '\n . '(1) All three must be positive numbers with mn <= av <= mx. '\n . '(2) Return ONLY a valid compact JSON array, NO whitespace/newlines between elements, no markdown, no extra keys. '\n . '(3) Each element must be exactly: {\"i\":,\"mn\":,\"av\":,\"mx\":} '\n . 'Example: [{\"i\":0,\"mn\":1200,\"av\":1500,\"mx\":1900}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $prompt]],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n Log::warning('PriceAnalysisService: DeepSeek range request failed.', ['status' => $response->status()]);\n return [];\n }\n\n return $this->parseRanges($response->json('choices.0.message.content') ?? '');\n } catch (\\Throwable $e) {\n Log::error('PriceAnalysisService: Exception fetching ranges.', ['message' => $e->getMessage()]);\n return [];\n }\n }\n\n /**\n * @return array\n */\n private function parseRanges(string $text): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $data = json_decode($text, true);\n\n if (! is_array($data)) {\n preg_match_all(\n '/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"mn\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"av\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"mx\"\\s*:\\s*([\\d.]+)\\s*\\}/',\n $text,\n $matches,\n PREG_SET_ORDER\n );\n $data = array_map(fn($m) => [\n 'i' => (int) $m[1],\n 'mn' => (float) $m[2],\n 'av' => (float) $m[3],\n 'mx' => (float) $m[4],\n ], $matches);\n }\n\n if (! is_array($data)) {\n return [];\n }\n\n $ranges = [];\n foreach ($data as $entry) {\n $idx = $entry['i'] ?? null;\n if (! is_int($idx) && ! (is_string($idx) && ctype_digit($idx))) {\n continue;\n }\n $mn = is_numeric($entry['mn'] ?? null) ? (float) $entry['mn'] : null;\n $av = is_numeric($entry['av'] ?? null) ? (float) $entry['av'] : null;\n $mx = is_numeric($entry['mx'] ?? null) ? (float) $entry['mx'] : null;\n\n if ($mn === null || $av === null || $mx === null || $mn <= 0 || $mx <= 0) {\n continue;\n }\n\n // Guard against the model returning them out of order.\n $sorted = [$mn, $av, $mx];\n sort($sorted);\n\n $ranges[(int) $idx] = [\n 'min' => round($sorted[0], 2),\n 'avg' => round($sorted[1], 2),\n 'max' => round($sorted[2], 2),\n ];\n }\n\n return $ranges;\n }\n\n // -------------------------------------------------------------------------\n // Helpers\n // -------------------------------------------------------------------------\n\n /**\n * A normalized grouping key: lower-cased, whitespace-collapsed description + unit.\n * Empty when there is no description to compare on.\n */\n private function normalizeKey(array $item): string\n {\n $desc = mb_strtolower(trim((string) ($item['description'] ?? '')));\n $desc = preg_replace('/\\s+/u', ' ', $desc);\n\n if ($desc === '') {\n return '';\n }\n\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return $desc . '|' . $unit;\n }\n\n /** Short human label for a finding message. */\n private function label(array $item): string\n {\n $desc = trim((string) ($item['description'] ?? ''));\n return $desc !== '' ? mb_substr($desc, 0, 60) : '—';\n }\n}\n"}, "ea6c7a169184892a12edda05c08c8a5b44d31c5d": {"t": 1785748351.1370747, "n": 11510, "h": "f2e102b846cf4867", "c": "> $items\n * @return array{\n * findings: list}>,\n * ranges: array,\n * summary: array{subtotal:float, vat:float, total:float}\n * }\n */\n public function analyze(array $items): array\n {\n return [\n 'findings' => array_values($this->detectPriceInconsistencies($items)),\n 'ranges' => $this->fetchMarketRanges($items),\n 'summary' => $this->computeSummary($items),\n ];\n }\n\n // -------------------------------------------------------------------------\n // PRICE_INCONSISTENCY\n // -------------------------------------------------------------------------\n\n /**\n * Rows that describe the same item (same normalized key) but carry different\n * unit prices. Rows without a numeric price are ignored.\n *\n * @param array> $items\n * @return list}>\n */\n private function detectPriceInconsistencies(array $items): array\n {\n $groups = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $key = $this->normalizeKey($item);\n if ($key === '') {\n continue;\n }\n $groups[$key][$index] = round((float) $price, 2);\n }\n\n $findings = [];\n foreach ($groups as $priced) {\n $distinct = array_unique(array_values($priced));\n if (count($distinct) < 2) {\n continue;\n }\n\n $rows = array_keys($priced);\n $findings[] = [\n 'code' => 'PRICE_INCONSISTENCY',\n 'severity' => 'danger',\n 'message' => __('app.analysis_inconsistency_msg', [\n 'desc' => $this->label($items[$rows[0]]),\n 'min' => number_format(min($distinct), 2),\n 'max' => number_format(max($distinct), 2),\n ]),\n 'rows' => $rows,\n ];\n }\n\n return $findings;\n }\n\n // -------------------------------------------------------------------------\n // Summary\n // -------------------------------------------------------------------------\n\n /**\n * Recompute subtotal / VAT / total from non-rejected priced rows.\n *\n * @param array> $items\n * @return array{subtotal:float, vat:float, total:float}\n */\n private function computeSummary(array $items): array\n {\n $subtotal = 0.0;\n foreach ($items as $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $subtotal += (float) $price * (float) ($item['quantity'] ?? 0);\n }\n\n $vat = round($subtotal * self::VAT_RATE, 2);\n\n return [\n 'subtotal' => round($subtotal, 2),\n 'vat' => $vat,\n 'total' => round($subtotal + $vat, 2),\n ];\n }\n\n // -------------------------------------------------------------------------\n // Market range (min / avg / max) via DeepSeek\n // -------------------------------------------------------------------------\n\n /**\n * Ask DeepSeek for a realistic Saudi market unit-price range per priced item.\n *\n * @param array> $items\n * @return array Keyed by item index.\n */\n private function fetchMarketRanges(array $items): array\n {\n $indices = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n if (is_numeric($item['unit_price'] ?? null) && (float) $item['unit_price'] > 0) {\n $indices[] = $index;\n }\n }\n\n if (empty($indices)) {\n return [];\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('PriceAnalysisService: DEEPSEEK_API_KEY not configured; skipping market ranges.');\n return [];\n }\n\n $ranges = [];\n foreach (array_chunk($indices, self::CHUNK_SIZE) as $chunk) {\n $ranges += $this->fetchRangeChunk($items, $chunk, $apiKey);\n }\n\n return $ranges;\n }\n\n /**\n * @param list $indices\n * @return array\n */\n private function fetchRangeChunk(array $items, array $indices, string $apiKey): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'For each item below, give a realistic CURRENT unit-price RANGE in SAR based on typical 2024-2026 Saudi '\n . 'supplier rates: the lowest (\"mn\"), the average (\"av\") and the highest (\"mx\") you would expect to see. '\n . 'RULES: '\n . '(1) All three must be positive numbers with mn <= av <= mx. '\n . '(2) Return ONLY a valid compact JSON array, NO whitespace/newlines between elements, no markdown, no extra keys. '\n . '(3) Each element must be exactly: {\"i\":,\"mn\":,\"av\":,\"mx\":} '\n . 'Example: [{\"i\":0,\"mn\":1200,\"av\":1500,\"mx\":1900}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $prompt]],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n Log::warning('PriceAnalysisService: DeepSeek range request failed.', ['status' => $response->status()]);\n return [];\n }\n\n return $this->parseRanges($response->json('choices.0.message.content') ?? '');\n } catch (\\Throwable $e) {\n Log::error('PriceAnalysisService: Exception fetching ranges.', ['message' => $e->getMessage()]);\n return [];\n }\n }\n\n /**\n * @return array\n */\n private function parseRanges(string $text): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $data = json_decode($text, true);\n\n if (! is_array($data)) {\n preg_match_all(\n '/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"mn\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"av\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"mx\"\\s*:\\s*([\\d.]+)\\s*\\}/',\n $text,\n $matches,\n PREG_SET_ORDER\n );\n $data = array_map(fn($m) => [\n 'i' => (int) $m[1],\n 'mn' => (float) $m[2],\n 'av' => (float) $m[3],\n 'mx' => (float) $m[4],\n ], $matches);\n }\n\n if (! is_array($data)) {\n return [];\n }\n\n $ranges = [];\n foreach ($data as $entry) {\n $idx = $entry['i'] ?? null;\n if (! is_int($idx) && ! (is_string($idx) && ctype_digit($idx))) {\n continue;\n }\n $mn = is_numeric($entry['mn'] ?? null) ? (float) $entry['mn'] : null;\n $av = is_numeric($entry['av'] ?? null) ? (float) $entry['av'] : null;\n $mx = is_numeric($entry['mx'] ?? null) ? (float) $entry['mx'] : null;\n\n if ($mn === null || $av === null || $mx === null || $mn <= 0 || $mx <= 0) {\n continue;\n }\n\n // Guard against the model returning them out of order.\n $sorted = [$mn, $av, $mx];\n sort($sorted);\n\n $ranges[(int) $idx] = [\n 'min' => round($sorted[0], 2),\n 'avg' => round($sorted[1], 2),\n 'max' => round($sorted[2], 2),\n ];\n }\n\n return $ranges;\n }\n\n // -------------------------------------------------------------------------\n // Helpers\n // -------------------------------------------------------------------------\n\n /**\n * A normalized grouping key: lower-cased, whitespace-collapsed description + unit.\n * Empty when there is no description to compare on.\n */\n private function normalizeKey(array $item): string\n {\n $desc = mb_strtolower(trim((string) ($item['description'] ?? '')));\n $desc = preg_replace('/\\s+/u', ' ', $desc);\n\n if ($desc === '') {\n return '';\n }\n\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return $desc . '|' . $unit;\n }\n\n /** Short human label for a finding message. */\n private function label(array $item): string\n {\n $desc = trim((string) ($item['description'] ?? ''));\n return $desc !== '' ? mb_substr($desc, 0, 60) : '—';\n }\n}\n"}, "1c80976405e15ef42f9881511c01c48fcb4daf79": {"t": 1785748352.2400446, "n": 11512, "h": "3a05161e071b3242", "c": "> $items\n * @return array{\n * findings: list}>,\n * ranges: array,\n * summary: array{subtotal:float, vat:float, total:float}\n * }\n */\n public function analyze(array $items): array\n {\n return [\n 'findings' => array_values($this->detectPriceInconsistencies($items)),\n 'ranges' => $this->fetchMarketRanges($items),\n 'summary' => $this->computeSummary($items),\n ];\n }\n\n // -------------------------------------------------------------------------\n // PRICE_INCONSISTENCY\n // -------------------------------------------------------------------------\n\n /**\n * Rows that describe the same item (same normalized key) but carry different\n * unit prices. Rows without a numeric price are ignored.\n *\n * @param array> $items\n * @return list}>\n */\n private function detectPriceInconsistencies(array $items): array\n {\n $groups = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $key = $this->normalizeKey($item);\n if ($key === '') {\n continue;\n }\n $groups[$key][$index] = round((float) $price, 2);\n }\n\n $findings = [];\n foreach ($groups as $priced) {\n $distinct = array_unique(array_values($priced));\n if (count($distinct) < 2) {\n continue;\n }\n\n $rows = array_keys($priced);\n $findings[] = [\n 'code' => 'PRICE_INCONSISTENCY',\n 'severity' => 'danger',\n 'message' => __('app.analysis_inconsistency_msg', [\n 'desc' => $this->label($items[$rows[0]]),\n 'min' => number_format(min($distinct), 2),\n 'max' => number_format(max($distinct), 2),\n ]),\n 'rows' => $rows,\n ];\n }\n\n return $findings;\n }\n\n // -------------------------------------------------------------------------\n // Summary\n // -------------------------------------------------------------------------\n\n /**\n * Recompute subtotal / VAT / total from non-rejected priced rows.\n *\n * @param array> $items\n * @return array{subtotal:float, vat:float, total:float}\n */\n private function computeSummary(array $items): array\n {\n $subtotal = 0.0;\n foreach ($items as $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $subtotal += (float) $price * (float) ($item['quantity'] ?? 0);\n }\n\n $vat = round($subtotal * self::VAT_RATE, 2);\n\n return [\n 'subtotal' => round($subtotal, 2),\n 'vat' => $vat,\n 'total' => round($subtotal + $vat, 2),\n ];\n }\n\n // -------------------------------------------------------------------------\n // Market range (min / avg / max) via DeepSeek\n // -------------------------------------------------------------------------\n\n /**\n * Ask DeepSeek for a realistic Saudi market unit-price range per priced item.\n *\n * @param array> $items\n * @return array Keyed by item index.\n */\n private function fetchMarketRanges(array $items): array\n {\n $indices = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n if (is_numeric($item['unit_price'] ?? null) && (float) $item['unit_price'] > 0) {\n $indices[] = $index;\n }\n }\n\n if (empty($indices)) {\n return [];\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('PriceAnalysisService: DEEPSEEK_API_KEY not configured; skipping market ranges.');\n return [];\n }\n\n $ranges = [];\n foreach (array_chunk($indices, self::CHUNK_SIZE) as $chunk) {\n $ranges += $this->fetchRangeChunk($items, $chunk, $apiKey);\n }\n\n return $ranges;\n }\n\n /**\n * @param list $indices\n * @return array\n */\n private function fetchRangeChunk(array $items, array $indices, string $apiKey): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'For each item below, give a realistic CURRENT unit-price RANGE in SAR based on typical 2024-2026 Saudi '\n . 'supplier rates: the lowest (\"mn\"), the average (\"av\") and the highest (\"mx\") you would expect to see. '\n . 'RULES: '\n . '(1) All three must be positive numbers with mn <= av <= mx. '\n . '(2) Return ONLY a valid compact JSON array, NO whitespace/newlines between elements, no markdown, no extra keys. '\n . '(3) Each element must be exactly: {\"i\":,\"mn\":,\"av\":,\"mx\":} '\n . 'Example: [{\"i\":0,\"mn\":1200,\"av\":1500,\"mx\":1900}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $prompt]],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n Log::warning('PriceAnalysisService: DeepSeek range request failed.', ['status' => $response->status()]);\n return [];\n }\n\n return $this->parseRanges($response->json('choices.0.message.content') ?? '');\n } catch (\\Throwable $e) {\n Log::error('PriceAnalysisService: Exception fetching ranges.', ['message' => $e->getMessage()]);\n return [];\n }\n }\n\n /**\n * @return array\n */\n private function parseRanges(string $text): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $data = json_decode($text, true);\n\n if (! is_array($data)) {\n preg_match_all(\n '/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"mn\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"av\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"mx\"\\s*:\\s*([\\d.]+)\\s*\\}/',\n $text,\n $matches,\n PREG_SET_ORDER\n );\n $data = array_map(fn($m) => [\n 'i' => (int) $m[1],\n 'mn' => (float) $m[2],\n 'av' => (float) $m[3],\n 'mx' => (float) $m[4],\n ], $matches);\n }\n\n if (! is_array($data)) {\n return [];\n }\n\n $ranges = [];\n foreach ($data as $entry) {\n $idx = $entry['i'] ?? null;\n if (! is_int($idx) && ! (is_string($idx) && ctype_digit($idx))) {\n continue;\n }\n $mn = is_numeric($entry['mn'] ?? null) ? (float) $entry['mn'] : null;\n $av = is_numeric($entry['av'] ?? null) ? (float) $entry['av'] : null;\n $mx = is_numeric($entry['mx'] ?? null) ? (float) $entry['mx'] : null;\n\n if ($mn === null || $av === null || $mx === null || $mn <= 0 || $mx <= 0) {\n continue;\n }\n\n // Guard against the model returning them out of order.\n $sorted = [$mn, $av, $mx];\n sort($sorted);\n\n $ranges[(int) $idx] = [\n 'min' => round($sorted[0], 2),\n 'avg' => round($sorted[1], 2),\n 'max' => round($sorted[2], 2),\n ];\n }\n\n return $ranges;\n }\n\n // -------------------------------------------------------------------------\n // Helpers\n // -------------------------------------------------------------------------\n\n /**\n * A normalized grouping key: lower-cased, whitespace-collapsed description + unit.\n * Empty when there is no description to compare on.\n */\n private function normalizeKey(array $item): string\n {\n $desc = mb_strtolower(trim((string) ($item['description'] ?? '')));\n $desc = preg_replace('/\\s+/u', ' ', $desc);\n\n if ($desc === '') {\n return '';\n }\n\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return $desc . '|' . $unit;\n }\n\n /** Short human label for a finding message. */\n private function label(array $item): string\n {\n $desc = trim((string) ($item['description'] ?? ''));\n return $desc !== '' ? mb_substr($desc, 0, 60) : '—';\n }\n}\n"}, "6bbe61ec72f324bcdd4b15b5136fa271f9eb004f": {"t": 1785748353.3665688, "n": 14623, "h": "0a369564ee746c25", "c": "> $items\n * @param float|null $reportedVat The VAT figure the caller displays, to be checked.\n * @return array{\n * findings: list}>,\n * ranges: array,\n * summary: array{subtotal:float, vat:float, total:float}\n * }\n */\n public function analyze(array $items, ?float $reportedVat = null): array\n {\n $findings = [];\n\n $findings = array_merge($findings, $this->detectDuplicates($items));\n $findings = array_merge($findings, $this->detectPriceInconsistencies($items));\n\n $summary = $this->computeSummary($items);\n\n if ($reportedVat !== null) {\n $vatFinding = $this->checkVat($reportedVat, $summary['vat']);\n if ($vatFinding !== null) {\n $findings[] = $vatFinding;\n }\n }\n\n return [\n 'findings' => array_values($findings),\n 'ranges' => $this->fetchMarketRanges($items),\n 'summary' => $summary,\n ];\n }\n\n // -------------------------------------------------------------------------\n // DUPLICATION\n // -------------------------------------------------------------------------\n\n /**\n * Group rows by a normalized (description + unit) key; any key with more than\n * one non-rejected row is a suspected duplicate.\n *\n * @param array> $items\n * @return list}>\n */\n private function detectDuplicates(array $items): array\n {\n $groups = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $key = $this->normalizeKey($item);\n if ($key === '') {\n continue;\n }\n $groups[$key][] = $index;\n }\n\n $findings = [];\n foreach ($groups as $rows) {\n if (count($rows) < 2) {\n continue;\n }\n $findings[] = [\n 'code' => 'DUPLICATION',\n 'severity' => 'warning',\n 'message' => trans_choice(\n 'app.analysis_duplication_msg',\n count($rows),\n ['count' => count($rows), 'desc' => $this->label($items[$rows[0]])]\n ),\n 'rows' => $rows,\n ];\n }\n\n return $findings;\n }\n\n // -------------------------------------------------------------------------\n // PRICE_INCONSISTENCY\n // -------------------------------------------------------------------------\n\n /**\n * Rows that describe the same item (same normalized key) but carry different\n * unit prices. Rows without a numeric price are ignored.\n *\n * @param array> $items\n * @return list}>\n */\n private function detectPriceInconsistencies(array $items): array\n {\n $groups = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $key = $this->normalizeKey($item);\n if ($key === '') {\n continue;\n }\n $groups[$key][$index] = round((float) $price, 2);\n }\n\n $findings = [];\n foreach ($groups as $priced) {\n $distinct = array_unique(array_values($priced));\n if (count($distinct) < 2) {\n continue;\n }\n\n $rows = array_keys($priced);\n $findings[] = [\n 'code' => 'PRICE_INCONSISTENCY',\n 'severity' => 'danger',\n 'message' => __('app.analysis_inconsistency_msg', [\n 'desc' => $this->label($items[$rows[0]]),\n 'min' => number_format(min($distinct), 2),\n 'max' => number_format(max($distinct), 2),\n ]),\n 'rows' => $rows,\n ];\n }\n\n return $findings;\n }\n\n // -------------------------------------------------------------------------\n // VAT review\n // -------------------------------------------------------------------------\n\n /**\n * Compare the caller's reported VAT against a fresh recomputation.\n * Returns a finding only when they diverge beyond tolerance.\n *\n * @return array{code:string, severity:string, message:string, rows:list}|null\n */\n private function checkVat(float $reportedVat, float $expectedVat): ?array\n {\n if (abs($reportedVat - $expectedVat) <= self::VAT_TOLERANCE) {\n return null;\n }\n\n return [\n 'code' => 'VAT_MISMATCH',\n 'severity' => 'danger',\n 'message' => __('app.analysis_vat_mismatch_msg', [\n 'reported' => number_format($reportedVat, 2),\n 'expected' => number_format($expectedVat, 2),\n ]),\n 'rows' => [],\n ];\n }\n\n /**\n * Recompute subtotal / VAT / total from non-rejected priced rows.\n *\n * @param array> $items\n * @return array{subtotal:float, vat:float, total:float}\n */\n private function computeSummary(array $items): array\n {\n $subtotal = 0.0;\n foreach ($items as $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $subtotal += (float) $price * (float) ($item['quantity'] ?? 0);\n }\n\n $vat = round($subtotal * self::VAT_RATE, 2);\n\n return [\n 'subtotal' => round($subtotal, 2),\n 'vat' => $vat,\n 'total' => round($subtotal + $vat, 2),\n ];\n }\n\n // -------------------------------------------------------------------------\n // Market range (min / avg / max) via DeepSeek\n // -------------------------------------------------------------------------\n\n /**\n * Ask DeepSeek for a realistic Saudi market unit-price range per priced item.\n *\n * @param array> $items\n * @return array Keyed by item index.\n */\n private function fetchMarketRanges(array $items): array\n {\n $indices = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n if (is_numeric($item['unit_price'] ?? null) && (float) $item['unit_price'] > 0) {\n $indices[] = $index;\n }\n }\n\n if (empty($indices)) {\n return [];\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('PriceAnalysisService: DEEPSEEK_API_KEY not configured; skipping market ranges.');\n return [];\n }\n\n $ranges = [];\n foreach (array_chunk($indices, self::CHUNK_SIZE) as $chunk) {\n $ranges += $this->fetchRangeChunk($items, $chunk, $apiKey);\n }\n\n return $ranges;\n }\n\n /**\n * @param list $indices\n * @return array\n */\n private function fetchRangeChunk(array $items, array $indices, string $apiKey): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'For each item below, give a realistic CURRENT unit-price RANGE in SAR based on typical 2024-2026 Saudi '\n . 'supplier rates: the lowest (\"mn\"), the average (\"av\") and the highest (\"mx\") you would expect to see. '\n . 'RULES: '\n . '(1) All three must be positive numbers with mn <= av <= mx. '\n . '(2) Return ONLY a valid compact JSON array, NO whitespace/newlines between elements, no markdown, no extra keys. '\n . '(3) Each element must be exactly: {\"i\":,\"mn\":,\"av\":,\"mx\":} '\n . 'Example: [{\"i\":0,\"mn\":1200,\"av\":1500,\"mx\":1900}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $prompt]],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n Log::warning('PriceAnalysisService: DeepSeek range request failed.', ['status' => $response->status()]);\n return [];\n }\n\n return $this->parseRanges($response->json('choices.0.message.content') ?? '');\n } catch (\\Throwable $e) {\n Log::error('PriceAnalysisService: Exception fetching ranges.', ['message' => $e->getMessage()]);\n return [];\n }\n }\n\n /**\n * @return array\n */\n private function parseRanges(string $text): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $data = json_decode($text, true);\n\n if (! is_array($data)) {\n preg_match_all(\n '/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"mn\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"av\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"mx\"\\s*:\\s*([\\d.]+)\\s*\\}/',\n $text,\n $matches,\n PREG_SET_ORDER\n );\n $data = array_map(fn($m) => [\n 'i' => (int) $m[1],\n 'mn' => (float) $m[2],\n 'av' => (float) $m[3],\n 'mx' => (float) $m[4],\n ], $matches);\n }\n\n if (! is_array($data)) {\n return [];\n }\n\n $ranges = [];\n foreach ($data as $entry) {\n $idx = $entry['i'] ?? null;\n if (! is_int($idx) && ! (is_string($idx) && ctype_digit($idx))) {\n continue;\n }\n $mn = is_numeric($entry['mn'] ?? null) ? (float) $entry['mn'] : null;\n $av = is_numeric($entry['av'] ?? null) ? (float) $entry['av'] : null;\n $mx = is_numeric($entry['mx'] ?? null) ? (float) $entry['mx'] : null;\n\n if ($mn === null || $av === null || $mx === null || $mn <= 0 || $mx <= 0) {\n continue;\n }\n\n // Guard against the model returning them out of order.\n $sorted = [$mn, $av, $mx];\n sort($sorted);\n\n $ranges[(int) $idx] = [\n 'min' => round($sorted[0], 2),\n 'avg' => round($sorted[1], 2),\n 'max' => round($sorted[2], 2),\n ];\n }\n\n return $ranges;\n }\n\n // -------------------------------------------------------------------------\n // Helpers\n // -------------------------------------------------------------------------\n\n /**\n * A normalized grouping key: lower-cased, whitespace-collapsed description + unit.\n * Empty when there is no description to compare on.\n */\n private function normalizeKey(array $item): string\n {\n $desc = mb_strtolower(trim((string) ($item['description'] ?? '')));\n $desc = preg_replace('/\\s+/u', ' ', $desc);\n\n if ($desc === '') {\n return '';\n }\n\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return $desc . '|' . $unit;\n }\n\n /** Short human label for a finding message. */\n private function label(array $item): string\n {\n $desc = trim((string) ($item['description'] ?? ''));\n return $desc !== '' ? mb_substr($desc, 0, 60) : '—';\n }\n}\n"}}, "app/Services/PricingService.php": {"9a5ada4ab1ceb0de386bc1ce99c561f8869c09d7": {"t": 1785745788.8965852, "n": 24430, "h": "08188f969b82d466", "c": "onProgress = $callback;\n return $this;\n }\n\n private function reportProgress(int $done, int $total): void\n {\n if ($this->onProgress !== null) {\n ($this->onProgress)($done, $total);\n }\n }\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array> $items\n * @return array> Items enriched with unit_price, price_source, price_status\n */\n /**\n * Build the cache key for one row's price.\n *\n * Keyed on the product itself — description and unit — not the quotation, so\n * the same item priced from two different BOQs agrees with itself. The unit\n * is part of the key because a price per metre and a price per piece are\n * different numbers for the same product.\n */\n private function priceCacheKey(array $item): string\n {\n $description = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''));\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'ai_price_' . hash('sha256', $description . '|' . $unit);\n }\n\n /**\n * Remember the AI's prices so a re-run returns the same numbers.\n *\n * @param array> $items\n * @param list $indices rows this pass priced\n */\n private function cachePrices(array $items, array $indices): void\n {\n foreach ($indices as $index) {\n $item = $items[$index] ?? null;\n\n if (! $item || empty($item['unit_price']) || $item['unit_price'] <= 0) {\n continue;\n }\n\n AiCache::store()->put(\n $this->priceCacheKey($item),\n [\n 'unit_price' => (float) $item['unit_price'],\n 'price_source' => $item['price_source'] ?? 'ai',\n ],\n now()->addDays(self::PRICE_CACHE_DAYS),\n );\n }\n }\n\n /**\n * The most recent price this product was quoted at, if it is still fresh.\n *\n * Searched before the AI is asked, so a product already priced on another\n * quotation keeps that price instead of being re-estimated at a different\n * figure. Only rows priced within PRICE_CACHE_DAYS count: past that the\n * market may genuinely have moved, and a stale number is worse than a\n * current one.\n *\n * Matching ignores case and collapses whitespace — \"Wooden Office Desk\" and\n * \"wooden office desk\" are the same product — but nothing more. A size or\n * spec in the description makes it a different product, and pricing it from\n * a shorter description would quote the wrong item.\n */\n private function lookupRecentQuotationPrice(array $item): ?float\n {\n $description = trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? '');\n $unit = trim((string) ($item['unit'] ?? ''));\n\n if ($description === '') {\n return null;\n }\n\n // LOWER() on both sides rather than relying on the collation, which\n // differs between servers and would make this behave inconsistently.\n $query = QuotationItem::query()\n ->whereRaw('LOWER(TRIM(description)) = ?', [mb_strtolower($description)])\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('updated_at', '>=', now()->subDays(self::PRICE_CACHE_DAYS));\n\n // Same product measured differently is a different price, so the unit\n // has to agree when the row carries one.\n if ($unit !== '') {\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [mb_strtolower($unit)])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [mb_strtolower($unit)]);\n });\n }\n\n // Newest first: if the product was re-priced, that is the figure to use.\n $price = $query->orderByDesc('updated_at')->value('unit_price');\n\n return $price !== null ? (float) $price : null;\n }\n\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n // Reuse a recent AI price for the same product.\n //\n // Without this, re-pricing the same BOQ produced different numbers\n // every time: the model is sampled at a non-zero temperature, so it\n // does not return an identical figure twice. A client seeing two\n // prices for one line on the same day has no reason to trust either.\n //\n // Held for PRICE_CACHE_DAYS, deliberately shorter than the\n // quotation's own expiry window, so a re-price after expiry does\n // fetch genuinely current rates.\n $cached = AiCache::store()->get($this->priceCacheKey($item));\n\n if (is_array($cached) && isset($cached['unit_price'])) {\n $items[$index]['unit_price'] = (float) $cached['unit_price'];\n $items[$index]['price_source'] = $cached['price_source'] ?? 'ai_cached';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n // Then what this product was priced at on a previous quotation.\n //\n // The cache above can expire or be flushed, and when it did the same\n // product went back to the AI and came back at a different figure.\n // The quotation rows are permanent, so they answer the question the\n // cache was only remembering: what did we quote this at recently?\n $recent = $this->lookupRecentQuotationPrice($item);\n\n if ($recent !== null) {\n $items[$index]['unit_price'] = $recent;\n $items[$index]['price_source'] = 'previous_quotation';\n $items[$index]['price_status'] = 'pending';\n\n // Re-warm the cache so the rest of this run does not re-query.\n AiCache::store()->put(\n $this->priceCacheKey($item),\n ['unit_price' => $recent, 'price_source' => 'previous_quotation'],\n now()->addDays(self::PRICE_CACHE_DAYS),\n );\n\n continue;\n }\n\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n $this->cachePrices($items, $chunks[0]);\n } else {\n // Multiple chunks. Http::pool fires every request it is given at\n // once, so a large BOQ would open thousands of concurrent calls\n // and be rate-limited into mass failure. Run the pool in batches\n // instead: still parallel, but bounded.\n $batches = array_chunk($chunks, self::MAX_PARALLEL_CHUNKS);\n $total = count($chunks);\n $done = 0;\n\n foreach ($batches as $batch) {\n $items = $this->enrichChunksParallel($items, $batch);\n\n $this->cachePrices($items, array_merge(...$batch));\n\n $done += count($batch);\n $this->reportProgress($done, $total);\n }\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n // Ordered explicitly. Without an ORDER BY, MySQL is free to return any\n // of the matching rows, and it does not always pick the same one — so a\n // keyword that matched several products priced the item differently on\n // different runs, while items with a single match stayed stable. That is\n // exactly the pattern that showed up: most rows identical, a few moving.\n //\n // Lowest price wins the tie, and the id breaks a tie on price, so the\n // result is both deterministic and defensible to a client.\n $product = $query->select('unit_price')\n ->orderBy('unit_price')\n ->orderBy('id')\n ->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":,\"p\":} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array> $items\n * @param list> $chunks\n * @return array>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n\n // Released as soon as it is parsed. max_tokens is 8192, and\n // Guzzle buffers each response in full — holding a whole\n // pool's worth is what exhausted the memory limit.\n unset($responses[$key], $response, $text);\n }\n\n unset($responses);\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array> $items\n * @param list $unmatchedIndices\n * @return array>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "5371a106a464b64117d1d68542f7104d14568a63": {"t": 1785748347.0960226, "n": 24430, "h": "08188f969b82d466", "c": "onProgress = $callback;\n return $this;\n }\n\n private function reportProgress(int $done, int $total): void\n {\n if ($this->onProgress !== null) {\n ($this->onProgress)($done, $total);\n }\n }\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array> $items\n * @return array> Items enriched with unit_price, price_source, price_status\n */\n /**\n * Build the cache key for one row's price.\n *\n * Keyed on the product itself — description and unit — not the quotation, so\n * the same item priced from two different BOQs agrees with itself. The unit\n * is part of the key because a price per metre and a price per piece are\n * different numbers for the same product.\n */\n private function priceCacheKey(array $item): string\n {\n $description = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''));\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'ai_price_' . hash('sha256', $description . '|' . $unit);\n }\n\n /**\n * Remember the AI's prices so a re-run returns the same numbers.\n *\n * @param array> $items\n * @param list $indices rows this pass priced\n */\n private function cachePrices(array $items, array $indices): void\n {\n foreach ($indices as $index) {\n $item = $items[$index] ?? null;\n\n if (! $item || empty($item['unit_price']) || $item['unit_price'] <= 0) {\n continue;\n }\n\n AiCache::store()->put(\n $this->priceCacheKey($item),\n [\n 'unit_price' => (float) $item['unit_price'],\n 'price_source' => $item['price_source'] ?? 'ai',\n ],\n now()->addDays(self::PRICE_CACHE_DAYS),\n );\n }\n }\n\n /**\n * The most recent price this product was quoted at, if it is still fresh.\n *\n * Searched before the AI is asked, so a product already priced on another\n * quotation keeps that price instead of being re-estimated at a different\n * figure. Only rows priced within PRICE_CACHE_DAYS count: past that the\n * market may genuinely have moved, and a stale number is worse than a\n * current one.\n *\n * Matching ignores case and collapses whitespace — \"Wooden Office Desk\" and\n * \"wooden office desk\" are the same product — but nothing more. A size or\n * spec in the description makes it a different product, and pricing it from\n * a shorter description would quote the wrong item.\n */\n private function lookupRecentQuotationPrice(array $item): ?float\n {\n $description = trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? '');\n $unit = trim((string) ($item['unit'] ?? ''));\n\n if ($description === '') {\n return null;\n }\n\n // LOWER() on both sides rather than relying on the collation, which\n // differs between servers and would make this behave inconsistently.\n $query = QuotationItem::query()\n ->whereRaw('LOWER(TRIM(description)) = ?', [mb_strtolower($description)])\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('updated_at', '>=', now()->subDays(self::PRICE_CACHE_DAYS));\n\n // Same product measured differently is a different price, so the unit\n // has to agree when the row carries one.\n if ($unit !== '') {\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [mb_strtolower($unit)])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [mb_strtolower($unit)]);\n });\n }\n\n // Newest first: if the product was re-priced, that is the figure to use.\n $price = $query->orderByDesc('updated_at')->value('unit_price');\n\n return $price !== null ? (float) $price : null;\n }\n\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n // Reuse a recent AI price for the same product.\n //\n // Without this, re-pricing the same BOQ produced different numbers\n // every time: the model is sampled at a non-zero temperature, so it\n // does not return an identical figure twice. A client seeing two\n // prices for one line on the same day has no reason to trust either.\n //\n // Held for PRICE_CACHE_DAYS, deliberately shorter than the\n // quotation's own expiry window, so a re-price after expiry does\n // fetch genuinely current rates.\n $cached = AiCache::store()->get($this->priceCacheKey($item));\n\n if (is_array($cached) && isset($cached['unit_price'])) {\n $items[$index]['unit_price'] = (float) $cached['unit_price'];\n $items[$index]['price_source'] = $cached['price_source'] ?? 'ai_cached';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n // Then what this product was priced at on a previous quotation.\n //\n // The cache above can expire or be flushed, and when it did the same\n // product went back to the AI and came back at a different figure.\n // The quotation rows are permanent, so they answer the question the\n // cache was only remembering: what did we quote this at recently?\n $recent = $this->lookupRecentQuotationPrice($item);\n\n if ($recent !== null) {\n $items[$index]['unit_price'] = $recent;\n $items[$index]['price_source'] = 'previous_quotation';\n $items[$index]['price_status'] = 'pending';\n\n // Re-warm the cache so the rest of this run does not re-query.\n AiCache::store()->put(\n $this->priceCacheKey($item),\n ['unit_price' => $recent, 'price_source' => 'previous_quotation'],\n now()->addDays(self::PRICE_CACHE_DAYS),\n );\n\n continue;\n }\n\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n $this->cachePrices($items, $chunks[0]);\n } else {\n // Multiple chunks. Http::pool fires every request it is given at\n // once, so a large BOQ would open thousands of concurrent calls\n // and be rate-limited into mass failure. Run the pool in batches\n // instead: still parallel, but bounded.\n $batches = array_chunk($chunks, self::MAX_PARALLEL_CHUNKS);\n $total = count($chunks);\n $done = 0;\n\n foreach ($batches as $batch) {\n $items = $this->enrichChunksParallel($items, $batch);\n\n $this->cachePrices($items, array_merge(...$batch));\n\n $done += count($batch);\n $this->reportProgress($done, $total);\n }\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n // Ordered explicitly. Without an ORDER BY, MySQL is free to return any\n // of the matching rows, and it does not always pick the same one — so a\n // keyword that matched several products priced the item differently on\n // different runs, while items with a single match stayed stable. That is\n // exactly the pattern that showed up: most rows identical, a few moving.\n //\n // Lowest price wins the tie, and the id breaks a tie on price, so the\n // result is both deterministic and defensible to a client.\n $product = $query->select('unit_price')\n ->orderBy('unit_price')\n ->orderBy('id')\n ->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":,\"p\":} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array> $items\n * @param list> $chunks\n * @return array>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n\n // Released as soon as it is parsed. max_tokens is 8192, and\n // Guzzle buffers each response in full — holding a whole\n // pool's worth is what exhausted the memory limit.\n unset($responses[$key], $response, $text);\n }\n\n unset($responses);\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array> $items\n * @param list $unmatchedIndices\n * @return array>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "06c9320bf7fd9a0d5e611ec5a69092739739349f": {"t": 1785748348.3113337, "n": 21195, "h": "894e35717191de36", "c": "onProgress = $callback;\n return $this;\n }\n\n private function reportProgress(int $done, int $total): void\n {\n if ($this->onProgress !== null) {\n ($this->onProgress)($done, $total);\n }\n }\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array> $items\n * @return array> Items enriched with unit_price, price_source, price_status\n */\n /**\n * Build the cache key for one row's price.\n *\n * Keyed on the product itself — description and unit — not the quotation, so\n * the same item priced from two different BOQs agrees with itself. The unit\n * is part of the key because a price per metre and a price per piece are\n * different numbers for the same product.\n */\n private function priceCacheKey(array $item): string\n {\n $description = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''));\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'ai_price_' . hash('sha256', $description . '|' . $unit);\n }\n\n /**\n * Remember the AI's prices so a re-run returns the same numbers.\n *\n * @param array> $items\n * @param list $indices rows this pass priced\n */\n private function cachePrices(array $items, array $indices): void\n {\n foreach ($indices as $index) {\n $item = $items[$index] ?? null;\n\n if (! $item || empty($item['unit_price']) || $item['unit_price'] <= 0) {\n continue;\n }\n\n AiCache::store()->put(\n $this->priceCacheKey($item),\n [\n 'unit_price' => (float) $item['unit_price'],\n 'price_source' => $item['price_source'] ?? 'ai',\n ],\n now()->addDays(self::PRICE_CACHE_DAYS),\n );\n }\n }\n\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n // Reuse a recent AI price for the same product.\n //\n // Without this, re-pricing the same BOQ produced different numbers\n // every time: the model is sampled at a non-zero temperature, so it\n // does not return an identical figure twice. A client seeing two\n // prices for one line on the same day has no reason to trust either.\n //\n // Held for PRICE_CACHE_DAYS, deliberately shorter than the\n // quotation's own expiry window, so a re-price after expiry does\n // fetch genuinely current rates.\n $cached = AiCache::store()->get($this->priceCacheKey($item));\n\n if (is_array($cached) && isset($cached['unit_price'])) {\n $items[$index]['unit_price'] = (float) $cached['unit_price'];\n $items[$index]['price_source'] = $cached['price_source'] ?? 'ai_cached';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n $this->cachePrices($items, $chunks[0]);\n } else {\n // Multiple chunks. Http::pool fires every request it is given at\n // once, so a large BOQ would open thousands of concurrent calls\n // and be rate-limited into mass failure. Run the pool in batches\n // instead: still parallel, but bounded.\n $batches = array_chunk($chunks, self::MAX_PARALLEL_CHUNKS);\n $total = count($chunks);\n $done = 0;\n\n foreach ($batches as $batch) {\n $items = $this->enrichChunksParallel($items, $batch);\n\n $this->cachePrices($items, array_merge(...$batch));\n\n $done += count($batch);\n $this->reportProgress($done, $total);\n }\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n // Ordered explicitly. Without an ORDER BY, MySQL is free to return any\n // of the matching rows, and it does not always pick the same one — so a\n // keyword that matched several products priced the item differently on\n // different runs, while items with a single match stayed stable. That is\n // exactly the pattern that showed up: most rows identical, a few moving.\n //\n // Lowest price wins the tie, and the id breaks a tie on price, so the\n // result is both deterministic and defensible to a client.\n $product = $query->select('unit_price')\n ->orderBy('unit_price')\n ->orderBy('id')\n ->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":,\"p\":} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array> $items\n * @param list> $chunks\n * @return array>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n\n // Released as soon as it is parsed. max_tokens is 8192, and\n // Guzzle buffers each response in full — holding a whole\n // pool's worth is what exhausted the memory limit.\n unset($responses[$key], $response, $text);\n }\n\n unset($responses);\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array> $items\n * @param list $unmatchedIndices\n * @return array>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "26efb22eb01080c935459c7864e4f4207036ee31": {"t": 1785748349.5627666, "n": 21174, "h": "0be7cdbd3b0bbe09", "c": "onProgress = $callback;\n return $this;\n }\n\n private function reportProgress(int $done, int $total): void\n {\n if ($this->onProgress !== null) {\n ($this->onProgress)($done, $total);\n }\n }\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array> $items\n * @return array> Items enriched with unit_price, price_source, price_status\n */\n /**\n * Build the cache key for one row's price.\n *\n * Keyed on the product itself — description and unit — not the quotation, so\n * the same item priced from two different BOQs agrees with itself. The unit\n * is part of the key because a price per metre and a price per piece are\n * different numbers for the same product.\n */\n private function priceCacheKey(array $item): string\n {\n $description = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''));\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'ai_price_' . hash('sha256', $description . '|' . $unit);\n }\n\n /**\n * Remember the AI's prices so a re-run returns the same numbers.\n *\n * @param array> $items\n * @param list $indices rows this pass priced\n */\n private function cachePrices(array $items, array $indices): void\n {\n foreach ($indices as $index) {\n $item = $items[$index] ?? null;\n\n if (! $item || empty($item['unit_price']) || $item['unit_price'] <= 0) {\n continue;\n }\n\n Cache::store('ai')->put(\n $this->priceCacheKey($item),\n [\n 'unit_price' => (float) $item['unit_price'],\n 'price_source' => $item['price_source'] ?? 'ai',\n ],\n now()->addDays(self::PRICE_CACHE_DAYS),\n );\n }\n }\n\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n // Reuse a recent AI price for the same product.\n //\n // Without this, re-pricing the same BOQ produced different numbers\n // every time: the model is sampled at a non-zero temperature, so it\n // does not return an identical figure twice. A client seeing two\n // prices for one line on the same day has no reason to trust either.\n //\n // Held for PRICE_CACHE_DAYS, deliberately shorter than the\n // quotation's own expiry window, so a re-price after expiry does\n // fetch genuinely current rates.\n $cached = Cache::store('ai')->get($this->priceCacheKey($item));\n\n if (is_array($cached) && isset($cached['unit_price'])) {\n $items[$index]['unit_price'] = (float) $cached['unit_price'];\n $items[$index]['price_source'] = $cached['price_source'] ?? 'ai_cached';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n $this->cachePrices($items, $chunks[0]);\n } else {\n // Multiple chunks. Http::pool fires every request it is given at\n // once, so a large BOQ would open thousands of concurrent calls\n // and be rate-limited into mass failure. Run the pool in batches\n // instead: still parallel, but bounded.\n $batches = array_chunk($chunks, self::MAX_PARALLEL_CHUNKS);\n $total = count($chunks);\n $done = 0;\n\n foreach ($batches as $batch) {\n $items = $this->enrichChunksParallel($items, $batch);\n\n $this->cachePrices($items, array_merge(...$batch));\n\n $done += count($batch);\n $this->reportProgress($done, $total);\n }\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n // Ordered explicitly. Without an ORDER BY, MySQL is free to return any\n // of the matching rows, and it does not always pick the same one — so a\n // keyword that matched several products priced the item differently on\n // different runs, while items with a single match stayed stable. That is\n // exactly the pattern that showed up: most rows identical, a few moving.\n //\n // Lowest price wins the tie, and the id breaks a tie on price, so the\n // result is both deterministic and defensible to a client.\n $product = $query->select('unit_price')\n ->orderBy('unit_price')\n ->orderBy('id')\n ->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":,\"p\":} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array> $items\n * @param list> $chunks\n * @return array>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n\n // Released as soon as it is parsed. max_tokens is 8192, and\n // Guzzle buffers each response in full — holding a whole\n // pool's worth is what exhausted the memory limit.\n unset($responses[$key], $response, $text);\n }\n\n unset($responses);\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array> $items\n * @param list $unmatchedIndices\n * @return array>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "8fd48f258c675a8fe54dab5da10ea820e80627cc": {"t": 1785748350.719583, "n": 20534, "h": "a5c92676359f578a", "c": "onProgress = $callback;\n return $this;\n }\n\n private function reportProgress(int $done, int $total): void\n {\n if ($this->onProgress !== null) {\n ($this->onProgress)($done, $total);\n }\n }\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array> $items\n * @return array> Items enriched with unit_price, price_source, price_status\n */\n /**\n * Build the cache key for one row's price.\n *\n * Keyed on the product itself — description and unit — not the quotation, so\n * the same item priced from two different BOQs agrees with itself. The unit\n * is part of the key because a price per metre and a price per piece are\n * different numbers for the same product.\n */\n private function priceCacheKey(array $item): string\n {\n $description = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''));\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'ai_price_' . hash('sha256', $description . '|' . $unit);\n }\n\n /**\n * Remember the AI's prices so a re-run returns the same numbers.\n *\n * @param array> $items\n * @param list $indices rows this pass priced\n */\n private function cachePrices(array $items, array $indices): void\n {\n foreach ($indices as $index) {\n $item = $items[$index] ?? null;\n\n if (! $item || empty($item['unit_price']) || $item['unit_price'] <= 0) {\n continue;\n }\n\n Cache::store('ai')->put(\n $this->priceCacheKey($item),\n [\n 'unit_price' => (float) $item['unit_price'],\n 'price_source' => $item['price_source'] ?? 'ai',\n ],\n now()->addDays(self::PRICE_CACHE_DAYS),\n );\n }\n }\n\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n // Reuse a recent AI price for the same product.\n //\n // Without this, re-pricing the same BOQ produced different numbers\n // every time: the model is sampled at a non-zero temperature, so it\n // does not return an identical figure twice. A client seeing two\n // prices for one line on the same day has no reason to trust either.\n //\n // Held for PRICE_CACHE_DAYS, deliberately shorter than the\n // quotation's own expiry window, so a re-price after expiry does\n // fetch genuinely current rates.\n $cached = Cache::store('ai')->get($this->priceCacheKey($item));\n\n if (is_array($cached) && isset($cached['unit_price'])) {\n $items[$index]['unit_price'] = (float) $cached['unit_price'];\n $items[$index]['price_source'] = $cached['price_source'] ?? 'ai_cached';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n $this->cachePrices($items, $chunks[0]);\n } else {\n // Multiple chunks. Http::pool fires every request it is given at\n // once, so a large BOQ would open thousands of concurrent calls\n // and be rate-limited into mass failure. Run the pool in batches\n // instead: still parallel, but bounded.\n $batches = array_chunk($chunks, self::MAX_PARALLEL_CHUNKS);\n $total = count($chunks);\n $done = 0;\n\n foreach ($batches as $batch) {\n $items = $this->enrichChunksParallel($items, $batch);\n\n $this->cachePrices($items, array_merge(...$batch));\n\n $done += count($batch);\n $this->reportProgress($done, $total);\n }\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":,\"p\":} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array> $items\n * @param list> $chunks\n * @return array>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n\n // Released as soon as it is parsed. max_tokens is 8192, and\n // Guzzle buffers each response in full — holding a whole\n // pool's worth is what exhausted the memory limit.\n unset($responses[$key], $response, $text);\n }\n\n unset($responses);\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array> $items\n * @param list $unmatchedIndices\n * @return array>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "4ecdad51bbb33161bd38b560a3c084a4457f317a": {"t": 1785748351.8776624, "n": 20508, "h": "e32b241b66720ea6", "c": "onProgress = $callback;\n return $this;\n }\n\n private function reportProgress(int $done, int $total): void\n {\n if ($this->onProgress !== null) {\n ($this->onProgress)($done, $total);\n }\n }\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array> $items\n * @return array> Items enriched with unit_price, price_source, price_status\n */\n /**\n * Build the cache key for one row's price.\n *\n * Keyed on the product itself — description and unit — not the quotation, so\n * the same item priced from two different BOQs agrees with itself. The unit\n * is part of the key because a price per metre and a price per piece are\n * different numbers for the same product.\n */\n private function priceCacheKey(array $item): string\n {\n $description = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''));\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'ai_price_' . hash('sha256', $description . '|' . $unit);\n }\n\n /**\n * Remember the AI's prices so a re-run returns the same numbers.\n *\n * @param array> $items\n * @param list $indices rows this pass priced\n */\n private function cachePrices(array $items, array $indices): void\n {\n foreach ($indices as $index) {\n $item = $items[$index] ?? null;\n\n if (! $item || empty($item['unit_price']) || $item['unit_price'] <= 0) {\n continue;\n }\n\n Cache::put(\n $this->priceCacheKey($item),\n [\n 'unit_price' => (float) $item['unit_price'],\n 'price_source' => $item['price_source'] ?? 'ai',\n ],\n now()->addDays(self::PRICE_CACHE_DAYS),\n );\n }\n }\n\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n // Reuse a recent AI price for the same product.\n //\n // Without this, re-pricing the same BOQ produced different numbers\n // every time: the model is sampled at a non-zero temperature, so it\n // does not return an identical figure twice. A client seeing two\n // prices for one line on the same day has no reason to trust either.\n //\n // Held for PRICE_CACHE_DAYS, deliberately shorter than the\n // quotation's own expiry window, so a re-price after expiry does\n // fetch genuinely current rates.\n $cached = Cache::get($this->priceCacheKey($item));\n\n if (is_array($cached) && isset($cached['unit_price'])) {\n $items[$index]['unit_price'] = (float) $cached['unit_price'];\n $items[$index]['price_source'] = $cached['price_source'] ?? 'ai_cached';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n $this->cachePrices($items, $chunks[0]);\n } else {\n // Multiple chunks. Http::pool fires every request it is given at\n // once, so a large BOQ would open thousands of concurrent calls\n // and be rate-limited into mass failure. Run the pool in batches\n // instead: still parallel, but bounded.\n $batches = array_chunk($chunks, self::MAX_PARALLEL_CHUNKS);\n $total = count($chunks);\n $done = 0;\n\n foreach ($batches as $batch) {\n $items = $this->enrichChunksParallel($items, $batch);\n\n $this->cachePrices($items, array_merge(...$batch));\n\n $done += count($batch);\n $this->reportProgress($done, $total);\n }\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":,\"p\":} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array> $items\n * @param list> $chunks\n * @return array>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n\n // Released as soon as it is parsed. max_tokens is 8192, and\n // Guzzle buffers each response in full — holding a whole\n // pool's worth is what exhausted the memory limit.\n unset($responses[$key], $response, $text);\n }\n\n unset($responses);\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array> $items\n * @param list $unmatchedIndices\n * @return array>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "883243d8e23c5409eda619b21c0be21c7b07a5f2": {"t": 1785748353.0120487, "n": 17495, "h": "d6ececa245852310", "c": "onProgress = $callback;\n return $this;\n }\n\n private function reportProgress(int $done, int $total): void\n {\n if ($this->onProgress !== null) {\n ($this->onProgress)($done, $total);\n }\n }\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array> $items\n * @return array> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n } else {\n // Multiple chunks. Http::pool fires every request it is given at\n // once, so a large BOQ would open thousands of concurrent calls\n // and be rate-limited into mass failure. Run the pool in batches\n // instead: still parallel, but bounded.\n $batches = array_chunk($chunks, self::MAX_PARALLEL_CHUNKS);\n $total = count($chunks);\n $done = 0;\n\n foreach ($batches as $batch) {\n $items = $this->enrichChunksParallel($items, $batch);\n\n $done += count($batch);\n $this->reportProgress($done, $total);\n }\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":,\"p\":} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array> $items\n * @param list> $chunks\n * @return array>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n\n // Released as soon as it is parsed. max_tokens is 8192, and\n // Guzzle buffers each response in full — holding a whole\n // pool's worth is what exhausted the memory limit.\n unset($responses[$key], $response, $text);\n }\n\n unset($responses);\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array> $items\n * @param list $unmatchedIndices\n * @return array>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "6dec2d69c09238721891e16d9089f7f02c568f15": {"t": 1785748354.1841776, "n": 17164, "h": "5c4d6ccb8f09c4fe", "c": "onProgress = $callback;\n return $this;\n }\n\n private function reportProgress(int $done, int $total): void\n {\n if ($this->onProgress !== null) {\n ($this->onProgress)($done, $total);\n }\n }\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array> $items\n * @return array> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n } else {\n // Multiple chunks. Http::pool fires every request it is given at\n // once, so a large BOQ would open thousands of concurrent calls\n // and be rate-limited into mass failure. Run the pool in batches\n // instead: still parallel, but bounded.\n $batches = array_chunk($chunks, self::MAX_PARALLEL_CHUNKS);\n $total = count($chunks);\n $done = 0;\n\n foreach ($batches as $batch) {\n $items = $this->enrichChunksParallel($items, $batch);\n\n $done += count($batch);\n $this->reportProgress($done, $total);\n }\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":,\"p\":} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array> $items\n * @param list> $chunks\n * @return array>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array> $items\n * @param list $unmatchedIndices\n * @return array>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "ca5c8d92c52e7a1a84de17de7cd5ce2ab473f372": {"t": 1785748355.3735616, "n": 15733, "h": "797d2e4c1cfad463", "c": "> $items\n * @return array> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n } else {\n // Multiple chunks — send ALL in parallel to save time\n $items = $this->enrichChunksParallel($items, $chunks);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":,\"p\":} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array> $items\n * @param list> $chunks\n * @return array>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array> $items\n * @param list $unmatchedIndices\n * @return array>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "77338c4152332c74abe738de7872f7686a0e46f9": {"t": 1785748356.5325863, "n": 17663, "h": "b82838a4a6ce291b", "c": "> $items\n * @return array> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n } else {\n // Multiple chunks — send ALL in parallel to save time\n $items = $this->enrichChunksParallel($items, $chunks);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars, not 80: the spec engine writes a full specification\n // here and truncating it would price a different product.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction, MEP and IT market. '\n . 'For each item below, pick the specific real product a Saudi supplier would actually quote '\n . 'against that specification, then price THAT product. '\n . 'RULES: '\n . '(1) \"m\" = the concrete product you priced, as \"Brand Model\" — e.g. \"HP ProBook 450 G10\", '\n . '\"Schneider Easy9 EZ9F56220\", \"Grundfos CR 5-8\". It must be a real, currently-sold product '\n . 'from a brand available in Saudi Arabia, and it must genuinely match the specification given. '\n . '(2) NEVER fabricate a model number. If you are not confident a specific model exists and fits, '\n . 'return \"m\":\"\" and price the specification generically — an empty \"m\" is far better than an '\n . 'invented one, because a buyer will try to source exactly what you name. '\n . '(3) \"p\" = the unit price in SAR for that product, for the unit given in the item. '\n . 'Always a positive non-zero number, based on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(4) Price the SPECIFICATION as written. If it states 16 GB RAM, do not price an 8 GB model. '\n . '(5) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(6) Each element must be exactly: {\"i\":,\"m\":\"\",\"p\":} '\n . '(7) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"m\":\"HP ProBook 450 G10\",\"p\":4200},{\"i\":1,\"m\":\"\",\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n // Salvage whole entries from a truncated or malformed response. \"m\" is\n // optional so this still recovers prices when the model name is absent.\n preg_match_all(\n '/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*(?:,\\s*\"m\"\\s*:\\s*\"([^\"]*)\"\\s*)?,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/',\n $text,\n $matches,\n PREG_SET_ORDER\n );\n if (! empty($matches)) {\n $priceData = array_map(\n fn ($m) => ['i' => (int) $m[1], 'm' => $m[2] ?? '', 'p' => (float) $m[3]],\n $matches\n );\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n\n // Record which real product the price belongs to, so the quotation\n // names something a buyer can actually go and source. Prepended to\n // the description because that is what the client reads.\n $priced = trim((string) ($entry['m'] ?? ''));\n if ($priced !== '') {\n $items[$idx]['priced_product'] = $priced;\n\n $description = (string) ($items[$idx]['description'] ?? '');\n if (mb_stripos($description, $priced) === false) {\n $items[$idx]['description'] = $priced . ' — ' . $description;\n }\n }\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array> $items\n * @param list> $chunks\n * @return array>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array> $items\n * @param list $unmatchedIndices\n * @return array>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "7b7fe92998dd076f92414fd2faa8951deaa85d50": {"t": 1785748357.649455, "n": 15564, "h": "520fea4f44339041", "c": "> $items\n * @return array> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n } else {\n // Multiple chunks — send ALL in parallel to save time\n $items = $this->enrichChunksParallel($items, $chunks);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":,\"p\":} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array> $items\n * @param list> $chunks\n * @return array>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array> $items\n * @param list $unmatchedIndices\n * @return array>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "b65ff6622e72f65525fe55404e124bc72c97539d": {"t": 1785748358.74915, "n": 13305, "h": "c4234a9adc62a7a0", "c": "> $items\n * @return array> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n } else {\n // Multiple chunks — send ALL in parallel to save time\n $items = $this->enrichChunksParallel($items, $chunks);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":,\"p\":} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array> $items\n * @param list> $chunks\n * @return array>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array> $items\n * @param list $unmatchedIndices\n * @return array>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "84d0c71fc84eb7075e9044adb63af7cc82cfd093": {"t": 1785748359.858464, "n": 13706, "h": "6914527d0403046d", "c": "> $items\n * @return array> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::GEMINI_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithGemini($items, $chunks[0]);\n } else {\n // Multiple chunks — send ALL in parallel to save time\n $items = $this->enrichChunksParallel($items, $chunks);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n // -------------------------------------------------------------------------\n // Gemini fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to Gemini for a set of item indices.\n */\n private function buildGeminiPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the Gemini prompt string for a given payload array.\n */\n private function buildGeminiPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":,\"p\":} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a Gemini text response and apply prices to $items.\n */\n private function applyGeminiText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from Gemini response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'gemini';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to Gemini in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array> $items\n * @param list> $chunks\n * @return array>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.gemini.key', '');\n $primaryModel = (string) config('services.gemini.model', 'gemini-2.0-flash-lite');\n $modelChain = array_unique(array_filter([$primaryModel, 'gemini-2.0-flash-lite', 'gemini-flash-lite-latest', 'gemini-flash-latest']));\n\n if (empty($apiKey)) {\n Log::warning('PricingService: GEMINI_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n // Build payloads + request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildGeminiPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'contents' => [\n ['role' => 'user', 'parts' => [['text' => $this->buildGeminiPrompt($payload)]]],\n ],\n 'generationConfig' => [\n 'responseMimeType' => 'application/json',\n 'temperature' => 0.2,\n 'maxOutputTokens' => 8192,\n ],\n ];\n }\n\n // Track which chunks still need pricing\n $failedChunks = array_keys($chunks);\n\n foreach ($modelChain as $model) {\n if (empty($failedChunks)) {\n break;\n }\n\n $url = \"https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}\";\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)->timeout(90)->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('candidates.0.content.parts.0.text') ?? '';\n $items = $this->applyGeminiText($text, $items, $model);\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n // Fall back: process remaining chunks one by one\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithGemini($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n Log::error('PricingService: Some chunks failed all models.', ['chunks' => $failedChunks]);\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Gemini fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to Gemini in ONE request to minimise token consumption.\n * Only the fields Gemini actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via maxOutputTokens to protect against runaway usage.\n *\n * @param array> $items\n * @param list $unmatchedIndices\n * @return array>\n */\n private function enrichWithGemini(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.gemini.key', '');\n $modelChain = array_unique(array_filter([\n (string) config('services.gemini.model', 'gemini-2.0-flash-lite'),\n 'gemini-2.0-flash-lite',\n 'gemini-flash-lite-latest',\n 'gemini-flash-latest',\n ]));\n\n if (empty($apiKey)) {\n Log::warning('PricingService: GEMINI_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildGeminiPayload($items, $unmatchedIndices);\n $prompt = $this->buildGeminiPrompt($payload);\n $lastStatus = null;\n\n foreach ($modelChain as $model) {\n $url = \"https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}\";\n\n try {\n $response = Http::timeout(90)->post($url, [\n 'contents' => [['role' => 'user', 'parts' => [['text' => $prompt]]]],\n 'generationConfig' => [\n 'responseMimeType' => 'application/json',\n 'temperature' => 0.2,\n 'maxOutputTokens' => 8192,\n ],\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: Gemini model failed, trying next.', ['model' => $model, 'status' => $lastStatus]);\n continue;\n }\n\n $text = $response->json('candidates.0.content.parts.0.text') ?? '';\n $items = $this->applyGeminiText($text, $items, $model);\n\n return $items;\n\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling Gemini.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n }\n\n Log::error('PricingService: All Gemini models failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "5e2e398c204c08368052e0f53f5d840a70bf1acb": {"t": 1785748360.9541714, "n": 9835, "h": "e65607a40dbde1d6", "c": "> $items\n * @return array> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n // Process ALL unmatched items — chunk into batches so we never drop any\n foreach (array_chunk($unmatched, self::GEMINI_CHUNK_SIZE) as $chunk) {\n $items = $this->enrichWithGemini($items, $chunk);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n // -------------------------------------------------------------------------\n // Gemini fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to Gemini in ONE request to minimise token consumption.\n * Only the fields Gemini actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via maxOutputTokens to protect against runaway usage.\n *\n * @param array> $items\n * @param list $unmatchedIndices\n * @return array>\n */\n private function enrichWithGemini(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.gemini.key', '');\n $primaryModel = (string) config('services.gemini.model', 'gemini-2.0-flash-lite');\n // Try models in order — all confirmed available for this API key\n $modelChain = array_unique(array_filter([$primaryModel, 'gemini-2.0-flash-lite', 'gemini-flash-lite-latest', 'gemini-flash-latest']));\n\n if (empty($apiKey)) {\n Log::warning('PricingService: GEMINI_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $batch = $unmatchedIndices;\n\n // Compact payload — only essential fields to minimise input tokens\n $payload = [];\n foreach ($batch as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":,\"p\":} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n $lastStatus = null;\n\n foreach ($modelChain as $model) {\n $url = \"https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}\";\n\n try {\n $response = Http::timeout(60)->post($url, [\n 'contents' => [\n ['role' => 'user', 'parts' => [['text' => $prompt]]],\n ],\n 'generationConfig' => [\n 'responseMimeType' => 'application/json',\n 'temperature' => 0.2,\n 'maxOutputTokens' => 8192,\n ],\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: Gemini model failed, trying next.', [\n 'model' => $model,\n 'status' => $lastStatus,\n ]);\n continue; // try next model\n }\n\n $text = $response->json('candidates.0.content.parts.0.text') ?? '';\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n // First try full JSON decode\n $priceData = json_decode($text, true);\n\n // Fallback: extract individual objects via regex if truncated\n if (! is_array($priceData)) {\n Log::warning('PricingService: Full JSON parse failed — attempting partial extraction.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 200),\n ]);\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from Gemini response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n continue; // try next model\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'gemini';\n }\n }\n\n return $items; // success — no need to try further models\n\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling Gemini.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n }\n }\n\n Log::error('PricingService: All Gemini models failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "d18974ac98ccde45831fafe18562ddf73e6c0c6c": {"t": 1785748362.0523841, "n": 9789, "h": "6f471e34cc26b11f", "c": "> $items\n * @return array> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $items = $this->enrichWithGemini($items, $unmatched);\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n // -------------------------------------------------------------------------\n // Gemini fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to Gemini in ONE request to minimise token consumption.\n * Only the fields Gemini actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via maxOutputTokens to protect against runaway usage.\n *\n * @param array> $items\n * @param list $unmatchedIndices\n * @return array>\n */\n private function enrichWithGemini(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.gemini.key', '');\n $primaryModel = (string) config('services.gemini.model', 'gemini-2.0-flash-lite');\n // Try models in order — all confirmed available for this API key\n $modelChain = array_unique(array_filter([$primaryModel, 'gemini-2.0-flash-lite', 'gemini-flash-lite-latest', 'gemini-flash-latest']));\n\n if (empty($apiKey)) {\n Log::warning('PricingService: GEMINI_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n // Token protection: cap batch size\n $batch = array_slice($unmatchedIndices, 0, self::MAX_GEMINI_BATCH);\n\n // Compact payload — only essential fields to minimise input tokens\n $payload = [];\n foreach ($batch as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":,\"p\":} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n $lastStatus = null;\n\n foreach ($modelChain as $model) {\n $url = \"https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}\";\n\n try {\n $response = Http::timeout(60)->post($url, [\n 'contents' => [\n ['role' => 'user', 'parts' => [['text' => $prompt]]],\n ],\n 'generationConfig' => [\n 'responseMimeType' => 'application/json',\n 'temperature' => 0.2,\n 'maxOutputTokens' => 8192,\n ],\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: Gemini model failed, trying next.', [\n 'model' => $model,\n 'status' => $lastStatus,\n ]);\n continue; // try next model\n }\n\n $text = $response->json('candidates.0.content.parts.0.text') ?? '';\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n // First try full JSON decode\n $priceData = json_decode($text, true);\n\n // Fallback: extract individual objects via regex if truncated\n if (! is_array($priceData)) {\n Log::warning('PricingService: Full JSON parse failed — attempting partial extraction.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 200),\n ]);\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from Gemini response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n continue; // try next model\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'gemini';\n }\n }\n\n return $items; // success — no need to try further models\n\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling Gemini.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n }\n }\n\n Log::error('PricingService: All Gemini models failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "53c8f6e7f81d8dfdc9642ff895a779f1b4cd7a67": {"t": 1785748363.180396, "n": 9009, "h": "05fee61fb8f23a97", "c": "> $items\n * @return array> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $items = $this->enrichWithGemini($items, $unmatched);\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n // -------------------------------------------------------------------------\n // Gemini fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to Gemini in ONE request to minimise token consumption.\n * Only the fields Gemini actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via maxOutputTokens to protect against runaway usage.\n *\n * @param array> $items\n * @param list $unmatchedIndices\n * @return array>\n */\n private function enrichWithGemini(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.gemini.key', '');\n $model = (string) config('services.gemini.model', 'gemini-2.5-flash');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: GEMINI_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n // Token protection: cap batch size\n $batch = array_slice($unmatchedIndices, 0, self::MAX_GEMINI_BATCH);\n\n // Compact payload — only essential fields to minimise input tokens\n $payload = [];\n foreach ($batch as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":,\"p\":} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n $url = \"https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}\";\n\n try {\n $response = Http::timeout(60)->post($url, [\n 'contents' => [\n ['role' => 'user', 'parts' => [['text' => $prompt]]],\n ],\n 'generationConfig' => [\n 'responseMimeType' => 'application/json',\n 'temperature' => 0.2,\n 'maxOutputTokens' => 8192,\n ],\n ]);\n\n if (! $response->successful()) {\n Log::error('PricingService: Gemini API error.', [\n 'status' => $response->status(),\n 'body' => $response->body(),\n ]);\n return $items;\n }\n\n $text = $response->json('candidates.0.content.parts.0.text') ?? '';\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n // First try full JSON decode\n $priceData = json_decode($text, true);\n\n // Fallback: if the response was truncated (json_decode fails), extract\n // individual {\"i\":N,\"p\":N} objects via regex so partial results are not wasted\n if (! is_array($priceData)) {\n Log::warning('PricingService: Full JSON parse failed — attempting partial extraction.', [\n 'text_preview' => mb_substr($text, 0, 200),\n ]);\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from Gemini response.', [\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n // Accept any positive price from Gemini — 0 means Gemini failed to estimate\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'gemini';\n }\n }\n\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling Gemini.', ['message' => $e->getMessage()]);\n }\n\n return $items;\n }\n}\n"}}, "app/Services/Pricing/ProductSpecEngine.php": {"9a5ada4ab1ceb0de386bc1ce99c561f8869c09d7": {"t": 1785746179.8393986, "n": 23769, "h": "70b12e5385e62908", "c": "> $items each with description, quantity, unit, category, brand\n * @param array $project shared context (name, type, location, delivery date…)\n * @return array> one qualified record per input item, same order\n */\n public function qualify(array $items, array $project = []): array\n {\n if (empty($items)) {\n return [];\n }\n\n // Deterministic pass first — it also gives the AI a cleaner starting point.\n $prepared = [];\n foreach ($items as $i => $item) {\n $prepared[$i] = $this->prepare($item);\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('ProductSpecEngine: DEEPSEEK_API_KEY not configured; returning deterministic results only.');\n return array_values($prepared);\n }\n\n $chunks = array_chunk(array_keys($prepared), self::CHUNK_SIZE);\n\n $prepared = count($chunks) === 1\n ? $this->qualifyChunk($prepared, $chunks[0], $project)\n : $this->qualifyChunksParallel($prepared, $chunks, $project);\n\n // Project-level cross-item intelligence runs last: it needs every line resolved.\n return array_values(ProjectIntelligence::analyse($prepared, $project));\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Deterministic pre-pass\n // ─────────────────────────────────────────────────────────────────────────\n\n /**\n * Normalise a single row's unit against its matched product family.\n *\n * Deterministic and free — no AI call — so it is safe to run over every row\n * at extraction time. Returns the corrected unit, or null to leave as-is.\n *\n * Without this, a printer arrives measured in \"liter/day\" and a UPS in\n * \"carton meter\", because the extractor copies whatever the sheet said.\n */\n public function normalizeUnitFor(string $description, string $rawUnit): ?string\n {\n $catalog = ProductSpecCatalog::match($description);\n if ($catalog === null) {\n return null;\n }\n\n $unit = UnitNormalizer::normalize($rawUnit, $catalog);\n\n $normalized = (string) ($unit['normalized'] ?? '');\n if ($normalized === '' || strcasecmp($normalized, $rawUnit) === 0) {\n return null;\n }\n\n return $normalized;\n }\n\n /**\n * Build the base record: catalog match, normalised unit, quantity sanity.\n * Everything here is decidable without the AI.\n */\n private function prepare(array $item): array\n {\n $description = trim((string) ($item['description'] ?? ''));\n $quantity = (float) ($item['quantity'] ?? 0);\n $rawUnit = (string) ($item['unit'] ?? '');\n\n $catalog = ProductSpecCatalog::match($description);\n $unit = UnitNormalizer::normalize($rawUnit, $catalog);\n\n $quantityWarnings = [];\n if ($quantity <= 0) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_NOT_POSITIVE',\n 'severity' => 'error',\n 'message' => 'Quantity must be greater than zero.',\n ];\n } elseif ($quantity != floor($quantity) && in_array($unit['normalized'], ['PCS', 'SET', 'LICENSE'], true)) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_FRACTIONAL',\n 'severity' => 'warning',\n 'message' => \"A fractional quantity ({$quantity}) is not valid for a countable unit ({$unit['normalized']}).\",\n ];\n }\n\n return [\n 'original_item_name' => $description,\n 'normalized_product_name' => $description,\n 'classification' => null,\n 'category' => (string) ($item['category'] ?? ''),\n 'catalog_key' => $catalog['key'] ?? null,\n 'quantity' => $quantity,\n 'original_unit' => $rawUnit,\n 'normalized_unit' => $unit['normalized'],\n 'supplyable' => true,\n 'pricing_status' => null,\n 'confirmed_specifications' => [],\n 'inferred_specifications' => [],\n 'missing_blocking_specifications' => [],\n 'conditional_questions' => [],\n 'assumptions' => [],\n 'quantity_warnings' => $quantityWarnings,\n 'unit_warnings' => $unit['warnings'],\n 'compatibility_warnings' => [],\n 'recommended_final_description' => $description,\n 'pricing_basis' => '',\n 'confidence_score' => 0,\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Prompt\n // ─────────────────────────────────────────────────────────────────────────\n\n private function buildPrompt(array $records, array $indices, array $project): string\n {\n $payload = [];\n foreach ($indices as $idx) {\n $r = $records[$idx];\n $catalog = $r['catalog_key'] ? ProductSpecCatalog::all()[$r['catalog_key']] ?? null : null;\n\n $payload[] = array_filter([\n 'i' => $idx,\n 'd' => mb_substr($r['original_item_name'], 0, 200),\n 'qty' => $r['quantity'],\n 'unit' => $r['normalized_unit'],\n 'cat' => mb_substr($r['category'], 0, 40),\n // The rubric for this family, so the model audits against fixed\n // criteria instead of improvising a different list each call.\n 'blocking' => $catalog['blocking'] ?? null,\n 'optional' => $catalog['conditional'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n }\n\n $ctx = array_filter([\n 'project_name' => $project['name'] ?? null,\n 'project_type' => $project['type'] ?? null,\n 'location' => $project['location'] ?? null,\n 'delivery_date' => $project['delivery_date'] ?? null,\n 'brand_policy' => $project['brand_policy'] ?? null,\n 'quotation_type' => $project['quotation_type'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n\n return 'You are a senior procurement engineer, quantity surveyor and technical pre-sales specialist '\n . 'for the Saudi Arabia (Riyadh) construction, MEP, IT, security, furniture and general-supply market. '\n . 'Qualify each BOQ line BEFORE pricing. Think like an experienced engineer, not a form-filler. '\n . ($ctx ? 'PROJECT CONTEXT: ' . json_encode($ctx, JSON_UNESCAPED_UNICODE) . '. ' : '')\n . 'FOR EACH ITEM DO ALL OF THE FOLLOWING: '\n . '(1) CLASSIFY as exactly one of: SUPPLY_PRODUCT, CUSTOM_MANUFACTURED_PRODUCT, SOFTWARE_OR_LICENSE, '\n . 'SERVICE, INSTALLATION, CONSULTATION, UNSUPPORTED_ITEM, UNCLEAR_ITEM. '\n . 'Installation, testing, commissioning, supervision, training, maintenance and consultancy are NOT supply products. '\n . 'If a line mixes supply AND installation, classify it as SUPPLY_PRODUCT and set \"split\":true so the caller separates the service. '\n . '(2) SEPARATE SPECIFICATIONS. Work through the item\\'s \"blocking\" list one entry at a time. '\n . 'Each one lands in exactly ONE of three places: \"cs\" if the description or project context '\n . 'states it; \"is\" if you can apply a defensible market-standard default; \"mb\" if it genuinely '\n . 'cannot be defaulted (a brand, a model number, an electrical rating, a custom dimension). '\n . 'Together, \"cs\" + \"is\" + \"mb\" MUST cover every entry in the \"blocking\" list — none may be skipped. '\n . 'Prefer \"is\" over \"mb\": most specs (processor generation, screen size, OS, warranty, colour '\n . 'temperature, IP rating) have a standard answer for this market, so default them and move on. '\n . 'Every entry in \"is\" MUST also appear as a plain-language sentence in \"as\" (assumptions). '\n . '(3) MISSING BLOCKING SPECS. \"mb\" = ONLY specs without which a defensible price is impossible. '\n . 'Do NOT list something already answered by the description, the project context, the product '\n . 'category, or a safe standard default. An empty \"mb\" is the expected outcome for most items. '\n . '(4) ONE GROUPED QUESTION. If \"mb\" is non-empty, \"q\" = a SINGLE Arabic question asking for all of them together '\n . '(maximum ' . self::MAX_QUESTION_PARTS . ' sub-parts, comma-separated in one sentence). Never one question per field. '\n . 'If \"mb\" is empty, \"q\" = \"\". '\n . '(5) STATUS \"st\": READY_TO_PRICE (all blocking specs confirmed), '\n . 'READY_WITH_ASSUMPTIONS (priceable as a budgetary estimate using stated assumptions), '\n . 'BLOCKED_MISSING_SPECIFICATIONS (a blocking spec is genuinely unknowable), '\n . 'NOT_A_SUPPLY_PRODUCT (service/installation/unsupported). '\n . '(6) \"fd\" = the FULL procurement-grade product description. This is the single most '\n . 'important field: a buyer must be able to source and price the item from \"fd\" ALONE, '\n . 'without seeing the original BOQ line. Write out EVERY spec in the \"blocking\" list for '\n . 'this item — the ones the client stated, plus the ones you inferred — as one comma-separated '\n . 'specification string. Expand vague trade shorthand into the real specification. '\n . 'It must NEVER contain questions, placeholders, or the words \"TBD\"/\"to be confirmed\". '\n . 'EXAMPLE — input \"Laptop for work - Core i7 / 16 GB / 512 GB SSD\" must become: '\n . '\"Business Laptop, Intel Core i7-1355U (13th Gen), 16 GB DDR4 RAM, 512 GB NVMe SSD, '\n . '14-inch FHD (1920x1080) Display, Intel Iris Xe Integrated Graphics, Windows 11 Pro, '\n . '3-Year Manufacturer Warranty\". Note how the generation, GPU, screen size, OS and warranty '\n . 'are all present even though the client never wrote them — each one is listed in \"is\" and '\n . 'restated in \"as\" as an assumption. A description that merely repeats the client\\'s words is WRONG. '\n . '(7) \"pb\" = one short phrase stating the pricing basis (e.g. \"budgetary, standard configuration assumed\"). '\n . '(8) \"cf\" = confidence 0-100 that this description can be priced accurately. '\n . 'HARD RULES: never invent a brand, model number, electrical rating, dimension for a custom-manufactured item, '\n . 'or a fire/safety approval. If such a value is genuinely required and unknown, it belongs in \"mb\", not in \"is\". '\n . 'OUTPUT: return ONLY a compact JSON array, no markdown, no commentary. Each element exactly: '\n . '{\"i\":,\"cl\":\"\",\"split\":,\"cs\":{},\"is\":{},\"as\":[],\"mb\":[],\"q\":\"\",\"st\":\"\",\"fd\":\"\",\"pb\":\"\",\"cf\":0} '\n . 'ITEMS: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n private function buildRequestBody(array $records, array $indices, array $project): array\n {\n return [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $this->buildPrompt($records, $indices, $project)]],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // HTTP\n // ─────────────────────────────────────────────────────────────────────────\n\n private function qualifyChunk(array $records, array $indices, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n\n try {\n $response = Http::timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', $this->buildRequestBody($records, $indices, $project));\n\n if (! $response->successful()) {\n Log::warning('ProductSpecEngine: DeepSeek request failed.', ['status' => $response->status()]);\n return $this->fallback($records, $indices);\n }\n\n return $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: exception calling DeepSeek.', ['message' => $e->getMessage()]);\n return $this->fallback($records, $indices);\n }\n }\n\n /** @param list> $chunks */\n private function qualifyChunksParallel(array $records, array $chunks, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $url = 'https://api.deepseek.com/chat/completions';\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $records, $chunks, $project) {\n $reqs = [];\n foreach ($chunks as $ci => $indices) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $this->buildRequestBody($records, $indices, $project));\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! ($response instanceof Response) || ! $response->successful()) {\n Log::warning('ProductSpecEngine: parallel chunk failed, retrying serially.', ['chunk' => $ci]);\n $records = $this->qualifyChunk($records, $chunks[$ci], $project);\n continue;\n }\n\n $records = $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n }\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: Http::pool exception, falling back to serial.', ['message' => $e->getMessage()]);\n foreach ($chunks as $indices) {\n $records = $this->qualifyChunk($records, $indices, $project);\n }\n }\n\n return $records;\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Response handling\n // ─────────────────────────────────────────────────────────────────────────\n\n private function apply(string $text, array $records): array\n {\n $text = preg_replace('/^```(?:json)?\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/', '', (string) $text);\n\n $data = json_decode((string) $text, true);\n\n if (! is_array($data) || $data === []) {\n Log::warning('ProductSpecEngine: unparseable AI response.', ['preview' => mb_substr($text, 0, 300)]);\n return $records;\n }\n\n foreach ($data as $entry) {\n if (! is_array($entry)) {\n continue;\n }\n\n $i = $entry['i'] ?? null;\n if ($i === null || ! array_key_exists($i, $records)) {\n continue;\n }\n\n $records[$i] = $this->mergeEntry($records[$i], $entry);\n }\n\n return $records;\n }\n\n /** Merge one AI verdict into its record, enforcing the engine's invariants. */\n private function mergeEntry(array $record, array $entry): array\n {\n $classification = in_array($entry['cl'] ?? null, self::CLASSIFICATIONS, true)\n ? $entry['cl']\n : 'UNCLEAR_ITEM';\n\n $supplyable = in_array($classification, self::SUPPLYABLE, true);\n\n $missing = array_values(array_filter(\n array_map(fn ($m) => trim((string) $m), (array) ($entry['mb'] ?? [])),\n fn ($m) => $m !== ''\n ));\n\n $assumptions = array_values(array_filter(\n array_map(fn ($a) => trim((string) $a), (array) ($entry['as'] ?? [])),\n fn ($a) => $a !== ''\n ));\n\n $status = in_array($entry['st'] ?? null, self::STATUSES, true) ? $entry['st'] : null;\n\n // Invariants the model is not allowed to violate, in priority order.\n if (! $supplyable) {\n $status = 'NOT_A_SUPPLY_PRODUCT';\n } elseif ($this->hasError($record['quantity_warnings']) || $this->hasError($record['unit_warnings'])) {\n $status = 'INVALID_QUANTITY_OR_UNIT';\n } elseif ($missing !== []) {\n $status = 'BLOCKED_MISSING_SPECIFICATIONS';\n } elseif ($status === null) {\n $status = $assumptions === [] ? 'READY_TO_PRICE' : 'READY_WITH_ASSUMPTIONS';\n } elseif ($status === 'READY_TO_PRICE' && $assumptions !== []) {\n // Assumptions were applied, so this is budgetary by definition.\n $status = 'READY_WITH_ASSUMPTIONS';\n }\n\n $finalDescription = trim((string) ($entry['fd'] ?? ''));\n if ($finalDescription === '' || $this->looksLikeQuestion($finalDescription)) {\n $finalDescription = $record['original_item_name'];\n }\n\n // The whole point of the pass is a description a buyer can source from.\n // If the model handed back the client's own wording, the line was not\n // actually enriched — surface it rather than letting it pass as qualified.\n if ($this->isNotEnriched($finalDescription, $record['original_item_name'])) {\n Log::info('ProductSpecEngine: description not enriched by the model.', [\n 'item' => mb_substr($record['original_item_name'], 0, 120),\n 'family' => $record['catalog_key'],\n ]);\n }\n\n $question = trim((string) ($entry['q'] ?? ''));\n\n return array_merge($record, [\n 'classification' => $classification,\n 'supplyable' => $supplyable,\n 'split_supply_and_installation' => (bool) ($entry['split'] ?? false),\n 'confirmed_specifications' => is_array($entry['cs'] ?? null) ? $entry['cs'] : [],\n 'inferred_specifications' => is_array($entry['is'] ?? null) ? $entry['is'] : [],\n 'assumptions' => $assumptions,\n 'missing_blocking_specifications' => $missing,\n 'grouped_question' => $missing === [] ? '' : $question,\n 'pricing_status' => $status,\n 'normalized_product_name' => trim((string) ($entry['fd'] ?? '')) ?: $record['original_item_name'],\n 'recommended_final_description' => $finalDescription,\n 'pricing_basis' => mb_substr(trim((string) ($entry['pb'] ?? '')), 0, 255),\n 'confidence_score' => max(0, min(100, (int) ($entry['cf'] ?? 0))),\n ]);\n }\n\n /**\n * When the AI is unreachable, fall back to the deterministic verdict rather\n * than guessing — an unqualified line is safer than a wrongly-qualified one.\n */\n private function fallback(array $records, array $indices): array\n {\n foreach ($indices as $i) {\n if (! array_key_exists($i, $records) || $records[$i]['pricing_status'] !== null) {\n continue;\n }\n\n $records[$i]['classification'] = 'UNCLEAR_ITEM';\n $records[$i]['pricing_status'] = $this->hasError($records[$i]['quantity_warnings'])\n || $this->hasError($records[$i]['unit_warnings'])\n ? 'INVALID_QUANTITY_OR_UNIT'\n : 'BLOCKED_MISSING_SPECIFICATIONS';\n $records[$i]['pricing_basis'] = 'Specification review unavailable; line not qualified.';\n }\n\n return $records;\n }\n\n /** @param list $warnings */\n private function hasError(array $warnings): bool\n {\n foreach ($warnings as $w) {\n if (($w['severity'] ?? '') === 'error') {\n return true;\n }\n }\n return false;\n }\n\n /**\n * True when the \"enriched\" description is really just the client's own line.\n *\n * A genuine qualification expands trade shorthand into a full specification,\n * so it is materially longer and carries more comma-separated parts. This is\n * a heuristic, used only to flag the case for review — never to block a line.\n */\n private function isNotEnriched(string $final, string $original): bool\n {\n $normalise = static fn (string $s) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim($s)));\n\n if ($normalise($final) === $normalise($original)) {\n return true;\n }\n\n // Fewer than two spec separators means it is still a bare product name.\n return substr_count($final, ',') < 2 && mb_strlen($final) < mb_strlen($original) * 1.5;\n }\n\n /** Guards rule 7: a question must never leak into the final description. */\n private function looksLikeQuestion(string $text): bool\n {\n if (str_contains($text, '?') || str_contains($text, '؟')) {\n return true;\n }\n\n foreach (['tbd', 'to be confirmed', 'to be advised', 'please confirm', 'يرجى تحديد', 'يرجى تأكيد', 'غير محدد'] as $needle) {\n if (mb_stripos($text, $needle) !== false) {\n return true;\n }\n }\n\n return false;\n }\n}\n"}, "ea6c7a169184892a12edda05c08c8a5b44d31c5d": {"t": 1785748347.8740175, "n": 23769, "h": "70b12e5385e62908", "c": "> $items each with description, quantity, unit, category, brand\n * @param array $project shared context (name, type, location, delivery date…)\n * @return array> one qualified record per input item, same order\n */\n public function qualify(array $items, array $project = []): array\n {\n if (empty($items)) {\n return [];\n }\n\n // Deterministic pass first — it also gives the AI a cleaner starting point.\n $prepared = [];\n foreach ($items as $i => $item) {\n $prepared[$i] = $this->prepare($item);\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('ProductSpecEngine: DEEPSEEK_API_KEY not configured; returning deterministic results only.');\n return array_values($prepared);\n }\n\n $chunks = array_chunk(array_keys($prepared), self::CHUNK_SIZE);\n\n $prepared = count($chunks) === 1\n ? $this->qualifyChunk($prepared, $chunks[0], $project)\n : $this->qualifyChunksParallel($prepared, $chunks, $project);\n\n // Project-level cross-item intelligence runs last: it needs every line resolved.\n return array_values(ProjectIntelligence::analyse($prepared, $project));\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Deterministic pre-pass\n // ─────────────────────────────────────────────────────────────────────────\n\n /**\n * Normalise a single row's unit against its matched product family.\n *\n * Deterministic and free — no AI call — so it is safe to run over every row\n * at extraction time. Returns the corrected unit, or null to leave as-is.\n *\n * Without this, a printer arrives measured in \"liter/day\" and a UPS in\n * \"carton meter\", because the extractor copies whatever the sheet said.\n */\n public function normalizeUnitFor(string $description, string $rawUnit): ?string\n {\n $catalog = ProductSpecCatalog::match($description);\n if ($catalog === null) {\n return null;\n }\n\n $unit = UnitNormalizer::normalize($rawUnit, $catalog);\n\n $normalized = (string) ($unit['normalized'] ?? '');\n if ($normalized === '' || strcasecmp($normalized, $rawUnit) === 0) {\n return null;\n }\n\n return $normalized;\n }\n\n /**\n * Build the base record: catalog match, normalised unit, quantity sanity.\n * Everything here is decidable without the AI.\n */\n private function prepare(array $item): array\n {\n $description = trim((string) ($item['description'] ?? ''));\n $quantity = (float) ($item['quantity'] ?? 0);\n $rawUnit = (string) ($item['unit'] ?? '');\n\n $catalog = ProductSpecCatalog::match($description);\n $unit = UnitNormalizer::normalize($rawUnit, $catalog);\n\n $quantityWarnings = [];\n if ($quantity <= 0) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_NOT_POSITIVE',\n 'severity' => 'error',\n 'message' => 'Quantity must be greater than zero.',\n ];\n } elseif ($quantity != floor($quantity) && in_array($unit['normalized'], ['PCS', 'SET', 'LICENSE'], true)) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_FRACTIONAL',\n 'severity' => 'warning',\n 'message' => \"A fractional quantity ({$quantity}) is not valid for a countable unit ({$unit['normalized']}).\",\n ];\n }\n\n return [\n 'original_item_name' => $description,\n 'normalized_product_name' => $description,\n 'classification' => null,\n 'category' => (string) ($item['category'] ?? ''),\n 'catalog_key' => $catalog['key'] ?? null,\n 'quantity' => $quantity,\n 'original_unit' => $rawUnit,\n 'normalized_unit' => $unit['normalized'],\n 'supplyable' => true,\n 'pricing_status' => null,\n 'confirmed_specifications' => [],\n 'inferred_specifications' => [],\n 'missing_blocking_specifications' => [],\n 'conditional_questions' => [],\n 'assumptions' => [],\n 'quantity_warnings' => $quantityWarnings,\n 'unit_warnings' => $unit['warnings'],\n 'compatibility_warnings' => [],\n 'recommended_final_description' => $description,\n 'pricing_basis' => '',\n 'confidence_score' => 0,\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Prompt\n // ─────────────────────────────────────────────────────────────────────────\n\n private function buildPrompt(array $records, array $indices, array $project): string\n {\n $payload = [];\n foreach ($indices as $idx) {\n $r = $records[$idx];\n $catalog = $r['catalog_key'] ? ProductSpecCatalog::all()[$r['catalog_key']] ?? null : null;\n\n $payload[] = array_filter([\n 'i' => $idx,\n 'd' => mb_substr($r['original_item_name'], 0, 200),\n 'qty' => $r['quantity'],\n 'unit' => $r['normalized_unit'],\n 'cat' => mb_substr($r['category'], 0, 40),\n // The rubric for this family, so the model audits against fixed\n // criteria instead of improvising a different list each call.\n 'blocking' => $catalog['blocking'] ?? null,\n 'optional' => $catalog['conditional'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n }\n\n $ctx = array_filter([\n 'project_name' => $project['name'] ?? null,\n 'project_type' => $project['type'] ?? null,\n 'location' => $project['location'] ?? null,\n 'delivery_date' => $project['delivery_date'] ?? null,\n 'brand_policy' => $project['brand_policy'] ?? null,\n 'quotation_type' => $project['quotation_type'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n\n return 'You are a senior procurement engineer, quantity surveyor and technical pre-sales specialist '\n . 'for the Saudi Arabia (Riyadh) construction, MEP, IT, security, furniture and general-supply market. '\n . 'Qualify each BOQ line BEFORE pricing. Think like an experienced engineer, not a form-filler. '\n . ($ctx ? 'PROJECT CONTEXT: ' . json_encode($ctx, JSON_UNESCAPED_UNICODE) . '. ' : '')\n . 'FOR EACH ITEM DO ALL OF THE FOLLOWING: '\n . '(1) CLASSIFY as exactly one of: SUPPLY_PRODUCT, CUSTOM_MANUFACTURED_PRODUCT, SOFTWARE_OR_LICENSE, '\n . 'SERVICE, INSTALLATION, CONSULTATION, UNSUPPORTED_ITEM, UNCLEAR_ITEM. '\n . 'Installation, testing, commissioning, supervision, training, maintenance and consultancy are NOT supply products. '\n . 'If a line mixes supply AND installation, classify it as SUPPLY_PRODUCT and set \"split\":true so the caller separates the service. '\n . '(2) SEPARATE SPECIFICATIONS. Work through the item\\'s \"blocking\" list one entry at a time. '\n . 'Each one lands in exactly ONE of three places: \"cs\" if the description or project context '\n . 'states it; \"is\" if you can apply a defensible market-standard default; \"mb\" if it genuinely '\n . 'cannot be defaulted (a brand, a model number, an electrical rating, a custom dimension). '\n . 'Together, \"cs\" + \"is\" + \"mb\" MUST cover every entry in the \"blocking\" list — none may be skipped. '\n . 'Prefer \"is\" over \"mb\": most specs (processor generation, screen size, OS, warranty, colour '\n . 'temperature, IP rating) have a standard answer for this market, so default them and move on. '\n . 'Every entry in \"is\" MUST also appear as a plain-language sentence in \"as\" (assumptions). '\n . '(3) MISSING BLOCKING SPECS. \"mb\" = ONLY specs without which a defensible price is impossible. '\n . 'Do NOT list something already answered by the description, the project context, the product '\n . 'category, or a safe standard default. An empty \"mb\" is the expected outcome for most items. '\n . '(4) ONE GROUPED QUESTION. If \"mb\" is non-empty, \"q\" = a SINGLE Arabic question asking for all of them together '\n . '(maximum ' . self::MAX_QUESTION_PARTS . ' sub-parts, comma-separated in one sentence). Never one question per field. '\n . 'If \"mb\" is empty, \"q\" = \"\". '\n . '(5) STATUS \"st\": READY_TO_PRICE (all blocking specs confirmed), '\n . 'READY_WITH_ASSUMPTIONS (priceable as a budgetary estimate using stated assumptions), '\n . 'BLOCKED_MISSING_SPECIFICATIONS (a blocking spec is genuinely unknowable), '\n . 'NOT_A_SUPPLY_PRODUCT (service/installation/unsupported). '\n . '(6) \"fd\" = the FULL procurement-grade product description. This is the single most '\n . 'important field: a buyer must be able to source and price the item from \"fd\" ALONE, '\n . 'without seeing the original BOQ line. Write out EVERY spec in the \"blocking\" list for '\n . 'this item — the ones the client stated, plus the ones you inferred — as one comma-separated '\n . 'specification string. Expand vague trade shorthand into the real specification. '\n . 'It must NEVER contain questions, placeholders, or the words \"TBD\"/\"to be confirmed\". '\n . 'EXAMPLE — input \"Laptop for work - Core i7 / 16 GB / 512 GB SSD\" must become: '\n . '\"Business Laptop, Intel Core i7-1355U (13th Gen), 16 GB DDR4 RAM, 512 GB NVMe SSD, '\n . '14-inch FHD (1920x1080) Display, Intel Iris Xe Integrated Graphics, Windows 11 Pro, '\n . '3-Year Manufacturer Warranty\". Note how the generation, GPU, screen size, OS and warranty '\n . 'are all present even though the client never wrote them — each one is listed in \"is\" and '\n . 'restated in \"as\" as an assumption. A description that merely repeats the client\\'s words is WRONG. '\n . '(7) \"pb\" = one short phrase stating the pricing basis (e.g. \"budgetary, standard configuration assumed\"). '\n . '(8) \"cf\" = confidence 0-100 that this description can be priced accurately. '\n . 'HARD RULES: never invent a brand, model number, electrical rating, dimension for a custom-manufactured item, '\n . 'or a fire/safety approval. If such a value is genuinely required and unknown, it belongs in \"mb\", not in \"is\". '\n . 'OUTPUT: return ONLY a compact JSON array, no markdown, no commentary. Each element exactly: '\n . '{\"i\":,\"cl\":\"\",\"split\":,\"cs\":{},\"is\":{},\"as\":[],\"mb\":[],\"q\":\"\",\"st\":\"\",\"fd\":\"\",\"pb\":\"\",\"cf\":0} '\n . 'ITEMS: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n private function buildRequestBody(array $records, array $indices, array $project): array\n {\n return [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $this->buildPrompt($records, $indices, $project)]],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // HTTP\n // ─────────────────────────────────────────────────────────────────────────\n\n private function qualifyChunk(array $records, array $indices, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n\n try {\n $response = Http::timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', $this->buildRequestBody($records, $indices, $project));\n\n if (! $response->successful()) {\n Log::warning('ProductSpecEngine: DeepSeek request failed.', ['status' => $response->status()]);\n return $this->fallback($records, $indices);\n }\n\n return $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: exception calling DeepSeek.', ['message' => $e->getMessage()]);\n return $this->fallback($records, $indices);\n }\n }\n\n /** @param list> $chunks */\n private function qualifyChunksParallel(array $records, array $chunks, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $url = 'https://api.deepseek.com/chat/completions';\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $records, $chunks, $project) {\n $reqs = [];\n foreach ($chunks as $ci => $indices) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $this->buildRequestBody($records, $indices, $project));\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! ($response instanceof Response) || ! $response->successful()) {\n Log::warning('ProductSpecEngine: parallel chunk failed, retrying serially.', ['chunk' => $ci]);\n $records = $this->qualifyChunk($records, $chunks[$ci], $project);\n continue;\n }\n\n $records = $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n }\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: Http::pool exception, falling back to serial.', ['message' => $e->getMessage()]);\n foreach ($chunks as $indices) {\n $records = $this->qualifyChunk($records, $indices, $project);\n }\n }\n\n return $records;\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Response handling\n // ─────────────────────────────────────────────────────────────────────────\n\n private function apply(string $text, array $records): array\n {\n $text = preg_replace('/^```(?:json)?\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/', '', (string) $text);\n\n $data = json_decode((string) $text, true);\n\n if (! is_array($data) || $data === []) {\n Log::warning('ProductSpecEngine: unparseable AI response.', ['preview' => mb_substr($text, 0, 300)]);\n return $records;\n }\n\n foreach ($data as $entry) {\n if (! is_array($entry)) {\n continue;\n }\n\n $i = $entry['i'] ?? null;\n if ($i === null || ! array_key_exists($i, $records)) {\n continue;\n }\n\n $records[$i] = $this->mergeEntry($records[$i], $entry);\n }\n\n return $records;\n }\n\n /** Merge one AI verdict into its record, enforcing the engine's invariants. */\n private function mergeEntry(array $record, array $entry): array\n {\n $classification = in_array($entry['cl'] ?? null, self::CLASSIFICATIONS, true)\n ? $entry['cl']\n : 'UNCLEAR_ITEM';\n\n $supplyable = in_array($classification, self::SUPPLYABLE, true);\n\n $missing = array_values(array_filter(\n array_map(fn ($m) => trim((string) $m), (array) ($entry['mb'] ?? [])),\n fn ($m) => $m !== ''\n ));\n\n $assumptions = array_values(array_filter(\n array_map(fn ($a) => trim((string) $a), (array) ($entry['as'] ?? [])),\n fn ($a) => $a !== ''\n ));\n\n $status = in_array($entry['st'] ?? null, self::STATUSES, true) ? $entry['st'] : null;\n\n // Invariants the model is not allowed to violate, in priority order.\n if (! $supplyable) {\n $status = 'NOT_A_SUPPLY_PRODUCT';\n } elseif ($this->hasError($record['quantity_warnings']) || $this->hasError($record['unit_warnings'])) {\n $status = 'INVALID_QUANTITY_OR_UNIT';\n } elseif ($missing !== []) {\n $status = 'BLOCKED_MISSING_SPECIFICATIONS';\n } elseif ($status === null) {\n $status = $assumptions === [] ? 'READY_TO_PRICE' : 'READY_WITH_ASSUMPTIONS';\n } elseif ($status === 'READY_TO_PRICE' && $assumptions !== []) {\n // Assumptions were applied, so this is budgetary by definition.\n $status = 'READY_WITH_ASSUMPTIONS';\n }\n\n $finalDescription = trim((string) ($entry['fd'] ?? ''));\n if ($finalDescription === '' || $this->looksLikeQuestion($finalDescription)) {\n $finalDescription = $record['original_item_name'];\n }\n\n // The whole point of the pass is a description a buyer can source from.\n // If the model handed back the client's own wording, the line was not\n // actually enriched — surface it rather than letting it pass as qualified.\n if ($this->isNotEnriched($finalDescription, $record['original_item_name'])) {\n Log::info('ProductSpecEngine: description not enriched by the model.', [\n 'item' => mb_substr($record['original_item_name'], 0, 120),\n 'family' => $record['catalog_key'],\n ]);\n }\n\n $question = trim((string) ($entry['q'] ?? ''));\n\n return array_merge($record, [\n 'classification' => $classification,\n 'supplyable' => $supplyable,\n 'split_supply_and_installation' => (bool) ($entry['split'] ?? false),\n 'confirmed_specifications' => is_array($entry['cs'] ?? null) ? $entry['cs'] : [],\n 'inferred_specifications' => is_array($entry['is'] ?? null) ? $entry['is'] : [],\n 'assumptions' => $assumptions,\n 'missing_blocking_specifications' => $missing,\n 'grouped_question' => $missing === [] ? '' : $question,\n 'pricing_status' => $status,\n 'normalized_product_name' => trim((string) ($entry['fd'] ?? '')) ?: $record['original_item_name'],\n 'recommended_final_description' => $finalDescription,\n 'pricing_basis' => mb_substr(trim((string) ($entry['pb'] ?? '')), 0, 255),\n 'confidence_score' => max(0, min(100, (int) ($entry['cf'] ?? 0))),\n ]);\n }\n\n /**\n * When the AI is unreachable, fall back to the deterministic verdict rather\n * than guessing — an unqualified line is safer than a wrongly-qualified one.\n */\n private function fallback(array $records, array $indices): array\n {\n foreach ($indices as $i) {\n if (! array_key_exists($i, $records) || $records[$i]['pricing_status'] !== null) {\n continue;\n }\n\n $records[$i]['classification'] = 'UNCLEAR_ITEM';\n $records[$i]['pricing_status'] = $this->hasError($records[$i]['quantity_warnings'])\n || $this->hasError($records[$i]['unit_warnings'])\n ? 'INVALID_QUANTITY_OR_UNIT'\n : 'BLOCKED_MISSING_SPECIFICATIONS';\n $records[$i]['pricing_basis'] = 'Specification review unavailable; line not qualified.';\n }\n\n return $records;\n }\n\n /** @param list $warnings */\n private function hasError(array $warnings): bool\n {\n foreach ($warnings as $w) {\n if (($w['severity'] ?? '') === 'error') {\n return true;\n }\n }\n return false;\n }\n\n /**\n * True when the \"enriched\" description is really just the client's own line.\n *\n * A genuine qualification expands trade shorthand into a full specification,\n * so it is materially longer and carries more comma-separated parts. This is\n * a heuristic, used only to flag the case for review — never to block a line.\n */\n private function isNotEnriched(string $final, string $original): bool\n {\n $normalise = static fn (string $s) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim($s)));\n\n if ($normalise($final) === $normalise($original)) {\n return true;\n }\n\n // Fewer than two spec separators means it is still a bare product name.\n return substr_count($final, ',') < 2 && mb_strlen($final) < mb_strlen($original) * 1.5;\n }\n\n /** Guards rule 7: a question must never leak into the final description. */\n private function looksLikeQuestion(string $text): bool\n {\n if (str_contains($text, '?') || str_contains($text, '؟')) {\n return true;\n }\n\n foreach (['tbd', 'to be confirmed', 'to be advised', 'please confirm', 'يرجى تحديد', 'يرجى تأكيد', 'غير محدد'] as $needle) {\n if (mb_stripos($text, $needle) !== false) {\n return true;\n }\n }\n\n return false;\n }\n}\n"}, "f40b6c77504eb4a1930246680abb3b0ffa5d04bc": {"t": 1785748349.176025, "n": 23771, "h": "5e83c88b1e4d595f", "c": "> $items each with description, quantity, unit, category, brand\n * @param array $project shared context (name, type, location, delivery date…)\n * @return array> one qualified record per input item, same order\n */\n public function qualify(array $items, array $project = []): array\n {\n if (empty($items)) {\n return [];\n }\n\n // Deterministic pass first — it also gives the AI a cleaner starting point.\n $prepared = [];\n foreach ($items as $i => $item) {\n $prepared[$i] = $this->prepare($item);\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('ProductSpecEngine: DEEPSEEK_API_KEY not configured; returning deterministic results only.');\n return array_values($prepared);\n }\n\n $chunks = array_chunk(array_keys($prepared), self::CHUNK_SIZE);\n\n $prepared = count($chunks) === 1\n ? $this->qualifyChunk($prepared, $chunks[0], $project)\n : $this->qualifyChunksParallel($prepared, $chunks, $project);\n\n // Project-level cross-item intelligence runs last: it needs every line resolved.\n return array_values(ProjectIntelligence::analyse($prepared, $project));\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Deterministic pre-pass\n // ─────────────────────────────────────────────────────────────────────────\n\n /**\n * Normalise a single row's unit against its matched product family.\n *\n * Deterministic and free — no AI call — so it is safe to run over every row\n * at extraction time. Returns the corrected unit, or null to leave as-is.\n *\n * Without this, a printer arrives measured in \"liter/day\" and a UPS in\n * \"carton meter\", because the extractor copies whatever the sheet said.\n */\n public function normalizeUnitFor(string $description, string $rawUnit): ?string\n {\n $catalog = ProductSpecCatalog::match($description);\n if ($catalog === null) {\n return null;\n }\n\n $unit = UnitNormalizer::normalize($rawUnit, $catalog);\n\n $normalized = (string) ($unit['normalized'] ?? '');\n if ($normalized === '' || strcasecmp($normalized, $rawUnit) === 0) {\n return null;\n }\n\n return $normalized;\n }\n\n /**\n * Build the base record: catalog match, normalised unit, quantity sanity.\n * Everything here is decidable without the AI.\n */\n private function prepare(array $item): array\n {\n $description = trim((string) ($item['description'] ?? ''));\n $quantity = (float) ($item['quantity'] ?? 0);\n $rawUnit = (string) ($item['unit'] ?? '');\n\n $catalog = ProductSpecCatalog::match($description);\n $unit = UnitNormalizer::normalize($rawUnit, $catalog);\n\n $quantityWarnings = [];\n if ($quantity <= 0) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_NOT_POSITIVE',\n 'severity' => 'error',\n 'message' => 'Quantity must be greater than zero.',\n ];\n } elseif ($quantity != floor($quantity) && in_array($unit['normalized'], ['PCS', 'SET', 'LICENSE'], true)) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_FRACTIONAL',\n 'severity' => 'warning',\n 'message' => \"A fractional quantity ({$quantity}) is not valid for a countable unit ({$unit['normalized']}).\",\n ];\n }\n\n return [\n 'original_item_name' => $description,\n 'normalized_product_name' => $description,\n 'classification' => null,\n 'category' => (string) ($item['category'] ?? ''),\n 'catalog_key' => $catalog['key'] ?? null,\n 'quantity' => $quantity,\n 'original_unit' => $rawUnit,\n 'normalized_unit' => $unit['normalized'],\n 'supplyable' => true,\n 'pricing_status' => null,\n 'confirmed_specifications' => [],\n 'inferred_specifications' => [],\n 'missing_blocking_specifications' => [],\n 'conditional_questions' => [],\n 'assumptions' => [],\n 'quantity_warnings' => $quantityWarnings,\n 'unit_warnings' => $unit['warnings'],\n 'compatibility_warnings' => [],\n 'recommended_final_description' => $description,\n 'pricing_basis' => '',\n 'confidence_score' => 0,\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Prompt\n // ─────────────────────────────────────────────────────────────────────────\n\n private function buildPrompt(array $records, array $indices, array $project): string\n {\n $payload = [];\n foreach ($indices as $idx) {\n $r = $records[$idx];\n $catalog = $r['catalog_key'] ? ProductSpecCatalog::all()[$r['catalog_key']] ?? null : null;\n\n $payload[] = array_filter([\n 'i' => $idx,\n 'd' => mb_substr($r['original_item_name'], 0, 200),\n 'qty' => $r['quantity'],\n 'unit' => $r['normalized_unit'],\n 'cat' => mb_substr($r['category'], 0, 40),\n // The rubric for this family, so the model audits against fixed\n // criteria instead of improvising a different list each call.\n 'blocking' => $catalog['blocking'] ?? null,\n 'optional' => $catalog['conditional'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n }\n\n $ctx = array_filter([\n 'project_name' => $project['name'] ?? null,\n 'project_type' => $project['type'] ?? null,\n 'location' => $project['location'] ?? null,\n 'delivery_date' => $project['delivery_date'] ?? null,\n 'brand_policy' => $project['brand_policy'] ?? null,\n 'quotation_type' => $project['quotation_type'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n\n return 'You are a senior procurement engineer, quantity surveyor and technical pre-sales specialist '\n . 'for the Saudi Arabia (Riyadh) construction, MEP, IT, security, furniture and general-supply market. '\n . 'Qualify each BOQ line BEFORE pricing. Think like an experienced engineer, not a form-filler. '\n . ($ctx ? 'PROJECT CONTEXT: ' . json_encode($ctx, JSON_UNESCAPED_UNICODE) . '. ' : '')\n . 'FOR EACH ITEM DO ALL OF THE FOLLOWING: '\n . '(1) CLASSIFY as exactly one of: SUPPLY_PRODUCT, CUSTOM_MANUFACTURED_PRODUCT, SOFTWARE_OR_LICENSE, '\n . 'SERVICE, INSTALLATION, CONSULTATION, UNSUPPORTED_ITEM, UNCLEAR_ITEM. '\n . 'Installation, testing, commissioning, supervision, training, maintenance and consultancy are NOT supply products. '\n . 'If a line mixes supply AND installation, classify it as SUPPLY_PRODUCT and set \"split\":true so the caller separates the service. '\n . '(2) SEPARATE SPECIFICATIONS. Work through the item\\'s \"blocking\" list one entry at a time. '\n . 'Each one lands in exactly ONE of three places: \"cs\" if the description or project context '\n . 'states it; \"is\" if you can apply a defensible market-standard default; \"mb\" if it genuinely '\n . 'cannot be defaulted (a brand, a model number, an electrical rating, a custom dimension). '\n . 'Together, \"cs\" + \"is\" + \"mb\" MUST cover every entry in the \"blocking\" list — none may be skipped. '\n . 'Prefer \"is\" over \"mb\": most specs (processor generation, screen size, OS, warranty, colour '\n . 'temperature, IP rating) have a standard answer for this market, so default them and move on. '\n . 'Every entry in \"is\" MUST also appear as a plain-language sentence in \"as\" (assumptions). '\n . '(3) MISSING BLOCKING SPECS. \"mb\" = ONLY specs without which a defensible price is impossible. '\n . 'Do NOT list something already answered by the description, the project context, the product '\n . 'category, or a safe standard default. An empty \"mb\" is the expected outcome for most items. '\n . '(4) ONE GROUPED QUESTION. If \"mb\" is non-empty, \"q\" = a SINGLE Arabic question asking for all of them together '\n . '(maximum ' . self::MAX_QUESTION_PARTS . ' sub-parts, comma-separated in one sentence). Never one question per field. '\n . 'If \"mb\" is empty, \"q\" = \"\". '\n . '(5) STATUS \"st\": READY_TO_PRICE (all blocking specs confirmed), '\n . 'READY_WITH_ASSUMPTIONS (priceable as a budgetary estimate using stated assumptions), '\n . 'BLOCKED_MISSING_SPECIFICATIONS (a blocking spec is genuinely unknowable), '\n . 'NOT_A_SUPPLY_PRODUCT (service/installation/unsupported). '\n . '(6) \"fd\" = the FULL procurement-grade product description. This is the single most '\n . 'important field: a buyer must be able to source and price the item from \"fd\" ALONE, '\n . 'without seeing the original BOQ line. Write out EVERY spec in the \"blocking\" list for '\n . 'this item — the ones the client stated, plus the ones you inferred — as one comma-separated '\n . 'specification string. Expand vague trade shorthand into the real specification. '\n . 'It must NEVER contain questions, placeholders, or the words \"TBD\"/\"to be confirmed\". '\n . 'EXAMPLE — input \"Laptop for work - Core i7 / 16 GB / 512 GB SSD\" must become: '\n . '\"Business Laptop, Intel Core i7-1355U (13th Gen), 16 GB DDR4 RAM, 512 GB NVMe SSD, '\n . '14-inch FHD (1920x1080) Display, Intel Iris Xe Integrated Graphics, Windows 11 Pro, '\n . '3-Year Manufacturer Warranty\". Note how the generation, GPU, screen size, OS and warranty '\n . 'are all present even though the client never wrote them — each one is listed in \"is\" and '\n . 'restated in \"as\" as an assumption. A description that merely repeats the client\\'s words is WRONG. '\n . '(7) \"pb\" = one short phrase stating the pricing basis (e.g. \"budgetary, standard configuration assumed\"). '\n . '(8) \"cf\" = confidence 0-100 that this description can be priced accurately. '\n . 'HARD RULES: never invent a brand, model number, electrical rating, dimension for a custom-manufactured item, '\n . 'or a fire/safety approval. If such a value is genuinely required and unknown, it belongs in \"mb\", not in \"is\". '\n . 'OUTPUT: return ONLY a compact JSON array, no markdown, no commentary. Each element exactly: '\n . '{\"i\":,\"cl\":\"\",\"split\":,\"cs\":{},\"is\":{},\"as\":[],\"mb\":[],\"q\":\"\",\"st\":\"\",\"fd\":\"\",\"pb\":\"\",\"cf\":0} '\n . 'ITEMS: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n private function buildRequestBody(array $records, array $indices, array $project): array\n {\n return [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $this->buildPrompt($records, $indices, $project)]],\n 'temperature' => 0.1,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // HTTP\n // ─────────────────────────────────────────────────────────────────────────\n\n private function qualifyChunk(array $records, array $indices, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n\n try {\n $response = Http::timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', $this->buildRequestBody($records, $indices, $project));\n\n if (! $response->successful()) {\n Log::warning('ProductSpecEngine: DeepSeek request failed.', ['status' => $response->status()]);\n return $this->fallback($records, $indices);\n }\n\n return $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: exception calling DeepSeek.', ['message' => $e->getMessage()]);\n return $this->fallback($records, $indices);\n }\n }\n\n /** @param list> $chunks */\n private function qualifyChunksParallel(array $records, array $chunks, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $url = 'https://api.deepseek.com/chat/completions';\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $records, $chunks, $project) {\n $reqs = [];\n foreach ($chunks as $ci => $indices) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $this->buildRequestBody($records, $indices, $project));\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! ($response instanceof Response) || ! $response->successful()) {\n Log::warning('ProductSpecEngine: parallel chunk failed, retrying serially.', ['chunk' => $ci]);\n $records = $this->qualifyChunk($records, $chunks[$ci], $project);\n continue;\n }\n\n $records = $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n }\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: Http::pool exception, falling back to serial.', ['message' => $e->getMessage()]);\n foreach ($chunks as $indices) {\n $records = $this->qualifyChunk($records, $indices, $project);\n }\n }\n\n return $records;\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Response handling\n // ─────────────────────────────────────────────────────────────────────────\n\n private function apply(string $text, array $records): array\n {\n $text = preg_replace('/^```(?:json)?\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/', '', (string) $text);\n\n $data = json_decode((string) $text, true);\n\n if (! is_array($data) || $data === []) {\n Log::warning('ProductSpecEngine: unparseable AI response.', ['preview' => mb_substr($text, 0, 300)]);\n return $records;\n }\n\n foreach ($data as $entry) {\n if (! is_array($entry)) {\n continue;\n }\n\n $i = $entry['i'] ?? null;\n if ($i === null || ! array_key_exists($i, $records)) {\n continue;\n }\n\n $records[$i] = $this->mergeEntry($records[$i], $entry);\n }\n\n return $records;\n }\n\n /** Merge one AI verdict into its record, enforcing the engine's invariants. */\n private function mergeEntry(array $record, array $entry): array\n {\n $classification = in_array($entry['cl'] ?? null, self::CLASSIFICATIONS, true)\n ? $entry['cl']\n : 'UNCLEAR_ITEM';\n\n $supplyable = in_array($classification, self::SUPPLYABLE, true);\n\n $missing = array_values(array_filter(\n array_map(fn ($m) => trim((string) $m), (array) ($entry['mb'] ?? [])),\n fn ($m) => $m !== ''\n ));\n\n $assumptions = array_values(array_filter(\n array_map(fn ($a) => trim((string) $a), (array) ($entry['as'] ?? [])),\n fn ($a) => $a !== ''\n ));\n\n $status = in_array($entry['st'] ?? null, self::STATUSES, true) ? $entry['st'] : null;\n\n // Invariants the model is not allowed to violate, in priority order.\n if (! $supplyable) {\n $status = 'NOT_A_SUPPLY_PRODUCT';\n } elseif ($this->hasError($record['quantity_warnings']) || $this->hasError($record['unit_warnings'])) {\n $status = 'INVALID_QUANTITY_OR_UNIT';\n } elseif ($missing !== []) {\n $status = 'BLOCKED_MISSING_SPECIFICATIONS';\n } elseif ($status === null) {\n $status = $assumptions === [] ? 'READY_TO_PRICE' : 'READY_WITH_ASSUMPTIONS';\n } elseif ($status === 'READY_TO_PRICE' && $assumptions !== []) {\n // Assumptions were applied, so this is budgetary by definition.\n $status = 'READY_WITH_ASSUMPTIONS';\n }\n\n $finalDescription = trim((string) ($entry['fd'] ?? ''));\n if ($finalDescription === '' || $this->looksLikeQuestion($finalDescription)) {\n $finalDescription = $record['original_item_name'];\n }\n\n // The whole point of the pass is a description a buyer can source from.\n // If the model handed back the client's own wording, the line was not\n // actually enriched — surface it rather than letting it pass as qualified.\n if ($this->isNotEnriched($finalDescription, $record['original_item_name'])) {\n Log::info('ProductSpecEngine: description not enriched by the model.', [\n 'item' => mb_substr($record['original_item_name'], 0, 120),\n 'family' => $record['catalog_key'],\n ]);\n }\n\n $question = trim((string) ($entry['q'] ?? ''));\n\n return array_merge($record, [\n 'classification' => $classification,\n 'supplyable' => $supplyable,\n 'split_supply_and_installation' => (bool) ($entry['split'] ?? false),\n 'confirmed_specifications' => is_array($entry['cs'] ?? null) ? $entry['cs'] : [],\n 'inferred_specifications' => is_array($entry['is'] ?? null) ? $entry['is'] : [],\n 'assumptions' => $assumptions,\n 'missing_blocking_specifications' => $missing,\n 'grouped_question' => $missing === [] ? '' : $question,\n 'pricing_status' => $status,\n 'normalized_product_name' => trim((string) ($entry['fd'] ?? '')) ?: $record['original_item_name'],\n 'recommended_final_description' => $finalDescription,\n 'pricing_basis' => mb_substr(trim((string) ($entry['pb'] ?? '')), 0, 255),\n 'confidence_score' => max(0, min(100, (int) ($entry['cf'] ?? 0))),\n ]);\n }\n\n /**\n * When the AI is unreachable, fall back to the deterministic verdict rather\n * than guessing — an unqualified line is safer than a wrongly-qualified one.\n */\n private function fallback(array $records, array $indices): array\n {\n foreach ($indices as $i) {\n if (! array_key_exists($i, $records) || $records[$i]['pricing_status'] !== null) {\n continue;\n }\n\n $records[$i]['classification'] = 'UNCLEAR_ITEM';\n $records[$i]['pricing_status'] = $this->hasError($records[$i]['quantity_warnings'])\n || $this->hasError($records[$i]['unit_warnings'])\n ? 'INVALID_QUANTITY_OR_UNIT'\n : 'BLOCKED_MISSING_SPECIFICATIONS';\n $records[$i]['pricing_basis'] = 'Specification review unavailable; line not qualified.';\n }\n\n return $records;\n }\n\n /** @param list $warnings */\n private function hasError(array $warnings): bool\n {\n foreach ($warnings as $w) {\n if (($w['severity'] ?? '') === 'error') {\n return true;\n }\n }\n return false;\n }\n\n /**\n * True when the \"enriched\" description is really just the client's own line.\n *\n * A genuine qualification expands trade shorthand into a full specification,\n * so it is materially longer and carries more comma-separated parts. This is\n * a heuristic, used only to flag the case for review — never to block a line.\n */\n private function isNotEnriched(string $final, string $original): bool\n {\n $normalise = static fn (string $s) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim($s)));\n\n if ($normalise($final) === $normalise($original)) {\n return true;\n }\n\n // Fewer than two spec separators means it is still a bare product name.\n return substr_count($final, ',') < 2 && mb_strlen($final) < mb_strlen($original) * 1.5;\n }\n\n /** Guards rule 7: a question must never leak into the final description. */\n private function looksLikeQuestion(string $text): bool\n {\n if (str_contains($text, '?') || str_contains($text, '؟')) {\n return true;\n }\n\n foreach (['tbd', 'to be confirmed', 'to be advised', 'please confirm', 'يرجى تحديد', 'يرجى تأكيد', 'غير محدد'] as $needle) {\n if (mb_stripos($text, $needle) !== false) {\n return true;\n }\n }\n\n return false;\n }\n}\n"}, "b000be05fdee9824c438a8df6d9aadbaaf8efb60": {"t": 1785748350.412268, "n": 22864, "h": "c430cbe35b37df8a", "c": "> $items each with description, quantity, unit, category, brand\n * @param array $project shared context (name, type, location, delivery date…)\n * @return array> one qualified record per input item, same order\n */\n public function qualify(array $items, array $project = []): array\n {\n if (empty($items)) {\n return [];\n }\n\n // Deterministic pass first — it also gives the AI a cleaner starting point.\n $prepared = [];\n foreach ($items as $i => $item) {\n $prepared[$i] = $this->prepare($item);\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('ProductSpecEngine: DEEPSEEK_API_KEY not configured; returning deterministic results only.');\n return array_values($prepared);\n }\n\n $chunks = array_chunk(array_keys($prepared), self::CHUNK_SIZE);\n\n $prepared = count($chunks) === 1\n ? $this->qualifyChunk($prepared, $chunks[0], $project)\n : $this->qualifyChunksParallel($prepared, $chunks, $project);\n\n // Project-level cross-item intelligence runs last: it needs every line resolved.\n return array_values(ProjectIntelligence::analyse($prepared, $project));\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Deterministic pre-pass\n // ─────────────────────────────────────────────────────────────────────────\n\n /**\n * Build the base record: catalog match, normalised unit, quantity sanity.\n * Everything here is decidable without the AI.\n */\n private function prepare(array $item): array\n {\n $description = trim((string) ($item['description'] ?? ''));\n $quantity = (float) ($item['quantity'] ?? 0);\n $rawUnit = (string) ($item['unit'] ?? '');\n\n $catalog = ProductSpecCatalog::match($description);\n $unit = UnitNormalizer::normalize($rawUnit, $catalog);\n\n $quantityWarnings = [];\n if ($quantity <= 0) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_NOT_POSITIVE',\n 'severity' => 'error',\n 'message' => 'Quantity must be greater than zero.',\n ];\n } elseif ($quantity != floor($quantity) && in_array($unit['normalized'], ['PCS', 'SET', 'LICENSE'], true)) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_FRACTIONAL',\n 'severity' => 'warning',\n 'message' => \"A fractional quantity ({$quantity}) is not valid for a countable unit ({$unit['normalized']}).\",\n ];\n }\n\n return [\n 'original_item_name' => $description,\n 'normalized_product_name' => $description,\n 'classification' => null,\n 'category' => (string) ($item['category'] ?? ''),\n 'catalog_key' => $catalog['key'] ?? null,\n 'quantity' => $quantity,\n 'original_unit' => $rawUnit,\n 'normalized_unit' => $unit['normalized'],\n 'supplyable' => true,\n 'pricing_status' => null,\n 'confirmed_specifications' => [],\n 'inferred_specifications' => [],\n 'missing_blocking_specifications' => [],\n 'conditional_questions' => [],\n 'assumptions' => [],\n 'quantity_warnings' => $quantityWarnings,\n 'unit_warnings' => $unit['warnings'],\n 'compatibility_warnings' => [],\n 'recommended_final_description' => $description,\n 'pricing_basis' => '',\n 'confidence_score' => 0,\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Prompt\n // ─────────────────────────────────────────────────────────────────────────\n\n private function buildPrompt(array $records, array $indices, array $project): string\n {\n $payload = [];\n foreach ($indices as $idx) {\n $r = $records[$idx];\n $catalog = $r['catalog_key'] ? ProductSpecCatalog::all()[$r['catalog_key']] ?? null : null;\n\n $payload[] = array_filter([\n 'i' => $idx,\n 'd' => mb_substr($r['original_item_name'], 0, 200),\n 'qty' => $r['quantity'],\n 'unit' => $r['normalized_unit'],\n 'cat' => mb_substr($r['category'], 0, 40),\n // The rubric for this family, so the model audits against fixed\n // criteria instead of improvising a different list each call.\n 'blocking' => $catalog['blocking'] ?? null,\n 'optional' => $catalog['conditional'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n }\n\n $ctx = array_filter([\n 'project_name' => $project['name'] ?? null,\n 'project_type' => $project['type'] ?? null,\n 'location' => $project['location'] ?? null,\n 'delivery_date' => $project['delivery_date'] ?? null,\n 'brand_policy' => $project['brand_policy'] ?? null,\n 'quotation_type' => $project['quotation_type'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n\n return 'You are a senior procurement engineer, quantity surveyor and technical pre-sales specialist '\n . 'for the Saudi Arabia (Riyadh) construction, MEP, IT, security, furniture and general-supply market. '\n . 'Qualify each BOQ line BEFORE pricing. Think like an experienced engineer, not a form-filler. '\n . ($ctx ? 'PROJECT CONTEXT: ' . json_encode($ctx, JSON_UNESCAPED_UNICODE) . '. ' : '')\n . 'FOR EACH ITEM DO ALL OF THE FOLLOWING: '\n . '(1) CLASSIFY as exactly one of: SUPPLY_PRODUCT, CUSTOM_MANUFACTURED_PRODUCT, SOFTWARE_OR_LICENSE, '\n . 'SERVICE, INSTALLATION, CONSULTATION, UNSUPPORTED_ITEM, UNCLEAR_ITEM. '\n . 'Installation, testing, commissioning, supervision, training, maintenance and consultancy are NOT supply products. '\n . 'If a line mixes supply AND installation, classify it as SUPPLY_PRODUCT and set \"split\":true so the caller separates the service. '\n . '(2) SEPARATE SPECIFICATIONS. Work through the item\\'s \"blocking\" list one entry at a time. '\n . 'Each one lands in exactly ONE of three places: \"cs\" if the description or project context '\n . 'states it; \"is\" if you can apply a defensible market-standard default; \"mb\" if it genuinely '\n . 'cannot be defaulted (a brand, a model number, an electrical rating, a custom dimension). '\n . 'Together, \"cs\" + \"is\" + \"mb\" MUST cover every entry in the \"blocking\" list — none may be skipped. '\n . 'Prefer \"is\" over \"mb\": most specs (processor generation, screen size, OS, warranty, colour '\n . 'temperature, IP rating) have a standard answer for this market, so default them and move on. '\n . 'Every entry in \"is\" MUST also appear as a plain-language sentence in \"as\" (assumptions). '\n . '(3) MISSING BLOCKING SPECS. \"mb\" = ONLY specs without which a defensible price is impossible. '\n . 'Do NOT list something already answered by the description, the project context, the product '\n . 'category, or a safe standard default. An empty \"mb\" is the expected outcome for most items. '\n . '(4) ONE GROUPED QUESTION. If \"mb\" is non-empty, \"q\" = a SINGLE Arabic question asking for all of them together '\n . '(maximum ' . self::MAX_QUESTION_PARTS . ' sub-parts, comma-separated in one sentence). Never one question per field. '\n . 'If \"mb\" is empty, \"q\" = \"\". '\n . '(5) STATUS \"st\": READY_TO_PRICE (all blocking specs confirmed), '\n . 'READY_WITH_ASSUMPTIONS (priceable as a budgetary estimate using stated assumptions), '\n . 'BLOCKED_MISSING_SPECIFICATIONS (a blocking spec is genuinely unknowable), '\n . 'NOT_A_SUPPLY_PRODUCT (service/installation/unsupported). '\n . '(6) \"fd\" = the FULL procurement-grade product description. This is the single most '\n . 'important field: a buyer must be able to source and price the item from \"fd\" ALONE, '\n . 'without seeing the original BOQ line. Write out EVERY spec in the \"blocking\" list for '\n . 'this item — the ones the client stated, plus the ones you inferred — as one comma-separated '\n . 'specification string. Expand vague trade shorthand into the real specification. '\n . 'It must NEVER contain questions, placeholders, or the words \"TBD\"/\"to be confirmed\". '\n . 'EXAMPLE — input \"Laptop for work - Core i7 / 16 GB / 512 GB SSD\" must become: '\n . '\"Business Laptop, Intel Core i7-1355U (13th Gen), 16 GB DDR4 RAM, 512 GB NVMe SSD, '\n . '14-inch FHD (1920x1080) Display, Intel Iris Xe Integrated Graphics, Windows 11 Pro, '\n . '3-Year Manufacturer Warranty\". Note how the generation, GPU, screen size, OS and warranty '\n . 'are all present even though the client never wrote them — each one is listed in \"is\" and '\n . 'restated in \"as\" as an assumption. A description that merely repeats the client\\'s words is WRONG. '\n . '(7) \"pb\" = one short phrase stating the pricing basis (e.g. \"budgetary, standard configuration assumed\"). '\n . '(8) \"cf\" = confidence 0-100 that this description can be priced accurately. '\n . 'HARD RULES: never invent a brand, model number, electrical rating, dimension for a custom-manufactured item, '\n . 'or a fire/safety approval. If such a value is genuinely required and unknown, it belongs in \"mb\", not in \"is\". '\n . 'OUTPUT: return ONLY a compact JSON array, no markdown, no commentary. Each element exactly: '\n . '{\"i\":,\"cl\":\"\",\"split\":,\"cs\":{},\"is\":{},\"as\":[],\"mb\":[],\"q\":\"\",\"st\":\"\",\"fd\":\"\",\"pb\":\"\",\"cf\":0} '\n . 'ITEMS: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n private function buildRequestBody(array $records, array $indices, array $project): array\n {\n return [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $this->buildPrompt($records, $indices, $project)]],\n 'temperature' => 0.1,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // HTTP\n // ─────────────────────────────────────────────────────────────────────────\n\n private function qualifyChunk(array $records, array $indices, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n\n try {\n $response = Http::timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', $this->buildRequestBody($records, $indices, $project));\n\n if (! $response->successful()) {\n Log::warning('ProductSpecEngine: DeepSeek request failed.', ['status' => $response->status()]);\n return $this->fallback($records, $indices);\n }\n\n return $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: exception calling DeepSeek.', ['message' => $e->getMessage()]);\n return $this->fallback($records, $indices);\n }\n }\n\n /** @param list> $chunks */\n private function qualifyChunksParallel(array $records, array $chunks, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $url = 'https://api.deepseek.com/chat/completions';\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $records, $chunks, $project) {\n $reqs = [];\n foreach ($chunks as $ci => $indices) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $this->buildRequestBody($records, $indices, $project));\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! ($response instanceof Response) || ! $response->successful()) {\n Log::warning('ProductSpecEngine: parallel chunk failed, retrying serially.', ['chunk' => $ci]);\n $records = $this->qualifyChunk($records, $chunks[$ci], $project);\n continue;\n }\n\n $records = $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n }\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: Http::pool exception, falling back to serial.', ['message' => $e->getMessage()]);\n foreach ($chunks as $indices) {\n $records = $this->qualifyChunk($records, $indices, $project);\n }\n }\n\n return $records;\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Response handling\n // ─────────────────────────────────────────────────────────────────────────\n\n private function apply(string $text, array $records): array\n {\n $text = preg_replace('/^```(?:json)?\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/', '', (string) $text);\n\n $data = json_decode((string) $text, true);\n\n if (! is_array($data) || $data === []) {\n Log::warning('ProductSpecEngine: unparseable AI response.', ['preview' => mb_substr($text, 0, 300)]);\n return $records;\n }\n\n foreach ($data as $entry) {\n if (! is_array($entry)) {\n continue;\n }\n\n $i = $entry['i'] ?? null;\n if ($i === null || ! array_key_exists($i, $records)) {\n continue;\n }\n\n $records[$i] = $this->mergeEntry($records[$i], $entry);\n }\n\n return $records;\n }\n\n /** Merge one AI verdict into its record, enforcing the engine's invariants. */\n private function mergeEntry(array $record, array $entry): array\n {\n $classification = in_array($entry['cl'] ?? null, self::CLASSIFICATIONS, true)\n ? $entry['cl']\n : 'UNCLEAR_ITEM';\n\n $supplyable = in_array($classification, self::SUPPLYABLE, true);\n\n $missing = array_values(array_filter(\n array_map(fn ($m) => trim((string) $m), (array) ($entry['mb'] ?? [])),\n fn ($m) => $m !== ''\n ));\n\n $assumptions = array_values(array_filter(\n array_map(fn ($a) => trim((string) $a), (array) ($entry['as'] ?? [])),\n fn ($a) => $a !== ''\n ));\n\n $status = in_array($entry['st'] ?? null, self::STATUSES, true) ? $entry['st'] : null;\n\n // Invariants the model is not allowed to violate, in priority order.\n if (! $supplyable) {\n $status = 'NOT_A_SUPPLY_PRODUCT';\n } elseif ($this->hasError($record['quantity_warnings']) || $this->hasError($record['unit_warnings'])) {\n $status = 'INVALID_QUANTITY_OR_UNIT';\n } elseif ($missing !== []) {\n $status = 'BLOCKED_MISSING_SPECIFICATIONS';\n } elseif ($status === null) {\n $status = $assumptions === [] ? 'READY_TO_PRICE' : 'READY_WITH_ASSUMPTIONS';\n } elseif ($status === 'READY_TO_PRICE' && $assumptions !== []) {\n // Assumptions were applied, so this is budgetary by definition.\n $status = 'READY_WITH_ASSUMPTIONS';\n }\n\n $finalDescription = trim((string) ($entry['fd'] ?? ''));\n if ($finalDescription === '' || $this->looksLikeQuestion($finalDescription)) {\n $finalDescription = $record['original_item_name'];\n }\n\n // The whole point of the pass is a description a buyer can source from.\n // If the model handed back the client's own wording, the line was not\n // actually enriched — surface it rather than letting it pass as qualified.\n if ($this->isNotEnriched($finalDescription, $record['original_item_name'])) {\n Log::info('ProductSpecEngine: description not enriched by the model.', [\n 'item' => mb_substr($record['original_item_name'], 0, 120),\n 'family' => $record['catalog_key'],\n ]);\n }\n\n $question = trim((string) ($entry['q'] ?? ''));\n\n return array_merge($record, [\n 'classification' => $classification,\n 'supplyable' => $supplyable,\n 'split_supply_and_installation' => (bool) ($entry['split'] ?? false),\n 'confirmed_specifications' => is_array($entry['cs'] ?? null) ? $entry['cs'] : [],\n 'inferred_specifications' => is_array($entry['is'] ?? null) ? $entry['is'] : [],\n 'assumptions' => $assumptions,\n 'missing_blocking_specifications' => $missing,\n 'grouped_question' => $missing === [] ? '' : $question,\n 'pricing_status' => $status,\n 'normalized_product_name' => trim((string) ($entry['fd'] ?? '')) ?: $record['original_item_name'],\n 'recommended_final_description' => $finalDescription,\n 'pricing_basis' => mb_substr(trim((string) ($entry['pb'] ?? '')), 0, 255),\n 'confidence_score' => max(0, min(100, (int) ($entry['cf'] ?? 0))),\n ]);\n }\n\n /**\n * When the AI is unreachable, fall back to the deterministic verdict rather\n * than guessing — an unqualified line is safer than a wrongly-qualified one.\n */\n private function fallback(array $records, array $indices): array\n {\n foreach ($indices as $i) {\n if (! array_key_exists($i, $records) || $records[$i]['pricing_status'] !== null) {\n continue;\n }\n\n $records[$i]['classification'] = 'UNCLEAR_ITEM';\n $records[$i]['pricing_status'] = $this->hasError($records[$i]['quantity_warnings'])\n || $this->hasError($records[$i]['unit_warnings'])\n ? 'INVALID_QUANTITY_OR_UNIT'\n : 'BLOCKED_MISSING_SPECIFICATIONS';\n $records[$i]['pricing_basis'] = 'Specification review unavailable; line not qualified.';\n }\n\n return $records;\n }\n\n /** @param list $warnings */\n private function hasError(array $warnings): bool\n {\n foreach ($warnings as $w) {\n if (($w['severity'] ?? '') === 'error') {\n return true;\n }\n }\n return false;\n }\n\n /**\n * True when the \"enriched\" description is really just the client's own line.\n *\n * A genuine qualification expands trade shorthand into a full specification,\n * so it is materially longer and carries more comma-separated parts. This is\n * a heuristic, used only to flag the case for review — never to block a line.\n */\n private function isNotEnriched(string $final, string $original): bool\n {\n $normalise = static fn (string $s) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim($s)));\n\n if ($normalise($final) === $normalise($original)) {\n return true;\n }\n\n // Fewer than two spec separators means it is still a bare product name.\n return substr_count($final, ',') < 2 && mb_strlen($final) < mb_strlen($original) * 1.5;\n }\n\n /** Guards rule 7: a question must never leak into the final description. */\n private function looksLikeQuestion(string $text): bool\n {\n if (str_contains($text, '?') || str_contains($text, '؟')) {\n return true;\n }\n\n foreach (['tbd', 'to be confirmed', 'to be advised', 'please confirm', 'يرجى تحديد', 'يرجى تأكيد', 'غير محدد'] as $needle) {\n if (mb_stripos($text, $needle) !== false) {\n return true;\n }\n }\n\n return false;\n }\n}\n"}, "22e72149f360cbbde8fe4f431392184a4b450afe": {"t": 1785748352.129806, "n": 19857, "h": "6482ba295ee50ddb", "c": "> $items each with description, quantity, unit, category, brand\n * @param array $project shared context (name, type, location, delivery date…)\n * @return array> one qualified record per input item, same order\n */\n public function qualify(array $items, array $project = []): array\n {\n if (empty($items)) {\n return [];\n }\n\n // Deterministic pass first — it also gives the AI a cleaner starting point.\n $prepared = [];\n foreach ($items as $i => $item) {\n $prepared[$i] = $this->prepare($item);\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('ProductSpecEngine: DEEPSEEK_API_KEY not configured; returning deterministic results only.');\n return array_values($prepared);\n }\n\n $chunks = array_chunk(array_keys($prepared), self::CHUNK_SIZE);\n\n $prepared = count($chunks) === 1\n ? $this->qualifyChunk($prepared, $chunks[0], $project)\n : $this->qualifyChunksParallel($prepared, $chunks, $project);\n\n // Project-level cross-item intelligence runs last: it needs every line resolved.\n return array_values(ProjectIntelligence::analyse($prepared, $project));\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Deterministic pre-pass\n // ─────────────────────────────────────────────────────────────────────────\n\n /**\n * Build the base record: catalog match, normalised unit, quantity sanity.\n * Everything here is decidable without the AI.\n */\n private function prepare(array $item): array\n {\n $description = trim((string) ($item['description'] ?? ''));\n $quantity = (float) ($item['quantity'] ?? 0);\n $rawUnit = (string) ($item['unit'] ?? '');\n\n $catalog = ProductSpecCatalog::match($description);\n $unit = UnitNormalizer::normalize($rawUnit, $catalog);\n\n $quantityWarnings = [];\n if ($quantity <= 0) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_NOT_POSITIVE',\n 'severity' => 'error',\n 'message' => 'Quantity must be greater than zero.',\n ];\n } elseif ($quantity != floor($quantity) && in_array($unit['normalized'], ['PCS', 'SET', 'LICENSE'], true)) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_FRACTIONAL',\n 'severity' => 'warning',\n 'message' => \"A fractional quantity ({$quantity}) is not valid for a countable unit ({$unit['normalized']}).\",\n ];\n }\n\n return [\n 'original_item_name' => $description,\n 'normalized_product_name' => $description,\n 'classification' => null,\n 'category' => (string) ($item['category'] ?? ''),\n 'catalog_key' => $catalog['key'] ?? null,\n 'quantity' => $quantity,\n 'original_unit' => $rawUnit,\n 'normalized_unit' => $unit['normalized'],\n 'supplyable' => true,\n 'pricing_status' => null,\n 'confirmed_specifications' => [],\n 'inferred_specifications' => [],\n 'missing_blocking_specifications' => [],\n 'conditional_questions' => [],\n 'assumptions' => [],\n 'quantity_warnings' => $quantityWarnings,\n 'unit_warnings' => $unit['warnings'],\n 'compatibility_warnings' => [],\n 'recommended_final_description' => $description,\n 'pricing_basis' => '',\n 'confidence_score' => 0,\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Prompt\n // ─────────────────────────────────────────────────────────────────────────\n\n private function buildPrompt(array $records, array $indices, array $project): string\n {\n $payload = [];\n foreach ($indices as $idx) {\n $r = $records[$idx];\n $catalog = $r['catalog_key'] ? ProductSpecCatalog::all()[$r['catalog_key']] ?? null : null;\n\n $payload[] = array_filter([\n 'i' => $idx,\n 'd' => mb_substr($r['original_item_name'], 0, 200),\n 'qty' => $r['quantity'],\n 'unit' => $r['normalized_unit'],\n 'cat' => mb_substr($r['category'], 0, 40),\n // The rubric for this family, so the model audits against fixed\n // criteria instead of improvising a different list each call.\n 'blocking' => $catalog['blocking'] ?? null,\n 'optional' => $catalog['conditional'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n }\n\n $ctx = array_filter([\n 'project_name' => $project['name'] ?? null,\n 'project_type' => $project['type'] ?? null,\n 'location' => $project['location'] ?? null,\n 'delivery_date' => $project['delivery_date'] ?? null,\n 'brand_policy' => $project['brand_policy'] ?? null,\n 'quotation_type' => $project['quotation_type'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n\n return 'You are a senior procurement engineer, quantity surveyor and technical pre-sales specialist '\n . 'for the Saudi Arabia (Riyadh) construction, MEP, IT, security, furniture and general-supply market. '\n . 'Qualify each BOQ line BEFORE pricing. Think like an experienced engineer, not a form-filler. '\n . ($ctx ? 'PROJECT CONTEXT: ' . json_encode($ctx, JSON_UNESCAPED_UNICODE) . '. ' : '')\n . 'FOR EACH ITEM DO ALL OF THE FOLLOWING: '\n . '(1) CLASSIFY as exactly one of: SUPPLY_PRODUCT, CUSTOM_MANUFACTURED_PRODUCT, SOFTWARE_OR_LICENSE, '\n . 'SERVICE, INSTALLATION, CONSULTATION, UNSUPPORTED_ITEM, UNCLEAR_ITEM. '\n . 'Installation, testing, commissioning, supervision, training, maintenance and consultancy are NOT supply products. '\n . 'If a line mixes supply AND installation, classify it as SUPPLY_PRODUCT and set \"split\":true so the caller separates the service. '\n . '(2) SEPARATE SPECIFICATIONS. \"cs\" = specs the description or project context actually states. '\n . '\"is\" = safe industry-standard defaults you applied. Every entry in \"is\" MUST also appear as a plain-language sentence in \"as\" (assumptions). '\n . '(3) MISSING BLOCKING SPECS. \"mb\" = ONLY specs without which a defensible price is impossible. '\n . 'Use the per-item \"blocking\" list as your rubric. Do NOT list something already answered by the description, '\n . 'the project context, the product category, or a safe standard default. '\n . '(4) ONE GROUPED QUESTION. If \"mb\" is non-empty, \"q\" = a SINGLE Arabic question asking for all of them together '\n . '(maximum ' . self::MAX_QUESTION_PARTS . ' sub-parts, comma-separated in one sentence). Never one question per field. '\n . 'If \"mb\" is empty, \"q\" = \"\". '\n . '(5) STATUS \"st\": READY_TO_PRICE (all blocking specs confirmed), '\n . 'READY_WITH_ASSUMPTIONS (priceable as a budgetary estimate using stated assumptions), '\n . 'BLOCKED_MISSING_SPECIFICATIONS (a blocking spec is genuinely unknowable), '\n . 'NOT_A_SUPPLY_PRODUCT (service/installation/unsupported). '\n . '(6) \"fd\" = the clean final product description containing ONLY confirmed and assumed specifications. '\n . 'It must NEVER contain questions, placeholders, or the words \"TBD\"/\"to be confirmed\". '\n . '(7) \"pb\" = one short phrase stating the pricing basis (e.g. \"budgetary, standard configuration assumed\"). '\n . '(8) \"cf\" = confidence 0-100 that this description can be priced accurately. '\n . 'HARD RULES: never invent a brand, model number, electrical rating, dimension for a custom-manufactured item, '\n . 'or a fire/safety approval. If such a value is genuinely required and unknown, it belongs in \"mb\", not in \"is\". '\n . 'OUTPUT: return ONLY a compact JSON array, no markdown, no commentary. Each element exactly: '\n . '{\"i\":,\"cl\":\"\",\"split\":,\"cs\":{},\"is\":{},\"as\":[],\"mb\":[],\"q\":\"\",\"st\":\"\",\"fd\":\"\",\"pb\":\"\",\"cf\":0} '\n . 'ITEMS: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n private function buildRequestBody(array $records, array $indices, array $project): array\n {\n return [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $this->buildPrompt($records, $indices, $project)]],\n 'temperature' => 0.1,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // HTTP\n // ─────────────────────────────────────────────────────────────────────────\n\n private function qualifyChunk(array $records, array $indices, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n\n try {\n $response = Http::timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', $this->buildRequestBody($records, $indices, $project));\n\n if (! $response->successful()) {\n Log::warning('ProductSpecEngine: DeepSeek request failed.', ['status' => $response->status()]);\n return $this->fallback($records, $indices);\n }\n\n return $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: exception calling DeepSeek.', ['message' => $e->getMessage()]);\n return $this->fallback($records, $indices);\n }\n }\n\n /** @param list> $chunks */\n private function qualifyChunksParallel(array $records, array $chunks, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $url = 'https://api.deepseek.com/chat/completions';\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $records, $chunks, $project) {\n $reqs = [];\n foreach ($chunks as $ci => $indices) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $this->buildRequestBody($records, $indices, $project));\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! ($response instanceof Response) || ! $response->successful()) {\n Log::warning('ProductSpecEngine: parallel chunk failed, retrying serially.', ['chunk' => $ci]);\n $records = $this->qualifyChunk($records, $chunks[$ci], $project);\n continue;\n }\n\n $records = $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n }\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: Http::pool exception, falling back to serial.', ['message' => $e->getMessage()]);\n foreach ($chunks as $indices) {\n $records = $this->qualifyChunk($records, $indices, $project);\n }\n }\n\n return $records;\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Response handling\n // ─────────────────────────────────────────────────────────────────────────\n\n private function apply(string $text, array $records): array\n {\n $text = preg_replace('/^```(?:json)?\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/', '', (string) $text);\n\n $data = json_decode((string) $text, true);\n\n if (! is_array($data) || $data === []) {\n Log::warning('ProductSpecEngine: unparseable AI response.', ['preview' => mb_substr($text, 0, 300)]);\n return $records;\n }\n\n foreach ($data as $entry) {\n if (! is_array($entry)) {\n continue;\n }\n\n $i = $entry['i'] ?? null;\n if ($i === null || ! array_key_exists($i, $records)) {\n continue;\n }\n\n $records[$i] = $this->mergeEntry($records[$i], $entry);\n }\n\n return $records;\n }\n\n /** Merge one AI verdict into its record, enforcing the engine's invariants. */\n private function mergeEntry(array $record, array $entry): array\n {\n $classification = in_array($entry['cl'] ?? null, self::CLASSIFICATIONS, true)\n ? $entry['cl']\n : 'UNCLEAR_ITEM';\n\n $supplyable = in_array($classification, self::SUPPLYABLE, true);\n\n $missing = array_values(array_filter(\n array_map(fn ($m) => trim((string) $m), (array) ($entry['mb'] ?? [])),\n fn ($m) => $m !== ''\n ));\n\n $assumptions = array_values(array_filter(\n array_map(fn ($a) => trim((string) $a), (array) ($entry['as'] ?? [])),\n fn ($a) => $a !== ''\n ));\n\n $status = in_array($entry['st'] ?? null, self::STATUSES, true) ? $entry['st'] : null;\n\n // Invariants the model is not allowed to violate, in priority order.\n if (! $supplyable) {\n $status = 'NOT_A_SUPPLY_PRODUCT';\n } elseif ($this->hasError($record['quantity_warnings']) || $this->hasError($record['unit_warnings'])) {\n $status = 'INVALID_QUANTITY_OR_UNIT';\n } elseif ($missing !== []) {\n $status = 'BLOCKED_MISSING_SPECIFICATIONS';\n } elseif ($status === null) {\n $status = $assumptions === [] ? 'READY_TO_PRICE' : 'READY_WITH_ASSUMPTIONS';\n } elseif ($status === 'READY_TO_PRICE' && $assumptions !== []) {\n // Assumptions were applied, so this is budgetary by definition.\n $status = 'READY_WITH_ASSUMPTIONS';\n }\n\n $finalDescription = trim((string) ($entry['fd'] ?? ''));\n if ($finalDescription === '' || $this->looksLikeQuestion($finalDescription)) {\n $finalDescription = $record['original_item_name'];\n }\n\n $question = trim((string) ($entry['q'] ?? ''));\n\n return array_merge($record, [\n 'classification' => $classification,\n 'supplyable' => $supplyable,\n 'split_supply_and_installation' => (bool) ($entry['split'] ?? false),\n 'confirmed_specifications' => is_array($entry['cs'] ?? null) ? $entry['cs'] : [],\n 'inferred_specifications' => is_array($entry['is'] ?? null) ? $entry['is'] : [],\n 'assumptions' => $assumptions,\n 'missing_blocking_specifications' => $missing,\n 'grouped_question' => $missing === [] ? '' : $question,\n 'pricing_status' => $status,\n 'normalized_product_name' => trim((string) ($entry['fd'] ?? '')) ?: $record['original_item_name'],\n 'recommended_final_description' => $finalDescription,\n 'pricing_basis' => mb_substr(trim((string) ($entry['pb'] ?? '')), 0, 255),\n 'confidence_score' => max(0, min(100, (int) ($entry['cf'] ?? 0))),\n ]);\n }\n\n /**\n * When the AI is unreachable, fall back to the deterministic verdict rather\n * than guessing — an unqualified line is safer than a wrongly-qualified one.\n */\n private function fallback(array $records, array $indices): array\n {\n foreach ($indices as $i) {\n if (! array_key_exists($i, $records) || $records[$i]['pricing_status'] !== null) {\n continue;\n }\n\n $records[$i]['classification'] = 'UNCLEAR_ITEM';\n $records[$i]['pricing_status'] = $this->hasError($records[$i]['quantity_warnings'])\n || $this->hasError($records[$i]['unit_warnings'])\n ? 'INVALID_QUANTITY_OR_UNIT'\n : 'BLOCKED_MISSING_SPECIFICATIONS';\n $records[$i]['pricing_basis'] = 'Specification review unavailable; line not qualified.';\n }\n\n return $records;\n }\n\n /** @param list $warnings */\n private function hasError(array $warnings): bool\n {\n foreach ($warnings as $w) {\n if (($w['severity'] ?? '') === 'error') {\n return true;\n }\n }\n return false;\n }\n\n /** Guards rule 7: a question must never leak into the final description. */\n private function looksLikeQuestion(string $text): bool\n {\n if (str_contains($text, '?') || str_contains($text, '؟')) {\n return true;\n }\n\n foreach (['tbd', 'to be confirmed', 'to be advised', 'please confirm', 'يرجى تحديد', 'يرجى تأكيد', 'غير محدد'] as $needle) {\n if (mb_stripos($text, $needle) !== false) {\n return true;\n }\n }\n\n return false;\n }\n}\n"}}}}