Compare commits

...

No commits in common. "pages" and "master" have entirely different histories.

35 changed files with 1389 additions and 345 deletions

View File

@ -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
```

View File

@ -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)

View File

@ -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?

File diff suppressed because one or more lines are too long

71
Dockerfile Normal file
View File

@ -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"}'

197
README.md Normal file
View File

@ -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

View File

@ -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."})})]})})})}}}]);

View File

@ -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)})})}}}]);

View File

@ -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)}}}]);

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.
*/

View File

@ -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

69
docker-compose.yml Normal file
View File

@ -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

View File

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

46
pyproject.toml Normal file
View File

@ -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"]

View File

@ -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>

20
src/ramble/__init__.py Normal file
View File

@ -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",
]

201
src/ramble/cli.py Normal file
View File

@ -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()

485
src/ramble/dialog.py Normal file
View File

@ -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

230
src/ramble/providers.py Normal file
View File

@ -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