From 6cdf3bb5a7ff8d7b3d47e8d5fb7938f31b5cd6c4 Mon Sep 17 00:00:00 2001 From: Fernando-Emanuel Donea Date: Mon, 11 May 2026 11:41:08 +0300 Subject: [PATCH 01/10] feat: Integrare Google Gemini API --- ai/llm_config.py | 43 ++++++++++++++++++++++++++++++++----------- requirements.txt | 2 ++ 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/ai/llm_config.py b/ai/llm_config.py index cbaefbd..7b9f723 100644 --- a/ai/llm_config.py +++ b/ai/llm_config.py @@ -30,9 +30,15 @@ import logging import os +from dotenv import load_dotenv +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_google_genai import ChatGoogleGenerativeAI from langchain_ollama import ChatOllama +# Încarcă variabilele de mediu din fișierul .env, dacă există +load_dotenv() + logger = logging.getLogger(__name__) # ─── Defaults (match docker-compose.yml & .env.example) ───────────── @@ -40,6 +46,8 @@ _DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434" _DEFAULT_REQUEST_TIMEOUT = 120.0 # seconds — OCR docs can be big +_DEFAULT_GOOGLE_MODEL = "gemini-2.0-flash" + # Model registry — one entry per Modelfile MODEL_CLASSIFIER = "ck-model" # ai/Modelfile MODEL_EXTRACTOR = "ck-extractor" # ai/Modelfile.extractor @@ -56,35 +64,48 @@ def get_llm( temperature: float | None = None, num_ctx: int | None = None, timeout: float | None = None, -) -> ChatOllama: - """Return a ChatOllama instance for the requested model. +) -> BaseChatModel: + """Return a chat model instance based on chosen AI_PROVIDER. Parameters ---------- model : str - Ollama model name. Use the module constants: - ``MODEL_CLASSIFIER`` (default) or ``MODEL_EXTRACTOR``. + Ollama model name or placeholder. For Google, uses the + defined DEFAULT_GOOGLE_MODEL override. temperature : float, optional - Override sampling temperature (model default: 0.1). + Override sampling temperature (default: 0.1). num_ctx : int, optional - Override context-window size. + Override context-window size (Ollama only). timeout : float, optional Override HTTP request timeout (default: 120 s). Returns ------- - ChatOllama + BaseChatModel A LangChain chat-model instance ready for ``.invoke()`` / ``.ainvoke()`` / agent binding. """ provider = os.getenv("AI_PROVIDER", _DEFAULT_PROVIDER).lower() if provider == "google": - # Future: return ChatGoogleGenerativeAI(...) - raise NotImplementedError( - "Google AI provider is not yet implemented. " - "Set AI_PROVIDER=ollama or leave unset." + api_key = os.getenv("GOOGLE_API_KEY") + if not api_key: + raise ValueError("GOOGLE_API_KEY is required when AI_PROVIDER='google'") + + google_model = os.getenv("GOOGLE_MODEL_NAME", _DEFAULT_GOOGLE_MODEL) + temp = temperature if temperature is not None else 0.1 + + logger.info( + "Initializing ChatGoogleGenerativeAI model=%s", + google_model, + ) + + g_llm = ChatGoogleGenerativeAI( + model=google_model, + temperature=temp, + google_api_key=api_key, ) + return g_llm if provider != "ollama": raise ValueError( diff --git a/requirements.txt b/requirements.txt index 7aee05c..77527ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,5 @@ ruff fpdf python-docx Pillow +langchain-google-genai +python-dotenv From c5ad492d11026e8b9f4e1c2eb966c60451544cb9 Mon Sep 17 00:00:00 2001 From: Fernando-Emanuel Donea Date: Mon, 11 May 2026 11:44:00 +0300 Subject: [PATCH 02/10] fix: Restore ai/agent_decider.py content --- ai/agent_decider.py | 219 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/ai/agent_decider.py b/ai/agent_decider.py index e69de29..55addd8 100644 --- a/ai/agent_decider.py +++ b/ai/agent_decider.py @@ -0,0 +1,219 @@ +""" +Decider Agent — ai/agent_decider.py + +Agent 2: Primește rezumatul (A1) și regula (A0) și decide ce face cu fișierul. +Uses PydanticOutputParser with the class ActionDecision(status, suggested_name, suggested_folder). +""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Literal + +from langchain_core.output_parsers import PydanticOutputParser, StrOutputParser +from langchain_core.prompts import PromptTemplate, ChatPromptTemplate +from pydantic import BaseModel, Field, field_validator + +from ai.llm_config import get_llm, MODEL_CLASSIFIER +from ai.agent_compiler import CompiledRule + +logger = logging.getLogger(__name__) + +_MAX_RETRIES = 2 + +# ===================================================================== +# Pydantic schema — formatul deciziei +# ===================================================================== + + +class ActionDecision(BaseModel): + """Decizia finală luată de Agent 2.""" + + status: Literal["move", "quarantine"] = Field( + ..., + description="Statusul deciziei: 'move' dacă fișierul corespunde regulii, 'quarantine' dacă nu sau dacă informațiile lipsesc.", + ) + suggested_name: str = Field( + ..., + description="Numele sugerat pentru fișier (conform naming_convention din regulă). Dacă e carantină, se păstrează numele original.", + ) + suggested_folder: str = Field( + ..., + description="Folderul de destinație. Dacă e 'quarantine', valoarea va fi 'Quarantine'.", + ) + + @field_validator("suggested_name") + @classmethod + def sanitize_filename(cls, v: str) -> str: + """Asigură-te că numele fișierului nu conține caractere invalide.""" + # Îndepărtăm caracterele care ar putea cauza erori de filepath + sanitized = re.sub(r'[<>:"/\\|?*]', "_", v) + return sanitized + + +# ===================================================================== +# Prompt template +# ===================================================================== + +_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. + +Rule Category: {rule_category} +Target Folder: {rule_folder} +Naming Convention: {rule_naming} + +Document Summary: +{document_summary} + +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. Calculate the new filename based on the Naming Convention. If the naming convention includes {{original_filename}}, replace it with the actual original filename. +4. If the status is "quarantine", the folder must be "Quarantine". +5. If the status is "quarantine", the suggested_name MUST be exactly the Original Filename. + +IMPORTANT: You must return ONLY the raw JSON object containing the actual values. Do NOT return a JSON schema, and do NOT wrap your answer in markdown fences (like ```json). + +{format_instructions} +""" + +_REPAIR_PROMPT = ChatPromptTemplate.from_messages( + [ + ( + "system", + "You previously attempted to output a JSON decision but your JSON was invalid or did not match the schema. " + "Fix the JSON below so it matches the required schema exactly. " + "Output ONLY valid JSON, nothing else.", + ), + ("human", "Broken output:\n{broken_json}\n\nValidation error:\n{error}"), + ] +) + +# ===================================================================== +# DeciderAgent +# ===================================================================== + + +class DeciderAgent: + """Agent that decides whether a file matches a rule based on its summary. + + Usage:: + + agent = DeciderAgent() + decision = agent.decide(summary, original_filename, rule) + print(decision.model_dump_json()) + """ + + def __init__(self, llm: Any | None = None) -> None: + self._llm = llm or get_llm(model=MODEL_CLASSIFIER) + self._parser = PydanticOutputParser(pydantic_object=ActionDecision) + + self._prompt = PromptTemplate( + template=_SYSTEM_PROMPT_TEMPLATE, + input_variables=[ + "rule_category", + "rule_folder", + "rule_naming", + "document_summary", + "original_filename", + ], + partial_variables={ + "format_instructions": self._parser.get_format_instructions() + }, + ) + + self._chain = self._prompt | self._llm | StrOutputParser() + self._repair_chain = _REPAIR_PROMPT | self._llm | StrOutputParser() + + # ── public API ─────────────────────────────────────────────────── + + def decide( + self, summary: str, original_filename: str, rule: CompiledRule + ) -> ActionDecision: + """Decide the fate of a document. + + Parameters + ---------- + summary : str + Technical summary extracted from the document. + original_filename : str + The original name of the file. + rule : CompiledRule + The compiled rule containing category, folder, and naming. + + Returns + ------- + ActionDecision + Validated Pydantic model with status, suggested_name, and suggested_folder. + """ + logger.info( + "DeciderAgent: Evaluăm documentul '%s' față de regula '%s'", + original_filename, + 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, + } + ) + + last_error: Exception | None = None + current_output = raw_output + + for attempt in range(_MAX_RETRIES + 1): + try: + # Funcția self._parser.parse suportă markdown fences fallback din LangChain + return self._parser.parse(current_output) + except Exception as exc: + last_error = exc + logger.warning( + "Parse attempt %d/%d failed: %s", + attempt + 1, + _MAX_RETRIES + 1, + exc, + ) + if attempt < _MAX_RETRIES: + current_output = self._repair_chain.invoke( + {"broken_json": current_output, "error": str(exc)} + ) + + logger.error("Eroare în timpul deciziei (după retry-uri): %s", last_error) + raise ValueError( + f"Failed to parse decision after retries: {last_error}" + ) from last_error + + +# ─── Quick self-test ───────────────────────────────────────────────── +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + + agent = DeciderAgent() + + test_rule = CompiledRule( + category="factură", + folder_structure="Facturi_Luna_Curenta", + naming_convention="factura_enel_10/20.pdf", # intenționat cu caractere invalide pentru test + ) + + test_summary_match = "Emitent: ENEL SA, Dată: 12.05.2023, Sumă: 150 RON, Tip: Factură energie electrică." + + test_filename = "doc_scanned_123.pdf" + + print(f"\n{'=' * 60}") + print("TEST 1: Sanitizare și Retry") + try: + decision1 = agent.decide(test_summary_match, test_filename, test_rule) + print("Output JSON (observă cum / a fost înlocuit):") + print(decision1.model_dump_json(indent=2)) + except Exception as e: + print(f"Error: {e}") + print(f"{'=' * 60}\n") From c40f3d74d548dabbaca6166e5672ce736a756f84 Mon Sep 17 00:00:00 2001 From: Fernando-Emanuel Donea Date: Mon, 11 May 2026 11:44:45 +0300 Subject: [PATCH 03/10] feat: Add core ScanWorker for AI pipeline orchestration (Task 21) --- core/scan_worker.py | 166 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 core/scan_worker.py diff --git a/core/scan_worker.py b/core/scan_worker.py new file mode 100644 index 0000000..d1684af --- /dev/null +++ b/core/scan_worker.py @@ -0,0 +1,166 @@ +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.tools import extract_text_from_pdf, extract_text_from_image +from core.quarantine_db import quarantine_db + +logger = logging.getLogger(__name__) + + +class ScanWorker(QThread): + """ + Thread real de scanare care folosește pipeline-ul de agenți AI: + 1. Agent 0 (Compiler) transformă regula naturală. + 2. Agent 1 (Extractor) citește fișierul și scoate un rezumat tehnic. + 3. Agent 2 (Decider) aplică regula pe rezumat pentru o decizie de rutare. + 4. Adaugă fiecare fișier în quarantine_db cu recomandările AI. + """ + + progress_updated = pyqtSignal(int) + log_updated = pyqtSignal(str) + scan_finished = pyqtSignal(int) + + def __init__(self, source_dir: str, dest_dir: str, user_rule: str): + super().__init__() + self.source_dir = Path(source_dir) + self.dest_dir = Path(dest_dir) + self.user_rule = user_rule + + def run(self): + # 1. Inițializăm agenții + 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.scan_finished.emit(0) + return + + # 2. Compilăm regula + if not self.user_rule.strip(): + self.log_updated.emit("⚠️ Regula nu a fost completată!") + self.scan_finished.emit(0) + return + + self.log_updated.emit(f"🧠 Compilare regulă: '{self.user_rule}'") + try: + compiled_rule = compiler.compile(self.user_rule) + self.log_updated.emit( + f"✅ Regulă compilată:\n{compiled_rule.model_dump_json(indent=2)}" + ) + 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()] + total = len(files) + + if total == 0: + 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...") + + added_count = 0 + skipped_count = 0 + + existing_paths = {r["original_path"] for r in quarantine_db.get_all()} + + for i, file_path in enumerate(files): + str_path = str(file_path) + + if str_path in existing_paths: + skipped_count += 1 + self.log_updated.emit(f"⏭️ {file_path.name} — deja în carantină, skip") + else: + self.log_updated.emit(f"📄 Procesare: {file_path.name}...") + + # a. Extragere text + text = "" + ext = file_path.suffix.lower() + try: + 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) + elif ext in [".txt", ".csv", ".md"]: + text = file_path.read_text(errors="ignore") + else: + text = f"Fișier de tip necunoscut ({ext}). Conținut text nedisponibil." + except Exception as e: + logger.warning( + f"Eroare extragere text pentru {file_path.name}: {e}" + ) + text = f"Eroare extracție: {e}" + + # b. Agent 1 (Extragere) + try: + extraction_result = extractor.extract( + text or "Conținut gol sau necitibil" + ) + summary = extraction_result.get_technical_summary() + except Exception as e: + logger.error(f"Eroare ExtractorAgent: {e}") + summary = f"Eroare procesare text: {e}" + + # c. Agent 2 (Decizie) + try: + 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: + from core.file_manager import move_and_rename_file + + 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}" + ) + 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}") + + else: + proposed_folder = "Quarantine" + + # Adăugăm în carantină pentru intervenție manuală + quarantine_db.add( + original_path=str_path, + ai_proposed_name=decision.suggested_name, + ai_proposed_folder=proposed_folder, + reason=f"Decizie AI ({decision.status}) bazată pe: {summary[:100]}...", + ) + added_count += 1 + self.log_updated.emit( + 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}") + + # 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"{skipped_count} existente ignorate." + ) + self.scan_finished.emit(added_count) From 2ef475e6854262ee5f4f2c35b4ef0533d8cdef14 Mon Sep 17 00:00:00 2001 From: Fernando-Emanuel Donea Date: Mon, 11 May 2026 11:45:31 +0300 Subject: [PATCH 04/10] feat: Integrate AI ScanWorker into ScanTab UI (Task 21) --- ui/tabs/scan_tab.py | 136 +++++++------------------------------------- 1 file changed, 21 insertions(+), 115 deletions(-) diff --git a/ui/tabs/scan_tab.py b/ui/tabs/scan_tab.py index 9b5e775..8b8d553 100644 --- a/ui/tabs/scan_tab.py +++ b/ui/tabs/scan_tab.py @@ -1,5 +1,3 @@ -from pathlib import Path - from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, @@ -11,117 +9,9 @@ QTextEdit, QFileDialog, ) -from PyQt6.QtCore import QThread, pyqtSignal - -from core.quarantine_db import quarantine_db - -# Categorii de fișiere bazate pe extensie (placeholder până la integrarea AI) -EXTENSION_CATEGORIES = { - ".pdf": "Documente/PDF", - ".doc": "Documente/Word", - ".docx": "Documente/Word", - ".txt": "Documente/Text", - ".rtf": "Documente/Text", - ".xlsx": "Documente/Excel", - ".xls": "Documente/Excel", - ".csv": "Documente/CSV", - ".pptx": "Documente/PowerPoint", - ".ppt": "Documente/PowerPoint", - ".jpg": "Imagini", - ".jpeg": "Imagini", - ".png": "Imagini", - ".gif": "Imagini", - ".bmp": "Imagini", - ".svg": "Imagini", - ".zip": "Arhive", - ".rar": "Arhive", - ".7z": "Arhive", - ".tar": "Arhive", - ".gz": "Arhive", - ".py": "Cod", - ".js": "Cod", - ".html": "Cod", - ".css": "Cod", - ".java": "Cod", - ".mp3": "Audio", - ".wav": "Audio", - ".mp4": "Video", - ".avi": "Video", - ".mkv": "Video", -} - - -class ScanThread(QThread): - """ - Thread real de scanare care: - 1. Parcurge recursiv folderul sursă - 2. Categorizează fișierele după extensie (până la integrarea AI) - 3. Adaugă fiecare fișier în quarantine_db pentru review-ul utilizatorului - """ - - progress_updated = pyqtSignal(int) - log_updated = pyqtSignal(str) - scan_finished = pyqtSignal(int) # emite numărul total de fișiere adăugate - - def __init__(self, source_dir: str, dest_dir: str): - super().__init__() - self.source_dir = Path(source_dir) - self.dest_dir = Path(dest_dir) - def run(self): - # Colectăm toate fișierele din source (recursiv) - files = [f for f in self.source_dir.rglob("*") if f.is_file()] - total = len(files) - if total == 0: - 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...") - - added_count = 0 - skipped_count = 0 - - # Preluăm fișierele deja existente în carantină (pentru a evita duplicatele) - existing_paths = {r["original_path"] for r in quarantine_db.get_all()} - - for i, file_path in enumerate(files): - str_path = str(file_path) - - # Verificăm dacă fișierul e deja în carantină - if str_path in existing_paths: - skipped_count += 1 - self.log_updated.emit( - f"⏭️ {file_path.name} — deja în carantină, skip" - ) - else: - # Categorizare pe bază de extensie - ext = file_path.suffix.lower() - category = EXTENSION_CATEGORIES.get(ext, "Altele") - proposed_folder = str(self.dest_dir / category) - - quarantine_db.add( - original_path=str_path, - ai_proposed_name=file_path.name, - ai_proposed_folder=proposed_folder, - reason=f"Categorizat automat după extensie: {ext or 'fără extensie'}", - ) - added_count += 1 - - self.log_updated.emit( - f"📄 {file_path.name} → 📂 {category}" - ) - - # Actualizăm progress bar-ul - progress = int((i + 1) / total * 100) - self.progress_updated.emit(progress) - - self.log_updated.emit( - f"\n✅ Scanare completă! {added_count} fișiere adăugate, " - f"{skipped_count} existente (skip)." - ) - self.scan_finished.emit(added_count) +from core.scan_worker import ScanWorker class ScanTab(QWidget): @@ -154,6 +44,16 @@ def init_ui(self): dest_layout.addWidget(self.dest_input) dest_layout.addWidget(self.dest_browse_btn) + # 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" + ) + rule_layout.addWidget(self.rule_label) + rule_layout.addWidget(self.rule_input) + # Start button self.start_btn = QPushButton("Start Scan") self.start_btn.setObjectName( @@ -172,6 +72,7 @@ def init_ui(self): # Add all to main layout layout.addLayout(source_layout) layout.addLayout(dest_layout) + layout.addLayout(rule_layout) layout.addWidget(self.start_btn) layout.addWidget(self.progress_bar) layout.addWidget(QLabel("Logs:")) @@ -192,9 +93,13 @@ def browse_dest(self): self.dest_input.setText(directory) def start_scan(self): - if not self.source_input.text() or not self.dest_input.text(): + if ( + not self.source_input.text() + or not self.dest_input.text() + or not self.rule_input.text() + ): self.log_area.append( - "⚠️ Selectează atât folderul sursă cât și cel destinație." + "⚠️ Selectează folderele și introdu o regulă de organizare AI." ) return @@ -202,10 +107,11 @@ def start_scan(self): self.progress_bar.setValue(0) self.log_area.clear() - # Pornim thread-ul real de scanare - self.scan_thread = ScanThread( + # Pornim worker-ul AI de scanare + self.scan_thread = ScanWorker( source_dir=self.source_input.text(), dest_dir=self.dest_input.text(), + user_rule=self.rule_input.text(), ) self.scan_thread.progress_updated.connect(self.update_progress) self.scan_thread.log_updated.connect(self.append_log) From b9dd6dfe39273d09d313a6e0712624946caa1704 Mon Sep 17 00:00:00 2001 From: Fernando-Emanuel Donea Date: Mon, 11 May 2026 11:47:06 +0300 Subject: [PATCH 05/10] chore: untrack __pycache__ files --- ui/__pycache__/__init__.cpython-314.pyc | Bin 141 -> 0 bytes ui/__pycache__/app_window.cpython-314.pyc | Bin 1990 -> 0 bytes ui/tabs/__pycache__/__init__.cpython-314.pyc | Bin 146 -> 0 bytes .../__pycache__/quarantine_tab.cpython-314.pyc | Bin 7821 -> 0 bytes ui/tabs/__pycache__/rules_tab.cpython-314.pyc | Bin 9531 -> 0 bytes ui/tabs/__pycache__/scan_tab.cpython-314.pyc | Bin 7593 -> 0 bytes 6 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 ui/__pycache__/__init__.cpython-314.pyc delete mode 100644 ui/__pycache__/app_window.cpython-314.pyc delete mode 100644 ui/tabs/__pycache__/__init__.cpython-314.pyc delete mode 100644 ui/tabs/__pycache__/quarantine_tab.cpython-314.pyc delete mode 100644 ui/tabs/__pycache__/rules_tab.cpython-314.pyc delete mode 100644 ui/tabs/__pycache__/scan_tab.cpython-314.pyc diff --git a/ui/__pycache__/__init__.cpython-314.pyc b/ui/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index dddcec1aaa80e6e5f49323006b3f1f54d71b7c15..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 141 zcmdPq&ryk0@&Ee@O9{FKt1RJ$TppjMFK Q#UREfW=2NFB4!{90O5%rT>t<8 diff --git a/ui/__pycache__/app_window.cpython-314.pyc b/ui/__pycache__/app_window.cpython-314.pyc deleted file mode 100644 index bdc0fe7bc6c2404dcb9590c90cba4c834ca52202..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1990 zcmb7F&2Jk;6rWwM*JC?&a3CLc>exMj|0miA$m!=+TIOrco_YjT&))3pb>cuLBZqc6Yok;?lLW^XB)S-+S}s_ujsh zP9+dLPk#Bw`awbHXTA|nG!V}2fUt|UkS5hpO`=jwrgAMpBQ=F81&+)0XicT6Cr9eB z8m8Ejm3q9Epos#SMJ1#~=aHt~m8ucnFHLH(^Qeq}<=e8tl8x(z<=nQMZtnq0G;SH~ z+g5kmq@ZYi)cbPX*y#-@i#2?-EV0Ra_DsRPTWDfPDCVEfOq5=k>_`)y zRgmFW_^b~hR>5s+th^a%qR*j})c`4=CfXFG9a|x!p=#jKvxrTo0#TDRxf&dyfLd{3 zAw2gX`OTz2!?h`eo?9t_hiG4J%A3;y57(`P|EmHq_yaLBz7(FOiJWmB;AK|m!xeIx z33o!8$Y_3y1p@e-64?L$dvj6f!xbzS!kv(`$VA2R0u>d5NaJ(AQlur}DO@#WB2r1< z6OqQXd!gY*>>?_@mjt3v1OaZ8)VyXtcNDCioj0m z4v>c_ylYt0YZz#=aj$HYXIRu7^i9IBu3L^pb)BVMllpYOWl`H?DluK|kMo_q2Ff+l zp~SHLo+O;sY=*-f8)S={J$19Q%ChF!=<2NhF61072+hvS5I zG{*@i7)yUK&Xj{NFBI3b@3C~p&p?0R4-3`y=R{oBJGSAvy6*De7mCGQR$2R?^_goD zx3zxblUjYFHBq(Jz-k%&KDgoYXQjVG_Wo_YZS9lY^f_Aoob=Ruh*9>qbK;-TXE z@e>>vr(J!OO!|6_BKtsG}7L;FX3`3w#V z6Poa@BRT%!okr$3iaA>xBT zuI}I^g@jS^?_0 zD}u4aBXCB*A!9uT&k6ix=6ZDY0)LPEB#ID=;Z?tMSI8yU@-KnQ@!uknB>jZyPf`68 Xy?Kf*oT5uF)SR^XDESA1#q;nlM|P(b diff --git a/ui/tabs/__pycache__/__init__.cpython-314.pyc b/ui/tabs/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index ec0d7773fa9aa7e136dc91603be51b11e37c5e4e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 146 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 diff --git a/ui/tabs/__pycache__/rules_tab.cpython-314.pyc b/ui/tabs/__pycache__/rules_tab.cpython-314.pyc deleted file mode 100644 index 717ec27cca7660f3ada3f45fd8515d55227658a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9531 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 H$RO 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 From e8f733409b40058cbcb6f40ff970ae874e05fa1f Mon Sep 17 00:00:00 2001 From: Fernando-Emanuel Donea Date: Mon, 11 May 2026 12:24:56 +0300 Subject: [PATCH 06/10] docs: update .env.example with GOOGLE_MODEL_NAME --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index 558cced..d34f305 100644 --- a/.env.example +++ b/.env.example @@ -5,5 +5,8 @@ AI_PROVIDER=ollama # Daca AI_PROVIDER este 'google', introdu aici cheia ta secreta de API GOOGLE_API_KEY=your_google_api_key_here +# Modelul implicit daca folosesti google +GOOGLE_MODEL_NAME=gemini-2.5-flash + # URL-ul de baza pentru containerul local de ollama OLLAMA_BASE_URL=http://ollama:11434 From f6e57c3086e89daa2709c64336dd4f9acafa4a58 Mon Sep 17 00:00:00 2001 From: Fernando-Emanuel Donea Date: Mon, 11 May 2026 12:40:04 +0300 Subject: [PATCH 07/10] update .env.example --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index d34f305..636dd69 100644 --- a/.env.example +++ b/.env.example @@ -9,4 +9,4 @@ GOOGLE_API_KEY=your_google_api_key_here GOOGLE_MODEL_NAME=gemini-2.5-flash # URL-ul de baza pentru containerul local de ollama -OLLAMA_BASE_URL=http://ollama:11434 +OLLAMA_BASE_URL=http://localhost:11434 From cc0206bd20b0ea6aaee1c07ff5ca11d4aa566a66 Mon Sep 17 00:00:00 2001 From: Fernando-Emanuel Donea Date: Mon, 11 May 2026 13:16:35 +0300 Subject: [PATCH 08/10] docs: update setup instructions in README --- README.md | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ba7e6fc..2184e79 100644 --- a/README.md +++ b/README.md @@ -139,24 +139,48 @@ To ensure consistent code quality and formatting, this project is configured to Once installed, `ruff` will automatically format your code on `git commit`. If changes are made by the formatter, the commit will abort—simply `git add` the updated files and run `git commit` again. -## 🐳 Docker & Local AI Setup +## 🐳 Environment & Local AI Setup The application leverages Docker to seamlessly run local AI models without complicating the host system. -### Setup Instructions: -1. Start the Docker containers: +### Step-by-Step Setup Instructions: + +1. **Configure Environment Variables**: ```bash - docker-compose up -d + cp .env.example .env ``` -2. Create the custom AI model (Gemma 2 based) configured for precision: + +2. **Start the Docker container** (for Ollama): + ```bash + docker-compose up -d ollama + ``` + +3. **Pull base model and Create Custom AI Models**: + ClutterKill uses two distinct models for processing (Classifier and Extractor): ```bash + # Pull the base model (Wait for the 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. + + # Create Agent 0 & 2 (Classifier) 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 ``` -3. Verify the model is running: + +4. **Verify the models are running**: ```bash curl http://localhost:11434/api/tags ``` -*Note: The `docker-compose.yml` configuration also provides environment variables (`AI_PROVIDER`, `GOOGLE_API_KEY`) to easily switch between local `ollama` processing and cloud-based alternatives like `google`.* +5. **Run the Application**: + Activate your virtual environment and run the graphical interface: + ```bash + source .venv/bin/activate + python main.py + ``` + +*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). From e241c31cf691d5956f024f137556615eb0470be7 Mon Sep 17 00:00:00 2001 From: Fernando-Emanuel Donea Date: Mon, 11 May 2026 13:21:43 +0300 Subject: [PATCH 09/10] fix: enforce smart file renaming by Agent 2 --- ai/agent_decider.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ai/agent_decider.py b/ai/agent_decider.py index 55addd8..4187bc8 100644 --- a/ai/agent_decider.py +++ b/ai/agent_decider.py @@ -72,9 +72,10 @@ def sanitize_filename(cls, v: str) -> str: 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. Calculate the new filename based on the Naming Convention. If the naming convention includes {{original_filename}}, replace it with the actual original filename. -4. If the status is "quarantine", the folder must be "Quarantine". -5. If the status is "quarantine", the suggested_name MUST be exactly the Original Filename. +3. Calculate the new filename based on the Naming Convention. If the naming convention is '{original_filename}', missing, or unclear, YOU MUST create a smart, descriptive, and short filename based on the Document Summary (e.g. 'Factura_ENEL_12_05_2023.pdf' or 'Curs_Analiza_MDS.pdf'). DO NOT keep chaotic original filenames like 'scan_123.pdf'. +4. CRITICAL: The new filename MUST keep the exact same file extension as the Original Filename. +5. If the status is "quarantine", the folder must be "Quarantine". +6. If the status is "quarantine", the suggested_name MUST be exactly the Original Filename. IMPORTANT: You must return ONLY the raw JSON object containing the actual values. Do NOT return a JSON schema, and do NOT wrap your answer in markdown fences (like ```json). From 0e9ef9a45803a5aa3d59b11b1f2713f02a5577e8 Mon Sep 17 00:00:00 2001 From: Fernando-Emanuel Donea Date: Mon, 11 May 2026 13:54:05 +0300 Subject: [PATCH 10/10] fix: agent decider treats naming convention as semantic template --- ai/agent_decider.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/ai/agent_decider.py b/ai/agent_decider.py index 4187bc8..25aeff3 100644 --- a/ai/agent_decider.py +++ b/ai/agent_decider.py @@ -72,10 +72,16 @@ def sanitize_filename(cls, v: str) -> str: 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. Calculate the new filename based on the Naming Convention. If the naming convention is '{original_filename}', missing, or unclear, YOU MUST create a smart, descriptive, and short filename based on the Document Summary (e.g. 'Factura_ENEL_12_05_2023.pdf' or 'Curs_Analiza_MDS.pdf'). DO NOT keep chaotic original filenames like 'scan_123.pdf'. -4. CRITICAL: The new filename MUST keep the exact same file extension as the Original Filename. -5. If the status is "quarantine", the folder must be "Quarantine". -6. If the status is "quarantine", the suggested_name MUST be exactly the Original Filename. +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". +7. If the status is "quarantine", the suggested_name MUST be exactly the Original Filename. IMPORTANT: You must return ONLY the raw JSON object containing the actual values. Do NOT return a JSON schema, and do NOT wrap your answer in markdown fences (like ```json).