Download models.py from FannyFa/FannyFa-Model-V1: direct link, hf CLI and curl.
- Browser
- Download file 39.2 kB
-
https://huggingface.co/FannyFa/FannyFa-Model-V1/resolve/main/models.py
- Command line
-
hf download hf://FannyFa/FannyFa-Model-V1/models.py
-
curl -L -o models.py https://huggingface.co/FannyFa/FannyFa-Model-V1/resolve/main/models.py
39.2 kB
| import os | |
| import json | |
| import time | |
| import shutil | |
| import hashlib | |
| from pathlib import Path | |
| from typing import Dict, Optional, Any, List, Tuple | |
| from config import ( | |
| CONFIG_FILE, | |
| MODEL_DIR, | |
| EXPORT_DIR, | |
| DEFAULT_MEMORY_LIMIT_GB, | |
| DEFAULT_MODEL, | |
| ) | |
| from utils import console, Theme, error_logger, debug_logger | |
| # ========================================================================== | |
| # Constants | |
| # ========================================================================== | |
| CONFIG_VERSION = 2 | |
| CONFIG_BACKUP_DIR = "backups" | |
| MAX_CONFIG_BACKUPS = 5 | |
| MIN_CONFIG_BACKUP_INTERVAL_SEC = 30.0 | |
| # Nilai default yang dipakai saat migrasi config lama. | |
| _TRAINING_DEFAULTS: Dict[str, Any] = { | |
| "base_model": DEFAULT_MODEL, | |
| "learning_rate": 3e-5, | |
| "batch_size": 4, | |
| "num_epochs": 6, | |
| "max_length": 256, | |
| "warmup_steps": 100, | |
| "warmup_ratio": 0.1, | |
| "weight_decay": 0.01, | |
| "gradient_accumulation_steps": 1, | |
| "validation_split": 0.1, | |
| "seed": 42, | |
| "save_steps": 500, | |
| "logging_steps": 50, | |
| "early_stopping_patience": 3, | |
| "fp16": False, | |
| "bf16": False, | |
| "max_samples_limit": 20000, | |
| "data_augmentation": { | |
| "enabled": False, | |
| "split_long": True, | |
| "split_max_chars": 300, | |
| }, | |
| "use_peft": False, | |
| "use_8bit": False, | |
| "peft_config": { | |
| "r": 8, | |
| "alpha": 16, | |
| "dropout": 0.05, | |
| "target_modules": "auto", | |
| }, | |
| "freeze_embeddings": False, | |
| "dynamic_grad_accumulation": False, | |
| "gradient_clip_value": 1.0, | |
| "eval_gen_samples": 16, | |
| "load_best_model_at_end": True, | |
| "max_context_tokens": 768, | |
| "num_proc": 1, | |
| } | |
| _GENERATION_DEFAULTS: Dict[str, Any] = { | |
| "max_new_tokens": 150, | |
| "temperature": 0.8, | |
| "top_p": 0.9, | |
| "top_k": 50, | |
| "repetition_penalty": 1.1, | |
| "no_repeat_ngram_size": 3, | |
| "max_context_length": 768, | |
| "stop_sequences": [], | |
| } | |
| # Rentang nilai yang dianggap valid (min, max), inclusive. | |
| _TRAINING_RANGES: Dict[str, Tuple[float, float]] = { | |
| "learning_rate": (1e-8, 1e-1), | |
| "batch_size": (1, 4096), | |
| "num_epochs": (1, 1000), | |
| "max_length": (16, 32768), | |
| "warmup_steps": (0, 1000000), | |
| "warmup_ratio": (0.0, 1.0), | |
| "weight_decay": (0.0, 1.0), | |
| "gradient_accumulation_steps": (1, 1024), | |
| "validation_split": (0.0, 0.9), | |
| "seed": (0, 2147483647), | |
| "save_steps": (1, 1000000), | |
| "logging_steps": (1, 1000000), | |
| "early_stopping_patience": (0, 1000), | |
| "max_samples_limit": (1, 100000000), | |
| "gradient_clip_value": (0.0, 100.0), | |
| "eval_gen_samples": (0, 10000), | |
| "max_context_tokens": (64, 131072), | |
| "num_proc": (1, 128), | |
| } | |
| _GENERATION_RANGES: Dict[str, Tuple[float, float]] = { | |
| "max_new_tokens": (1, 8192), | |
| "temperature": (0.01, 2.0), | |
| "top_p": (0.0, 1.0), | |
| "top_k": (0, 1000), | |
| "repetition_penalty": (1.0, 3.0), | |
| "no_repeat_ngram_size": (0, 20), | |
| "max_context_length": (64, 131072), | |
| } | |
| # Nama file yang dianggap sebagai penanda komponen model. | |
| _WEIGHT_FILE_NAMES = { | |
| "pytorch_model.bin", | |
| "model.safetensors", | |
| "tf_model.h5", | |
| "adapter_model.bin", | |
| "adapter_model.safetensors", | |
| } | |
| _TOKENIZER_FILE_NAMES = { | |
| "tokenizer.json", | |
| "tokenizer_config.json", | |
| "vocab.json", | |
| "vocab.txt", | |
| "merges.txt", | |
| "special_tokens_map.json", | |
| "spiece.model", | |
| } | |
| # ========================================================================== | |
| # Presets | |
| # ========================================================================== | |
| TRAINING_PRESETS: Dict[str, Dict[str, Any]] = { | |
| "fast": { | |
| "learning_rate": 5e-5, | |
| "batch_size": 8, | |
| "num_epochs": 3, | |
| "max_length": 128, | |
| "gradient_accumulation_steps": 1, | |
| "warmup_ratio": 0.05, | |
| "save_steps": 200, | |
| "logging_steps": 25, | |
| "early_stopping_patience": 2, | |
| }, | |
| "balanced": { | |
| "learning_rate": 3e-5, | |
| "batch_size": 4, | |
| "num_epochs": 6, | |
| "max_length": 256, | |
| "gradient_accumulation_steps": 1, | |
| "warmup_ratio": 0.1, | |
| "save_steps": 500, | |
| "logging_steps": 50, | |
| "early_stopping_patience": 3, | |
| }, | |
| "quality": { | |
| "learning_rate": 2e-5, | |
| "batch_size": 2, | |
| "num_epochs": 12, | |
| "max_length": 512, | |
| "gradient_accumulation_steps": 4, | |
| "warmup_ratio": 0.15, | |
| "save_steps": 500, | |
| "logging_steps": 50, | |
| "early_stopping_patience": 5, | |
| }, | |
| "low_vram": { | |
| "learning_rate": 3e-5, | |
| "batch_size": 1, | |
| "num_epochs": 6, | |
| "max_length": 128, | |
| "gradient_accumulation_steps": 4, | |
| "warmup_ratio": 0.1, | |
| "save_steps": 200, | |
| "logging_steps": 25, | |
| "early_stopping_patience": 3, | |
| "use_peft": True, | |
| "use_8bit": True, | |
| "freeze_embeddings": True, | |
| "dynamic_grad_accumulation": True, | |
| }, | |
| } | |
| GENERATION_PRESETS: Dict[str, Dict[str, Any]] = { | |
| "balanced": { | |
| "temperature": 0.8, | |
| "top_p": 0.9, | |
| "top_k": 50, | |
| "repetition_penalty": 1.1, | |
| "no_repeat_ngram_size": 3, | |
| "max_new_tokens": 150, | |
| }, | |
| "creative": { | |
| "temperature": 1.1, | |
| "top_p": 0.95, | |
| "top_k": 80, | |
| "repetition_penalty": 1.05, | |
| "no_repeat_ngram_size": 2, | |
| "max_new_tokens": 200, | |
| }, | |
| "precise": { | |
| "temperature": 0.4, | |
| "top_p": 0.85, | |
| "top_k": 30, | |
| "repetition_penalty": 1.2, | |
| "no_repeat_ngram_size": 4, | |
| "max_new_tokens": 120, | |
| }, | |
| "coding": { | |
| "temperature": 0.2, | |
| "top_p": 0.9, | |
| "top_k": 40, | |
| "repetition_penalty": 1.15, | |
| "no_repeat_ngram_size": 3, | |
| "max_new_tokens": 256, | |
| }, | |
| } | |
| # ========================================================================== | |
| # ModelMetadata | |
| # ========================================================================== | |
| class ModelMetadata: | |
| def __init__( | |
| self, | |
| model_name: str, | |
| base_model: str, | |
| dataset_path: str, | |
| dataset_format: str, | |
| dataset_size: int, | |
| training_samples: int, | |
| validation_samples: int, | |
| created_date: str, | |
| status: str = "trained", | |
| description: str = "", | |
| version: str = "1.0.0", | |
| hash: str = "", | |
| tags: list = None, | |
| training_env: dict = None, | |
| metrics: dict = None, | |
| config: dict = None, | |
| ): | |
| self.model_name = model_name | |
| self.base_model = base_model | |
| self.dataset_path = dataset_path | |
| self.dataset_format = dataset_format | |
| self.dataset_size = dataset_size | |
| self.training_samples = training_samples | |
| self.validation_samples = validation_samples | |
| self.created_date = created_date | |
| self.status = status | |
| self.description = description | |
| self.version = version | |
| self.hash = hash | |
| self.tags = tags or [] | |
| self.training_env = training_env or {} | |
| self.metrics = metrics or {} | |
| self.config = config or {} | |
| def to_dict(self) -> Dict: | |
| return { | |
| "model_name": self.model_name, | |
| "base_model": self.base_model, | |
| "dataset_path": self.dataset_path, | |
| "dataset_format": self.dataset_format, | |
| "dataset_size": self.dataset_size, | |
| "training_samples": self.training_samples, | |
| "validation_samples": self.validation_samples, | |
| "created_date": self.created_date, | |
| "status": self.status, | |
| "description": self.description, | |
| "version": self.version, | |
| "hash": self.hash, | |
| "tags": self.tags, | |
| "training_env": self.training_env, | |
| "metrics": self.metrics, | |
| "config": self.config, | |
| } | |
| def from_dict(cls, data: Dict) -> "ModelMetadata": | |
| return cls( | |
| model_name=data.get("model_name", ""), | |
| base_model=data.get("base_model", ""), | |
| dataset_path=data.get("dataset_path", ""), | |
| dataset_format=data.get("dataset_format", ""), | |
| dataset_size=data.get("dataset_size", 0), | |
| training_samples=data.get("training_samples", 0), | |
| validation_samples=data.get("validation_samples", 0), | |
| created_date=data.get("created_date", ""), | |
| status=data.get("status", "trained"), | |
| description=data.get("description", ""), | |
| version=data.get("version", "1.0.0"), | |
| hash=data.get("hash", ""), | |
| tags=data.get("tags", []), | |
| training_env=data.get("training_env", {}), | |
| metrics=data.get("metrics", {}), | |
| config=data.get("config", {}), | |
| ) | |
| # ========================================================================== | |
| # ModelManager | |
| # ========================================================================== | |
| class ModelManager: | |
| def __init__(self): | |
| # Inisialisasi atribut yang dipakai oleh save_config/backup SEBELUM | |
| # load_config() dipanggil, supaya tidak ada AttributeError. | |
| self._last_backup_time: float = 0.0 | |
| self.config_file = CONFIG_FILE | |
| self.config: Dict = self.load_config() | |
| # ------------------------------------------------------------------ | |
| # Config load / create / migration | |
| # ------------------------------------------------------------------ | |
| def load_config(self) -> Dict: | |
| if os.path.exists(self.config_file): | |
| try: | |
| with open(self.config_file, "r", encoding="utf-8") as f: | |
| data = json.load(f) | |
| except (json.JSONDecodeError, IOError) as e: | |
| console.print(Theme.warning(f"Config error: {e}, using default")) | |
| error_logger.error(f"Config load failed: {e}") | |
| return self.create_default_config() | |
| if not isinstance(data, dict): | |
| console.print( | |
| Theme.warning("Config bukan dict, menggunakan default") | |
| ) | |
| return self.create_default_config() | |
| migrated, was_migrated = self._migrate_config(data) | |
| if was_migrated: | |
| try: | |
| self.save_config(migrated) | |
| debug_logger.debug("Config migrated and saved") | |
| except Exception as e: | |
| # Jangan crash hanya karena gagal simpan hasil migrasi. | |
| error_logger.error(f"Config migration save failed: {e}") | |
| # Validasi hanya untuk memberi tahu, tidak menghentikan load. | |
| self._validate_and_log(migrated, prefix="load_config") | |
| return migrated | |
| else: | |
| return self.create_default_config() | |
| def create_default_config(self) -> Dict: | |
| import torch | |
| config: Dict[str, Any] = { | |
| "config_version": CONFIG_VERSION, | |
| "current_model": None, | |
| "models": {}, | |
| "memory_limit_gb": DEFAULT_MEMORY_LIMIT_GB, | |
| "multi_gpu": False, | |
| "training_config": dict(_TRAINING_DEFAULTS, **{ | |
| "fp16": True if torch.cuda.is_available() else False, | |
| }), | |
| "generation_config": dict(_GENERATION_DEFAULTS), | |
| } | |
| # Deep-copy untuk field nested agar tidak ada shared reference. | |
| config["training_config"]["data_augmentation"] = dict( | |
| _TRAINING_DEFAULTS["data_augmentation"] | |
| ) | |
| config["training_config"]["peft_config"] = dict( | |
| _TRAINING_DEFAULTS["peft_config"] | |
| ) | |
| self.save_config(config) | |
| return config | |
| def _migrate_config(self, cfg: Dict) -> Tuple[Dict, bool]: | |
| """ | |
| Migrasi config lama ke versi terbaru. | |
| Return (config_baru, apakah_berubah). | |
| Tidak menghapus field yang sudah ada. | |
| """ | |
| changed = False | |
| try: | |
| version = int(cfg.get("config_version", 1)) | |
| except (TypeError, ValueError): | |
| version = 1 | |
| if version < CONFIG_VERSION: | |
| # Pastikan top-level field penting ada. | |
| for key, value in [ | |
| ("current_model", None), | |
| ("models", {}), | |
| ("memory_limit_gb", DEFAULT_MEMORY_LIMIT_GB), | |
| ("multi_gpu", False), | |
| ]: | |
| if key not in cfg: | |
| cfg[key] = value | |
| changed = True | |
| # Migrasi training_config | |
| tc = cfg.get("training_config") | |
| if not isinstance(tc, dict): | |
| tc = {} | |
| changed = True | |
| for key, default_value in _TRAINING_DEFAULTS.items(): | |
| if key not in tc: | |
| if isinstance(default_value, dict): | |
| tc[key] = dict(default_value) | |
| else: | |
| tc[key] = default_value | |
| changed = True | |
| cfg["training_config"] = tc | |
| # Migrasi generation_config | |
| gc = cfg.get("generation_config") | |
| if not isinstance(gc, dict): | |
| gc = {} | |
| changed = True | |
| for key, default_value in _GENERATION_DEFAULTS.items(): | |
| if key not in gc: | |
| if isinstance(default_value, list): | |
| gc[key] = list(default_value) | |
| else: | |
| gc[key] = default_value | |
| changed = True | |
| cfg["generation_config"] = gc | |
| cfg["config_version"] = CONFIG_VERSION | |
| changed = True | |
| return cfg, changed | |
| # ------------------------------------------------------------------ | |
| # Validation | |
| # ------------------------------------------------------------------ | |
| def _check_range_dict( | |
| self, | |
| cfg: Dict, | |
| ranges: Dict[str, Tuple[float, float]], | |
| issues: List[str], | |
| label: str, | |
| ) -> None: | |
| for key, (lo, hi) in ranges.items(): | |
| if key not in cfg: | |
| continue | |
| val = cfg[key] | |
| # bool adalah subclass int - lewati agar tidak salah nilai. | |
| if isinstance(val, bool): | |
| issues.append(f"{label}.{key} bertipe bool, diharapkan angka") | |
| continue | |
| if not isinstance(val, (int, float)): | |
| issues.append( | |
| f"{label}.{key} bertipe {type(val).__name__}, diharapkan angka" | |
| ) | |
| continue | |
| if val < lo or val > hi: | |
| issues.append( | |
| f"{label}.{key}={val} di luar rentang [{lo}, {hi}]" | |
| ) | |
| def _validate_training_config(self, cfg: Dict) -> List[str]: | |
| issues: List[str] = [] | |
| if not isinstance(cfg, dict): | |
| return ["training_config bukan dict"] | |
| self._check_range_dict(cfg, _TRAINING_RANGES, issues, "training_config") | |
| # Cek nilai yang tidak masuk rentang numerik tapi boolean/logika. | |
| for key in ("fp16", "bf16"): | |
| if key in cfg and not isinstance(cfg[key], bool): | |
| issues.append(f"training_config.{key} bukan boolean") | |
| for key in ( | |
| "use_peft", | |
| "use_8bit", | |
| "freeze_embeddings", | |
| "dynamic_grad_accumulation", | |
| "load_best_model_at_end", | |
| ): | |
| if key in cfg and not isinstance(cfg[key], bool): | |
| issues.append(f"training_config.{key} bukan boolean") | |
| bm = cfg.get("base_model") | |
| if bm is not None and not isinstance(bm, str): | |
| issues.append("training_config.base_model bukan string") | |
| return issues | |
| def _validate_generation_config(self, cfg: Dict) -> List[str]: | |
| issues: List[str] = [] | |
| if not isinstance(cfg, dict): | |
| return ["generation_config bukan dict"] | |
| self._check_range_dict(cfg, _GENERATION_RANGES, issues, "generation_config") | |
| ss = cfg.get("stop_sequences") | |
| if ss is not None and not isinstance(ss, list): | |
| issues.append("generation_config.stop_sequences bukan list") | |
| return issues | |
| def _validate_global_config(self, cfg: Dict) -> List[str]: | |
| issues: List[str] = [] | |
| if "memory_limit_gb" in cfg: | |
| ml = cfg["memory_limit_gb"] | |
| if isinstance(ml, bool) or not isinstance(ml, (int, float)): | |
| issues.append("memory_limit_gb bukan angka") | |
| elif ml < 0 or ml > 4096: | |
| issues.append(f"memory_limit_gb={ml} di luar rentang [0, 4096]") | |
| if "multi_gpu" in cfg and not isinstance(cfg["multi_gpu"], bool): | |
| issues.append("multi_gpu bukan boolean") | |
| if "models" in cfg and not isinstance(cfg["models"], dict): | |
| issues.append("models bukan dict") | |
| return issues | |
| def validate_config( | |
| self, cfg: Optional[Dict] = None, log: bool = False | |
| ) -> Dict[str, List[str]]: | |
| """ | |
| Validasi config. Return dict berisi daftar issue per kategori. | |
| Tidak crash — hanya melaporkan. | |
| """ | |
| if cfg is None: | |
| cfg = self.config | |
| report = { | |
| "global": self._validate_global_config(cfg), | |
| "training": self._validate_training_config( | |
| cfg.get("training_config", {}) | |
| ), | |
| "generation": self._validate_generation_config( | |
| cfg.get("generation_config", {}) | |
| ), | |
| } | |
| if log: | |
| total = sum(len(v) for v in report.values()) | |
| if total == 0: | |
| console.print(Theme.success("Config valid")) | |
| else: | |
| console.print(Theme.warning(f"Config issues: {total}")) | |
| for category, items in report.items(): | |
| for item in items: | |
| console.print(f" [{category}] {item}") | |
| return report | |
| def _validate_and_log(self, cfg: Dict, prefix: str = "") -> None: | |
| try: | |
| report = self.validate_config(cfg, log=False) | |
| except Exception as e: | |
| debug_logger.debug(f"Validation error: {e}") | |
| return | |
| for category, items in report.items(): | |
| for item in items: | |
| msg = f"{prefix + ': ' if prefix else ''}[{category}] {item}" | |
| debug_logger.debug(msg) | |
| # ------------------------------------------------------------------ | |
| # Backup | |
| # ------------------------------------------------------------------ | |
| def _backup_config(self) -> None: | |
| """ | |
| Buat backup config lama sebelum ditimpa. | |
| Throttled: minimal MIN_CONFIG_BACKUP_INTERVAL_SEC detik antar backup. | |
| """ | |
| if not os.path.exists(self.config_file): | |
| return | |
| now = time.time() | |
| if now - self._last_backup_time < MIN_CONFIG_BACKUP_INTERVAL_SEC: | |
| return | |
| try: | |
| os.makedirs(CONFIG_BACKUP_DIR, exist_ok=True) | |
| ts = time.strftime("%Y%m%d_%H%M%S") | |
| # Tambahkan mikrodetik agar tidak tabrakan. | |
| backup_path = os.path.join( | |
| CONFIG_BACKUP_DIR, | |
| f"config_backup_{ts}_{int((now % 1) * 1000):03d}.json", | |
| ) | |
| shutil.copy2(self.config_file, backup_path) | |
| self._last_backup_time = now | |
| self._prune_backups(CONFIG_BACKUP_DIR) | |
| except (OSError, shutil.Error) as e: | |
| error_logger.error(f"Config backup failed: {e}") | |
| def _prune_backups(self, backup_dir: str) -> None: | |
| """Simpan hanya MAX_CONFIG_BACKUPS backup terbaru.""" | |
| try: | |
| entries = [ | |
| f | |
| for f in os.listdir(backup_dir) | |
| if f.startswith("config_backup_") and f.endswith(".json") | |
| ] | |
| if len(entries) <= MAX_CONFIG_BACKUPS: | |
| return | |
| entries.sort( | |
| key=lambda f: os.path.getmtime(os.path.join(backup_dir, f)) | |
| ) | |
| to_remove = entries[:-MAX_CONFIG_BACKUPS] | |
| for name in to_remove: | |
| try: | |
| os.remove(os.path.join(backup_dir, name)) | |
| except OSError: | |
| pass | |
| except OSError: | |
| pass | |
| # ------------------------------------------------------------------ | |
| # Save | |
| # ------------------------------------------------------------------ | |
| def save_config(self, config: Dict = None) -> None: | |
| if config is None: | |
| config = self.config | |
| # Pastikan field versi selalu ada. | |
| if isinstance(config, dict) and "config_version" not in config: | |
| config["config_version"] = CONFIG_VERSION | |
| self._backup_config() | |
| temp_path = f"{self.config_file}.tmp" | |
| try: | |
| with open(temp_path, "w", encoding="utf-8") as f: | |
| json.dump(config, f, indent=2, ensure_ascii=False) | |
| os.replace(temp_path, self.config_file) | |
| except Exception as e: | |
| error_logger.error(f"Config save error: {e}") | |
| raise | |
| # ------------------------------------------------------------------ | |
| # Existing API (backward-compatible) | |
| # ------------------------------------------------------------------ | |
| def get_current_model(self) -> Optional[str]: | |
| return self.config.get("current_model") | |
| def set_current_model(self, model_id: str) -> None: | |
| self.config["current_model"] = model_id | |
| self.save_config() | |
| def get_model_info(self, model_id: str) -> Optional[Dict]: | |
| return self.config["models"].get(model_id) | |
| def list_models(self) -> Dict: | |
| return self.config["models"] | |
| def add_model(self, model_id: str, metadata: ModelMetadata) -> None: | |
| self.config["models"][model_id] = metadata.to_dict() | |
| self.save_config() | |
| def delete_model(self, model_id: str) -> bool: | |
| if model_id in self.config["models"]: | |
| del self.config["models"][model_id] | |
| if self.config["current_model"] == model_id: | |
| self.config["current_model"] = None | |
| self.save_config() | |
| model_path = os.path.join(MODEL_DIR, model_id) | |
| if os.path.exists(model_path): | |
| try: | |
| shutil.rmtree(model_path) | |
| except OSError as e: | |
| error_logger.error(f"Gagal hapus folder model: {e}") | |
| return False | |
| return True | |
| return False | |
| def get_training_config(self) -> Dict: | |
| return self.config.get("training_config", {}) | |
| def update_training_config(self, **kwargs) -> None: | |
| for key, value in kwargs.items(): | |
| if key in self.config["training_config"]: | |
| self.config["training_config"][key] = value | |
| self.save_config() | |
| def get_generation_config(self) -> Dict: | |
| return self.config.get("generation_config", {}) | |
| def update_generation_config(self, **kwargs) -> None: | |
| for key, value in kwargs.items(): | |
| if key in self.config["generation_config"]: | |
| self.config["generation_config"][key] = value | |
| self.save_config() | |
| def is_multi_gpu(self) -> bool: | |
| return self.config.get("multi_gpu", False) | |
| # ------------------------------------------------------------------ | |
| # Presets | |
| # ------------------------------------------------------------------ | |
| def list_training_presets(self) -> List[str]: | |
| return sorted(TRAINING_PRESETS.keys()) | |
| def list_generation_presets(self) -> List[str]: | |
| return sorted(GENERATION_PRESETS.keys()) | |
| def apply_training_preset(self, preset_name: str) -> bool: | |
| """ | |
| Terapkan preset ke training_config. | |
| Hanya mengubah key yang sudah ada di training_config. | |
| """ | |
| preset = TRAINING_PRESETS.get(preset_name) | |
| if preset is None: | |
| console.print( | |
| Theme.error(f"Preset training tidak dikenal: {preset_name}") | |
| ) | |
| return False | |
| tc = self.config.get("training_config", {}) | |
| applied: List[str] = [] | |
| skipped: List[str] = [] | |
| for key, value in preset.items(): | |
| if key in tc: | |
| tc[key] = value | |
| applied.append(key) | |
| else: | |
| skipped.append(key) | |
| self.save_config() | |
| console.print( | |
| Theme.success(f"Preset training '{preset_name}' diterapkan " | |
| f"({len(applied)} parameter)") | |
| ) | |
| if skipped: | |
| console.print( | |
| Theme.dim(f" Dilewati (tidak ada di config): {skipped}") | |
| ) | |
| return True | |
| def apply_generation_preset(self, preset_name: str) -> bool: | |
| preset = GENERATION_PRESETS.get(preset_name) | |
| if preset is None: | |
| console.print( | |
| Theme.error(f"Preset generation tidak dikenal: {preset_name}") | |
| ) | |
| return False | |
| gc = self.config.get("generation_config", {}) | |
| applied: List[str] = [] | |
| skipped: List[str] = [] | |
| for key, value in preset.items(): | |
| if key in gc: | |
| gc[key] = value | |
| applied.append(key) | |
| else: | |
| skipped.append(key) | |
| self.save_config() | |
| console.print( | |
| Theme.success(f"Preset generation '{preset_name}' diterapkan " | |
| f"({len(applied)} parameter)") | |
| ) | |
| if skipped: | |
| console.print( | |
| Theme.dim(f" Dilewati (tidak ada di config): {skipped}") | |
| ) | |
| return True | |
| # ------------------------------------------------------------------ | |
| # Model health & statistics | |
| # ------------------------------------------------------------------ | |
| def _scan_dir(self, path: str) -> Tuple[int, int]: | |
| """Return (total_size_bytes, file_count) untuk folder.""" | |
| total = 0 | |
| count = 0 | |
| try: | |
| for root, _dirs, files in os.walk(path): | |
| for name in files: | |
| full = os.path.join(root, name) | |
| try: | |
| total += os.path.getsize(full) | |
| count += 1 | |
| except OSError: | |
| continue | |
| except OSError: | |
| pass | |
| return total, count | |
| def check_model_health(self, model_id: str) -> Dict[str, Any]: | |
| """ | |
| Cek apakah model masih valid. | |
| Return dict tanpa melempar exception. | |
| """ | |
| result: Dict[str, Any] = { | |
| "model_id": model_id, | |
| "path": None, | |
| "exists": False, | |
| "is_dir": False, | |
| "size_bytes": 0, | |
| "file_count": 0, | |
| "has_config": False, | |
| "has_tokenizer": False, | |
| "has_weights": False, | |
| "in_config": model_id in self.config.get("models", {}), | |
| "is_current": self.config.get("current_model") == model_id, | |
| "status": "missing", | |
| "issues": [], | |
| } | |
| path = os.path.join(MODEL_DIR, model_id) | |
| result["path"] = path | |
| if not os.path.exists(path): | |
| result["issues"].append("model directory tidak ditemukan") | |
| return result | |
| if not os.path.isdir(path): | |
| result["issues"].append("path ada tapi bukan directory") | |
| return result | |
| result["exists"] = True | |
| result["is_dir"] = True | |
| size, count = self._scan_dir(path) | |
| result["size_bytes"] = size | |
| result["file_count"] = count | |
| try: | |
| entries = os.listdir(path) | |
| except OSError as e: | |
| result["issues"].append(f"tidak bisa list directory: {e}") | |
| entries = [] | |
| lower_entries = {name.lower() for name in entries} | |
| result["has_config"] = "config.json" in lower_entries | |
| result["has_tokenizer"] = any( | |
| name in lower_entries for name in _TOKENIZER_FILE_NAMES | |
| ) | |
| result["has_weights"] = any( | |
| name in lower_entries for name in _WEIGHT_FILE_NAMES | |
| ) | |
| if count == 0: | |
| result["issues"].append("directory kosong") | |
| result["status"] = "empty" | |
| return result | |
| if not result["has_weights"]: | |
| result["issues"].append("file bobot model tidak ditemukan") | |
| if not result["has_tokenizer"]: | |
| result["issues"].append("file tokenizer tidak ditemukan") | |
| if not result["has_config"]: | |
| result["issues"].append("config.json tidak ditemukan") | |
| if result["issues"]: | |
| result["status"] = "warning" | |
| else: | |
| result["status"] = "ok" | |
| return result | |
| def get_model_stats(self, model_id: Optional[str] = None) -> Dict[str, Any]: | |
| """ | |
| Ambil statistik model. Jika model_id None, gunakan model aktif. | |
| """ | |
| if model_id is None: | |
| model_id = self.get_current_model() | |
| stats: Dict[str, Any] = { | |
| "model_id": model_id, | |
| "path": None, | |
| "exists": False, | |
| "total_size": 0, | |
| "total_size_human": "0 B", | |
| "file_count": 0, | |
| "is_current": False, | |
| "metadata": None, | |
| } | |
| if not model_id: | |
| stats["error"] = "Tidak ada model aktif" | |
| return stats | |
| path = os.path.join(MODEL_DIR, model_id) | |
| stats["path"] = path | |
| stats["exists"] = os.path.isdir(path) | |
| stats["is_current"] = self.config.get("current_model") == model_id | |
| stats["metadata"] = self.config.get("models", {}).get(model_id) | |
| if stats["exists"]: | |
| size, count = self._scan_dir(path) | |
| stats["total_size"] = size | |
| stats["total_size_human"] = self._human_size(size) | |
| stats["file_count"] = count | |
| return stats | |
| def get_all_models_stats(self) -> List[Dict[str, Any]]: | |
| """Statistik semua model yang terdaftar di config.""" | |
| return [ | |
| self.get_model_stats(mid) | |
| for mid in self.config.get("models", {}).keys() | |
| ] | |
| def _human_size(num_bytes: int) -> str: | |
| size = float(num_bytes) | |
| for unit in ("B", "KB", "MB", "GB", "TB"): | |
| if size < 1024: | |
| return f"{size:.2f} {unit}" | |
| size /= 1024 | |
| return f"{size:.2f} PB" | |
| # ------------------------------------------------------------------ | |
| # Model hash | |
| # ------------------------------------------------------------------ | |
| def compute_model_hash( | |
| self, | |
| model_id: str, | |
| chunk_size: int = 1024 * 1024, | |
| max_total_bytes: int = 0, | |
| ) -> Optional[str]: | |
| """ | |
| Hitung SHA256 dari isi folder model secara chunked (streaming). | |
| max_total_bytes = 0 berarti tanpa batas. | |
| Return None kalau tidak bisa dihitung. | |
| """ | |
| path = os.path.join(MODEL_DIR, model_id) | |
| if not os.path.isdir(path): | |
| return None | |
| if chunk_size <= 0: | |
| chunk_size = 1024 * 1024 | |
| hasher = hashlib.sha256() | |
| total_read = 0 | |
| try: | |
| rel_files: List[str] = [] | |
| for root, _dirs, files in os.walk(path): | |
| for name in files: | |
| full = os.path.join(root, name) | |
| rel = os.path.relpath(full, path).replace("\\", "/") | |
| rel_files.append(rel) | |
| rel_files.sort() | |
| for rel in rel_files: | |
| full = os.path.join(path, rel.replace("/", os.sep)) | |
| try: | |
| size = os.path.getsize(full) | |
| except OSError: | |
| continue | |
| # Selalu masukkan nama + ukuran untuk stabilitas. | |
| hasher.update(rel.encode("utf-8")) | |
| hasher.update(b"\0") | |
| hasher.update(str(size).encode("utf-8")) | |
| hasher.update(b"\0") | |
| if max_total_bytes > 0 and total_read + size > max_total_bytes: | |
| # Kalau melebihi batas, hanya hash sebagian. | |
| remaining = max_total_bytes - total_read | |
| if remaining <= 0: | |
| continue | |
| read_bytes = remaining | |
| else: | |
| read_bytes = size | |
| try: | |
| with open(full, "rb") as fh: | |
| remaining_to_read = read_bytes | |
| while remaining_to_read > 0: | |
| chunk = fh.read(min(chunk_size, remaining_to_read)) | |
| if not chunk: | |
| break | |
| hasher.update(chunk) | |
| remaining_to_read -= len(chunk) | |
| total_read += len(chunk) | |
| except OSError as e: | |
| error_logger.error(f"Hash read error ({rel}): {e}") | |
| continue | |
| hasher.update(b"\0") | |
| except OSError as e: | |
| error_logger.error(f"Hash scan error: {e}") | |
| return None | |
| return hasher.hexdigest() | |
| def update_model_hash(self, model_id: str) -> Optional[str]: | |
| """Hitung hash dan simpan ke metadata model.""" | |
| h = self.compute_model_hash(model_id) | |
| if h is None: | |
| return None | |
| if model_id in self.config.get("models", {}): | |
| self.config["models"][model_id]["hash"] = h | |
| self.save_config() | |
| return h | |
| # ------------------------------------------------------------------ | |
| # Clone / export | |
| # ------------------------------------------------------------------ | |
| def clone_model( | |
| self, source_id: str, dest_id: str, overwrite: bool = False | |
| ) -> bool: | |
| """ | |
| Clone model dari source_id ke dest_id di dalam MODEL_DIR. | |
| Tidak menghapus source. Metadata ikut disalin. | |
| """ | |
| if not source_id or not dest_id: | |
| console.print(Theme.error("ID tidak boleh kosong")) | |
| return False | |
| if source_id == dest_id: | |
| console.print(Theme.warning("Source dan destination sama")) | |
| return False | |
| src_path = os.path.join(MODEL_DIR, source_id) | |
| dst_path = os.path.join(MODEL_DIR, dest_id) | |
| if not os.path.isdir(src_path): | |
| console.print(Theme.error(f"Source tidak ada: {src_path}")) | |
| return False | |
| if os.path.exists(dst_path): | |
| if not overwrite: | |
| console.print( | |
| Theme.error(f"Destination sudah ada: {dst_path}") | |
| ) | |
| return False | |
| try: | |
| shutil.rmtree(dst_path) | |
| except OSError as e: | |
| error_logger.error(f"Gagal hapus destination: {e}") | |
| console.print(Theme.error(f"Gagal hapus destination: {e}")) | |
| return False | |
| try: | |
| os.makedirs(MODEL_DIR, exist_ok=True) | |
| shutil.copytree(src_path, dst_path) | |
| except (OSError, shutil.Error) as e: | |
| error_logger.error(f"Clone gagal: {e}") | |
| console.print(Theme.error(f"Clone gagal: {e}")) | |
| return False | |
| # Salin metadata kalau ada. | |
| src_meta = self.config.get("models", {}).get(source_id) | |
| if isinstance(src_meta, dict): | |
| try: | |
| new_meta = json.loads(json.dumps(src_meta)) | |
| except (TypeError, ValueError): | |
| new_meta = dict(src_meta) | |
| new_meta["model_name"] = dest_id | |
| new_meta["created_date"] = time.strftime("%Y-%m-%d %H:%M:%S") | |
| tags = list(new_meta.get("tags", []) or []) | |
| if "cloned" not in tags: | |
| tags.append("cloned") | |
| new_meta["tags"] = tags | |
| self.config["models"][dest_id] = new_meta | |
| self.save_config() | |
| console.print( | |
| Theme.success(f"Model di-clone: {source_id} -> {dest_id}") | |
| ) | |
| return True | |
| def export_model_metadata( | |
| self, model_id: str, dest_path: Optional[str] = None | |
| ) -> Optional[str]: | |
| """ | |
| Export metadata model ke file JSON. | |
| Kalau dest_path None, gunakan folder EXPORT_DIR. | |
| Return path file yang ditulis, atau None kalau gagal. | |
| """ | |
| meta = self.config.get("models", {}).get(model_id) | |
| if meta is None: | |
| console.print(Theme.error(f"Metadata tidak ada untuk: {model_id}")) | |
| return None | |
| if dest_path is None: | |
| try: | |
| os.makedirs(EXPORT_DIR, exist_ok=True) | |
| except OSError as e: | |
| error_logger.error(f"Gagal buat EXPORT_DIR: {e}") | |
| return None | |
| dest_path = os.path.join(EXPORT_DIR, f"{model_id}_metadata.json") | |
| payload = { | |
| "exported_at": time.strftime("%Y-%m-%d %H:%M:%S"), | |
| "config_version": self.config.get("config_version", CONFIG_VERSION), | |
| "model_id": model_id, | |
| "is_current": self.config.get("current_model") == model_id, | |
| "metadata": meta, | |
| } | |
| try: | |
| with open(dest_path, "w", encoding="utf-8") as f: | |
| json.dump(payload, f, indent=2, ensure_ascii=False) | |
| except OSError as e: | |
| error_logger.error(f"Export metadata gagal: {e}") | |
| console.print(Theme.error(f"Export metadata gagal: {e}")) | |
| return None | |
| console.print(Theme.success(f"Metadata diexport: {dest_path}")) | |
| return dest_path | |
| def export_model_files( | |
| self, model_id: str, dest_dir: str, overwrite: bool = False | |
| ) -> bool: | |
| """ | |
| Copy seluruh folder model ke dest_dir (di luar MODEL_DIR). | |
| Tidak menghapus source. | |
| """ | |
| src_path = os.path.join(MODEL_DIR, model_id) | |
| if not os.path.isdir(src_path): | |
| console.print(Theme.error(f"Model tidak ada: {src_path}")) | |
| return False | |
| try: | |
| abs_src = os.path.abspath(src_path) | |
| abs_dst = os.path.abspath(dest_dir) | |
| if abs_dst == abs_src or abs_dst.startswith(abs_src + os.sep): | |
| console.print( | |
| Theme.error("Destination tidak boleh di dalam source") | |
| ) | |
| return False | |
| except OSError: | |
| pass | |
| if os.path.exists(dest_dir): | |
| if not overwrite: | |
| console.print( | |
| Theme.error(f"Destination sudah ada: {dest_dir}") | |
| ) | |
| return False | |
| try: | |
| shutil.rmtree(dest_dir) | |
| except OSError as e: | |
| error_logger.error(f"Gagal hapus destination: {e}") | |
| return False | |
| try: | |
| parent = os.path.dirname(dest_dir) | |
| if parent: | |
| os.makedirs(parent, exist_ok=True) | |
| shutil.copytree(src_path, dest_dir) | |
| except (OSError, shutil.Error) as e: | |
| error_logger.error(f"Export model gagal: {e}") | |
| console.print(Theme.error(f"Export model gagal: {e}")) | |
| return False | |
| console.print(Theme.success(f"Model diexport ke: {dest_dir}")) | |
| return True | |