From b1386facd1311e9c217d96def05e8081da9dc6d8 Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 28 Dec 2025 04:06:25 -0400 Subject: [PATCH] Initial Ramble project - AI-powered field extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracted from CascadingDev as a standalone tool. Features: - PySide6/PyQt5 GUI for "rambling" unstructured text - AI-powered extraction of configurable fields - Field locking for iterative refinement - Multiple providers (Claude, Codex, Gemini, mock) - PlantUML diagram generation - Headless mode for CLI use - Docker support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Dockerfile | 58 +++++ README.md | 163 ++++++++++++++ docker-compose.yml | 69 ++++++ pyproject.toml | 46 ++++ src/ramble/__init__.py | 20 ++ src/ramble/cli.py | 201 +++++++++++++++++ src/ramble/dialog.py | 475 ++++++++++++++++++++++++++++++++++++++++ src/ramble/providers.py | 230 +++++++++++++++++++ 8 files changed, 1262 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 src/ramble/__init__.py create mode 100644 src/ramble/cli.py create mode 100644 src/ramble/dialog.py create mode 100644 src/ramble/providers.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f53ff79 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,58 @@ +# Ramble - AI-powered structured field extraction +# +# Build: docker build -t ramble . +# Run: docker run -it --rm -e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix ramble + +FROM python:3.12-slim + +LABEL maintainer="rob" +LABEL description="Ramble - AI-powered structured field extraction GUI" + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + plantuml \ + # Qt6 dependencies + libgl1-mesa-glx \ + libegl1-mesa \ + libxkbcommon0 \ + libdbus-1-3 \ + libxcb-cursor0 \ + libxcb-icccm4 \ + libxcb-keysyms1 \ + libxcb-shape0 \ + libxcb-xinerama0 \ + libxcb-randr0 \ + libxcb-render-util0 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy project files +COPY pyproject.toml README.md ./ +COPY src/ ./src/ + +# Install Ramble +RUN pip install --no-cache-dir -e . + +# Verify installation +RUN ramble --help + +# Default: show help +CMD ["ramble", "--help"] + +# ============================================================================== +# Usage Examples +# ============================================================================== +# Build: +# docker build -t ramble . +# +# Run with mock provider (no AI): +# xhost +local:docker +# docker run -it --rm \ +# -e DISPLAY=$DISPLAY \ +# -v /tmp/.X11-unix:/tmp/.X11-unix \ +# ramble --provider mock +# +# Headless mode (no GUI): +# docker run -it --rm ramble \ +# --field-values '{"Title":"MyApp","Summary":"A cool app"}' diff --git a/README.md b/README.md new file mode 100644 index 0000000..1af1f0f --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# Ramble + +**AI-powered structured field extraction from unstructured text.** + +A configurable GUI tool that lets users "ramble" about an idea and extracts structured fields using AI. Just type your thoughts freely, and Ramble generates organized, structured data. + +## Features + +- **Ramble freely** - Type unstructured thoughts in the input box +- **AI extraction** - Generates structured fields from your ramble +- **Configurable fields** - Define any fields you want to capture +- **Field criteria** - Set validation/formatting rules per field +- **Lock mechanism** - Lock fields you like; they become context for next generation +- **PlantUML diagrams** - Auto-generates and renders diagrams +- **Multiple providers** - Claude, Codex, Gemini, or mock for testing +- **Headless mode** - Skip GUI, pass values directly via CLI + +## Installation + +```bash +# Clone and install +git clone https://gitea.brrd.tech/rob/ramble.git +cd ramble +pip install -e . + +# Requirements +sudo apt install plantuml # For diagram rendering +``` + +## Docker + +Run without installing anything locally: + +```bash +# Clone the repo +git clone https://gitea.brrd.tech/rob/ramble.git +cd ramble + +# Build +docker-compose build + +# Launch GUI (requires: xhost +local:docker) +docker-compose run --rm gui + +# Headless mode +docker-compose run --rm cli ramble --field-values '{"Title":"MyApp","Summary":"A cool app"}' +``` + +## Usage + +### Launch the GUI + +```bash +# With mock provider (no AI calls) +ramble --provider mock + +# With Claude +ramble --provider claude + +# Custom fields +ramble --fields Title Summary Intent "Problem it solves" "Target audience" + +# With field criteria +ramble --fields Title Summary --criteria '{"Title":"camelCase, <=20 chars","Summary":"<=2 sentences"}' +``` + +### Headless Mode + +Skip the GUI and provide values directly: + +```bash +ramble --field-values '{"Title":"mouseRacingGame","Summary":"Fun racing with mice"}' +``` + +### Programmatic Use + +```python +from ramble import open_ramble_dialog, ClaudeCLIProvider + +# With mock provider +result = open_ramble_dialog( + prompt="Describe your feature idea", + fields=["Title", "Summary", "Intent"], + field_criteria={"Title": "camelCase, <=20 chars"}, +) + +if result: + print(result["summary"]) + print(result["fields"]) + +# With Claude +provider = ClaudeCLIProvider(cmd="claude", timeout_s=60) +result = open_ramble_dialog( + prompt="Describe your idea", + fields=["Title", "Summary"], + provider=provider, +) +``` + +## How It Works + +1. **Ramble** - Type your unstructured thoughts in the blue input box +2. **Generate** - Click "Generate / Update" to extract fields +3. **Refine** - Add more detail or edit fields directly +4. **Lock** - Check "Lock" on fields you like (they become authoritative context) +5. **Submit** - Click Submit to output structured JSON + +The locked fields become "ground truth" for subsequent generations, allowing iterative refinement. + +## CLI Options + +| Option | Description | +|--------|-------------| +| `--provider` | AI provider: mock, claude, codex, gemini | +| `--cmd` | Path to CLI command | +| `--model` | Model to use | +| `--fields` | Fields to extract | +| `--criteria` | JSON mapping of field -> criteria | +| `--hints` | JSON list of hint strings | +| `--field-values` | JSON values for headless mode | +| `--timeout` | Provider timeout in seconds | +| `--debug` | Enable debug logging | + +## Providers + +| Provider | Description | +|----------|-------------| +| `mock` | No AI calls, returns placeholder data | +| `claude` | Anthropic Claude CLI | +| `codex` | OpenAI Codex CLI | +| `gemini` | Google Gemini CLI | + +## Output Format + +```json +{ + "summary": "A brief summary of the idea", + "fields": { + "Title": "mouseRacingGame", + "Summary": "Fun arcade racing with mice driving cars", + "Intent": "Entertainment", + "ProblemItSolves": "Unique gameplay experience" + } +} +``` + +## Architecture + +``` +ramble/ +├── src/ramble/ +│ ├── __init__.py # Public API +│ ├── cli.py # CLI entry point +│ ├── dialog.py # PySide6/PyQt5 GUI +│ └── providers.py # AI provider implementations +├── Dockerfile +├── docker-compose.yml +└── pyproject.toml +``` + +## License + +MIT diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..76a64b1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,69 @@ +# Ramble - AI-powered structured field extraction +# +# Quick Start: +# docker-compose build # Build the image +# docker-compose run --rm gui # Launch GUI (requires X11) +# docker-compose run --rm cli ramble --help + +version: '3.8' + +services: + # ============================================================================ + # CLI (headless mode) + # ============================================================================ + cli: + build: + context: . + dockerfile: Dockerfile + image: ramble:latest + command: ["ramble", "--help"] + + # ============================================================================ + # GUI (requires X11 forwarding) + # ============================================================================ + gui: + build: + context: . + dockerfile: Dockerfile + image: ramble:latest + environment: + - DISPLAY=${DISPLAY:-:0} + - QT_QPA_PLATFORM=xcb + volumes: + - /tmp/.X11-unix:/tmp/.X11-unix:ro + command: ["ramble", "--provider", "mock"] + network_mode: host + + # ============================================================================ + # Interactive Shell + # ============================================================================ + shell: + build: + context: . + dockerfile: Dockerfile + image: ramble:latest + environment: + - DISPLAY=${DISPLAY:-:0} + - QT_QPA_PLATFORM=xcb + volumes: + - /tmp/.X11-unix:/tmp/.X11-unix:ro + command: ["/bin/bash"] + stdin_open: true + tty: true + network_mode: host + +# ============================================================================== +# Usage Examples +# ============================================================================== +# +# Build: +# docker-compose build +# +# Launch GUI (requires: xhost +local:docker): +# docker-compose run --rm gui +# +# Headless mode: +# docker-compose run --rm cli ramble --field-values '{"Title":"Test"}' +# +# Interactive shell: +# docker-compose run --rm shell diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e3e7aa4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ramble" +version = "0.1.0" +description = "AI-powered structured field extraction from unstructured text" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +authors = [ + {name = "Rob"} +] +keywords = ["ai", "llm", "structured-data", "gui", "pyside6"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] + +dependencies = [ + "PySide6>=6.4.0", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-cov", +] +images = [ + "requests", +] + +[project.scripts] +ramble = "ramble.cli:main" + +[project.urls] +Repository = "https://gitea.brrd.tech/rob/ramble" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/ramble/__init__.py b/src/ramble/__init__.py new file mode 100644 index 0000000..688c935 --- /dev/null +++ b/src/ramble/__init__.py @@ -0,0 +1,20 @@ +""" +Ramble - AI-powered structured field extraction from unstructured text. + +A configurable GUI tool that lets users "ramble" about an idea and extracts +structured fields using AI. Supports multiple providers, custom fields, +field-level criteria, and a lock mechanism for iterative refinement. +""" + +from ramble.dialog import open_ramble_dialog, RambleDialog, RambleResult +from ramble.providers import RambleProvider, MockProvider, ClaudeCLIProvider + +__version__ = "0.1.0" +__all__ = [ + "open_ramble_dialog", + "RambleDialog", + "RambleResult", + "RambleProvider", + "MockProvider", + "ClaudeCLIProvider", +] diff --git a/src/ramble/cli.py b/src/ramble/cli.py new file mode 100644 index 0000000..168782d --- /dev/null +++ b/src/ramble/cli.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Ramble CLI - AI-powered structured field extraction. + +Examples: + # Mock provider (no AI calls) + ramble --fields Title Summary "Problem it solves" + + # With Claude + ramble --provider claude --fields Title Summary Intent + + # Headless mode (skip GUI) + ramble --field-values '{"Title":"My App","Summary":"A cool app"}' + + # With custom criteria + ramble --fields Title Summary --criteria '{"Title":"camelCase, <=20 chars"}' +""" + +import argparse +import json +import sys +from typing import Dict, List, Any, Optional, cast + +from ramble.providers import RambleProvider, MockProvider, ClaudeCLIProvider +from ramble.dialog import open_ramble_dialog, ensure_plantuml_present + + +def build_provider(name: str, args: argparse.Namespace) -> RambleProvider: + """Build a provider from CLI arguments.""" + if name == "mock": + return cast(RambleProvider, MockProvider()) + + if name in {"claude", "codex", "gemini"}: + cmd = args.cmd or name + extra_args = [] + if args.model: + extra_args.extend(["--model", args.model]) + + return cast( + RambleProvider, + ClaudeCLIProvider( + cmd=cmd, + extra_args=extra_args, + timeout_s=args.timeout, + tail_chars=args.tail, + use_arg_p=True, + debug=args.debug, + ), + ) + + raise ValueError(f"Unknown provider: {name}") + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser( + description="Ramble - AI-powered structured field extraction", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + + p.add_argument( + "--provider", + choices=["mock", "claude", "codex", "gemini"], + default="mock", + help="AI provider to use (default: mock)" + ) + p.add_argument( + "--cmd", + help="Path to CLI command (default: provider name)" + ) + p.add_argument( + "--model", + help="Model to use (passed to CLI)" + ) + p.add_argument( + "--prompt", + default="Explain your idea", + help="Prompt shown to user" + ) + p.add_argument( + "--fields", + nargs="+", + default=["Summary", "Title", "Intent", "ProblemItSolves", "BriefOverview"], + help="Fields to extract" + ) + p.add_argument( + "--criteria", + default="", + help="JSON mapping of field -> criteria" + ) + p.add_argument( + "--hints", + default="", + help="JSON list of hint strings" + ) + p.add_argument( + "--field-values", + default="", + help="JSON mapping of field -> value (headless mode, skips GUI)" + ) + p.add_argument( + "--timeout", + type=int, + default=90, + help="Provider timeout in seconds" + ) + p.add_argument( + "--tail", + type=int, + default=6000, + help="Max characters of ramble to send" + ) + p.add_argument( + "--stability", + action="store_true", + help="Enable Stability AI images (needs STABILITY_API_KEY)" + ) + p.add_argument( + "--pexels", + action="store_true", + help="Enable Pexels images (needs PEXELS_API_KEY)" + ) + p.add_argument( + "--debug", + action="store_true", + help="Enable debug logging" + ) + + return p.parse_args() + + +def main(): + args = parse_args() + + # Parse JSON arguments + try: + criteria = json.loads(args.criteria) if args.criteria else {} + if not isinstance(criteria, dict): + criteria = {} + except json.JSONDecodeError: + criteria = {} + + try: + hints = json.loads(args.hints) if args.hints else None + if hints is not None and not isinstance(hints, list): + hints = None + except json.JSONDecodeError: + hints = None + + # Headless mode + if args.field_values: + try: + field_values = json.loads(args.field_values) + if not isinstance(field_values, dict): + print("[ERROR] --field-values must be a JSON object", file=sys.stderr) + sys.exit(1) + + result = { + "summary": field_values.get("Summary", ""), + "fields": field_values, + } + print(json.dumps(result, ensure_ascii=False, indent=2)) + sys.exit(0) + except json.JSONDecodeError as e: + print(f"[ERROR] Invalid JSON in --field-values: {e}", file=sys.stderr) + sys.exit(1) + + # Build provider + try: + provider = build_provider(args.provider, args) + except Exception as e: + print(f"[WARN] Provider '{args.provider}' unavailable ({e}); using mock", file=sys.stderr) + provider = cast(RambleProvider, MockProvider()) + + # Check PlantUML + try: + ensure_plantuml_present() + except Exception as e: + print(f"[ERROR] {e}", file=sys.stderr) + sys.exit(2) + + # Open dialog + result = open_ramble_dialog( + prompt=args.prompt, + fields=args.fields, + field_criteria=criteria, + hints=hints, + provider=provider, + enable_stability=args.stability, + enable_pexels=args.pexels, + ) + + if result: + print(json.dumps(result, ensure_ascii=False, indent=2)) + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/ramble/dialog.py b/src/ramble/dialog.py new file mode 100644 index 0000000..3d71e3f --- /dev/null +++ b/src/ramble/dialog.py @@ -0,0 +1,475 @@ +""" +Ramble GUI Dialog. + +A PySide6/PyQt5 dialog for capturing unstructured text and extracting +structured fields using AI. +""" + +from __future__ import annotations +from dataclasses import dataclass +from typing import Dict, List, Optional, Any, cast +import sys +import shutil +import subprocess + +from ramble.providers import RambleProvider, MockProvider + +# Qt imports (PySide6 preferred, PyQt5 fallback) +QT_LIB = None +try: + from PySide6.QtCore import (Qt, QTimer, QPropertyAnimation, QEasingCurve, QObject, + QThreadPool, QRunnable, Signal, Slot) + from PySide6.QtGui import (QFont, QLinearGradient, QPainter, QPalette, QColor, QPixmap, QTextCursor) + from PySide6.QtWidgets import ( + QApplication, QDialog, QGridLayout, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QPlainTextEdit, QTextEdit, QWidget, QGroupBox, QFormLayout, QSizePolicy, + QFrame, QMessageBox, QScrollArea, QCheckBox + ) + QT_LIB = "PySide6" +except ImportError: + from PyQt5.QtCore import (Qt, QTimer, QPropertyAnimation, QEasingCurve, QObject, + QThreadPool, QRunnable, pyqtSignal as Signal, pyqtSlot as Slot) + from PyQt5.QtGui import (QFont, QLinearGradient, QPainter, QPalette, QColor, QPixmap, QTextCursor) + from PyQt5.QtWidgets import ( + QApplication, QDialog, QGridLayout, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QPlainTextEdit, QTextEdit, QWidget, QGroupBox, QFormLayout, QSizePolicy, + QFrame, QMessageBox, QScrollArea, QCheckBox + ) + QT_LIB = "PyQt5" + + +def ensure_plantuml_present(): + """Check that PlantUML is available.""" + exe = shutil.which("plantuml") + if not exe: + raise RuntimeError("plantuml not found in PATH. Install with: sudo apt install plantuml") + return exe + + +def render_plantuml_to_png_bytes(uml_text: str) -> bytes: + """Render PlantUML text to PNG bytes.""" + exe = ensure_plantuml_present() + proc = subprocess.run( + [exe, "-tpng", "-pipe"], + input=uml_text.encode("utf-8"), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False + ) + if proc.returncode != 0 or not proc.stdout: + raise RuntimeError(f"plantuml failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}") + return proc.stdout + + +class FadeLabel(QLabel): + """Label with fade animation for text changes.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._anim = QPropertyAnimation(self, b"windowOpacity") + self._anim.setDuration(300) + self._anim.setEasingCurve(QEasingCurve.InOutQuad) + self.setWindowOpacity(1.0) + + def fade_to_text(self, text: str): + def set_text(): + self.setText(text) + try: + self._anim.finished.disconnect(set_text) + except Exception: + pass + self._anim.setStartValue(0.0) + self._anim.setEndValue(1.0) + self._anim.start() + self._anim.stop() + self._anim.setStartValue(1.0) + self._anim.setEndValue(0.0) + self._anim.finished.connect(set_text) + self._anim.start() + + +class FadingRambleEdit(QPlainTextEdit): + """Text edit with fading top gradient for the ramble input.""" + + def __init__(self, max_blocks: int = 500, parent=None): + super().__init__(parent) + self.setPlaceholderText("Start here. Ramble about your idea...") + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setFrameStyle(QFrame.StyledPanel | QFrame.Raised) + self.document().setMaximumBlockCount(max_blocks) + font = QFont("DejaVu Sans Mono") + font.setPointSize(11) + self.setFont(font) + self.setFixedHeight(190) + self.setStyleSheet(""" + QPlainTextEdit { + background:#f4fbff; border:2px solid #9ed6ff; border-radius:8px; padding:8px; + } + """) + + def wheelEvent(self, event): + event.ignore() + + def keyPressEvent(self, event): + super().keyPressEvent(event) + self.moveCursor(QTextCursor.End) + + def paintEvent(self, e): + super().paintEvent(e) + painter = QPainter(self.viewport()) + grad = QLinearGradient(0, 0, 0, 30) + bg = self.palette().color(QPalette.ColorRole.Base) + grad.setColorAt(0.0, QColor(bg.red(), bg.green(), bg.blue(), 255)) + grad.setColorAt(1.0, QColor(bg.red(), bg.green(), bg.blue(), 0)) + painter.fillRect(0, 0, self.viewport().width(), 30, grad) + painter.end() + + +class GenWorker(QRunnable): + """Background worker for AI generation.""" + + class Signals(QObject): + finished = Signal(dict) + error = Signal(str) + + def __init__(self, provider: RambleProvider, payload: Dict[str, Any]): + super().__init__() + self.provider = provider + self.payload = payload + self.signals = GenWorker.Signals() + + def run(self): + try: + data = self.provider.generate(**self.payload) + self.signals.finished.emit(data) + except Exception as e: + self.signals.error.emit(str(e)) + + +@dataclass +class RambleResult: + """Result from the Ramble dialog.""" + summary: str + fields: Dict[str, str] + + +class RambleDialog(QDialog): + """ + Main Ramble dialog. + + Allows users to type unstructured text ("ramble") and generates + structured fields using an AI provider. + """ + + def __init__( + self, + *, + prompt: str, + fields: List[str], + field_criteria: Optional[Dict[str, str]] = None, + hints: Optional[List[str]] = None, + provider: Optional[RambleProvider] = None, + enable_stability: bool = False, + enable_pexels: bool = False, + parent: Optional[QWidget] = None + ): + super().__init__(parent) + self.setWindowTitle("Ramble - Generate") + self.resize(1120, 760) + + self.provider = provider or cast(RambleProvider, MockProvider()) + self._prompt_text = prompt + self._fields = fields[:] if fields else ["Summary"] + if "Summary" not in self._fields: + self._fields.insert(0, "Summary") + self._criteria = field_criteria or {} + self._criteria.setdefault("Summary", "<= 2 sentences") + + self._hints = hints or [ + "What is it called?", + "Who benefits most?", + "What problem does it solve?", + "What would success look like?", + ] + self.enable_stability = enable_stability + self.enable_pexels = enable_pexels and not enable_stability + + self.thread_pool = QThreadPool.globalInstance() + self.result: Optional[RambleResult] = None + + self.field_lock_boxes: Dict[str, QCheckBox] = {} + self.field_outputs: Dict[str, QTextEdit] = {} + + self._setup_ui() + self._setup_hint_timer() + + def _setup_ui(self): + outer = QVBoxLayout(self) + + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + content = QWidget() + self.scroll.setWidget(content) + outer.addWidget(self.scroll, 1) + + footer = QHBoxLayout() + self.help_btn = QPushButton("How to use") + self.help_btn.setCheckable(True) + self.help_btn.setChecked(False) + self.help_btn.clicked.connect(self._toggle_help) + footer.addWidget(self.help_btn) + footer.addStretch(1) + self.submit_btn = QPushButton("Submit") + self.submit_btn.clicked.connect(self.on_submit) + footer.addWidget(self.submit_btn) + outer.addLayout(footer) + + grid = QGridLayout(content) + grid.setHorizontalSpacing(16) + grid.setVerticalSpacing(12) + + title = QLabel("Ramble about your idea. Fields will fill themselves.") + title.setWordWrap(True) + grid.addWidget(title, 0, 0, 1, 2) + self.hint_label = FadeLabel() + self.hint_label.setText(self._hints[0]) + self.hint_label.setStyleSheet("color:#666; font-style:italic;") + grid.addWidget(self.hint_label, 1, 0, 1, 2) + + # Left column + left_col = QVBoxLayout() + self.ramble_edit = FadingRambleEdit(max_blocks=900) + left_col.addWidget(self.ramble_edit) + + gen_row = QHBoxLayout() + self.generate_btn = QPushButton("Generate / Update") + self.generate_btn.setStyleSheet("QPushButton { background:#e6f4ff; border:1px solid #9ed6ff; padding:6px 12px; }") + self.generate_btn.clicked.connect(self.on_generate) + self.provider_label = QLabel(f"Provider: {type(self.provider).__name__}") + self.provider_label.setStyleSheet("color:#555;") + gen_row.addWidget(self.generate_btn) + gen_row.addStretch(1) + gen_row.addWidget(self.provider_label) + left_col.addLayout(gen_row) + + # UML group + uml_group = QGroupBox("Diagram (PlantUML)") + uml_v = QVBoxLayout(uml_group) + self.uml_image_label = QLabel() + self.uml_image_label.setAlignment(Qt.AlignCenter) + if QT_LIB == "PyQt5": + self.uml_image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + else: + self.uml_image_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + uml_v.addWidget(self.uml_image_label) + left_col.addWidget(uml_group) + + # Images group + img_group = QGroupBox("Images / Descriptions") + img_v = QVBoxLayout(img_group) + self.img_desc_text = QTextEdit() + self.img_desc_text.setReadOnly(True) + self.img_desc_text.setMinimumHeight(80) + self.img_desc_text.setPlaceholderText("Descriptions of suggested images...") + img_v.addWidget(self.img_desc_text) + left_col.addWidget(img_group) + + # Right column - Fields + right_col = QVBoxLayout() + fields_group = QGroupBox("Fields") + fields_group.setStyleSheet("QGroupBox { background:#fafafa; border:1px solid #ddd; border-radius:8px; margin-top:6px; }") + fg_form = QFormLayout(fields_group) + + def apply_lock_style(widget: QTextEdit, locked: bool): + widget.setStyleSheet("background-color: #fff7cc;" if locked else "") + + for f in self._fields: + row = QHBoxLayout() + te = QTextEdit() + te.setReadOnly(False) + te.setMinimumHeight(64) + te.setPlaceholderText(f"{f}...") + self.field_outputs[f] = te + lock = QCheckBox("Lock") + lock.setChecked(False) + self.field_lock_boxes[f] = lock + lock.stateChanged.connect(lambda _=0, name=f: apply_lock_style(self.field_outputs[name], self.field_lock_boxes[name].isChecked())) + + crit = self._criteria.get(f, "") + if crit: + hint = QLabel(f"({crit})") + else: + hint = QLabel("") + row.addWidget(te, 1) + row.addWidget(lock) + fg_form.addRow(QLabel(f"{f}:"), row) + fg_form.addRow(hint) + apply_lock_style(te, False) + + right_col.addWidget(fields_group, 1) + + # Help panel + self.help_panel = QLabel( + "How to use:\n" + "- Start in the blue box above and just ramble about your idea.\n" + "- Click 'Generate / Update' to fill fields automatically.\n" + "- Not happy? Add more detail to your ramble or edit fields directly.\n" + "- Love a field? Tick 'Lock' so it won't change next time.\n" + "- When satisfied, click Submit to output your structured JSON." + ) + self.help_panel.setWordWrap(True) + self.help_panel.setVisible(False) + self.help_panel.setStyleSheet("background:#f8fbff; border:1px dashed #bcdcff; padding:8px; border-radius:6px;") + right_col.addWidget(self.help_panel) + + # Status + self.status_label = QLabel("") + self.status_label.setStyleSheet("color:#777;") + right_col.addWidget(self.status_label) + + # Place columns + left_w = QWidget() + left_w.setLayout(left_col) + right_w = QWidget() + right_w.setLayout(right_col) + grid.addWidget(left_w, 2, 0) + grid.addWidget(right_w, 2, 1) + + def _toggle_help(self): + self.help_panel.setVisible(self.help_btn.isChecked()) + + def _setup_hint_timer(self): + self._hint_idx = 0 + self._hint_timer = QTimer(self) + self._hint_timer.setInterval(3500) + self._hint_timer.timeout.connect(self._advance_hint) + self._hint_timer.start() + + def _advance_hint(self): + if not self._hints: + return + self._hint_idx = (self._hint_idx + 1) % len(self._hints) + self.hint_label.fade_to_text(self._hints[self._hint_idx]) + + @Slot() + def on_generate(self): + ramble_text = self.ramble_edit.toPlainText() + if not ramble_text.strip(): + QMessageBox.information(self, "Nothing to generate", "Type your thoughts first, then click Generate / Update.") + return + + locked_context: Dict[str, str] = {} + for name, box in self.field_lock_boxes.items(): + if box.isChecked(): + locked_context[name] = self.field_outputs[name].toPlainText().strip() + + payload = { + "prompt": self._prompt_text, + "ramble_text": ramble_text, + "fields": self._fields, + "field_criteria": self._criteria, + "locked_context": locked_context, + } + + self._set_busy(True, "Generating...") + worker = GenWorker(self.provider, payload) + worker.signals.finished.connect(self._on_generated) + worker.signals.error.connect(self._on_gen_error) + self.thread_pool.start(worker) + + def _on_generated(self, data: Dict[str, Any]): + new_fields: Dict[str, str] = data.get("fields", {}) + for name, widget in self.field_outputs.items(): + if self.field_lock_boxes[name].isChecked(): + continue + widget.setPlainText(new_fields.get(name, "")) + + if "Summary" in self.field_outputs and not self.field_lock_boxes["Summary"].isChecked(): + s = data.get("summary", "") or new_fields.get("Summary", "") + if s: + self.field_outputs["Summary"].setPlainText(s) + + # Render UML + uml_blocks = data.get("uml_blocks", []) + self.uml_image_label.clear() + for (uml_text, maybe_png) in uml_blocks: + png_bytes = maybe_png + if not png_bytes and uml_text: + try: + png_bytes = render_plantuml_to_png_bytes(uml_text) + except Exception as e: + print(f"[UML render] {e}", file=sys.stderr) + png_bytes = None + if png_bytes: + pix = QPixmap() + if pix.loadFromData(png_bytes): + self.uml_image_label.setPixmap(pix) + break + + img_desc = [str(s) for s in data.get("image_descriptions", [])] + self.img_desc_text.setPlainText("\n\n".join(f"- {d}" for d in img_desc)) + + self._set_busy(False, "Ready.") + + def _on_gen_error(self, msg: str): + self._set_busy(False, "Error.") + QMessageBox.critical(self, "Generation Error", msg) + + def _set_busy(self, busy: bool, msg: str): + self.generate_btn.setEnabled(not busy) + self.submit_btn.setEnabled(not busy) + self.status_label.setText(msg) + + @Slot() + def on_submit(self): + out_fields = {k: self.field_outputs[k].toPlainText() for k in self.field_outputs.keys()} + summary = out_fields.get("Summary", "").strip() + self.result = RambleResult(summary=summary, fields=out_fields) + self.accept() + + +def open_ramble_dialog( + *, + prompt: str, + fields: List[str], + field_criteria: Optional[Dict[str, str]] = None, + hints: Optional[List[str]] = None, + provider: Optional[RambleProvider] = None, + enable_stability: bool = False, + enable_pexels: bool = False, + parent: Optional[QWidget] = None +) -> Optional[Dict[str, Any]]: + """ + Open the Ramble dialog and return the result. + + Returns a dict with 'summary' and 'fields' keys, or None if cancelled. + """ + app_created = False + app = QApplication.instance() + if app is None: + app_created = True + app = QApplication(sys.argv) + + dlg = RambleDialog( + prompt=prompt, + fields=fields, + field_criteria=field_criteria, + hints=hints, + provider=provider, + enable_stability=enable_stability, + enable_pexels=enable_pexels, + parent=parent + ) + rc = dlg.exec_() if QT_LIB == "PyQt5" else dlg.exec() + + out = None + if rc == QDialog.Accepted and dlg.result: + out = { + "summary": dlg.result.summary, + "fields": dlg.result.fields, + } + + if app_created: + app.quit() + return out diff --git a/src/ramble/providers.py b/src/ramble/providers.py new file mode 100644 index 0000000..9bf84df --- /dev/null +++ b/src/ramble/providers.py @@ -0,0 +1,230 @@ +""" +AI providers for Ramble. + +Each provider implements the RambleProvider protocol and can generate +structured output from unstructured ramble text. +""" + +from __future__ import annotations +from typing import Dict, List, Optional, Any, Protocol, runtime_checkable +import json +import re +import shutil +import subprocess +import textwrap +import time + + +@runtime_checkable +class RambleProvider(Protocol): + """ + Protocol for Ramble AI providers. + + generate() must return a dict with: + - summary: str + - fields: Dict[str, str] + - uml_blocks: List[Tuple[str, Optional[bytes]]] + - image_descriptions: List[str] + """ + def generate( + self, + *, + prompt: str, + ramble_text: str, + fields: List[str], + field_criteria: Dict[str, str], + locked_context: Dict[str, str], + ) -> Dict[str, Any]: + ... + + +class MockProvider: + """Mock provider for testing without AI calls.""" + + def generate( + self, *, prompt: str, ramble_text: str, fields: List[str], + field_criteria: Dict[str, str], locked_context: Dict[str, str] + ) -> Dict[str, Any]: + words = ramble_text.strip().split() + cap = min(25, len(words)) + summary = " ".join(words[-cap:]) if words else "(no content yet)" + summary = (summary[:1].upper() + summary[1:]).rstrip(".") + "." + + field_map = {} + for f in fields: + crit = field_criteria.get(f, "").strip() + suffix = f" [criteria: {crit}]" if crit else "" + field_map[f] = f"{f}: Derived from ramble ({len(words)} words).{suffix}" + + uml_blocks = [ + ("@startuml\nactor User\nUser -> System: Ramble\nSystem -> LLM: Generate\nLLM --> System: Summary/Fields/UML\nSystem --> User: Updates\n@enduml", None) + ] + image_descriptions = [ + "Illustrate the core actor interacting with the system.", + "Abstract icon that represents the idea's domain." + ] + return { + "summary": summary, + "fields": field_map, + "uml_blocks": uml_blocks, + "image_descriptions": image_descriptions, + } + + +class ClaudeCLIProvider: + """ + Provider that calls Claude CLI (or compatible: Codex, Gemini). + + Works with any CLI that accepts a prompt via -p flag and returns text. + """ + + def __init__( + self, + cmd: str = "claude", + extra_args: Optional[List[str]] = None, + timeout_s: int = 120, + tail_chars: int = 8000, + use_arg_p: bool = True, + debug: bool = False, + log_path: str = "/tmp/ramble_ai.log", + ): + self.cmd = shutil.which(cmd) or cmd + self.extra_args = extra_args or [] + self.timeout_s = timeout_s + self.tail_chars = tail_chars + self.use_arg_p = use_arg_p + self.debug = debug + self.log_path = log_path + + def _log(self, msg: str): + if not self.debug: + return + with open(self.log_path, "a", encoding="utf-8") as f: + print(f"[{time.strftime('%H:%M:%S')}] {msg}", file=f) + + def _build_prompt( + self, *, user_prompt: str, ramble_text: str, + fields: List[str], field_criteria: Dict[str, str], locked_context: Dict[str, str] + ) -> str: + fields_yaml = "\n".join([f'- "{f}"' for f in fields]) + criteria_yaml = "\n".join([f'- {name}: {field_criteria[name]}' for name in fields if field_criteria.get(name)]) + locked_yaml = "\n".join([f'- {k}: {locked_context[k]}' for k in locked_context.keys()]) if locked_context else "" + ramble_tail = ramble_text[-self.tail_chars:] if self.tail_chars and len(ramble_text) > self.tail_chars else ramble_text + + return textwrap.dedent(f"""\ + You are an assistant that returns ONLY compact JSON. No preamble, no markdown, no code fences. + + Goal: + - Read the user's "ramble" and synthesize structured outputs. + + Required JSON keys: + - "summary": string + - "fields": object with these keys: {fields_yaml} + - "uml_blocks": array of objects: {{"uml_text": string, "png_base64": null}} + - "image_descriptions": array of short strings + + Per-field criteria (enforce tightly; if ambiguous, choose the most useful interpretation): + {criteria_yaml if criteria_yaml else "- (no special criteria provided)"} + + Guidance: + - The PlantUML diagram must reflect concrete entities/flows drawn from the ramble and any locked fields. + - Use 3-7 nodes where possible; prefer meaningful arrow labels. + - Image descriptions must be specific to this idea (avoid generic phrasing). + + Authoritative context from previously LOCKED fields (treat as ground truth if present): + {locked_yaml if locked_yaml else "- (none locked yet)"} + + Prompt: {user_prompt} + + Ramble (tail, possibly truncated): + {ramble_tail} + """).strip() + "\n" + + @staticmethod + def _strip_fences(s: str) -> str: + s = s.strip() + if s.startswith("```"): + s = re.sub(r"^```(?:json)?\s*", "", s, flags=re.IGNORECASE) + s = re.sub(r"\s*```$", "", s) + return s.strip() + + def _run_once(self, prompt_text: str, timeout: int) -> str: + if self.use_arg_p: + argv = [self.cmd, "-p", prompt_text, *self.extra_args] + stdin = None + else: + argv = [self.cmd, *self.extra_args] + stdin = prompt_text + self._log(f"argv: {argv}") + t0 = time.time() + try: + proc = subprocess.run(argv, input=stdin, capture_output=True, text=True, timeout=timeout, check=False) + except FileNotFoundError as e: + self._log(f"FileNotFoundError: {e}") + raise RuntimeError(f"CLI not found at {self.cmd}.") from e + except subprocess.TimeoutExpired: + self._log("TimeoutExpired") + raise + out = (proc.stdout or "").strip() + err = (proc.stderr or "").strip() + self._log(f"rc={proc.returncode} elapsed={time.time()-t0:.2f}s out={len(out)}B err={len(err)}B") + if proc.returncode != 0: + raise RuntimeError(f"CLI exited {proc.returncode}:\n{err or out or '(no output)'}") + return out + + def _normalize(self, raw: Dict[str, Any], fields: List[str]) -> Dict[str, Any]: + import base64 + fields_map = raw.get("fields", {}) or {} + uml_objs = raw.get("uml_blocks", []) or [] + image_desc = raw.get("image_descriptions", []) or [] + uml_blocks: List[tuple] = [] + for obj in uml_objs: + uml_text = (obj or {}).get("uml_text") or "" + png_b64 = (obj or {}).get("png_base64") + png_bytes = None + if isinstance(png_b64, str) and png_b64: + try: + png_bytes = base64.b64decode(png_b64) + except Exception: + png_bytes = None + uml_blocks.append((uml_text, png_bytes)) + normalized_fields = {name: str(fields_map.get(name, "")) for name in fields} + return { + "summary": str(raw.get("summary", "")), + "fields": normalized_fields, + "uml_blocks": uml_blocks, + "image_descriptions": [str(s) for s in image_desc], + } + + def generate( + self, *, prompt: str, ramble_text: str, fields: List[str], + field_criteria: Dict[str, str], locked_context: Dict[str, str] + ) -> Dict[str, Any]: + p = self._build_prompt( + user_prompt=prompt, ramble_text=ramble_text, + fields=fields, field_criteria=field_criteria, locked_context=locked_context + ) + try: + out = self._run_once(p, timeout=self.timeout_s) + except subprocess.TimeoutExpired: + rt = ramble_text[-min(self.tail_chars or 3000, 3000):] + self._log("Retrying with smaller prompt...") + shorter = self._build_prompt( + user_prompt=prompt, ramble_text=rt, + fields=fields, field_criteria=field_criteria, locked_context=locked_context + ) + out = self._run_once(shorter, timeout=max(45, self.timeout_s // 2)) + + txt = self._strip_fences(out) + try: + data = json.loads(txt) + except json.JSONDecodeError: + words = txt.split() + summary = " ".join(words[:30]) + ("..." if len(words) > 30 else "") + data = { + "summary": summary or "(no content)", + "fields": {f: f"{f}: {summary}" for f in fields}, + "uml_blocks": [{"uml_text": "@startuml\nactor User\nUser -> System: Ramble\n@enduml", "png_base64": None}], + "image_descriptions": ["Abstract illustration related to the idea."], + } + return self._normalize(data, fields)