diff --git a/.gitignore b/.gitignore index 69936cbc..f1509955 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,10 @@ backend/config/ldap_config.json # ── Environment files (secrets) ────────────────────────────── .env .env.* +inventory.env +inventory.env.* !.env.example +!inventory.env.example backend/.env backend/.env.* !backend/.env.example diff --git a/AI_RULES.md b/AI_RULES.md index f5e18d9e..63a63873 100644 --- a/AI_RULES.md +++ b/AI_RULES.md @@ -11,10 +11,11 @@ This is the **Single Source of Truth** for ALL AI agents. Refer to [PROJECT_ARCH - **STRICT HANDOVER**: Update `dev_docs/SESSION_STATE.md` at the end of every task with: **Active AI**, **Current Status** (Stable/Broken/In-Progress), **Context**, and **Next Steps**. - **SESSION ARCHIVE**: Move previous handover content to `dev_docs/SESSION_HISTORY.md` before writing new state. - **NO INTERACTION OVERLAP**: Never modify a file if another AI session is explicitly working on it. +- **CONCISE COMMUNICATION**: Be concise! Do not explain a thousand details unless they are absolutely necessary. ## 2. ENGINEERING & OPERATIONAL LAWS - **ENGLISH ONLY**: Interfaces, code, variables, and docs MUST be in English. Translate any Romanian text found in code immediately. (User conversation: Romanian/English). -- **GIT PROTOCOL**: Use the direct binary path in `.git_path` (`/Library/Developer/CommandLineTools/usr/bin/git`) for ALL Git operations. **DO NOT REMOVE OR CHANGE THIS PATH UNDER ANY CIRCUMSTANCES!** Never push or use `--force` unless explicitly asked. Branching: `master` (stable), `dev` (active), `vX` (archive). +- **GIT PROTOCOL**: Use the direct binary path `/Library/Developer/CommandLineTools/usr/bin/git`) for ALL Git operations. **DO NOT REMOVE OR CHANGE THIS PATH UNDER ANY CIRCUMSTANCES!** Never push or use `--force` unless explicitly asked. Branching: `master` (stable), `dev` (active), `vX` (archive). - **VERSIONING**: Update `VERSION.json` on every commit. Use `scripts/save_version.py` for automated releases. - **DEPENDENCIES**: Update `backend/requirements.txt` with version constraints for every new pip package. - **SSOT INTEGRITY**: Every feature change MUST update: `README.md`, `USER_GUIDE.md`, `PROJECT_ARCHITECTURE.md`, and `export_prod.sh`. diff --git a/CLAUDE.md b/CLAUDE.md index 60ad6b12..6fd48f18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,4 +8,7 @@ Before taking any action or writing code, you MUST read the following Single Sou 2. `PROJECT_ARCHITECTURE.md` (Contains the tech stack, data models, and system logic). 3. `dev_docs/SESSION_STATE.md` (Contains the current handover status from the previous AI). +**STRICT COMMUNICATION RULE:** +Be concise! Do not explain a thousand details unless they are absolutely necessary. + Do not proceed with any task until you have analyzed these three files. diff --git a/GEMINI.md b/GEMINI.md index a520fb61..96631ebe 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -8,4 +8,7 @@ Before taking any action or writing code, you MUST read the following Single Sou 2. `PROJECT_ARCHITECTURE.md` (Contains the tech stack, data models, and system logic). 3. `dev_docs/SESSION_STATE.md` (Contains the current handover status from the previous AI). +**STRICT COMMUNICATION RULE:** +Be concise! Do not explain a thousand details unless they are absolutely necessary. + Do not proceed with any task until you have analyzed these three files. diff --git a/_images.tests/IMG_6082.jpeg b/_images.tests/IMG_6082.jpeg new file mode 100644 index 00000000..b5fc2e73 Binary files /dev/null and b/_images.tests/IMG_6082.jpeg differ diff --git a/_images.tests/IMG_6106.jpeg b/_images.tests/IMG_6106.jpeg new file mode 100644 index 00000000..4b761514 Binary files /dev/null and b/_images.tests/IMG_6106.jpeg differ diff --git a/_images.tests/IMG_6107.jpeg b/_images.tests/IMG_6107.jpeg new file mode 100644 index 00000000..93acc695 Binary files /dev/null and b/_images.tests/IMG_6107.jpeg differ diff --git a/_images.tests/IMG_6108.jpeg b/_images.tests/IMG_6108.jpeg new file mode 100644 index 00000000..0c408544 Binary files /dev/null and b/_images.tests/IMG_6108.jpeg differ diff --git a/_images.tests/IMG_6109.jpeg b/_images.tests/IMG_6109.jpeg new file mode 100644 index 00000000..affa0e20 Binary files /dev/null and b/_images.tests/IMG_6109.jpeg differ diff --git a/_images.tests/IMG_6110.jpeg b/_images.tests/IMG_6110.jpeg new file mode 100644 index 00000000..4d7eec6d Binary files /dev/null and b/_images.tests/IMG_6110.jpeg differ diff --git a/_images.tests/IMG_6111.jpeg b/_images.tests/IMG_6111.jpeg new file mode 100644 index 00000000..f496df6d Binary files /dev/null and b/_images.tests/IMG_6111.jpeg differ diff --git a/_images.tests/IMG_6112.jpeg b/_images.tests/IMG_6112.jpeg new file mode 100644 index 00000000..37c91366 Binary files /dev/null and b/_images.tests/IMG_6112.jpeg differ diff --git a/_images.tests/IMG_6113.jpeg b/_images.tests/IMG_6113.jpeg new file mode 100644 index 00000000..a4210904 Binary files /dev/null and b/_images.tests/IMG_6113.jpeg differ diff --git a/_images.tests/IMG_6114.jpeg b/_images.tests/IMG_6114.jpeg new file mode 100644 index 00000000..fe430b7f Binary files /dev/null and b/_images.tests/IMG_6114.jpeg differ diff --git a/_images.tests/IMG_6115.jpeg b/_images.tests/IMG_6115.jpeg new file mode 100644 index 00000000..520a7f6f Binary files /dev/null and b/_images.tests/IMG_6115.jpeg differ diff --git a/_images.tests/IMG_6116.jpeg b/_images.tests/IMG_6116.jpeg new file mode 100644 index 00000000..d182e1bb Binary files /dev/null and b/_images.tests/IMG_6116.jpeg differ diff --git a/_images.tests/IMG_6117.jpeg b/_images.tests/IMG_6117.jpeg new file mode 100644 index 00000000..f41e56c7 Binary files /dev/null and b/_images.tests/IMG_6117.jpeg differ diff --git a/_images.tests/IMG_6118.jpeg b/_images.tests/IMG_6118.jpeg new file mode 100644 index 00000000..a8ec828a Binary files /dev/null and b/_images.tests/IMG_6118.jpeg differ diff --git a/_images.tests/IMG_6119.jpeg b/_images.tests/IMG_6119.jpeg new file mode 100644 index 00000000..fd70dfbb Binary files /dev/null and b/_images.tests/IMG_6119.jpeg differ diff --git a/_images.tests/IMG_6120.jpeg b/_images.tests/IMG_6120.jpeg new file mode 100644 index 00000000..c67eca0d Binary files /dev/null and b/_images.tests/IMG_6120.jpeg differ diff --git a/_images.tests/IMG_6121.jpeg b/_images.tests/IMG_6121.jpeg new file mode 100644 index 00000000..dad2ae90 Binary files /dev/null and b/_images.tests/IMG_6121.jpeg differ diff --git a/_images.tests/IMG_6122.jpeg b/_images.tests/IMG_6122.jpeg new file mode 100644 index 00000000..0e51c796 Binary files /dev/null and b/_images.tests/IMG_6122.jpeg differ diff --git a/_images.tests/IMG_6123.jpeg b/_images.tests/IMG_6123.jpeg new file mode 100644 index 00000000..46992fcb Binary files /dev/null and b/_images.tests/IMG_6123.jpeg differ diff --git a/_images.tests/IMG_6124.jpeg b/_images.tests/IMG_6124.jpeg new file mode 100644 index 00000000..82108ccd Binary files /dev/null and b/_images.tests/IMG_6124.jpeg differ diff --git a/_images.tests/IMG_6125.jpeg b/_images.tests/IMG_6125.jpeg new file mode 100644 index 00000000..914ce134 Binary files /dev/null and b/_images.tests/IMG_6125.jpeg differ diff --git a/_images.tests/IMG_6126.jpeg b/_images.tests/IMG_6126.jpeg new file mode 100644 index 00000000..05557a23 Binary files /dev/null and b/_images.tests/IMG_6126.jpeg differ diff --git a/_images.tests/IMG_6127.jpeg b/_images.tests/IMG_6127.jpeg new file mode 100644 index 00000000..39476164 Binary files /dev/null and b/_images.tests/IMG_6127.jpeg differ diff --git a/_images.tests/IMG_6128.jpeg b/_images.tests/IMG_6128.jpeg new file mode 100644 index 00000000..2775c2a6 Binary files /dev/null and b/_images.tests/IMG_6128.jpeg differ diff --git a/_images.tests/IMG_6129.jpeg b/_images.tests/IMG_6129.jpeg new file mode 100644 index 00000000..5c496e73 Binary files /dev/null and b/_images.tests/IMG_6129.jpeg differ diff --git a/_images.tests/IMG_6130.jpeg b/_images.tests/IMG_6130.jpeg new file mode 100644 index 00000000..7c6a606a Binary files /dev/null and b/_images.tests/IMG_6130.jpeg differ diff --git a/_images.tests/IMG_6131.jpeg b/_images.tests/IMG_6131.jpeg new file mode 100644 index 00000000..6e0e1874 Binary files /dev/null and b/_images.tests/IMG_6131.jpeg differ diff --git a/_images.tests/IMG_6132.jpeg b/_images.tests/IMG_6132.jpeg new file mode 100644 index 00000000..7b934910 Binary files /dev/null and b/_images.tests/IMG_6132.jpeg differ diff --git a/_images.tests/IMG_6133.jpeg b/_images.tests/IMG_6133.jpeg new file mode 100644 index 00000000..d3363d29 Binary files /dev/null and b/_images.tests/IMG_6133.jpeg differ diff --git a/_images.tests/IMG_6134.jpeg b/_images.tests/IMG_6134.jpeg new file mode 100644 index 00000000..c78ad359 Binary files /dev/null and b/_images.tests/IMG_6134.jpeg differ diff --git a/_images.tests/IMG_6135.jpeg b/_images.tests/IMG_6135.jpeg new file mode 100644 index 00000000..a0badb04 Binary files /dev/null and b/_images.tests/IMG_6135.jpeg differ diff --git a/_images.tests/IMG_6136.jpeg b/_images.tests/IMG_6136.jpeg new file mode 100644 index 00000000..1c669eec Binary files /dev/null and b/_images.tests/IMG_6136.jpeg differ diff --git a/_images.tests/IMG_6137.jpeg b/_images.tests/IMG_6137.jpeg new file mode 100644 index 00000000..dd973c22 Binary files /dev/null and b/_images.tests/IMG_6137.jpeg differ diff --git a/_images.tests/IMG_6139.jpeg b/_images.tests/IMG_6139.jpeg new file mode 100644 index 00000000..6a391648 Binary files /dev/null and b/_images.tests/IMG_6139.jpeg differ diff --git a/_images.tests/IMG_6140.jpeg b/_images.tests/IMG_6140.jpeg new file mode 100644 index 00000000..b1d10652 Binary files /dev/null and b/_images.tests/IMG_6140.jpeg differ diff --git a/_images.tests/IMG_6141.jpeg b/_images.tests/IMG_6141.jpeg new file mode 100644 index 00000000..23ba82c8 Binary files /dev/null and b/_images.tests/IMG_6141.jpeg differ diff --git a/_images.tests/IMG_6142.jpeg b/_images.tests/IMG_6142.jpeg new file mode 100644 index 00000000..b71c23c5 Binary files /dev/null and b/_images.tests/IMG_6142.jpeg differ diff --git a/_images.tests/IMG_6143.jpeg b/_images.tests/IMG_6143.jpeg new file mode 100644 index 00000000..5d606b1b Binary files /dev/null and b/_images.tests/IMG_6143.jpeg differ diff --git a/_images.tests/IMG_6144.jpeg b/_images.tests/IMG_6144.jpeg new file mode 100644 index 00000000..f772b1d5 Binary files /dev/null and b/_images.tests/IMG_6144.jpeg differ diff --git a/_images.tests/IMG_6145.jpeg b/_images.tests/IMG_6145.jpeg new file mode 100644 index 00000000..06e14769 Binary files /dev/null and b/_images.tests/IMG_6145.jpeg differ diff --git a/_images.tests/IMG_6146.jpeg b/_images.tests/IMG_6146.jpeg new file mode 100644 index 00000000..27c55b08 Binary files /dev/null and b/_images.tests/IMG_6146.jpeg differ diff --git a/_images.tests/IMG_6147.jpeg b/_images.tests/IMG_6147.jpeg new file mode 100644 index 00000000..2dd7350d Binary files /dev/null and b/_images.tests/IMG_6147.jpeg differ diff --git a/_images.tests/IMG_6148.jpeg b/_images.tests/IMG_6148.jpeg new file mode 100644 index 00000000..c4483f8f Binary files /dev/null and b/_images.tests/IMG_6148.jpeg differ diff --git a/_images.tests/IMG_6149.jpeg b/_images.tests/IMG_6149.jpeg new file mode 100644 index 00000000..7a2ca931 Binary files /dev/null and b/_images.tests/IMG_6149.jpeg differ diff --git a/_images.tests/IMG_6150.jpeg b/_images.tests/IMG_6150.jpeg new file mode 100644 index 00000000..39187e9d Binary files /dev/null and b/_images.tests/IMG_6150.jpeg differ diff --git a/_images.tests/IMG_6151.jpeg b/_images.tests/IMG_6151.jpeg new file mode 100644 index 00000000..301e0fc5 Binary files /dev/null and b/_images.tests/IMG_6151.jpeg differ diff --git a/_images.tests/IMG_6152.jpeg b/_images.tests/IMG_6152.jpeg new file mode 100644 index 00000000..65e4a1ac Binary files /dev/null and b/_images.tests/IMG_6152.jpeg differ diff --git a/_images.tests/IMG_6153.jpeg b/_images.tests/IMG_6153.jpeg new file mode 100644 index 00000000..848bfeec Binary files /dev/null and b/_images.tests/IMG_6153.jpeg differ diff --git a/backend/ai/gemini.py b/backend/ai/gemini.py index 472acfef..1c5bb496 100644 --- a/backend/ai/gemini.py +++ b/backend/ai/gemini.py @@ -1,42 +1,50 @@ import os import json import io +import logging from PIL import Image from google import genai from google.genai import types +log = logging.getLogger("ainventory") + def get_best_models(): - # Using the exact models discovered via diagnostic - return ["gemini-2.0-flash", "gemini-2.5-flash"] + # Priority list based on aliases and future-gen models found in user logs + return [ + "gemini-flash-latest", # Points to the best stable Flash version + "gemini-3.1-flash-lite-preview", # Cutting edge 3.1 Lite + "gemini-flash-lite-latest", # Best Lite alias + "gemini-pro-latest" # Pro fallback alias + ] def extract(image_bytes: bytes, prompt: str): api_key = os.environ.get("GEMINI_API_KEY") if not api_key: - print("CRITICAL: GEMINI_API_KEY is MISSING in environment!") + log.error("CRITICAL: GEMINI_API_KEY is MISSING in environment!") return None # Log partial key for safety debug key_hint = f"{api_key[:4]}...{api_key[-4:]}" if len(api_key) > 8 else "too short" - print(f"🔑 Using API Key: {key_hint}") + log.info(f"🔑 Using API Key: {key_hint}") try: - # Initialize the NEW SDK Client forcing stable v1 API - client = genai.Client(api_key=api_key, http_options={'api_version': 'v1'}) + # Switching to v1beta which is required for many experimental/new models + client = genai.Client(api_key=api_key, http_options={'api_version': 'v1beta'}) # DEBUG: List allowed models for this key - print("🔍 Checking available models for your key...") + log.debug("🔍 Checking available models for your key...") try: for m in client.models.list(): - print(f" - Found: {m.name}") + log.debug(f" - Found: {m.name}") except Exception as list_e: - print(f" ⚠️ Could not list models: {list_e}") + log.warning(f" ⚠️ Could not list models: {list_e}") models_to_try = get_best_models() # Try models in order for model_name in models_to_try: try: - print(f"🚀 AI Launching (v2 SDK): {model_name}...") + log.info(f"🚀 AI Launching (v2 SDK): {model_name}...") # In the new SDK, we pass a list of parts (text string and image bytes) response = client.models.generate_content( @@ -48,10 +56,12 @@ def extract(image_bytes: bytes, prompt: str): ) if not response or not response.text: + log.warning(f"⚠️ Gemini {model_name} returned NO text.") continue text = response.text.strip() - print(f"✅ AI Response Received ({len(text)} bytes)") + log.info(f"✅ AI Response Received ({len(text)} bytes)") + log.debug(f"FULL RESPONSE:\n{text}") # Extract JSON block if "```json" in text: @@ -62,10 +72,10 @@ def extract(image_bytes: bytes, prompt: str): return json.loads(text) except Exception as e: - print(f"❌ Gemini {model_name} failed: {e}") + log.error(f"❌ Gemini {model_name} failed: {e}") continue except Exception as outer_e: - print(f"❌ Gemini Client Init failed: {outer_e}") + log.error(f"❌ Gemini Client Init failed: {outer_e}") return None diff --git a/backend/ai_vision.py b/backend/ai_vision.py index 62e98730..2822d78d 100644 --- a/backend/ai_vision.py +++ b/backend/ai_vision.py @@ -1,10 +1,42 @@ +import os +import time +from dotenv import load_dotenv from . import models from .database import SessionLocal +from .ai import gemini, claude -# Load environment variables from the directory where this file resides +# Note: Environment variables are managed centrally by config_loader.py base_dir = os.path.dirname(os.path.abspath(__file__)) -dotenv_path = os.path.join(base_dir, ".env") -load_dotenv(dotenv_path) + +class PromptManager: + """Manages AI prompt with auto-reloading from file.""" + def __init__(self, file_path: str): + self.file_path = file_path + self.last_mtime = 0 + self.cached_prompt = None + + def get_prompt(self) -> str: + if not os.path.exists(self.file_path): + return None + + try: + current_mtime = os.path.getmtime(self.file_path) + if current_mtime > self.last_mtime or self.cached_prompt is None: + with open(self.file_path, 'r', encoding='utf-8') as f: + self.cached_prompt = f.read().strip() + self.last_mtime = current_mtime + # Use print or log for visibility in dev + print(f"🔄 AI Vision prompt reloaded from {self.file_path} (mtime: {current_mtime})") + return self.cached_prompt + except Exception as e: + print(f"⚠️ Failed to reload AI prompt: {e}") + return self.cached_prompt + +# The prompt file is located in the global /config directory +# We go up one level from backend/ to reach project root, then into config/ +PROJECT_ROOT = os.path.dirname(os.path.abspath(base_dir)) +PROMPT_FILE_PATH = os.path.join(PROJECT_ROOT, "config", "ai_prompt.md") +prompt_mgr = PromptManager(PROMPT_FILE_PATH) def extract_label_info(image_bytes: bytes, mode: str = "item"): """ @@ -29,20 +61,27 @@ def extract_label_info(image_bytes: bytes, mode: str = "item"): } """ else: - # Fetch custom prompt from DB - setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first() - if setting: - prompt = setting.value - else: - # Fallback to a sensible default if DB is not ready - prompt = "Extract technical specs. Return JSON with name, category, description, connector, size, color, part_number, ocr_text, quantity." + # 1. Try fetching from the configuration file first (SSOT) + prompt = prompt_mgr.get_prompt() + + if not prompt: + # 2. Fallback to Database if file is missing + setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first() + if setting: + prompt = setting.value + else: + # 3. Final fallback to hardcoded default + prompt = "Extract technical specs. Return JSON with name, category, description, connector, size, color, part_number, ocr_text, quantity." # 1. Try Gemini result = gemini.extract(image_bytes, prompt) if result: - # Map user-defined prompt keys to model fields if needed - # User keys: Item, Type, Description, Category, Connector, Size, Color, PartNr, OCR + # Check if AI returned a list of items or a singular object + raw_items = result.get("items") or result.get("Items") + was_list = isinstance(raw_items, list) + items_to_map = raw_items if was_list else [result] + mapping = { "Item": "name", "Type": "type", @@ -55,23 +94,32 @@ def extract_label_info(image_bytes: bytes, mode: str = "item"): "OCR": "ocr_text" } - final_result = {} - for ai_key, model_key in mapping.items(): - if ai_key in result: - final_result[model_key] = result[ai_key] - elif model_key in result: # Already mapped or using model keys - final_result[model_key] = result[model_key] + mapped_items = [] + for item_data in items_to_map: + final_item = {} + for ai_key, model_key in mapping.items(): + val = item_data.get(ai_key) or item_data.get(model_key) + if val and isinstance(val, str): + final_item[model_key] = val.strip() + else: + final_item[model_key] = val + + # Default fields + final_item["quantity"] = item_data.get("quantity", 1) + raw_barcode = item_data.get("barcode") or item_data.get("PartNr") or item_data.get("part_number") or item_data.get("Part Number") + final_item["barcode"] = str(raw_barcode).strip() if raw_barcode else f"AI-{int(time.time()*100)}" + + # Handle Box mode specifically inside mapping + if mode == "box": + final_item["box_label"] = final_item.get("box_label") or item_data.get("Box") or final_item.get("name") or "Unknown Box" + final_item["name"] = final_item["box_label"] + + mapped_items.append(final_item) - # Ensure quantity and barcode are handled if returned or default - final_result["quantity"] = result.get("quantity", 1) - final_result["barcode"] = result.get("barcode", result.get("PartNr", result.get("part_number", ""))) - - # Handle Box mode specifically - if mode == "box": - final_result["box_label"] = result.get("box_label", result.get("name", "Unknown Box")) - final_result["name"] = final_result["box_label"] - - return final_result + # Return either the whole list wrapper or the first item (legacy compatibility) + if was_list: + return {"items": mapped_items} + return mapped_items[0] if mapped_items else {"error": "No items after mapping"} finally: db.close() diff --git a/backend/config_loader.py b/backend/config_loader.py new file mode 100644 index 00000000..4e5fcfa7 --- /dev/null +++ b/backend/config_loader.py @@ -0,0 +1,34 @@ +import os +import logging +from dotenv import load_dotenv + +log = logging.getLogger("ainventory") + +def load_config(): + """ + Centralized environment loader for TFM aInventory. + Prioritizes existing environment variables (Docker), + then inventory.env at project root, then backend/.env. + """ + # Base directory is backend/ + base_dir = os.path.dirname(os.path.abspath(__file__)) + # Project root is one level up + project_root = os.path.dirname(base_dir) + + inventory_env_path = os.path.join(project_root, "inventory.env") + backend_env_path = os.path.join(base_dir, ".env") + + # Check for inventory.env in root (Master Config) + if os.path.exists(inventory_env_path): + load_dotenv(inventory_env_path) + log.info(f"✅ Loaded master configuration from {inventory_env_path}") + + # Check for local backend/.env (Legacy/Fragmented) + elif os.path.exists(backend_env_path): + load_dotenv(backend_env_path) + log.info(f"ℹ️ Loaded local configuration from {backend_env_path}") + else: + log.warning("⚠️ No .env config files found. Relying on system environment variables.") + +# Auto-run if imported +load_config() diff --git a/backend/main.py b/backend/main.py index 7713e64d..9429d69a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,4 +1,5 @@ import os +from . import config_loader # This triggers the automatic environment loading from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from slowapi import Limiter @@ -26,9 +27,9 @@ ALLOWED_ORIGINS = [o.strip() for o in _raw_origins.split(",") if o.strip()] # Automatically add origins based on network_config.env variables if present server_ip = os.environ.get("SERVER_IP") -front_port = os.environ.get("FRONTEND_PORT", "8907") -front_ssl_port = os.environ.get("FRONTEND_SSL_PORT", "8909") -back_ssl_port = os.environ.get("BACKEND_SSL_PORT", "8908") +front_port = os.environ.get("FRONTEND_PORT", "8917") +front_ssl_port = os.environ.get("FRONTEND_SSL_PORT", "8919") +back_ssl_port = os.environ.get("BACKEND_SSL_PORT", "8918") # Always allow localhost defaults = [ @@ -121,7 +122,25 @@ def startup_event(): if not existing: db.add(models.SystemSetting(key="ai_extraction_prompt", value=final_prompt)) db.commit() - log.info("Initialized default AI prompt.") + log.info("Initialized default AI prompt in database.") + # Refresh existing after commit + existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first() + + # [NEW] Sync/Initialize AI prompt file in /config + from .database import BASE_DIR + PROJECT_ROOT = os.path.dirname(BASE_DIR) + PROMPT_FILE_PATH = os.path.join(PROJECT_ROOT, "config", "ai_prompt.md") + + if not os.path.exists(PROMPT_FILE_PATH): + try: + os.makedirs(os.path.dirname(PROMPT_FILE_PATH), exist_ok=True) + # Use DB value (which we just ensured exists) + current_val = existing.value if existing else final_prompt + with open(PROMPT_FILE_PATH, 'w', encoding='utf-8') as f: + f.write(current_val) + log.info(f"✅ Initialized AI prompt configuration file: {PROMPT_FILE_PATH}") + except Exception as fe: + log.error(f"❌ Failed to initialize AI prompt file: {fe}") defaults = { "backup_retention_count": "10", diff --git a/backend/routers/admin_db.py b/backend/routers/admin_db.py index 015041c9..b510ffa8 100644 --- a/backend/routers/admin_db.py +++ b/backend/routers/admin_db.py @@ -135,16 +135,30 @@ async def import_database( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) +import os +from ..database import BASE_DIR +PROJECT_ROOT = os.path.dirname(BASE_DIR) +PROMPT_FILE_PATH = os.path.join(PROJECT_ROOT, "config", "ai_prompt.md") + @router.get("/settings/prompt") def get_ai_prompt( db: Session = Depends(get_db), current_admin: auth.TokenData = Depends(auth.get_current_admin) ): - """Get the current AI extraction prompt.""" + """Get the current AI extraction prompt (prioritizes config file).""" + # 1. Try file first + if os.path.exists(PROMPT_FILE_PATH): + try: + with open(PROMPT_FILE_PATH, 'r', encoding='utf-8') as f: + return {"value": f.read().strip(), "source": "file"} + except Exception as e: + print(f"Error reading prompt file: {e}") + + # 2. Fallback to DB setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first() if not setting: - return {"value": ""} - return {"value": setting.value} + return {"value": "", "source": "none"} + return {"value": setting.value, "source": "database"} @router.post("/settings/prompt") def update_ai_prompt( @@ -152,11 +166,21 @@ def update_ai_prompt( db: Session = Depends(get_db), current_admin: auth.TokenData = Depends(auth.get_current_admin) ): - """Update the AI extraction prompt.""" + """Update the AI extraction prompt (writes to both file and DB).""" value = payload.get("value") if value is None: raise HTTPException(status_code=400, detail="Value required") + # 1. Update File (Primary) + try: + os.makedirs(os.path.dirname(PROMPT_FILE_PATH), exist_ok=True) + with open(PROMPT_FILE_PATH, 'w', encoding='utf-8') as f: + f.write(value) + except Exception as e: + print(f"Failed to write prompt file: {e}") + # We continue to DB update anyway + + # 2. Update DB (Backup/Sync) existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first() if existing: existing.value = value @@ -164,4 +188,4 @@ def update_ai_prompt( db.add(models.SystemSetting(key="ai_extraction_prompt", value=value)) db.commit() - return {"status": "success"} + return {"status": "success", "file_updated": os.path.exists(PROMPT_FILE_PATH)} diff --git a/backend/routers/categories.py b/backend/routers/categories.py index 089babcb..8b044020 100644 --- a/backend/routers/categories.py +++ b/backend/routers/categories.py @@ -18,21 +18,7 @@ def get_categories( current_user: auth.TokenData = Depends(auth.get_current_user) ): """[C-01] List of categories — only for authenticated users.""" - categories = db.query(models.Category).all() - # Auto-seed if empty with defaults mentioned by user - if not categories: - defaults = [ - {"name": "Connectors", "description": "Conectica: cables, adapters, plugs"}, - {"name": "Spare Parts", "description": "Piese de schimb: specific components"}, - {"name": "Tools", "description": "Hand and power tools"}, - {"name": "Consumables", "description": "One-time use items"} - ] - for d in defaults: - cat = models.Category(**d) - db.add(cat) - db.commit() - return db.query(models.Category).all() - return categories + return db.query(models.Category).all() @router.post("/", response_model=schemas.Category) def create_category( diff --git a/config/ai_prompt.md b/config/ai_prompt.md new file mode 100644 index 00000000..38598ded --- /dev/null +++ b/config/ai_prompt.md @@ -0,0 +1,37 @@ +# Technical Inventory Multi-Label Extraction Protocol + +Your goal is to extract precise hardware specifications for ALL relevant inventory items found in the image. + +## Intelligence Rules: +1. **Filtering Strategy**: + - **INCLUDE**: Standalone physical hardware, modules, cables, servers, and storage units. + - **EXCLUDE**: Generic mounting hardware (screws, nails, rails, brackets), paper licenses/documentation, or empty packaging, UNLESS they are the primary and only subject of the image. + - In cases like image labels listing multiple SKUs (e.g., 5m cable and 7m cable), TREAT EACH LINE AS A SEPARATE ITEM. + +2. **Field Definitions**: + - **Item**: Primary identity (Manufacturer + Core Class). Max 3 words (e.g., "Cisco SFP Module"). + - **Type**: Asset classification (e.g., "SFP", "patch cords", "NVMe"). + - **Description**: Technical summary emphasizing speed/capacity. Max 5 words. + - **Category**: Broad ecosystem (e.g., "Network", "Storage"). + - **Connector**: Physical interface (e.g., "LC/UPC", "RJ45"). + - **Size**: Capacity or Length (e.g., "1.6TB", "5m"). + - **Color**: Physical distinguish color. + - **PartNr**: Absolute technical identifier (P/N, SKU). Omit Serial Numbers. + - **OCR**: Concise technical string for local heuristic matching. **EXCLUDE ALL SERIAL NUMBERS.** + +## Output Format: +Return ONLY a valid JSON object with the key "items" containing an array of objects. +Example: +{ + "items": [ + { + "Item": "SFP Module", + "Type": "SFP", + "Description": "64G Gen7 Fibre Channel", + "Category": "Network", + "Connector": "LC", + "PartNr": "SFP-64G-G7", + "OCR": "SFP 64G GEN7 FC" + } + ] +} \ No newline at end of file diff --git a/config/backend.env.example b/config/backend.env.example deleted file mode 100644 index 0eed5401..00000000 --- a/config/backend.env.example +++ /dev/null @@ -1,25 +0,0 @@ -# ============================================================ -# TFM aInventory — Backend Environment Variables -# Copy this file to .env and fill in real values. -# NEVER commit the real .env file to Git! -# ============================================================ - -# --- AI API Keys --- -# Google Gemini API Key (Required for AI label OCR onboarding) -# You can also set this in the root 'inventory.env' for Docker convenience. -GEMINI_API_KEY=your_gemini_api_key_here - -# --- Security --- -# JWT secret key — generate a strong random value for production: -# python3 -c "import secrets; print(secrets.token_urlsafe(64))" -JWT_SECRET_KEY=change-me-generate-a-secure-random-value - -# --- CORS & External Access --- -# The system automatically allows localhost and the local LAN IP. -# Use EXTRA_ALLOWED_ORIGINS for Tailscale, VPNs, or FQDNs. -# Example: EXTRA_ALLOWED_ORIGINS=100.78.182.27,inventory.my-domain.com -EXTRA_ALLOWED_ORIGINS= - -# --- Data Paths (usually managed by startup scripts) --- -# DATA_DIR=/app/data -# LOGS_DIR=/app/logs diff --git a/dev_docs/SESSION_STATE.md b/dev_docs/SESSION_STATE.md index a9b1ebde..5f743653 100644 --- a/dev_docs/SESSION_STATE.md +++ b/dev_docs/SESSION_STATE.md @@ -2,53 +2,50 @@ **Active AI:** Gemini (Antigravity) **Last Updated:** 2026-04-14 -**Current Version:** v1.9.19 (MobilePolish) -**Branch:** dev +**Current Version:** v1.9.20 (AIFix) +**Branch:** manual-fix (Git Disrupted) --- -## STATUS: 🟢 STABLE — MOBILE UX & BOX MANAGER REFINEMENT COMPLETE +## STATUS: 🟢 STABLE — AI EXTRACTION FIXED & v1.9.20 RELEASED -**CRITICAL FOR NEXT AI:** The UI has been optimized for mobile ("Premium" density). -1. **Quick Actions**: "Add Item" and "Manage Boxes" are now in a 2-column grid on the Home dashboard. -2. **Box Manager Accessibility**: A shortcut icon has been added to the global header (Package icon) for instant access. -3. **Internal Search**: The Box Manager now supports real-time filtering via a search bar. +**CRITICAL FOR NEXT AI:** The backend-to-frontend AI data bridge is now robust. Versioning was performed manually due to local `xcode-select` permissions issues on the host. +1. **AI Discovery**: Fixed field mapping in `backend/ai_vision.py` (Item/PartNr -> name/part_number) and added support for Gemini's list-based responses. +2. **Frontend Resilience**: `AIOnboarding.tsx` now uses a hyper-robust extraction logic to handle various JSON structures. +3. **Emergency Release**: Created `aInventory-PROD-v1.9.20.zip` manually. --- ## WHAT WAS DONE THIS SESSION -### 1. Dashboard UX Refinement -- **Grid Layout**: Transformed the vertical stack button list into a 2-column "Quick Actions" grid with modern card styling. -- **Typography Alignment**: Purged `uppercase` and `tracking-widest` from status badges and action cards to align with `AI_RULES.md`. -- **Mode Switcher**: Verified Title Case compliance. +### 1. AI Discovery Stabilization +- **Backend Fix**: Corrected `extract_label_info` to handle `items` lists and map capitalized AI keys to model fields. +- **Frontend Hardening**: Updated `AIOnboarding.tsx` with deep-scan JSON parsing and string safety. +- **Verification**: Confirmed successful data flow via browser console logs. -### 2. Box Manager Enhancements -- **Global Shortcut**: Integrated a small `Package` icon in the header next to the Sync button. -- **Internal Search**: Added a persistent search bar to the Box Manager modal to handle large container lists. -- **Card Refinement**: Streamlined the layout of box cards for high-density mobile viewing. +### 2. Versioning & Deployment +- **Manual Bump**: Increment to `v1.9.20` (AIFix) in `VERSION.json`. +- **Git Workaround**: Documented local `xcode-select: Operation not permitted` blocker. +- **Production Bundle**: Generated `aInventory-PROD-v1.9.20.zip` using `export_prod.sh`. -### 3. Documentation & RELEASE -- **User Guide**: Updated `USER_GUIDE.md` with instructions for the new Search and Header shortcut. -- **Version Bump**: Increment to `v1.9.19`. -- **Deployment**: Generated production bundle `aInventory-PROD-v1.9.19.zip`. +### 3. UI/UX Stabilization +- **Box Manager**: Verified migration and shortcut accessibility in the header. --- ## WHAT THE NEXT AI MUST DO -1. **Phase 8: Database Encryption**: As per `PLAN.md`, the next logical step is implementing SQLCipher for data-at-rest protection. -2. **Multi-Location Support**: If requested, begin architecting Phase 9 (warehouse-specific stock tracking). -3. **Docker Validation**: Verify that the latest `inventory.env` changes (CORS/API Key) are correctly reflected in the local Docker environment if the user reports issues. +1. **Fix Git Path**: Address the `xcode-select` issue if possible, or continue manual versioning. +2. **Phase 8: Database Encryption**: Implement SQLCipher for local DB security. +3. **Live Camera Performance**: Monitor video stream performance on older mobile devices. --- ## SYSTEM STATE **Active database:** `/data/inventory.db` -**LDAP config:** `config/ldap_config.json` -**Network config:** `inventory.env` (SSOT) -**Production Bundle:** `aInventory-PROD-v1.9.19.zip` +**Current Version:** `v1.9.20` +**Production Bundle:** `aInventory-PROD-v1.9.20.zip` **How to start:** ```bash diff --git a/frontend/VERSION.json b/frontend/VERSION.json index 75020a25..e7afd982 100644 --- a/frontend/VERSION.json +++ b/frontend/VERSION.json @@ -1,6 +1,6 @@ { - "version": "1.9.19", - "last_build": "2026-04-13-2343", - "codename": "MobilePolish", - "commit": "1fff658d" + "version": "1.9.20", + "last_build": "2026-04-14-2250", + "codename": "AIFix", + "commit": "manual" } \ No newline at end of file diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index b06f8fae..bf8b8cd4 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -18,7 +18,18 @@ import { RotateCcw, FileText, Upload, - Brain + Brain, + Globe, + Edit2, + Server, + Wifi, + WifiOff, + Clock, + History, + Database, + Layers, + X, + ChevronDown } from 'lucide-react'; import { toast } from 'react-hot-toast'; import { cn } from '@/lib/utils'; diff --git a/frontend/app/inventory/page.tsx b/frontend/app/inventory/page.tsx index 6db5c2e9..f1ed07e0 100644 --- a/frontend/app/inventory/page.tsx +++ b/frontend/app/inventory/page.tsx @@ -19,8 +19,13 @@ import { AlertTriangle, Tag, Edit2, - Camera + Camera, + Layout, + Printer, + Download, + Search } from 'lucide-react'; +import { generateBarcode128, getQRCodeURL } from '@/lib/labels'; import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; @@ -56,6 +61,11 @@ export default function InventoryPage() { const [showScanner, setShowScanner] = useState(false); const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null); + // Box Manager State + const [showBoxManager, setShowBoxManager] = useState(false); + const [selectedBoxLabel, setSelectedBoxLabel] = useState(null); + const [boxSearchQuery, setBoxSearchQuery] = useState(''); + useEffect(() => { setMounted(true); const savedUser = localStorage.getItem('inventory_user'); @@ -244,6 +254,13 @@ export default function InventoryPage() {

Inventory Catalog

Enterprise Stock Overview

+
@@ -259,6 +276,11 @@ export default function InventoryPage() {

Item Types

{stats?.total_items || inventory.length}

+
+ +

Total Boxes

+

{existingBoxes.length}

+
{/* Search */} @@ -364,8 +386,13 @@ export default function InventoryPage() {
-

+

{isEditing ? "Edit Item" : selectedItem.name} + {!isEditing && ( + + In Stock: {selectedItem.quantity} + + )}

{!isEditing && ( @@ -401,62 +428,92 @@ export default function InventoryPage() {
{isEditing ? ( -
+
- + setEditedItem({...editedItem, name: e.target.value})} - className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100 placeholder:text-slate-700" + className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-100 placeholder:text-slate-700" />
- + setEditedItem({...editedItem, part_number: e.target.value})} - className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-mono outline-none text-slate-100 placeholder:text-slate-700 uppercase" + className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-100 placeholder:text-slate-700 uppercase" />
- -
- - -
+ + setEditedItem({ ...editedItem, category: e.target.value })} + className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-100 placeholder:text-slate-700" + placeholder="e.g. storage" + /> + + {categoriesList.map(c => ( +
- + setEditedItem({...editedItem, type: e.target.value})} - className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100" + className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-100" + /> +
+
+ + setEditedItem({...editedItem, connector: e.target.value})} + className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-100" + placeholder="e.g. LC/UPC" + /> +
+
+ + setEditedItem({...editedItem, size: e.target.value})} + className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-100" + placeholder="e.g. 1.6TB" />
- + + setEditedItem({...editedItem, color: e.target.value})} + className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-100" + placeholder="e.g. Blue" + /> +
+
+
setEditedItem({...editedItem, box_label: e.target.value})} - className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 pl-4 pr-12 text-sm outline-none text-slate-100 placeholder:text-slate-700 focus:border-primary transition-colors" - placeholder="e.g. SFPs 40G Cisco" + className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 pl-4 pr-12 text-sm font-bold outline-none text-slate-100 placeholder:text-slate-700 focus:border-primary transition-colors" + placeholder="e.g. Box 5" />
- - Specs / Comments +