Add history learning module
Some checks failed
CI / test (push) Failing after 12s
CI / lint (push) Failing after 5s
CI / type-check (push) Failing after 10s

This commit is contained in:
2026-02-04 11:01:48 +00:00
parent 5ef78e1402
commit 079c58afbe

371
shellgenius/history.py Normal file
View File

@@ -0,0 +1,371 @@
"""Command history learning system for ShellGenius."""
import json
import os
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
from shellgenius.config import get_config
@dataclass
class HistoryEntry:
"""A command history entry."""
id: str
timestamp: str
description: str
commands: List[str]
shell_type: str
usage_count: int = 1
tags: List[str] = field(default_factory=list)
success: bool = True
class HistoryStorage:
"""Storage management for command history."""
def __init__(self, storage_path: Optional[str] = None):
"""Initialize history storage.
Args:
storage_path: Path to history file
"""
config = get_config()
self.storage_path = storage_path or config.get(
"history.storage_path",
"~/.config/shellgenius/history.yaml",
)
self.storage_path = os.path.expanduser(self.storage_path)
self._ensure_storage_exists()
def _ensure_storage_exists(self) -> None:
"""Ensure storage directory exists."""
Path(self.storage_path).parent.mkdir(parents=True, exist_ok=True)
if not Path(self.storage_path).exists():
self._save({"entries": [], "metadata": {"version": "1.0"}})
def _load(self) -> Dict[str, Any]:
"""Load history from file.
Returns:
History data dictionary
"""
try:
with open(self.storage_path, "r") as f:
return yaml.safe_load(f) or {"entries": [], "metadata": {}}
except Exception:
return {"entries": [], "metadata": {}}
def _save(self, data: Dict[str, Any]) -> None:
"""Save history to file.
Args:
data: History data to save
"""
with open(self.storage_path, "w") as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
def add_entry(self, entry: HistoryEntry) -> None:
"""Add a new history entry.
Args:
entry: HistoryEntry to add
"""
data = self._load()
data["entries"].append(
{
"id": entry.id,
"timestamp": entry.timestamp,
"description": entry.description,
"commands": entry.commands,
"shell_type": entry.shell_type,
"usage_count": entry.usage_count,
"tags": entry.tags,
"success": entry.success,
}
)
self._save(data)
def get_entries(
self, limit: Optional[int] = None, offset: int = 0
) -> List[HistoryEntry]:
"""Get history entries.
Args:
limit: Maximum entries to return
offset: Starting offset
Returns:
List of HistoryEntry objects
"""
data = self._load()
entries = data.get("entries", [])
if offset > 0:
entries = entries[offset:]
if limit:
entries = entries[:limit]
return [
HistoryEntry(
id=e["id"],
timestamp=e["timestamp"],
description=e["description"],
commands=e["commands"],
shell_type=e["shell_type"],
usage_count=e.get("usage_count", 1),
tags=e.get("tags", []),
success=e.get("success", True),
)
for e in entries
]
def search(
self, query: str, limit: int = 10
) -> List[HistoryEntry]:
"""Search history by query.
Args:
query: Search query
limit: Maximum results
Returns:
List of matching HistoryEntry objects
"""
data = self._load()
results = []
query_lower = query.lower()
for entry in data.get("entries", []):
if (
query_lower in entry["description"].lower()
or any(query_lower in cmd.lower() for cmd in entry["commands"])
or any(query_lower in tag.lower() for tag in entry.get("tags", []))
):
results.append(
HistoryEntry(
id=entry["id"],
timestamp=entry["timestamp"],
description=entry["description"],
commands=entry["commands"],
shell_type=entry["shell_type"],
usage_count=e.get("usage_count", 1),
tags=e.get("tags", []),
success=e.get("success", True),
)
)
if len(results) >= limit:
break
return results
def get_popular(self, limit: int = 10) -> List[HistoryEntry]:
"""Get most used entries.
Args:
limit: Maximum entries to return
Returns:
List of popular HistoryEntry objects
"""
data = self._load()
entries = [
HistoryEntry(
id=e["id"],
timestamp=e["timestamp"],
description=e["description"],
commands=e["commands"],
shell_type=e["shell_type"],
usage_count=e.get("usage_count", 1),
tags=e.get("tags", []),
success=e.get("success", True),
)
for e in data.get("entries", [])
]
entries.sort(key=lambda x: x.usage_count, reverse=True)
return entries[:limit]
def update_usage(self, entry_id: str) -> bool:
"""Increment usage count for an entry.
Args:
entry_id: Entry ID to update
Returns:
True if updated successfully
"""
data = self._load()
for entry in data.get("entries", []):
if entry["id"] == entry_id:
entry["usage_count"] = entry.get("usage_count", 0) + 1
self._save(data)
return True
return False
def delete_entry(self, entry_id: str) -> bool:
"""Delete a history entry.
Args:
entry_id: Entry ID to delete
Returns:
True if deleted successfully
"""
data = self._load()
original_len = len(data.get("entries", []))
data["entries"] = [e for e in data.get("entries", []) if e["id"] != entry_id]
if len(data["entries"]) < original_len:
self._save(data)
return True
return False
def clear(self) -> None:
"""Clear all history."""
self._save({"entries": [], "metadata": {"version": "1.0"}})
class HistoryLearner:
"""Learning from command history for personalized suggestions."""
def __init__(self):
"""Initialize history learner."""
self.storage = HistoryStorage()
self.config = get_config()
def learn(
self,
description: str,
commands: List[str],
shell_type: str = "bash",
tags: Optional[List[str]] = None,
) -> HistoryEntry:
"""Learn from a generated command.
Args:
description: Description of what was generated
commands: List of commands generated
shell_type: Shell type used
tags: Optional tags for categorization
Returns:
Created HistoryEntry
"""
import uuid
entry = HistoryEntry(
id=str(uuid.uuid4()),
timestamp=datetime.utcnow().isoformat(),
description=description,
commands=commands,
shell_type=shell_type,
tags=tags or [],
)
self.storage.add_entry(entry)
return entry
def find_similar(self, query: str, limit: int = 5) -> List[HistoryEntry]:
"""Find similar commands from history.
Args:
query: Query to match against
limit: Maximum results
Returns:
List of similar HistoryEntry objects
"""
return self.storage.search(query, limit)
def suggest(
self, description: str, limit: int = 3
) -> List[HistoryEntry]:
"""Get personalized suggestions based on history.
Args:
description: Current task description
limit: Maximum suggestions
Returns:
List of suggested HistoryEntry objects
"""
similar = self.find_similar(description, limit)
popular = self.storage.get_popular(limit)
suggestions = []
seen = set()
for entry in similar + popular:
if entry.id not in seen:
suggestions.append(entry)
seen.add(entry.id)
if len(suggestions) >= limit:
break
return suggestions
def get_frequent_patterns(self) -> Dict[str, Any]:
"""Analyze frequent command patterns.
Returns:
Dictionary with pattern statistics
"""
entries = self.storage.get_entries(limit=100)
patterns = {
"shell_types": {},
"common_commands": {},
"frequent_tags": {},
}
for entry in entries:
patterns["shell_types"][entry.shell_type] = (
patterns["shell_types"].get(entry.shell_type, 0) + 1
)
for cmd in entry.commands:
first_word = cmd.strip().split()[0] if cmd.strip() else ""
if first_word:
patterns["common_commands"][first_word] = (
patterns["common_commands"].get(first_word, 0) + 1
)
for tag in entry.tags:
patterns["frequent_tags"][tag] = (
patterns["frequent_tags"].get(tag, 0) + 1
)
return patterns
def update_from_feedback(
self, entry_id: str, was_useful: bool, tags: Optional[List[str]] = None
) -> bool:
"""Update entry based on user feedback.
Args:
entry_id: Entry ID
was_useful: Whether the suggestion was useful
tags: Optional tags to add
Returns:
True if updated successfully
"""
if was_useful:
return self.storage.update_usage(entry_id)
return False
def get_history_storage(storage_path: Optional[str] = None) -> HistoryStorage:
"""Get history storage instance.
Args:
storage_path: Optional storage path
Returns:
HistoryStorage instance
"""
return HistoryStorage(storage_path)