Compare commits
No commits in common. "pages" and "master" have entirely different histories.
|
|
@ -0,0 +1,32 @@
|
||||||
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: Report something that isn't working
|
||||||
|
labels: bug
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
What happened?
|
||||||
|
|
||||||
|
## Steps to Reproduce
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
What should have happened?
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- OS:
|
||||||
|
- Ramble version:
|
||||||
|
- Provider (mock/claude/codex/gemini):
|
||||||
|
- Docker or native install?
|
||||||
|
|
||||||
|
## Error Output
|
||||||
|
|
||||||
|
```
|
||||||
|
Paste any error messages here
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
---
|
||||||
|
name: Feature Request
|
||||||
|
about: Suggest a new feature or improvement
|
||||||
|
labels: enhancement
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Description
|
||||||
|
|
||||||
|
A clear description of the feature you'd like to see.
|
||||||
|
|
||||||
|
## Use Case
|
||||||
|
|
||||||
|
Why do you need this? What problem does it solve?
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
How do you think it should work? (optional)
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
Any other approaches you've thought about? (optional)
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
name: Question
|
||||||
|
about: Ask a question about Ramble
|
||||||
|
labels: question
|
||||||
|
---
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
What would you like to know?
|
||||||
|
|
||||||
|
## What I've Tried
|
||||||
|
|
||||||
|
Any steps you've already taken to find the answer?
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Any additional context that might help?
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
# 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 xcb platform plugin dependencies
|
||||||
|
libgl1 \
|
||||||
|
libegl1 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxkbcommon-x11-0 \
|
||||||
|
libdbus-1-3 \
|
||||||
|
libxcb1 \
|
||||||
|
libxcb-cursor0 \
|
||||||
|
libxcb-icccm4 \
|
||||||
|
libxcb-image0 \
|
||||||
|
libxcb-keysyms1 \
|
||||||
|
libxcb-render0 \
|
||||||
|
libxcb-render-util0 \
|
||||||
|
libxcb-shape0 \
|
||||||
|
libxcb-shm0 \
|
||||||
|
libxcb-sync1 \
|
||||||
|
libxcb-xfixes0 \
|
||||||
|
libxcb-xinerama0 \
|
||||||
|
libxcb-xkb1 \
|
||||||
|
libxcb-randr0 \
|
||||||
|
libx11-xcb1 \
|
||||||
|
libfontconfig1 \
|
||||||
|
libfreetype6 \
|
||||||
|
&& 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
|
||||||
|
|
||||||
|
# ENTRYPOINT makes ramble the base command
|
||||||
|
# CMD provides default args (can be overridden)
|
||||||
|
ENTRYPOINT ["ramble"]
|
||||||
|
CMD ["--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,197 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Option 1: Pre-built Docker Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Allow X11 access
|
||||||
|
xhost +local:docker
|
||||||
|
|
||||||
|
# Run the GUI with mock provider (no AI required)
|
||||||
|
docker run -it --rm \
|
||||||
|
-e DISPLAY=$DISPLAY \
|
||||||
|
-v /tmp/.X11-unix:/tmp/.X11-unix \
|
||||||
|
gitea.brrd.tech/rob/ramble --provider mock
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Native Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.brrd.tech/rob/ramble.git
|
||||||
|
cd ramble
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
sudo apt install plantuml # For diagram rendering
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
### Pre-built Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull the pre-built container
|
||||||
|
docker pull gitea.brrd.tech/rob/ramble:latest
|
||||||
|
|
||||||
|
# Run GUI (requires: xhost +local:docker)
|
||||||
|
docker run -it --rm \
|
||||||
|
-e DISPLAY=$DISPLAY \
|
||||||
|
-v /tmp/.X11-unix:/tmp/.X11-unix \
|
||||||
|
gitea.brrd.tech/rob/ramble --provider mock
|
||||||
|
|
||||||
|
# Headless mode
|
||||||
|
docker run -it --rm gitea.brrd.tech/rob/ramble \
|
||||||
|
--field-values '{"Title":"MyApp","Summary":"A cool app"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.brrd.tech/rob/ramble.git
|
||||||
|
cd ramble
|
||||||
|
docker-compose build
|
||||||
|
|
||||||
|
# Launch GUI
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- **Repository**: https://gitea.brrd.tech/rob/ramble
|
||||||
|
- **Docker Image**: `gitea.brrd.tech/rob/ramble:latest`
|
||||||
|
- **Issues**: https://gitea.brrd.tech/rob/ramble/issues
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
||||||
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[237],{2237(e,t,i){i.r(t),i.d(t,{default:()=>l});i(6540);var o=i(1312),n=i(5500),s=i(1656),r=i(3363),a=i(4848);function l(){const e=(0,o.T)({id:"theme.NotFound.title",message:"Page Not Found"});return(0,a.jsxs)(a.Fragment,{children:[(0,a.jsx)(n.be,{title:e}),(0,a.jsx)(s.A,{children:(0,a.jsx)(r.A,{})})]})}},3363(e,t,i){i.d(t,{A:()=>a});i(6540);var o=i(4164),n=i(1312),s=i(1107),r=i(4848);function a({className:e}){return(0,r.jsx)("main",{className:(0,o.A)("container margin-vert--xl",e),children:(0,r.jsx)("div",{className:"row",children:(0,r.jsxs)("div",{className:"col col--6 col--offset-3",children:[(0,r.jsx)(s.A,{as:"h1",className:"hero__title",children:(0,r.jsx)(n.A,{id:"theme.NotFound.title",description:"The title of the 404 page",children:"Page Not Found"})}),(0,r.jsx)("p",{children:(0,r.jsx)(n.A,{id:"theme.NotFound.p1",description:"The first paragraph of the 404 page",children:"We could not find what you were looking for."})}),(0,r.jsx)("p",{children:(0,r.jsx)(n.A,{id:"theme.NotFound.p2",description:"The 2nd paragraph of the 404 page",children:"Please contact the owner of the site that linked you to the original URL and let them know their link is broken."})})]})})})}}}]);
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[647],{7121(e,c,s){s.r(c),s.d(c,{default:()=>t});s(6540);var r=s(4164),u=s(7559),a=s(5500),l=s(2831),o=s(1656),p=s(4848);function t(e){return(0,p.jsx)(a.e3,{className:(0,r.A)(u.G.wrapper.docsPages),children:(0,p.jsx)(o.A,{children:(0,l.v)(e.route.routes)})})}}}]);
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[894],{7836(e,s,t){t.r(s),t.d(s,{assets:()=>o,contentTitle:()=>c,default:()=>u,frontMatter:()=>a,metadata:()=>i,toc:()=>r});const i=JSON.parse('{"id":"goals","title":"Goals","description":"Active","source":"@site/docs/goals.md","sourceDirName":".","slug":"/goals","permalink":"/rob/ramble/goals","draft":false,"unlisted":false,"tags":[],"version":"current","frontMatter":{"type":"goals","project":"ramble","updated":"2026-01-07T00:00:00.000Z"},"sidebar":"docs","previous":{"title":"Ramble TODOs","permalink":"/rob/ramble/todos"},"next":{"title":"Milestones","permalink":"/rob/ramble/milestones"}}');var n=t(4848),l=t(8453);const a={type:"goals",project:"ramble",updated:new Date("2026-01-07T00:00:00.000Z")},c="Goals",o={},r=[{value:"Active",id:"active",level:2},{value:"Future",id:"future",level:2},{value:"Non-Goals",id:"non-goals",level:2}];function d(e){const s={h1:"h1",h2:"h2",header:"header",input:"input",li:"li",strong:"strong",ul:"ul",...(0,l.R)(),...e.components};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(s.header,{children:(0,n.jsx)(s.h1,{id:"goals",children:"Goals"})}),"\n",(0,n.jsx)(s.h2,{id:"active",children:"Active"}),"\n",(0,n.jsxs)(s.ul,{className:"contains-task-list",children:["\n",(0,n.jsxs)(s.li,{className:"task-list-item",children:[(0,n.jsx)(s.input,{type:"checkbox",checked:!0,disabled:!0})," ","Create voice-to-structured-data CLI #high"]}),"\n",(0,n.jsxs)(s.li,{className:"task-list-item",children:[(0,n.jsx)(s.input,{type:"checkbox",checked:!0,disabled:!0})," ","Multi-provider support (Claude, Codex, Gemini) #high"]}),"\n",(0,n.jsxs)(s.li,{className:"task-list-item",children:[(0,n.jsx)(s.input,{type:"checkbox",disabled:!0})," ","Improve transcription accuracy #medium"]}),"\n",(0,n.jsxs)(s.li,{className:"task-list-item",children:[(0,n.jsx)(s.input,{type:"checkbox",disabled:!0})," ","Add support for longer recordings #medium"]}),"\n",(0,n.jsxs)(s.li,{className:"task-list-item",children:[(0,n.jsx)(s.input,{type:"checkbox",disabled:!0})," ","Better field extraction with custom criteria #medium"]}),"\n",(0,n.jsxs)(s.li,{className:"task-list-item",children:[(0,n.jsx)(s.input,{type:"checkbox",disabled:!0})," ","Docker containerization for easy deployment #high"]}),"\n"]}),"\n",(0,n.jsx)(s.h2,{id:"future",children:"Future"}),"\n",(0,n.jsxs)(s.ul,{className:"contains-task-list",children:["\n",(0,n.jsxs)(s.li,{className:"task-list-item",children:[(0,n.jsx)(s.input,{type:"checkbox",disabled:!0})," ","Real-time transcription mode"]}),"\n",(0,n.jsxs)(s.li,{className:"task-list-item",children:[(0,n.jsx)(s.input,{type:"checkbox",disabled:!0})," ","Custom vocabulary support"]}),"\n",(0,n.jsxs)(s.li,{className:"task-list-item",children:[(0,n.jsx)(s.input,{type:"checkbox",disabled:!0})," ","Integration with more applications"]}),"\n",(0,n.jsxs)(s.li,{className:"task-list-item",children:[(0,n.jsx)(s.input,{type:"checkbox",disabled:!0})," ","Voice command mode"]}),"\n",(0,n.jsxs)(s.li,{className:"task-list-item",children:[(0,n.jsx)(s.input,{type:"checkbox",disabled:!0})," ","Stability AI image generation integration"]}),"\n",(0,n.jsxs)(s.li,{className:"task-list-item",children:[(0,n.jsx)(s.input,{type:"checkbox",disabled:!0})," ","Pexels image search integration"]}),"\n"]}),"\n",(0,n.jsx)(s.h2,{id:"non-goals",children:"Non-Goals"}),"\n",(0,n.jsxs)(s.ul,{children:["\n",(0,n.jsxs)(s.li,{children:[(0,n.jsx)(s.strong,{children:"Replacing full dictation software"})," - Ramble focuses on structured extraction, not general transcription"]}),"\n",(0,n.jsxs)(s.li,{children:[(0,n.jsx)(s.strong,{children:"Standalone transcription service"})," - It's a tool for developers, not an end-user service"]}),"\n"]})]})}function u(e={}){const{wrapper:s}={...(0,l.R)(),...e.components};return s?(0,n.jsx)(s,{...e,children:(0,n.jsx)(d,{...e})}):d(e)}},8453(e,s,t){t.d(s,{R:()=>a,x:()=>c});var i=t(6540);const n={},l=i.createContext(n);function a(e){const s=i.useContext(l);return i.useMemo(function(){return"function"==typeof e?e(s):{...s,...e}},[s,e])}function c(e){let s;return s=e.disableParentContext?"function"==typeof e.components?e.components(n):e.components||n:a(e.components),i.createElement(l.Provider,{value:s},e.children)}}}]);
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[155],{7741(e){e.exports=JSON.parse('{"version":{"pluginId":"default","version":"current","label":"Next","banner":null,"badge":false,"noIndex":false,"className":"docs-version-current","isLast":true,"docsSidebars":{"docs":[{"type":"link","href":"/rob/ramble/","label":"Ramble Overview","docId":"overview","unlisted":false},{"type":"link","href":"/rob/ramble/configuration","label":"Ramble Configuration","docId":"configuration","unlisted":false},{"type":"link","href":"/rob/ramble/todos","label":"Ramble TODOs","docId":"todos","unlisted":false},{"type":"link","href":"/rob/ramble/goals","label":"Goals","docId":"goals","unlisted":false},{"type":"link","href":"/rob/ramble/milestones","label":"Milestones","docId":"milestones","unlisted":false}]},"docs":{"configuration":{"id":"configuration","title":"Ramble Configuration","description":"Field Definitions","sidebar":"docs"},"goals":{"id":"goals","title":"Goals","description":"Active","sidebar":"docs"},"milestones":{"id":"milestones","title":"Milestones","description":"Active","sidebar":"docs"},"overview":{"id":"overview","title":"Ramble Overview","description":"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.","sidebar":"docs"},"todos":{"id":"todos","title":"Ramble TODOs","description":"Active Tasks","sidebar":"docs"}}}}')}}]);
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
||||||
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[98],{1723(n,e,s){s.r(e),s.d(e,{default:()=>d});s(6540);var r=s(5500);function o(n,e){return`docs-${n}-${e}`}var c=s(3025),t=s(2831),i=s(1463),u=s(4848);function l(n){const{version:e}=n;return(0,u.jsxs)(u.Fragment,{children:[(0,u.jsx)(i.A,{version:e.version,tag:o(e.pluginId,e.version)}),(0,u.jsx)(r.be,{children:e.noIndex&&(0,u.jsx)("meta",{name:"robots",content:"noindex, nofollow"})})]})}function a(n){const{version:e,route:s}=n;return(0,u.jsx)(r.e3,{className:e.className,children:(0,u.jsx)(c.n,{version:e,children:(0,t.v)(s.routes)})})}function d(n){return(0,u.jsxs)(u.Fragment,{children:[(0,u.jsx)(l,{...n}),(0,u.jsx)(a,{...n})]})}}}]);
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
||||||
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[742],{7093(c){c.exports=JSON.parse('{"name":"docusaurus-plugin-content-docs","id":"default"}')}}]);
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,61 +0,0 @@
|
||||||
/* NProgress, (c) 2013, 2014 Rico Sta. Cruz - http://ricostacruz.com/nprogress
|
|
||||||
* @license MIT */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @license React
|
|
||||||
* react-dom-client.production.js
|
|
||||||
*
|
|
||||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @license React
|
|
||||||
* react-dom.production.js
|
|
||||||
*
|
|
||||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @license React
|
|
||||||
* react-jsx-runtime.production.js
|
|
||||||
*
|
|
||||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @license React
|
|
||||||
* react.production.js
|
|
||||||
*
|
|
||||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @license React
|
|
||||||
* scheduler.production.js
|
|
||||||
*
|
|
||||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @license React v16.13.1
|
|
||||||
* react-is.production.min.js
|
|
||||||
*
|
|
||||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*/
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
(()=>{"use strict";var e,r,t,a,o,n={},i={};function c(e){var r=i[e];if(void 0!==r)return r.exports;var t=i[e]={id:e,loaded:!1,exports:{}};return n[e].call(t.exports,t,t.exports,c),t.loaded=!0,t.exports}c.m=n,c.c=i,e=[],c.O=(r,t,a,o)=>{if(!t){var n=1/0;for(f=0;f<e.length;f++){for(var[t,a,o]=e[f],i=!0,d=0;d<t.length;d++)(!1&o||n>=o)&&Object.keys(c.O).every(e=>c.O[e](t[d]))?t.splice(d--,1):(i=!1,o<n&&(n=o));if(i){e.splice(f--,1);var l=a();void 0!==l&&(r=l)}}return r}o=o||0;for(var f=e.length;f>0&&e[f-1][2]>o;f--)e[f]=e[f-1];e[f]=[t,a,o]},c.n=e=>{var r=e&&e.__esModule?()=>e.default:()=>e;return c.d(r,{a:r}),r},t=Object.getPrototypeOf?e=>Object.getPrototypeOf(e):e=>e.__proto__,c.t=function(e,a){if(1&a&&(e=this(e)),8&a)return e;if("object"==typeof e&&e){if(4&a&&e.__esModule)return e;if(16&a&&"function"==typeof e.then)return e}var o=Object.create(null);c.r(o);var n={};r=r||[null,t({}),t([]),t(t)];for(var i=2&a&&e;("object"==typeof i||"function"==typeof i)&&!~r.indexOf(i);i=t(i))Object.getOwnPropertyNames(i).forEach(r=>n[r]=()=>e[r]);return n.default=()=>e,c.d(o,n),o},c.d=(e,r)=>{for(var t in r)c.o(r,t)&&!c.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:r[t]})},c.f={},c.e=e=>Promise.all(Object.keys(c.f).reduce((r,t)=>(c.f[t](e,r),r),[])),c.u=e=>"assets/js/"+({48:"a94703ab",98:"a7bd4aaa",155:"781ac23e",393:"1db78e9f",401:"17896441",413:"1db64337",574:"817f7194",647:"5e95c892",742:"aba21aa0",873:"9ed00105",894:"5eebbccf"}[e]||e)+"."+{48:"b8c77466",98:"3ba34601",155:"7c7002ee",237:"447ba118",393:"449f7024",401:"a2525508",413:"4b84b095",574:"2b39dd0b",647:"a3b66919",742:"4a552a5c",873:"56df50b7",894:"d3ad48c8"}[e]+".js",c.miniCssF=e=>{},c.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),a={},o="project-public-docs:",c.l=(e,r,t,n)=>{if(a[e])a[e].push(r);else{var i,d;if(void 0!==t)for(var l=document.getElementsByTagName("script"),f=0;f<l.length;f++){var u=l[f];if(u.getAttribute("src")==e||u.getAttribute("data-webpack")==o+t){i=u;break}}i||(d=!0,(i=document.createElement("script")).charset="utf-8",c.nc&&i.setAttribute("nonce",c.nc),i.setAttribute("data-webpack",o+t),i.src=e),a[e]=[r];var s=(r,t)=>{i.onerror=i.onload=null,clearTimeout(b);var o=a[e];if(delete a[e],i.parentNode&&i.parentNode.removeChild(i),o&&o.forEach(e=>e(t)),r)return r(t)},b=setTimeout(s.bind(null,void 0,{type:"timeout",target:i}),12e4);i.onerror=s.bind(null,i.onerror),i.onload=s.bind(null,i.onload),d&&document.head.appendChild(i)}},c.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},c.p="/rob/ramble/",c.gca=function(e){return e={17896441:"401",a94703ab:"48",a7bd4aaa:"98","781ac23e":"155","1db78e9f":"393","1db64337":"413","817f7194":"574","5e95c892":"647",aba21aa0:"742","9ed00105":"873","5eebbccf":"894"}[e]||e,c.p+c.u(e)},(()=>{var e={354:0,869:0};c.f.j=(r,t)=>{var a=c.o(e,r)?e[r]:void 0;if(0!==a)if(a)t.push(a[2]);else if(/^(354|869)$/.test(r))e[r]=0;else{var o=new Promise((t,o)=>a=e[r]=[t,o]);t.push(a[2]=o);var n=c.p+c.u(r),i=new Error;c.l(n,t=>{if(c.o(e,r)&&(0!==(a=e[r])&&(e[r]=void 0),a)){var o=t&&("load"===t.type?"missing":t.type),n=t&&t.target&&t.target.src;i.message="Loading chunk "+r+" failed.\n("+o+": "+n+")",i.name="ChunkLoadError",i.type=o,i.request=n,a[1](i)}},"chunk-"+r,r)}},c.O.j=r=>0===e[r];var r=(r,t)=>{var a,o,[n,i,d]=t,l=0;if(n.some(r=>0!==e[r])){for(a in i)c.o(i,a)&&(c.m[a]=i[a]);if(d)var f=d(c)}for(r&&r(t);l<n.length;l++)o=n[l],c.o(e,o)&&e[o]&&e[o][0](),e[o]=0;return c.O(f)},t=globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[];t.forEach(r.bind(null,0)),t.push=r.bind(null,t.push.bind(t))})()})();
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||||
File diff suppressed because one or more lines are too long
46
index.html
46
index.html
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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"]
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"><url><loc>https://pages.brrd.tech/rob/ramble/configuration</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://pages.brrd.tech/rob/ramble/goals</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://pages.brrd.tech/rob/ramble/milestones</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://pages.brrd.tech/rob/ramble/todos</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://pages.brrd.tech/rob/ramble/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url></urlset>
|
|
||||||
|
|
@ -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,485 @@
|
||||||
|
"""
|
||||||
|
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._active_worker: Optional[GenWorker] = None # Keep reference to prevent premature GC
|
||||||
|
|
||||||
|
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...")
|
||||||
|
self._active_worker = GenWorker(self.provider, payload)
|
||||||
|
self._active_worker.signals.finished.connect(self._on_generated)
|
||||||
|
self._active_worker.signals.error.connect(self._on_gen_error)
|
||||||
|
self.thread_pool.start(self._active_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 closeEvent(self, event):
|
||||||
|
"""Wait for background workers to finish before closing."""
|
||||||
|
self.thread_pool.waitForDone(1000) # Wait up to 1 second
|
||||||
|
# Clean up worker reference to prevent Qt warning
|
||||||
|
if self._active_worker is not None:
|
||||||
|
self._active_worker.signals.deleteLater()
|
||||||
|
self._active_worker = None
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue