Initial Ramble project - AI-powered field extraction
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 <noreply@anthropic.com>
This commit is contained in:
commit
b1386facd1
|
|
@ -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"}'
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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("<b>Ramble about your idea. Fields will fill themselves.</b>")
|
||||||
|
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"<span style='color:#777; font-size:11px'>({crit})</span>")
|
||||||
|
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
|
||||||
|
|
@ -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)
|
||||||
Loading…
Reference in New Issue