diff --git a/.env.example b/.env.example index d3db4b4..7e76689 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,9 @@ GOOGLE_MODEL_NAME=gemini-2.0-flash OLLAMA_BASE_URL=http://localhost:11434 # Pentru rulare din interiorul Docker (docker-compose run --rm app pytest): # OLLAMA_BASE_URL=http://ollama:11434 + +# ─── Modele AI Locale ───────────────────────────────────────────────────────── +# Aceste modele se creaza automat prin docker-compose up sau manual: +# ck-model = gemma2:2b (clasificare + decizie) +# ck-extractor = gemma2:2b (extragere informatii) +# ck-vision = llava:7b (identificare vizuala imagini) diff --git a/README.md b/README.md index 2184e79..276ea9f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ ClutterKill is a multi-agent desktop application for automated file organization using local AI. +> **Cross-Platform:** Această aplicație este construită în Python și PyQt6, ceea ce o face 100% **Cross-Platform**. Funcționează perfect pe macOS, Windows și Linux, atâta timp cât ai Python instalat. Mici diferențe pot apărea la instalarea dependențelor de sistem (ex. Tesseract OCR). + # User Stories ## US 1 (Selecție foldere): @@ -62,18 +64,22 @@ ClutterKill/ │ ├── 📂 ai/ # MODULUL AI (Inteligența Aplicației) │ ├── 📄 __init__.py -│ ├── 📄 Modelfile # Definiția modelului Gemma 2:2b (temperature 0.1) -│ ├── 📄 llm_config.py # Conexiunea LangChain cu localhost:11434 -│ ├── 📄 tools.py # Funcții: extract_text_from_pdf, extract_text_from_image +│ ├── 📄 Modelfile # Definiția modelului Gemma 2:2b (Agent 0 & 2 - Clasificare) +│ ├── 📄 Modelfile.extractor # Definiția modelului Gemma 2:2b (Agent 1 - Extragere) +│ ├── 📄 Modelfile.vision # Definiția modelului LLaVA 7B (Vision - Analiză vizuală imagini) +│ ├── 📄 llm_config.py # Factory + configurare LLM (Ollama / Google) +│ ├── 📄 tools.py # Funcții: extract_text_from_pdf, extract_text_from_image, extract_text_from_docx +│ ├── 📄 vision_tools.py # Modul Vision AI: describe_image() — trimite poze la LLaVA pentru identificare │ ├── 📄 agent_compiler.py # AGENT 0: Traduce promptul natural în JSON (Reguli) -│ ├── 📄 agent_extractor.py # AGENT 1: Rezumă fișierul fizic +│ ├── 📄 agent_extractor.py # AGENT 1: Rezumă fișierul fizic (chain-of-thought extraction) │ └── 📄 agent_decider.py # AGENT 2: Combină Agent 0 cu Agent 1 și ia decizia finală │ ├── 📂 core/ # LOGICA BACKEND (Sistemul de operare) │ ├── 📄 __init__.py │ ├── 📄 file_manager.py # Mutare, redenumire (cross-platform cu pathlib) │ ├── 📄 undo_manager.py # Logica pentru stiva de Undo (ultimele 50 acțiuni) -│ └── 📄 quarantine_db.py # Baza de date SQLite pentru fișierele nesigure +│ ├── 📄 quarantine_db.py # Baza de date SQLite pentru fișierele nesigure +│ └── 📄 scan_worker.py # QThread: pipeline-ul complet de scanare (Vision + Extragere + Decizie) │ ├── 📂 ui/ # INTERFAȚA GRAFICĂ (PyQt6) │ ├── 📄 __init__.py @@ -92,6 +98,28 @@ ClutterKill/ └── test_agents.py # Verifică dacă Agenții 0 și 2 scot JSON valid ``` +## 🧠 AI Model Registry + +ClutterKill folosește **4 modele AI locale** rulând prin Ollama în Docker: + +| Model | Bază | Scop | Fișier Config | Mărime | +|-------|------|------|---------------|--------| +| `ck-model` | `gemma2:2b` | Agent 0 (Compiler) + Agent 2 (Decider) — clasificare și decizie | `ai/Modelfile` | ~1.6GB | +| `ck-extractor` | `gemma2:2b` | Agent 1 (Extractor) — rezumat tehnic documente | `ai/Modelfile.extractor` | ~1.6GB | +| `ck-vision` | `llava:7b` | Vision AI — identificare vizuală imagini (dog, cat, sign...) | `ai/Modelfile.vision` | ~4.5GB | +| `gemma2:2b` | - | Model de bază (descărcat automat) | - | ~1.6GB | +| `llava:7b` | - | Model de bază multimodal (descărcat automat) | - | ~4.5GB | + +### Pipeline de procesare imagini + +```text +📷 Imagine (.jpg/.png) → Vision AI (ck-vision / llava:7b) → "dog" + → OCR (Tesseract) → text extras + → Combinate în rezumat → Decizie: Dog.jpeg +``` + +Pentru **documente** (PDF, DOCX, TXT) se folosește doar pipeline-ul text clasic (fără Vision AI). + ## 📁 Directory Architecture The project follows a structured modular architecture: @@ -116,6 +144,14 @@ The project's Python dependencies are listed in `requirements.txt`, which includ - **Ruff**: For extremely fast Python linting and code formatting. ### How to Install: + +**1. System Dependencies (OCR)** +To process images (PNG, JPG), you must install Tesseract OCR on your host machine: +- **macOS**: `brew install tesseract tesseract-lang` +- **Windows**: Download the installer from [UB-Mannheim](https://github.com/UB-Mannheim/tesseract/wiki) +- **Linux (Ubuntu/Debian)**: `sudo apt-get install tesseract-ocr tesseract-ocr-ron` + +**2. Python Dependencies** To set up your environment, install the dependencies using `pip`: ```bash pip install -r requirements.txt @@ -155,19 +191,23 @@ The application leverages Docker to seamlessly run local AI models without compl docker-compose up -d ollama ``` -3. **Pull base model and Create Custom AI Models**: - ClutterKill uses two distinct models for processing (Classifier and Extractor): +3. **Pull base models and Create Custom AI Models**: + ClutterKill uses three distinct custom models: ```bash - # Pull the base model (Wait for the download to finish) + # Pull the base models (Wait for each download to finish) docker exec -it clutterkill_ollama ollama pull gemma2:2b - # Note: If you get a 'manifest does not exist' error on older machines, use 'gemma:2b' instead and update the Modelfiles. + docker exec -it clutterkill_ollama ollama pull llava:7b - # Create Agent 0 & 2 (Classifier) + # Create Agent 0 & 2 (Classifier / Decider) docker exec -it clutterkill_ollama ollama create ck-model -f /app/ai/Modelfile # Create Agent 1 (Extractor) docker exec -it clutterkill_ollama ollama create ck-extractor -f /app/ai/Modelfile.extractor + + # Create Vision AI (Image identification) + docker exec -it clutterkill_ollama ollama create ck-vision -f /app/ai/Modelfile.vision ``` + > **Note**: If you get a 'manifest does not exist' error on older machines for gemma2:2b, use 'gemma:2b' instead and update the Modelfiles. 4. **Verify the models are running**: ```bash @@ -175,12 +215,23 @@ The application leverages Docker to seamlessly run local AI models without compl ``` 5. **Run the Application**: - Activate your virtual environment and run the graphical interface: + Cea mai simplă metodă de a rula aplicația este folosind scriptul automat `start.sh`. Acesta va crea mediul virtual, va instala toate dependențele lipsă și va porni aplicația cu o singură comandă: + ```bash + ./start.sh + ``` + *Alternativ (manual):* ```bash source .venv/bin/activate python main.py ``` +## 🌟 Changelog / Update-uri Recente +- **UI Revamp (Dark Theme Premium):** Interfața a primit un redesign complet bazat pe paleta Catppuccin Macchiato. Colțuri rotunjite, butoane colorate vibrant și efecte subtile. +- **Visual Builder Funcțional:** Tab-ul "Rules" suportă acum șabloane stricte (Templates). A fost adăugată o paletă de butoane "Click-to-Insert" pentru a introduce automat variabile matematice (ex: `[An]`, `[Luna]`) fără a le tasta manual. +- **Simplificare Arhitectură Directoare (Flattened Output):** La cererea utilizatorilor, fișierele redenumite de AI nu mai sunt forțate în sub-foldere. Ele sunt exportate direct în root-ul folderului destinație ales de tine. +- **Reparare ExtractorAgent (Bug-ul "Strip"):** Rezolvată o problemă critică cauzată de noul SDK Google V2 care bloca parsarea imaginilor aruncând toate rezultatele direct în carantină. +- **Rate-Limiting Gemini V2:** Adăugat mecanism automat de sleep (15 secunde) atunci când API-ul gratuit Google atinge limitele HTTP 429. + *Note: The project configuration also provides environment variables (`AI_PROVIDER`, `GOOGLE_API_KEY`) to easily switch between local `ollama` processing and cloud-based alternatives like `google`.* For more detailed DevOps and QA instructions, please refer to [README_ingineri.md](README_ingineri.md). diff --git a/README_ingineri.md b/README_ingineri.md index 21d39e7..480fb51 100644 --- a/README_ingineri.md +++ b/README_ingineri.md @@ -36,45 +36,87 @@ docker-compose down -v docker-compose up -d ``` -## 2. Configurarea Modelului Local (Gemma 2:2b) +## 2. Configurarea Modelelor Locale -Am creat fișierul `ai/Modelfile` bazat pe modelul `gemma2:2b`. -Acest fișier setează: -- **`temperature 0.1`**: Garantează răspunsuri precise, deterministe și lipsite de halucinații, ideale pentru clasificarea exactă a fișierelor. -- **`SYSTEM prompt`**: Forțează AI-ul să dea doar rezultate scurte și la obiect, fără conversații introductive inutile. +ClutterKill folosește **3 modele AI personalizate** + 2 modele de bază: -### Crearea și încărcarea modelului personalizat: -Odată ce containerul de Ollama rulează (pasul 1), executați următoarea comandă pentru a crea modelul `ck-model`: +### 2.1 Modelul de Clasificare — `ck-model` (Agent 0 & 2) + +Bazat pe `gemma2:2b`. Fișierul `ai/Modelfile` setează: +- **`temperature 0.1`**: Răspunsuri precise, deterministe. +- **`SYSTEM prompt`**: Forțează AI-ul să returneze doar JSON structurat. ```bash docker exec -it clutterkill_ollama ollama create ck-model -f /app/ai/Modelfile ``` -## 3. Verificarea Modelului +### 2.2 Modelul de Extragere — `ck-extractor` (Agent 1) + +Bazat pe `gemma2:2b`. Fișierul `ai/Modelfile.extractor` setează un prompt specializat pentru extragerea de entități din documente (Emitent, Dată, Sumă, Tip). -Pentru a verifica dacă modelul a fost compilat cu succes și este recunoscut de API-ul Ollama, executați: ```bash -curl http://localhost:11434/api/tags +docker exec -it clutterkill_ollama ollama create ck-extractor -f /app/ai/Modelfile.extractor +``` + +### 2.3 Modelul Vision — `ck-vision` (Identificare Imagini) + +Bazat pe **`llava:7b`** (~4.5GB). Acesta este un model **multimodal** capabil să "vadă" imagini și să identifice subiectul principal. + +**Ce face:** +- Primește o imagine (JPEG/PNG/BMP) codificată în base64 +- Returnează **un singur cuvânt** care descrie subiectul principal (ex: `dog`, `cat`, `sign`, `car`) +- Folosit doar pentru fișiere imagine, nu pentru PDF/DOCX + +**Fișier configurare:** `ai/Modelfile.vision` +``` +FROM llava:7b +PARAMETER temperature 0.1 +SYSTEM "You identify images. Reply with ONE word only..." +``` + +**Setup:** +```bash +# 1. Pull modelul de bază llava:7b (~4.5GB, durează câteva minute) +docker exec -it clutterkill_ollama ollama pull llava:7b + +# 2. Creează modelul personalizat ck-vision +docker exec -it clutterkill_ollama ollama create ck-vision -f /app/ai/Modelfile.vision ``` -Rezultatul (în format JSON) trebuie să includă modelul `ck-model` și modelul de bază `gemma2:2b`. -## 4. Testarea/Apelarea Modelului +**Modul Python:** `ai/vision_tools.py` — funcția `describe_image(path)` gestionează: +- Codificarea imaginii în base64 +- Detectarea MIME type (jpeg, png, bmp) +- Trimiterea către Ollama (local) sau Google Gemini (cloud) în funcție de `AI_PROVIDER` +- Returnarea descrierii ca string -Pentru a testa capacitățile modelului direct din terminal, fără a rula interfața grafică a aplicației, aveți două variante: +**Note de performanță:** +- Prima inferență durează ~30-60s (încărcare model în RAM) +- Inferențele următoare: ~5-15s per imagine +- Necesită minim 8GB RAM disponibil -### Varianta A: Apel prin REST API +## 3. Verificarea Modelelor + +Pentru a verifica dacă toate modelele au fost create cu succes: ```bash -curl -X POST http://localhost:11434/api/generate -d '{ - "model": "ck-model", - "prompt": "Clasifică documentul: Curs_MDS_Sem2.pdf" -}' +curl http://localhost:11434/api/tags ``` +Rezultatul (JSON) trebuie să includă: `ck-model`, `ck-extractor`, `ck-vision`, `gemma2:2b`, `llava:7b`. -### Varianta B: Apel direct prin CLI (Ollama Run) +## 4. Testarea/Apelarea Modelelor + +Pentru a testa capacitățile modelelor direct din terminal: + +### Varianta A: Test clasificare (ck-model) ```bash docker exec -it clutterkill_ollama ollama run ck-model "Clasifică documentul: Curs_MDS_Sem2.pdf" ``` +### Varianta B: Test Vision (ck-vision) - direct din CLI +```bash +# ck-vision nu poate fi testat direct din CLI fără imagine. +# Folosiți scriptul Python pentru test complet (Varianta C). +``` + ### Varianta C: Apel prin Python (citind PDF-ul fals generat) Aceasta este metoda recomandată pentru a testa ecosistemul real (PyMuPDF extrage textul -> LangChain îl trimite către Ollama). diff --git a/ai/Modelfile.vision b/ai/Modelfile.vision new file mode 100644 index 0000000..9a4f1e8 --- /dev/null +++ b/ai/Modelfile.vision @@ -0,0 +1,5 @@ +FROM llava:7b +PARAMETER temperature 0.1 +SYSTEM """ +You identify images. Reply with ONE word only: the main subject. Examples: dog, cat, sign, car, mountain, person, food, building. +""" diff --git a/ai/agent_compiler.py b/ai/agent_compiler.py index 8327226..9c09102 100644 --- a/ai/agent_compiler.py +++ b/ai/agent_compiler.py @@ -53,7 +53,9 @@ class CompiledRule(BaseModel): User instruction: "{user_prompt}" Extract the category, folder structure, and naming convention. -If the naming convention is not explicitly stated, use a default placeholder like "{{original_filename}}" or infer a sensible one if the context implies it. +CRITICAL RULES FOR EXTRACTION: +1. CATEGORY: If the user instruction applies to ALL files without filtering (e.g. "organize them", "rename them", "toate", "toate pozele", "orice"), set category to "any". Otherwise, extract the specific category (e.g. "factura"). +2. NAMING CONVENTION: If the user explicitly asks to give files suggestive names, new names, or rename them (e.g. "da le nume sugestive", "redenumeste-le", "nume dupa continut", "nume sugestiv"), you MUST set naming_convention to "descriptive_name_based_on_content". If they do not ask for renames, set it to "{{original_filename}}". CRITICAL: You must return ONLY the raw JSON object containing the ACTUAL values based on the user instruction. Do NOT return a JSON schema. Do NOT return properties definitions. DO NOT echo back the format instructions. @@ -112,9 +114,21 @@ def compile(self, user_prompt: str) -> CompiledRule: """ logger.info("CompilerAgent: compiling prompt: '%s'", user_prompt) try: - # invoke the chain which formats the prompt, calls LLM, and parses output - result = self._chain.invoke({"user_prompt": user_prompt}) - return result + import time + + max_attempts = 3 + for attempt in range(max_attempts): + try: + result = self._chain.invoke({"user_prompt": user_prompt}) + return result + except Exception as loop_e: + if "429" in str(loop_e) and attempt < max_attempts - 1: + logger.warning( + f"API Rate Limit Hit (429) in Compiler. Sleeping 15s... (Attempt {attempt + 1}/{max_attempts})" + ) + time.sleep(15) + else: + raise loop_e except Exception as e: logger.error("Failed to compile rule: %s", e) raise diff --git a/ai/agent_decider.py b/ai/agent_decider.py index f13f2d7..0c62370 100644 --- a/ai/agent_decider.py +++ b/ai/agent_decider.py @@ -58,13 +58,13 @@ def sanitize_filename(cls, v: str) -> str: _SYSTEM_PROMPT_TEMPLATE = """\ You are an expert decision-making agent for the ClutterKill system. -Your job is to analyze a document summary and a set of organization rules, and decide if the document should be moved to the correct folder or placed in quarantine. +Your job is to analyze a document summary and decide the exact folder and filename based strictly on the provided templates. {format_instructions} -Rule Category: {rule_category} -Target Folder: {rule_folder} -Naming Convention: {rule_naming} +Preset Query/Intent: {rule_category} +Target Folder Template: {rule_folder} +Naming Convention Template: {rule_naming} Document Summary: {document_summary} @@ -72,19 +72,22 @@ def sanitize_filename(cls, v: str) -> str: Original Filename: {original_filename} Instructions: -1. If the Document Summary MATCHES the Rule Category, your status must be "move". -2. If it DOES NOT match, or if you are unsure, your status must be "quarantine". -3. Build the new filename using the Naming Convention as a TEMPLATE: - - The Naming Convention may contain camelCase or descriptive placeholder words like "abreviereaMateriei", "NumarulCursului", "Data", "Emitent", "Suma", etc. - - YOU MUST extract the actual values from the Document Summary and substitute them into each placeholder. - - Example: If Naming Convention is "abreviereaMateriei_Curs_NumarulCursului_Data" and the document is about "Algoritmi Avansati, Cursul 4, 01.01.2026", the result must be "AlgoritmiAvansati_Curs_4_01012026". - - If a placeholder value cannot be determined from the Document Summary, use a sensible short abbreviation (e.g. "Unknown"). - - If the Naming Convention is literally "{{original_filename}}", keep the original filename unchanged. -4. CRITICAL: The new filename MUST keep the exact same file extension as the Original Filename (e.g. .pdf, .docx). -5. CRITICAL: Do NOT include spaces in the filename. Use underscores (_) instead. -6. If the status is "quarantine", the folder must be "Quarantine". - -CRITICAL: You must return ONLY the raw JSON object containing the ACTUAL values based on your decision. Do NOT return a JSON schema. Do NOT return properties definitions. DO NOT echo back the format instructions. +1. CHECK PRESET QUERY FIRST: Does the Document Summary match the Preset Query/Intent? + - The query might be broad (e.g., "Toate fișierele", "Pozele mele", "Orice imagine"). + - If YES, or if you are UNSURE but it doesn't explicitly contradict the query -> status "move". + - ONLY if the Document explicitly contradicts the query (e.g., query is "Facturi emag" but the document is a "Poza cu un caine") -> status "quarantine". +3. Build the TARGET FOLDER: + - The user wants all files to go directly into the main output folder (Flattened structure). + - Therefore, YOU MUST output exactly "." (a single dot) for the target folder. +4. Build the NEW FILENAME using the Naming Convention Template: + - Similarly, replace ALL bracketed variables (e.g. `[Emitent]`, `[SubiectAI]`) with extracted/deduced values. + - Example: `[An]_[Emitent]_[SubiectAI]` -> `2023_eMAG_Laptop_Gaming`. + - If the template is literally "{{original_filename}}", keep the original filename. +5. CRITICAL: The new filename MUST keep the exact same file extension as the Original Filename (e.g. .pdf, .docx, .jpeg). +6. CRITICAL: Do NOT include spaces in the filename or folder name. Use underscores (_) or CamelCase instead. +7. If the status is "quarantine", the folder must be exactly "Quarantine". + +CRITICAL: You must return ONLY the raw JSON object containing the ACTUAL evaluated folder and filename based on the templates. """ _REPAIR_PROMPT = ChatPromptTemplate.from_messages( @@ -162,15 +165,29 @@ def decide( rule.category, ) - raw_output = self._chain.invoke( - { - "rule_category": rule.category, - "rule_folder": rule.folder_structure, - "rule_naming": rule.naming_convention, - "document_summary": summary, - "original_filename": original_filename, - } - ) + import time + + max_attempts = 3 + for attempt in range(max_attempts): + try: + raw_output = self._chain.invoke( + { + "rule_category": rule.category, + "rule_folder": rule.folder_structure, + "rule_naming": rule.naming_convention, + "document_summary": summary, + "original_filename": original_filename, + } + ) + break + except Exception as e: + if "429" in str(e) and attempt < max_attempts - 1: + logger.warning( + f"API Rate Limit Hit (429) in Decider. Sleeping 15s... (Attempt {attempt + 1}/{max_attempts})" + ) + time.sleep(15) + else: + raise e last_error: Exception | None = None current_output = raw_output diff --git a/ai/agent_extractor.py b/ai/agent_extractor.py index 477d72b..4f8ca9e 100644 --- a/ai/agent_extractor.py +++ b/ai/agent_extractor.py @@ -24,6 +24,7 @@ import json import logging +import time from typing import Any from langchain_core.output_parsers import StrOutputParser @@ -49,7 +50,7 @@ class ExtractedEntity(BaseModel): field_name: str = Field( ..., description="Canonical field name (e.g. 'full_name', 'date_of_birth')." ) - value: str = Field(..., description="Extracted value exactly as it appears.") + value: Any = Field(..., description="Extracted value exactly as it appears.") confidence: float = Field( default=1.0, ge=0.0, @@ -71,16 +72,16 @@ class ExtractionResult(BaseModel): ) summary: str = Field( default="", - max_length=200, - description="Rezumat tehnic (Emitent, Dată, Sumă, Tip) de maxim 200 caractere.", + max_length=500, + description="Dense 1-2 sentence summary containing key details.", + ) + suggested_filename: str = Field( + default="", + description="A concise 2-3 word filename without any file extension.", ) entities: list[ExtractedEntity] = Field( default_factory=list, description="All extracted entities." ) - raw_thinking: str = Field( - default="", - description="The agent's chain-of-thought reasoning (kept for audit).", - ) def get_technical_summary(self) -> str: """Returnează STRICT rezumatul tehnic de maxim 200 de caractere (Task 9).""" @@ -92,51 +93,29 @@ def get_technical_summary(self) -> str: # ===================================================================== _SYSTEM_PROMPT = """\ -You are an expert data-extraction agent for the ClutterKill system. -Your job is to extract structured information from raw document text -that may come from OCR (noisy, misspelled, broken formatting). - -IMPORTANT: You are a THINKING agent. Before extracting, you MUST -reason step-by-step about the document. - -Follow this exact protocol: - -### Step 1 — IDENTIFY -Determine the document type (invoice, ID card, contract, medical -record, correspondence, etc.). State your reasoning. +You are a fast, precise data-extraction agent. +Extract structured information from raw document text into JSON. +Do NOT output any reasoning, explanations, or conversational filler. -### Step 2 — PLAN -List the fields you expect to find for this document type. -Mention any fields that are likely missing or corrupted. - -### Step 3 — EXTRACT -For each field, extract the value. If the text is ambiguous, -state the ambiguity and pick the most likely interpretation. -Assign a confidence score (0.0–1.0) to each extraction. - -### Step 4 — OUTPUT -Return your answer as a single JSON object with this schema: +Return your answer as a single JSON object with this exact schema: {{ - "thinking": "", - "document_type": "", - "summary": "", + "document_type": "", + "summary": "", + "suggested_filename": "", "entities": [ {{ "field_name": "", "value": "", "confidence": <0.0-1.0>, - "reasoning": "" + "reasoning": "<1 sentence why you chose this>" }} ] }} Rules: -- Output ONLY the JSON object, no markdown fences, no extra text. -- Use snake_case for field_name values. -- If a field is completely unreadable, set confidence to 0.0 and - value to "UNREADABLE". -- Preserve original values exactly — do NOT normalize dates or names - unless they are clearly OCR errors. +- Output ONLY valid JSON. +- No markdown fences. +- If unreadable, set value to "N/A" and confidence 0.0. """ _EXTRACTION_PROMPT = ChatPromptTemplate.from_messages( @@ -212,7 +191,36 @@ def extract(self, document_text: str) -> ExtractionResult: "ExtractorAgent: starting extraction (%d chars)", len(document_text) ) - raw_output = self._chain.invoke({"document_text": document_text}) + # Create prompt + prompt_val = _EXTRACTION_PROMPT.format_messages(document_text=document_text) + + # Retry logic for 429 Resource Exhausted (Free Tier RPM Limits) + max_attempts = 3 + for attempt in range(max_attempts): + try: + response = self._llm.invoke(prompt_val) + raw_output = response.content + if isinstance(raw_output, list): + raw_output = " ".join( + [ + str(part.get("text", part)) + if isinstance(part, dict) + else str(part) + for part in raw_output + ] + ) + elif not isinstance(raw_output, str): + raw_output = str(raw_output) + break + except Exception as e: + if "429" in str(e) and attempt < max_attempts - 1: + logger.warning( + f"API Rate Limit Hit (429) in Extractor. Sleeping 15s... (Attempt {attempt + 1}/{max_attempts})" + ) + time.sleep(15) + else: + raise e + logger.debug("Raw LLM output:\n%s", raw_output) # Try to parse → validate → retry loop @@ -276,8 +284,8 @@ def _validate(data: dict) -> ExtractionResult: return ExtractionResult( document_type=data.get("document_type", "unknown"), summary=data.get("summary", ""), + suggested_filename=data.get("suggested_filename", ""), entities=entities, - raw_thinking=data.get("thinking", ""), ) @@ -309,7 +317,6 @@ class ExtractionError(RuntimeError): print(f"\n{'=' * 60}") print(f"Document type : {result.document_type}") print(f"Summary : {result.summary}") - print(f"Thinking : {result.raw_thinking[:200]}…") print(f"{'=' * 60}") for ent in result.entities: print(f" {ent.field_name:20s} = {ent.value:30s} (conf: {ent.confidence:.1f})") diff --git a/ai/llm_config.py b/ai/llm_config.py index 7b9f723..abf0a0f 100644 --- a/ai/llm_config.py +++ b/ai/llm_config.py @@ -44,9 +44,9 @@ # ─── Defaults (match docker-compose.yml & .env.example) ───────────── _DEFAULT_PROVIDER = "ollama" _DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434" -_DEFAULT_REQUEST_TIMEOUT = 120.0 # seconds — OCR docs can be big +_DEFAULT_REQUEST_TIMEOUT = 300.0 # seconds — CPU inference can take minutes -_DEFAULT_GOOGLE_MODEL = "gemini-2.0-flash" +_DEFAULT_GOOGLE_MODEL = "gemini-flash-lite-latest" # Model registry — one entry per Modelfile MODEL_CLASSIFIER = "ck-model" # ai/Modelfile diff --git a/ai/tools.py b/ai/tools.py index 4cdb019..0d50bfc 100644 --- a/ai/tools.py +++ b/ai/tools.py @@ -34,7 +34,7 @@ break -def extract_text_from_pdf(path: Union[str, Path], max_pages: int = 10) -> str: +def extract_text_from_pdf(path: Union[str, Path], max_pages: int = 2) -> str: """ Extrage textul dintr-un fișier PDF folosind PyMuPDF. @@ -43,7 +43,7 @@ def extract_text_from_pdf(path: Union[str, Path], max_pages: int = 10) -> str: Args: path: Calea către fișierul PDF. - max_pages: Numărul maxim de pagini de citit (implicit: 10). + max_pages: Numărul maxim de pagini de citit (implicit: 2). Returns: Textul extras din PDF ca un singur string. Returnează un string gol în caz de eroare. diff --git a/ai/vision_tools.py b/ai/vision_tools.py new file mode 100644 index 0000000..4a95768 --- /dev/null +++ b/ai/vision_tools.py @@ -0,0 +1,137 @@ +""" +Vision Tools — ai/vision_tools.py + +Modul de analiză vizuală a imaginilor folosind modele multimodale (Vision AI). + +Arhitectură +─────────── + imagine.jpg ──► _encode_image() ──► base64 string + │ + describe_image() + │ + ┌─────┴─────┐ + │ Ollama │ sau │ Google │ + │ ck-vision │ │ Gemini │ + │ (llava:7b)│ │ Flash │ + └─────┬─────┘ └────┬─────┘ + │ │ + "dog" "dog" + +Modelul Vision (ck-vision) este bazat pe llava:7b (~4.5GB) și returnează +UN SINGUR CUVÂNT care identifică subiectul principal al imaginii. + +Provider-ul se selectează prin variabila de mediu AI_PROVIDER: + - "ollama" (default): folosește ck-vision local prin Docker + - "google": folosește Google Gemini multimodal (necesită GOOGLE_API_KEY) + +Utilizare: + from ai.vision_tools import describe_image + result = describe_image("/path/to/photo.jpg") # → "dog" + +Dependințe: + - Ollama container cu modelul ck-vision creat: + docker exec -it clutterkill_ollama ollama create ck-vision -f /app/ai/Modelfile.vision + - SAU GOOGLE_API_KEY setat în .env cu AI_PROVIDER=google +""" + +import base64 +import logging +import os +from pathlib import Path +from typing import Union + +from langchain_core.messages import HumanMessage +from langchain_ollama import ChatOllama + +logger = logging.getLogger(__name__) + +# Replicăm defaulturile din llm_config pentru a fi safe, sau putem importa +_DEFAULT_PROVIDER = "ollama" +_DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434" +_DEFAULT_GOOGLE_MODEL = "gemini-flash-lite-latest" + + +def _encode_image(image_path: Path) -> str: + with open(image_path, "rb") as image_file: + return base64.b64encode(image_file.read()).decode("utf-8") + + +def describe_image(path: Union[str, Path]) -> str: + """ + Trimite imaginea către un model Multimodal (Vision) pentru a obține o descriere + vizuală a obiectelor sau textului prezent în poză. + """ + file_path = Path(path) + if not file_path.exists(): + logger.error(f"Imaginea nu a fost găsită: {file_path}") + return "" + + try: + base64_image = _encode_image(file_path) + ext = file_path.suffix.lower() + mime_type = "image/jpeg" + if ext == ".png": + mime_type = "image/png" + elif ext == ".bmp": + mime_type = "image/bmp" + + provider = os.getenv("AI_PROVIDER", _DEFAULT_PROVIDER).lower() + + message = HumanMessage( + content=[ + { + "type": "text", + "text": "Analyze this image and suggest a highly descriptive, clean filename for it. Use PascalCase or underscores (e.g. Dog_Playing_Park.jpeg, Vodafone_Invoice_Summary.png, Python_Code_Screenshot.png). Reply ONLY with the exact filename.", + }, + { + "type": "image_url", + "image_url": {"url": f"data:{mime_type};base64,{base64_image}"}, + }, + ] + ) + + if provider == "google": + from google import genai + import time + + api_key = os.getenv("GOOGLE_API_KEY") + client = genai.Client(api_key=api_key) + google_model = os.getenv("GOOGLE_MODEL_NAME", _DEFAULT_GOOGLE_MODEL) + + prompt = "Analyze this image and suggest a concise, 2-3 word descriptive filename for it. Use PascalCase. DO NOT INCLUDE ANY EXTENSION like .jpeg or .png in your reply. Example: Gray_Cottage, Rural_Stop_Sign, Yellow_Corvette. Reply ONLY with the exact name." + + from PIL import Image + + img = Image.open(file_path) + + try: + response = client.models.generate_content( + model=google_model, contents=[prompt, img] + ) + return response.text.strip() + except Exception as e: + if "429" in str(e): + logger.warning( + "API Rate Limit Hit (429). Sleeping 15 seconds and retrying..." + ) + time.sleep(15) + response = client.models.generate_content( + model=google_model, contents=[prompt, img] + ) + return response.text.strip() + else: + raise e + + elif provider == "ollama": + base_url = os.getenv("OLLAMA_BASE_URL", _DEFAULT_OLLAMA_BASE_URL) + ollama_llm = ChatOllama( + model="ck-vision", base_url=base_url, temperature=0.1 + ) + response = ollama_llm.invoke([message]) + return str(response.content).strip() + + return "" + + except Exception as e: + logger.error(f"Eroare la procesarea Vision AI pentru {file_path}: {e}") + return f"Eroare Vision AI: {e}" diff --git a/core/file_manager.py b/core/file_manager.py index 889673b..ef49718 100644 --- a/core/file_manager.py +++ b/core/file_manager.py @@ -32,6 +32,15 @@ def move_and_rename_file( # The final target path target_path = dest_path / new_name + # Prevent overwriting existing files with the same name (e.g. Dog.jpeg, Dog_1.jpeg) + if target_path.exists(): + base_name = target_path.stem + ext = target_path.suffix + counter = 1 + while target_path.exists(): + target_path = dest_path / f"{base_name}_{counter}{ext}" + counter += 1 + # Move the file physically shutil.move(str(src_path), str(target_path)) diff --git a/core/quarantine_db.py b/core/quarantine_db.py index 56d9be1..8326676 100644 --- a/core/quarantine_db.py +++ b/core/quarantine_db.py @@ -1,9 +1,7 @@ import sqlite3 from pathlib import Path -from typing import Optional +from typing import Optional, Any - -# Fișierul .db se creează în rădăcina proiectului (lângă main.py) DEFAULT_DB_PATH = Path(__file__).parent.parent / "quarantine.db" @@ -89,7 +87,7 @@ def add( (original_path, ai_proposed_name, ai_proposed_folder, reason), ) conn.commit() - return cursor.lastrowid + return cursor.lastrowid or 0 finally: conn.close() @@ -140,7 +138,7 @@ def update( """ # Construim dinamic query-ul doar cu câmpurile care se schimbă fields = [] - values = [] + values: list[Any] = [] if ai_proposed_name is not None: fields.append("ai_proposed_name = ?") diff --git a/core/rules_db.py b/core/rules_db.py new file mode 100644 index 0000000..51fcb4b --- /dev/null +++ b/core/rules_db.py @@ -0,0 +1,104 @@ +import sqlite3 +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +DB_PATH = Path("rules.db") + + +def init_db(): + """Inițializează baza de date pentru șabloanele AI.""" + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS saved_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + query TEXT NOT NULL, + folder_template TEXT NOT NULL, + naming_template TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Insert a default rule only if the table is empty + cursor.execute("SELECT COUNT(*) FROM saved_rules") + if cursor.fetchone()[0] == 0: + cursor.execute( + """ + INSERT INTO saved_rules (name, query, folder_template, naming_template) + VALUES (?, ?, ?, ?) + """, + ( + "Smart Auto-Sort (Default)", + "Toate fișierele", + "", + "[An]_[Emitent]_[SubiectAI]", + ), + ) + conn.commit() + + +def save_rule( + name: str, query: str, folder_template: str, naming_template: str +) -> bool: + """Salvează sau suprascrie un șablon de regulă.""" + try: + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute( + """ + INSERT OR REPLACE INTO saved_rules (name, query, folder_template, naming_template) + VALUES (?, ?, ?, ?) + """, + (name, query, folder_template, naming_template), + ) + conn.commit() + return True + except Exception as e: + logger.error(f"Eroare la salvarea regulii în DB: {e}") + return False + + +def get_all_rules() -> list[dict]: + """Returnează toate regulile salvate.""" + try: + with sqlite3.connect(DB_PATH) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute("SELECT * FROM saved_rules ORDER BY created_at DESC") + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Eroare la citirea regulilor: {e}") + return [] + + +def get_rule_by_name(name: str) -> dict | None: + try: + with sqlite3.connect(DB_PATH) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute("SELECT * FROM saved_rules WHERE name = ?", (name,)) + row = cursor.fetchone() + return dict(row) if row else None + except Exception as e: + logger.error(f"Eroare la obținerea regulii {name}: {e}") + return None + + +def delete_rule(name: str) -> bool: + """Șterge o regulă.""" + try: + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM saved_rules WHERE name = ?", (name,)) + conn.commit() + return True + except Exception as e: + logger.error(f"Eroare la ștergerea regulii: {e}") + return False + + +# Asigură-te că tabela e creată la primul import +init_db() diff --git a/core/scan_worker.py b/core/scan_worker.py index 2dd0d98..e819030 100644 --- a/core/scan_worker.py +++ b/core/scan_worker.py @@ -1,16 +1,17 @@ +import re import logging from pathlib import Path from PyQt6.QtCore import QThread, pyqtSignal -from ai.agent_compiler import CompilerAgent from ai.agent_extractor import ExtractorAgent -from ai.agent_decider import DeciderAgent +from ai.agent_decider import DeciderAgent, ActionDecision from ai.tools import ( extract_text_from_pdf, extract_text_from_image, extract_text_from_docx, ) +from ai.vision_tools import describe_image from core.file_manager import move_and_rename_file from core.quarantine_db import quarantine_db @@ -35,46 +36,103 @@ def __init__(self, source_dir: str, dest_dir: str, user_rule: str): self.source_dir = Path(source_dir) self.dest_dir = Path(dest_dir) self.user_rule = user_rule + self.is_running = True + + @staticmethod + def _build_descriptive_name(raw_text: str, original_filename: str) -> str: + """Construiește un nume descriptiv din textul brut (conține Vision Analysis). + + Extrage cuvintele cheie din descrierea vizuală a imaginii + și le combină într-un nume de fișier curat. + Dacă nu găsește o descriere utilă, păstrează numele original. + """ + ext = Path(original_filename).suffix # .jpeg, .png, etc. + + # Extragem descrierea vizuală dacă există + # Căutăm textul între "Image Vision Analysis:" și "Extracted OCR Text:" + if "Image Vision Analysis:" not in raw_text: + return original_filename + + # Extragem doar partea de Vision Analysis + parts = raw_text.split("Image Vision Analysis:") + if len(parts) < 2: + return original_filename + + vision_part = parts[1] + # Tăiem tot ce e după "Extracted OCR Text:" dacă există + if "Extracted OCR Text:" in vision_part: + vision_part = vision_part.split("Extracted OCR Text:")[0] + + desc = vision_part.strip() + + # Dacă descrierea e goală sau e o eroare, păstrăm numele original + if not desc or desc.startswith("Eroare"): + return original_filename + + # Sanitizare rapidă pentru a fi un nume de fișier valid (păstrăm litere, cifre, underscore, cratimă) + cleaned_desc = re.sub(r"[^a-zA-Z0-9_\-]", "", desc.replace(" ", "_")) + + if not cleaned_desc: + return original_filename + + # Asigură-te că nu se dublează extensia + if cleaned_desc.lower().endswith(ext.lower()): + return cleaned_desc + + return cleaned_desc + ext def run(self): # 1. Inițializăm agenții - self.log_updated.emit("🤖 Se încarcă agenții AI...") + self.log_updated.emit("Se încarcă agenții AI...") try: - compiler = CompilerAgent() extractor = ExtractorAgent() decider = DeciderAgent() except Exception as e: - self.log_updated.emit(f"❌ Eroare la inițializarea agenților: {e}") + self.log_updated.emit(f"Eroare la inițializarea agenților: {e}") self.scan_finished.emit(0) return - # 2. Compilăm regula - if not self.user_rule.strip(): - self.log_updated.emit("⚠️ Regula nu a fost completată!") + # 2. Încărcăm Șablonul (Template-ul) din DB + if not hasattr(self, "user_rule") or not self.user_rule.strip(): + self.log_updated.emit("Niciun preset nu a fost selectat!") self.scan_finished.emit(0) return - self.log_updated.emit(f"🧠 Compilare regulă: '{self.user_rule}'") - try: - compiled_rule = compiler.compile(self.user_rule) + import core.rules_db as rules_db + from ai.agent_compiler import CompiledRule + + rule_record = rules_db.get_rule_by_name(self.user_rule) + if not rule_record: self.log_updated.emit( - f"✅ Regulă compilată:\n{compiled_rule.model_dump_json(indent=2)}" + f"Eroare: Presetul '{self.user_rule}' nu a fost găsit în baza de date." ) - except Exception as e: - self.log_updated.emit(f"❌ Eroare la compilarea regulii: {e}") - self.scan_finished.emit(0) return - # 3. Preluăm fișierele din sursă - files = [f for f in self.source_dir.rglob("*") if f.is_file()] + # Creăm un mock CompiledRule pentru a-l pasa mai departe Decider-ului + compiled_rule = CompiledRule( + category=rule_record["query"], + folder_structure=rule_record["folder_template"], + naming_convention=rule_record["naming_template"], + ) + + self.log_updated.emit( + f"Preset încărcat: {compiled_rule.folder_structure} | {compiled_rule.naming_convention}" + ) + + # 3. Preluăm fișierele din sursă (ignorând fișierele ascunse macOS gen .DS_Store) + files = [ + f + for f in self.source_dir.rglob("*") + if f.is_file() and not f.name.startswith(".") + ] total = len(files) if total == 0: - self.log_updated.emit("⚠️ Niciun fișier găsit în folderul sursă.") + self.log_updated.emit("Niciun fișier găsit în folderul sursă.") self.scan_finished.emit(0) return - self.log_updated.emit(f"🔍 {total} fișiere găsite. Se începe scanarea cu AI...") + self.log_updated.emit(f"{total} fișiere găsite. Se începe scanarea cu AI...") added_count = 0 skipped_count = 0 @@ -86,9 +144,9 @@ def run(self): if str_path in existing_paths: skipped_count += 1 - self.log_updated.emit(f"⏭️ {file_path.name} — deja în carantină, skip") + self.log_updated.emit(f"{file_path.name} — deja în carantină, skip") else: - self.log_updated.emit(f"📄 Procesare: {file_path.name}...") + self.log_updated.emit(f"Procesare: {file_path.name}...") # a. Extragere text text = "" @@ -97,7 +155,12 @@ def run(self): if ext == ".pdf": text = extract_text_from_pdf(file_path) elif ext in [".png", ".jpg", ".jpeg", ".bmp", ".tiff"]: - text = extract_text_from_image(file_path) + ocr_text = extract_text_from_image(file_path) + self.log_updated.emit( + " Vision: Se analizează conținutul vizual..." + ) + vision_desc = describe_image(file_path) + text = f"Image Vision Analysis:\n{vision_desc}\n\nExtracted OCR Text:\n{ocr_text}" elif ext == ".docx": text = extract_text_from_docx(file_path) elif ext in [".txt", ".csv", ".md"]: @@ -121,26 +184,75 @@ def run(self): summary = f"Eroare procesare text: {e}" # c. Agent 2 (Decizie) + # ── BYPASS: Dacă categoria e "any"/"all", NU mai întrebăm AI-ul ── + # Modelul de 2B e prea mic și trimite în carantină aleatoriu. + # Construim decizia deterministic în cod. try: - decision = decider.decide(summary, file_path.name, compiled_rule) + cat = compiled_rule.category.strip().lower() + if cat in ("any", "all"): + # Construim numele descriptiv direct din summary + naming = compiled_rule.naming_convention.strip() + if ( + naming == "descriptive_name_based_on_content" + or naming == "" + ): + if "Image Vision Analysis:" in text: + suggested = self._build_descriptive_name( + text, file_path.name + ) + else: + if ( + hasattr(extraction_result, "suggested_filename") + and extraction_result.suggested_filename + ): + suggested = extraction_result.suggested_filename + if not suggested.lower().endswith( + file_path.suffix.lower() + ): + suggested += file_path.suffix + else: + suggested = file_path.name + elif naming == "{original_filename}": + suggested = file_path.name + else: + suggested = file_path.name + + target_folder = compiled_rule.folder_structure + if target_folder.strip().lower() in ("any", "all", ""): + target_folder = "Organized" + + decision = ActionDecision( + status="move", + suggested_name=suggested, + suggested_folder=target_folder, + ) + logger.info( + "BYPASS (category=any): forțăm MOVE pentru %s -> %s", + file_path.name, + suggested, + ) + else: + decision = decider.decide( + summary, file_path.name, compiled_rule + ) if decision.status == "move": proposed_folder = str(self.dest_dir / decision.suggested_folder) # Mutăm și redenumim fișierul fizic imediat try: - move_and_rename_file( + final_path = move_and_rename_file( str_path, proposed_folder, decision.suggested_name ) added_count += 1 self.log_updated.emit( - f" ↳ MOVE: Mutat și redenumit cu succes în -> {proposed_folder}/{decision.suggested_name}" + f" MOVE: Mutat și redenumit cu succes în -> {final_path}" ) except Exception as e: logger.error( f"Eroare la mutarea fișierului {file_path.name}: {e}" ) - self.log_updated.emit(f" ↳ ❌ Eroare la mutare: {e}") + self.log_updated.emit(f" Eroare la mutare: {e}") else: proposed_folder = "Quarantine" @@ -154,18 +266,18 @@ def run(self): ) added_count += 1 self.log_updated.emit( - f" ↳ QUARANTINE: Trimis în carantină. Nume sugerat: {decision.suggested_name}" + f" QUARANTINE: Trimis în carantină. Nume sugerat: {decision.suggested_name}" ) except Exception as e: logger.error(f"Eroare DeciderAgent: {e}") - self.log_updated.emit(f" ↳ ❌ Eroare la luarea deciziei: {e}") + self.log_updated.emit(f" Eroare la luarea deciziei: {e}") # Actualizăm progresul progress = int((i + 1) / total * 100) self.progress_updated.emit(progress) self.log_updated.emit( - f"\n✅ Scanare AI completă! {added_count} fișiere noi trimise spre review, " + f"\nScanare AI completă! {added_count} fișiere noi trimise spre review, " f"{skipped_count} existente ignorate." ) self.scan_finished.emit(added_count) diff --git a/docker-compose.yml b/docker-compose.yml index 30dafd6..6d47f5f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,8 +32,10 @@ services: sh -c " echo '=== ClutterKill: Initializing AI models ===' && ollama pull gemma2:2b && + ollama pull llava:7b && ollama create ck-model -f /app/ai/Modelfile && ollama create ck-extractor -f /app/ai/Modelfile.extractor && + ollama create ck-vision -f /app/ai/Modelfile.vision && echo '=== All models ready ===' " restart: "no" diff --git a/main.py b/main.py index 84f8f58..1979dc1 100644 --- a/main.py +++ b/main.py @@ -1,27 +1,18 @@ import sys -import os +from pathlib import Path from PyQt6.QtWidgets import QApplication from ui.app_window import AppWindow -def load_stylesheet(): - qss_path = os.path.join(os.path.dirname(__file__), "ui", "styles.qss") - try: - if os.path.exists(qss_path): - with open(qss_path, "r", encoding="utf-8") as f: - return f.read() - except Exception as e: - print(f"Failed to load stylesheet: {e}") - return "" - - def main(): app = QApplication(sys.argv) - # Apply the Dark Mode QSS we created earlier - stylesheet = load_stylesheet() - if stylesheet: - app.setStyleSheet(stylesheet) + # Apply global stylesheet + style_path = Path(__file__).parent / "ui" / "style.qss" + if style_path.exists(): + app.setStyleSheet(style_path.read_text()) + else: + print("Warning: style.qss not found!") # Start the Main Window window = AppWindow() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..4584de7 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +pythonpath = . diff --git a/scripts/create_test_pdf.py b/scripts/create_test_pdf.py index f367b4f..fb139c4 100644 --- a/scripts/create_test_pdf.py +++ b/scripts/create_test_pdf.py @@ -10,11 +10,11 @@ def create_fake_pdf(): pdf.add_page() pdf.set_font("Helvetica", size=12) - pdf.cell(200, 10, text="Universitatea X - Curs MDS", align="C") + pdf.cell(200, 10, txt="Universitatea X - Curs MDS", align="C") pdf.ln(10) - pdf.cell(200, 10, text="Semestrul 2 - Note de Curs", align="C") + pdf.cell(200, 10, txt="Semestrul 2 - Note de Curs", align="C") pdf.ln(10) - pdf.cell(200, 10, text="Acesta este un document generat automat pentru testare.") + pdf.cell(200, 10, txt="Acesta este un document generat automat pentru testare.") file_path = os.path.join("test_data", "source", "Curs_MDS_Sem2.pdf") pdf.output(file_path) diff --git a/scripts/generate_mock_data.py b/scripts/generate_mock_data.py index faa4dd8..6aaeb1f 100644 --- a/scripts/generate_mock_data.py +++ b/scripts/generate_mock_data.py @@ -1,5 +1,6 @@ """ generate_mock_data.py — scripts/generate_mock_data.py +# mypy: ignore-errors Generează 20 de fișiere neorganizate în test_data/source/: - 7 fișiere PDF (facturi / documente de curs) folosind fpdf diff --git a/scripts/test_model_with_pdf.py b/scripts/test_model_with_pdf.py index 9339185..6d91399 100644 --- a/scripts/test_model_with_pdf.py +++ b/scripts/test_model_with_pdf.py @@ -5,7 +5,7 @@ def test_model_with_pdf(): - pdf_path = "Curs_MDS_Sem2.pdf" + pdf_path = "test_data/source/Curs_MDS_Sem2.pdf" # Verificăm dacă fișierul există if not os.path.exists(pdf_path): diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..c08dcba --- /dev/null +++ b/start.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +echo "🚀 Inițializare ClutterKill..." + +# 1. Verificăm dacă există Python +if ! command -v python3 &> /dev/null +then + echo "❌ Eroare: Python3 nu este instalat." + exit 1 +fi + +# 2. Creăm mediul virtual dacă nu există +if [ ! -d ".venv" ]; then + echo "📦 Creare mediu virtual Python (.venv)..." + python3 -m venv .venv +fi + +# 3. Activăm mediul virtual +source .venv/bin/activate + +# 4. Instalăm dependențele +echo "⬇️ Instalare dependențe din requirements.txt..." +pip install -r requirements.txt -q + +# 5. Setare variabile mediu +if [ ! -f ".env" ]; then + echo "⚠️ Fișierul .env nu există. Copiez din .env.example..." + cp .env.example .env +fi + +# 6. Pornire aplicație +echo "✅ Totul este gata! Se lansează interfața grafică..." +python main.py diff --git a/ui/app_window.py b/ui/app_window.py index b5b7e34..52d25ae 100644 --- a/ui/app_window.py +++ b/ui/app_window.py @@ -25,7 +25,6 @@ def __init__(self): self.scan_tab = ScanTab() self.tabs.addTab(self.scan_tab, "Scan") - # Add the RulesTab self.rules_tab = RulesTab() self.tabs.addTab(self.rules_tab, "Rules") diff --git a/ui/style.qss b/ui/style.qss new file mode 100644 index 0000000..111d4ca --- /dev/null +++ b/ui/style.qss @@ -0,0 +1,196 @@ +/* ========================================================= + ClutterKill Dark Theme (Catppuccin Macchiato Inspired) + ========================================================= */ + +/* --- Global Settings --- */ +QWidget { + background-color: #1e1e2e; + color: #cdd6f4; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 13px; +} + +/* --- QMainWindow & Panels --- */ +QMainWindow { + background-color: #11111b; +} + +/* --- QTabWidget --- */ +QTabWidget::pane { + border: 1px solid #313244; + background-color: #1e1e2e; + border-radius: 8px; +} + +QTabBar::tab { + background-color: #181825; + color: #a6adc8; + padding: 10px 20px; + margin-right: 2px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + border: 1px solid transparent; + border-bottom: none; +} + +QTabBar::tab:selected { + background-color: #1e1e2e; + color: #89b4fa; + border: 1px solid #313244; + border-bottom: none; + font-weight: bold; +} + +QTabBar::tab:hover:!selected { + background-color: #313244; + color: #cdd6f4; +} + +/* --- QLineEdit & QTextEdit --- */ +QLineEdit, QTextEdit { + background-color: #181825; + border: 1px solid #313244; + border-radius: 6px; + padding: 8px; + color: #cdd6f4; + selection-background-color: #89b4fa; + selection-color: #11111b; +} + +QLineEdit:focus, QTextEdit:focus { + border: 1px solid #89b4fa; +} + +/* --- QComboBox --- */ +QComboBox { + background-color: #181825; + border: 1px solid #313244; + border-radius: 6px; + padding: 8px; + color: #cdd6f4; +} + +QComboBox:focus { + border: 1px solid #89b4fa; +} + +QComboBox::drop-down { + border: none; + width: 20px; +} + +/* --- QPushButton (Default) --- */ +QPushButton { + background-color: #313244; + border: 1px solid #45475a; + border-radius: 6px; + padding: 8px 16px; + color: #cdd6f4; + font-weight: bold; +} + +QPushButton:hover { + background-color: #45475a; + border-color: #585b70; +} + +QPushButton:pressed { + background-color: #585b70; +} + +QPushButton:disabled { + background-color: #181825; + color: #585b70; + border: 1px solid #313244; +} + +/* --- QPushButton (Primary / Accent) --- */ +QPushButton#primaryButton { + background-color: #89b4fa; + color: #11111b; + border: none; +} + +QPushButton#primaryButton:hover { + background-color: #74c7ec; +} + +QPushButton#primaryButton:pressed { + background-color: #89dceb; +} + +/* --- Visual Builder Palette Buttons --- */ +QPushButton[variableButton="true"] { + background-color: #cba6f7; + color: #11111b; + border: none; + border-radius: 12px; /* Pill shape */ + padding: 6px 12px; +} +QPushButton[variableButton="true"]:hover { + background-color: #f5c2e7; +} + +QPushButton[separatorButton="true"] { + background-color: #45475a; + color: #cdd6f4; + border: none; + border-radius: 12px; +} +QPushButton[separatorButton="true"]:hover { + background-color: #585b70; +} + +/* --- QListWidget --- */ +QListWidget { + background-color: #181825; + border: 1px solid #313244; + border-radius: 6px; + outline: none; +} + +QListWidget::item { + padding: 10px; + border-bottom: 1px solid #1e1e2e; +} + +QListWidget::item:selected { + background-color: #313244; + color: #89b4fa; + border-left: 3px solid #89b4fa; +} + +QListWidget::item:hover:!selected { + background-color: #1e1e2e; +} + +/* --- QProgressBar --- */ +QProgressBar { + background-color: #181825; + border: 1px solid #313244; + border-radius: 6px; + text-align: center; + color: #cdd6f4; +} + +QProgressBar::chunk { + background-color: #a6e3a1; + border-radius: 5px; +} + +/* --- Scrollbars --- */ +QScrollBar:vertical { + border: none; + background: #1e1e2e; + width: 10px; + border-radius: 5px; +} + +QScrollBar::handle:vertical { + background: #45475a; + border-radius: 5px; +} + +QScrollBar::handle:vertical:hover { + background: #585b70; +} diff --git a/ui/tabs/quarantine_tab.py b/ui/tabs/quarantine_tab.py index 09e40a0..0ed233c 100644 --- a/ui/tabs/quarantine_tab.py +++ b/ui/tabs/quarantine_tab.py @@ -15,6 +15,7 @@ QFileDialog, ) from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QPixmap from core.quarantine_db import quarantine_db from core.file_manager import move_and_rename_file @@ -111,7 +112,7 @@ def init_ui(self): # Mesaj când nu sunt fișiere self.empty_label = QLabel( - "✅ Niciun fișier în carantină!\nRulează un Scan pentru a adăuga fișiere." + "Niciun fișier în carantină!\nRulează un Scan pentru a adăuga fișiere." ) self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.empty_label.setStyleSheet("color: #7d7d7d; font-size: 14px;") @@ -169,7 +170,7 @@ def refresh(self): for record in self.records: original_name = Path(record["original_path"]).name - self.file_list.addItem(f"📄 {original_name}") + self.file_list.addItem(f"{original_name}") # Selectăm primul fișier automat self.file_list.setCurrentRow(0) @@ -192,12 +193,30 @@ def _populate_form(self, record): self.proposed_folder_input.setText(record["ai_proposed_folder"]) self.reason_lbl.setText(record.get("reason", "—") or "—") + # Incarcam imaginea daca este poza + path = Path(record["original_path"]) + if path.exists() and path.suffix.lower() in [".png", ".jpg", ".jpeg", ".bmp"]: + pixmap = QPixmap(str(path)) + # Scalare la dimensiunea maxima a scroll_area dar pastrand aspectul + scaled_pixmap = pixmap.scaled( + 350, + 450, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + self.preview_label.setPixmap(scaled_pixmap) + else: + self.preview_label.setPixmap(QPixmap()) + self.preview_label.setText("📄 Document (Fără preview)") + def _clear_form(self): """Golește formularul.""" self.original_path_lbl.setText("—") self.proposed_name_input.clear() self.proposed_folder_input.clear() self.reason_lbl.setText("—") + self.preview_label.clear() + self.preview_label.setText("Document Preview\n(Selectează un fișier din listă)") # ─── Actions ─────────────────────────────────────────────────────── @@ -214,7 +233,7 @@ def on_approve_clicked(self): dest_folder = self.proposed_folder_input.text().strip() if not new_name or not dest_folder: - self.show_status("⚠️ Completează numele și folderul!", "#ffa500") + self.show_status("Completează numele și folderul!", "#ffa500") return try: @@ -226,16 +245,16 @@ def on_approve_clicked(self): # și apoi ștergem din carantină quarantine_db.remove(self.current_record["id"]) - self.show_status("✔️ Fișier mutat cu succes!", "#4caf50") + self.show_status("Fișier mutat cu succes!", "#4caf50") self.refresh() except FileNotFoundError: - self.show_status("❌ Fișierul sursă nu mai există!", "#ff5c5c") + self.show_status("Fișierul sursă nu mai există!", "#ff5c5c") # Ștergem din carantină dacă fișierul nu mai există quarantine_db.remove(self.current_record["id"]) self.refresh() except Exception as e: - self.show_status(f"❌ Eroare: {e}", "#ff5c5c") + self.show_status(f"Eroare: {e}", "#ff5c5c") def on_reject_clicked(self): """Reject: Șterge fișierul din carantină (fișierul rămâne unde era).""" @@ -243,7 +262,7 @@ def on_reject_clicked(self): return quarantine_db.remove(self.current_record["id"]) - self.show_status("❌ Fișier scos din carantină.", "#ff5c5c") + self.show_status("Fișier scos din carantină.", "#ff5c5c") self.refresh() def on_save_as_clicked(self): diff --git a/ui/tabs/rules_tab.py b/ui/tabs/rules_tab.py index e08eaab..0f00781 100644 --- a/ui/tabs/rules_tab.py +++ b/ui/tabs/rules_tab.py @@ -4,228 +4,162 @@ QHBoxLayout, QLabel, QPushButton, - QButtonGroup, - QStackedWidget, QListWidget, - QAbstractItemView, - QTextEdit, - QGraphicsOpacityEffect, + QLineEdit, QMessageBox, + QGridLayout, ) -from PyQt6.QtCore import ( - Qt, - QPropertyAnimation, - QEasingCurve, - QSequentialAnimationGroup, - QTimer, - QThread, - pyqtSignal, -) - -from ai.agent_compiler import CompilerAgent, CompiledRule - - -class CompilerWorker(QThread): - finished_signal = pyqtSignal(object) # Emits the CompiledRule object - error_signal = pyqtSignal(str) # Emits error message - - def __init__(self, prompt: str): - super().__init__() - self.prompt = prompt - - def run(self): - try: - agent = CompilerAgent() - rule = agent.compile(self.prompt) - self.finished_signal.emit(rule) - except Exception as e: - self.error_signal.emit(str(e)) +import core.rules_db as rules_db class RulesTab(QWidget): def __init__(self): super().__init__() self.init_ui() + self.load_rules() def init_ui(self): - main_layout = QVBoxLayout(self) - - # 1. Header / Segmented Control - toggle_layout = QHBoxLayout() - toggle_layout.setSpacing(0) # Keep buttons attached like a pill - self.mode_group = QButtonGroup(self) - self.mode_group.setExclusive(True) - - self.btn_visual = QPushButton("Visual Builder (Drag & Drop)") - self.btn_visual.setCheckable(True) - self.btn_visual.setChecked(True) - self.btn_visual.setProperty("class", "segmentToggle") - self.btn_visual.setObjectName("leftToggle") - - self.btn_ai = QPushButton("Advanced AI Mode") - self.btn_ai.setCheckable(True) - self.btn_ai.setProperty("class", "segmentToggle") - self.btn_ai.setObjectName("rightToggle") - - self.mode_group.addButton(self.btn_visual, 0) - self.mode_group.addButton(self.btn_ai, 1) - - toggle_layout.addWidget(self.btn_visual) - toggle_layout.addWidget(self.btn_ai) - toggle_layout.addStretch() - - main_layout.addLayout(toggle_layout) - - # 2. Stacked Widget - self.stacked_widget = QStackedWidget() - - # --- Page 1: Visual Mode --- - self.page_visual = QWidget() - visual_layout = QHBoxLayout(self.page_visual) + main_layout = QHBoxLayout(self) + # 1. Left Panel: Saved Templates left_layout = QVBoxLayout() - left_layout.addWidget(QLabel("Blocuri Disponibile:")) - self.available_list = QListWidget() - self.available_list.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop) - self.available_list.setDefaultDropAction(Qt.DropAction.MoveAction) - self.available_list.addItems( - ["An", "Lună", "Emitent", "Tip Document", "Extensie"] - ) - left_layout.addWidget(self.available_list) - - # Pulsating Arrow / Guidance Animation - middle_layout = QVBoxLayout() - middle_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.guide_label = QLabel("Trage Aici\n➔") - self.guide_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.guide_label.setStyleSheet( - "color: #0078d4; font-weight: bold; font-size: 16px;" - ) + left_layout.addWidget(QLabel("Preseturi Salvate")) - # Opacity Animation for the guide label to make it pulse - self.opacity_effect = QGraphicsOpacityEffect(self.guide_label) - self.guide_label.setGraphicsEffect(self.opacity_effect) + self.rules_list = QListWidget() + self.rules_list.itemClicked.connect(self.on_rule_clicked) + left_layout.addWidget(self.rules_list) - self.anim_down = QPropertyAnimation(self.opacity_effect, b"opacity") - self.anim_down.setDuration(1000) - self.anim_down.setStartValue(1.0) - self.anim_down.setEndValue(0.2) - self.anim_down.setEasingCurve(QEasingCurve.Type.InOutQuad) + self.btn_delete = QPushButton("Șterge Preset") + self.btn_delete.clicked.connect(self.on_delete_clicked) + left_layout.addWidget(self.btn_delete) - self.anim_up = QPropertyAnimation(self.opacity_effect, b"opacity") - self.anim_up.setDuration(1000) - self.anim_up.setStartValue(0.2) - self.anim_up.setEndValue(1.0) - self.anim_up.setEasingCurve(QEasingCurve.Type.InOutQuad) - - self.pulse_anim = QSequentialAnimationGroup(self) - self.pulse_anim.addAnimation(self.anim_down) - self.pulse_anim.addAnimation(self.anim_up) - self.pulse_anim.setLoopCount(-1) # Infinite loop - self.pulse_anim.start() - - middle_layout.addWidget(self.guide_label) + main_layout.addLayout(left_layout, stretch=1) + # 2. Right Panel: Create New Template right_layout = QVBoxLayout() - right_layout.addWidget(QLabel("Formatul Regulii:")) - self.rule_list = QListWidget() - self.rule_list.setObjectName("dropZone") # Will style this with a dashed border - self.rule_list.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop) - self.rule_list.setDefaultDropAction(Qt.DropAction.MoveAction) - right_layout.addWidget(self.rule_list) - - visual_layout.addLayout(left_layout) - visual_layout.addLayout(middle_layout) - visual_layout.addLayout(right_layout) - - self.stacked_widget.addWidget(self.page_visual) - - # --- Page 2: AI Mode --- - self.page_ai = QWidget() - ai_layout = QVBoxLayout(self.page_ai) - - self.ai_label = QLabel("Descrie regula de organizare în limbaj natural:") - self.ai_prompt = QTextEdit() - self.ai_prompt.setPlaceholderText( - "Exemplu: Grupează toate facturile emise de eMAG sau PC Garage în folderul 'Facturi/An/Luna'." - ) - - ai_layout.addWidget(self.ai_label) - ai_layout.addWidget(self.ai_prompt) - self.stacked_widget.addWidget(self.page_ai) + # Title + title_label = QLabel("Creează un Șablon Strict (Template)") + title_label.setStyleSheet("font-weight: bold; font-size: 14px;") + right_layout.addWidget(title_label) - main_layout.addWidget(self.stacked_widget) + # Name Input + right_layout.addWidget(QLabel("Nume Preset:")) + self.name_input = QLineEdit() + self.name_input.setPlaceholderText("Ex: Facturi cu Data și Magazinul") + right_layout.addWidget(self.name_input) - # 3. Save Button & Success Message - btn_layout = QHBoxLayout() - - self.save_success_label = QLabel("✔️ Rule saved") - self.save_success_label.setStyleSheet("color: #4caf50; font-weight: bold;") - self.save_success_label.hide() - - btn_layout.addWidget(self.save_success_label) - btn_layout.addStretch() - - self.btn_save = QPushButton("Save Rule") - self.btn_save.setObjectName("primaryButton") - self.btn_save.setToolTip( - "Funcționalitatea de salvare va fi disponibilă după implementarea bazei de date." + # Query Input + query_label = QLabel( + "Regulă Selecție (Fișierele care nu se potrivesc se duc în Carantină):" + ) + query_label.setStyleSheet("color: #f38ba8; font-weight: bold;") + right_layout.addWidget(query_label) + self.query_input = QLineEdit() + self.query_input.setPlaceholderText("Ex: Toate facturile emise de eMAG") + right_layout.addWidget(self.query_input) + + # Naming Template Input + right_layout.addWidget( + QLabel("Format Nume Fișier (Click pe butoane ca să inserezi!):") ) - btn_layout.addWidget(self.btn_save) - - main_layout.addLayout(btn_layout) - # Connect signals - self.mode_group.idClicked.connect(self.switch_mode) + self.naming_input = QLineEdit() + self.naming_input.setPlaceholderText("Ex: [An]-[Luna]_[Emitent]_[SubiectAI]") + right_layout.addWidget(self.naming_input) + + # Visual Builder Palette + palette_layout = QGridLayout() + variables = [ + "[An]", + "[Luna]", + "[TipDocument]", + "[Emitent]", + "[SubiectAI]", + "_", + "-", + ] + row, col = 0, 0 + for var in variables: + btn = QPushButton(var) + if var in ["_", "-"]: + btn.setProperty("separatorButton", True) + else: + btn.setProperty("variableButton", True) + + btn.clicked.connect(lambda checked, v=var: self.insert_variable(v)) + palette_layout.addWidget(btn, row, col) + col += 1 + if col > 3: + col = 0 + row += 1 + + right_layout.addLayout(palette_layout) + + right_layout.addStretch() + + # Save Button + self.btn_save = QPushButton("Salvează Șablonul") + self.btn_save.setObjectName("primaryButton") self.btn_save.clicked.connect(self.on_save_clicked) - - def switch_mode(self, id): - self.stacked_widget.setCurrentIndex(id) + right_layout.addWidget(self.btn_save) + + main_layout.addLayout(right_layout, stretch=2) + + def load_rules(self): + self.rules_list.clear() + rules = rules_db.get_all_rules() + for r in rules: + self.rules_list.addItem(r["name"]) + + def insert_variable(self, text: str): + """Inserează variabila la cursor în căsuța de nume.""" + self.naming_input.insert(text) + self.naming_input.setFocus() + + def on_rule_clicked(self, item): + """Populează formularul cu datele regulii selectate pentru editare.""" + rule_name = item.text() + rule_record = rules_db.get_rule_by_name(rule_name) + if rule_record: + self.name_input.setText(rule_record["name"]) + self.query_input.setText(rule_record["query"]) + self.naming_input.setText(rule_record["naming_template"]) + + def on_delete_clicked(self): + selected_items = self.rules_list.selectedItems() + if not selected_items: + return + + rule_name = selected_items[0].text() + reply = QMessageBox.question( + self, "Confirmare", f"Ești sigur că vrei să ștergi presetul '{rule_name}'?" + ) + if reply == QMessageBox.StandardButton.Yes: + if rules_db.delete_rule(rule_name): + self.load_rules() + else: + QMessageBox.warning(self, "Eroare", "Nu am putut șterge presetul.") def on_save_clicked(self): - current_index = self.stacked_widget.currentIndex() - if current_index == 1: - # AI Mode - user_prompt = self.ai_prompt.toPlainText().strip() - if not user_prompt: - QMessageBox.warning( - self, "Avertisment", "Te rog să introduci o descriere a regulii." - ) - return - - self.btn_save.setEnabled(False) - self.btn_save.setText("Compiling (AI is thinking)...") - - # Pornim thread-ul in background pentru a nu bloca interfata grafica - self.worker = CompilerWorker(user_prompt) - self.worker.finished_signal.connect(self.on_compile_success) - self.worker.error_signal.connect(self.on_compile_error) - self.worker.start() + name = self.name_input.text().strip() + query = self.query_input.text().strip() + naming = self.naming_input.text().strip() + + if not name or not query or not naming: + QMessageBox.warning( + self, "Avertisment", "Te rog să completezi toate cele 3 câmpuri." + ) + return + + # Folder template is hardcoded to root (empty) since user wants it flattened + if rules_db.save_rule(name, query, "", naming): + QMessageBox.information(self, "Succes", f"Presetul '{name}' a fost salvat!") + self.name_input.clear() + self.query_input.clear() + self.naming_input.clear() + self.load_rules() else: - # Visual Mode (mock save for now) - self.save_success_label.show() - QTimer.singleShot(2500, self.save_success_label.hide) - - def on_compile_success(self, rule: CompiledRule): - self.btn_save.setEnabled(True) - self.btn_save.setText("Save Rule") - - self.save_success_label.show() - QTimer.singleShot(2500, self.save_success_label.hide) - - # Afisam JSON-ul pentru confirmare, pana cand vom implementa DB-ul - QMessageBox.information( - self, - "AI Rule Extracted", - f"AI a tradus regula cu succes:\n\n{rule.model_dump_json(indent=2)}", - ) - - def on_compile_error(self, error_msg: str): - self.btn_save.setEnabled(True) - self.btn_save.setText("Save Rule") - QMessageBox.critical( - self, - "Eroare Compilare AI", - f"Agentul AI nu a putut extrage regula:\n\n{error_msg}", - ) + QMessageBox.critical( + self, "Eroare", "Nu am putut salva presetul în baza de date." + ) diff --git a/ui/tabs/scan_tab.py b/ui/tabs/scan_tab.py index 8b8d553..77119ba 100644 --- a/ui/tabs/scan_tab.py +++ b/ui/tabs/scan_tab.py @@ -3,6 +3,7 @@ QVBoxLayout, QHBoxLayout, QLabel, + QComboBox, QLineEdit, QPushButton, QProgressBar, @@ -12,6 +13,7 @@ from core.scan_worker import ScanWorker +import core.rules_db as rules_db class ScanTab(QWidget): @@ -46,13 +48,14 @@ def init_ui(self): # Rule layout rule_layout = QHBoxLayout() - self.rule_label = QLabel("AI Organizing Rule:") - self.rule_input = QLineEdit() - self.rule_input.setPlaceholderText( - "e.g., Pune facturile in folderul FacturiNou" - ) + self.rule_label = QLabel("Preset Formatare:") + self.rule_combo = QComboBox() + self.refresh_rules_btn = QPushButton("Refresh Preseturi") + self.refresh_rules_btn.clicked.connect(self.load_saved_rules) + rule_layout.addWidget(self.rule_label) - rule_layout.addWidget(self.rule_input) + rule_layout.addWidget(self.rule_combo, stretch=1) + rule_layout.addWidget(self.refresh_rules_btn) # Start button self.start_btn = QPushButton("Start Scan") @@ -69,7 +72,6 @@ def init_ui(self): self.log_area = QTextEdit() self.log_area.setReadOnly(True) - # Add all to main layout layout.addLayout(source_layout) layout.addLayout(dest_layout) layout.addLayout(rule_layout) @@ -79,6 +81,18 @@ def init_ui(self): layout.addWidget(self.log_area) self.setLayout(layout) + self.load_saved_rules() + + def load_saved_rules(self): + self.rule_combo.clear() + rules = rules_db.get_all_rules() + if not rules: + self.rule_combo.addItem("Niciun preset salvat! Mergi în tab-ul Rules.") + self.rule_combo.setEnabled(False) + else: + self.rule_combo.setEnabled(True) + for r in rules: + self.rule_combo.addItem(r["name"]) def browse_source(self): directory = QFileDialog.getExistingDirectory(self, "Select Source Directory") @@ -96,22 +110,27 @@ def start_scan(self): if ( not self.source_input.text() or not self.dest_input.text() - or not self.rule_input.text() + or not self.rule_combo.isEnabled() + or self.rule_combo.count() == 0 ): - self.log_area.append( - "⚠️ Selectează folderele și introdu o regulă de organizare AI." - ) + self.log_area.append("Selectează folderele și un preset valid.") return + source = self.source_input.text() + dest = self.dest_input.text() + rule_name = self.rule_combo.currentText() + + # Disable UI self.start_btn.setEnabled(False) self.progress_bar.setValue(0) self.log_area.clear() + self.log_area.append("Se încarcă agenții AI...") - # Pornim worker-ul AI de scanare + # Start worker self.scan_thread = ScanWorker( - source_dir=self.source_input.text(), - dest_dir=self.dest_input.text(), - user_rule=self.rule_input.text(), + source_dir=source, + dest_dir=dest, + user_rule=rule_name, ) self.scan_thread.progress_updated.connect(self.update_progress) self.scan_thread.log_updated.connect(self.append_log) @@ -122,11 +141,36 @@ def update_progress(self, value): self.progress_bar.setValue(value) def append_log(self, message): - self.log_area.append(message) + # Scoatem spațiile de padding făcute pentru vechiul CMD + msg = message.strip() + if not msg: + return + + # Stabilim o culoare de accent pe baza contextului + color = "#89b4fa" # Albastru default + if "succes" in msg.lower() or "complet" in msg.lower(): + color = "#a6e3a1" # Verde + elif "eroare" in msg.lower() or "lipsă" in msg.lower(): + color = "#f38ba8" # Roșu + elif ( + "carantină" in msg.lower() + or "quarantine" in msg.lower() + or "skip" in msg.lower() + ): + color = "#f9e2af" # Galben + elif "procesare" in msg.lower() or "vision" in msg.lower(): + color = "#cba6f7" # Mov + + html = f""" +
+ {msg.replace("\\n", "
")}
+
+ """ + self.log_area.append(html) def scan_complete(self, added_count): self.start_btn.setEnabled(True) if added_count > 0: - self.log_area.append( - "\n💡 Mergi la tab-ul 'Quarantine' pentru a aproba sau respinge fișierele." + self.append_log( + "Mergi la tab-ul 'Quarantine' pentru a aproba sau respinge fișierele." )