slidict 0.5.2 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +10 -0
- data/README.md +76 -2
- data/lib/slidict/cli/app.rb +182 -96
- data/lib/slidict/cli/lint.rb +26 -36
- data/lib/slidict/cli/options.rb +148 -0
- data/lib/slidict/cli/serve.rb +9 -4
- data/lib/slidict/cli/slides.rb +39 -44
- data/lib/slidict/deck.rb +4 -2
- data/lib/slidict/env.rb +47 -0
- data/lib/slidict/generator.rb +45 -0
- data/lib/slidict/llm/client.rb +52 -5
- data/lib/slidict/version.rb +1 -1
- data/lib/slidict.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 31c1ca403d68f971c84728e80c5aafda7af449fa5d7b9b7be6202c49472c615f
|
|
4
|
+
data.tar.gz: 363578c6cfd6c0da50346204b124d10fa5779e7a0f73b262b0a3f1bd2854266c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d58f207c65c0b1e0ea8d0e9369aa40dce69af3e54bcedb56b0932a1b77d32238152e15194bcadfba824442c1bf47550e381ecefde58dc3525838ac42a4ae35d4
|
|
7
|
+
data.tar.gz: 41cb0a82175bdc62be2369dbe4ef65a3f0a9cf4716b6b0b044bbb8ca0a6aa2c767cf8b829a6f1a8704944b5fd1f5df610b1d3294a7478aa5dd5cd461290d459b
|
data/AGENTS.md
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
- Add source-text slide generation through `--text`, `--text-file`, and the reusable
|
|
4
|
+
`Slidict::Generator` integration API.
|
|
5
|
+
- Add `slidict init` to create a `.env` file (for `SLIDICT_LLM_*`, `SLIDICT_FRAMEWORK`,
|
|
6
|
+
and `SLIDICT_METHOD`) and add it to `.gitignore`. `.env` is loaded automatically and
|
|
7
|
+
fills in unset environment variables; CLI flags still take precedence.
|
|
8
|
+
- Add `SLIDICT_FRAMEWORK` and `SLIDICT_METHOD` environment variables as defaults for
|
|
9
|
+
`--framework` and `--method`.
|
|
10
|
+
- Add `--language` to generate slide titles and bullets in a language other than English,
|
|
11
|
+
and to translate the interactive prompt questions into that language via the LLM.
|
|
12
|
+
|
|
3
13
|
## [0.5.0] - 2026-07-01
|
|
4
14
|
|
|
5
15
|
- Add data-driven presentation methods with SCQA, PREP, and Pyramid Principle templates.
|
data/README.md
CHANGED
|
@@ -25,7 +25,7 @@ Unlike traditional slide generators, Slidict focuses on communication before sli
|
|
|
25
25
|
|
|
26
26
|
## Requirements
|
|
27
27
|
|
|
28
|
-
- Ruby 3.
|
|
28
|
+
- Ruby 3.2.0 or later
|
|
29
29
|
|
|
30
30
|
## Usage
|
|
31
31
|
|
|
@@ -63,6 +63,34 @@ bin/slidict \
|
|
|
63
63
|
--output slides.adoc
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
+
To create a presentation from an existing article, memo, or other prose, pass the
|
|
67
|
+
text directly or read it from a file. Source-text generation requires an LLM endpoint;
|
|
68
|
+
the first non-empty line is used as the topic when `--topic` is omitted. Slidict asks
|
|
69
|
+
the model to preserve the source's facts and examples instead of inventing claims.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
bin/slidict --text-file proposal.md \
|
|
73
|
+
--llm-base-url https://api.openai.com/v1 \
|
|
74
|
+
--llm-api-key "$OPENAI_API_KEY" --llm-model gpt-4o-mini
|
|
75
|
+
|
|
76
|
+
bin/slidict --text "Why our team should adopt automated accessibility testing ..." \
|
|
77
|
+
--llm-base-url http://localhost:11434/v1 --llm-api-key ollama --llm-model llama3
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Applications can use the same pipeline without CLI or filesystem side effects. The
|
|
81
|
+
result contains both the structured `deck` and rendered presentation `content`:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
client = Slidict::Llm::Client.new(base_url: url, api_key: key, model: model)
|
|
85
|
+
result = Slidict::Generator.new(client: client).generate(
|
|
86
|
+
text: article,
|
|
87
|
+
framework: "slidev",
|
|
88
|
+
language: "Japanese"
|
|
89
|
+
)
|
|
90
|
+
result.deck # => Slidict::Deck
|
|
91
|
+
result.content # => Slidev source
|
|
92
|
+
```
|
|
93
|
+
|
|
66
94
|
Add `--publish` to also save the generated slides to slidict.io as a draft (requires
|
|
67
95
|
`slidict auth` first). Pass `--slide-id` to edit an existing draft instead of creating a
|
|
68
96
|
new one:
|
|
@@ -94,6 +122,16 @@ Asciidoctor Reveal.js -> public/001.adoc, public/002.adoc, ...
|
|
|
94
122
|
|
|
95
123
|
## Commands
|
|
96
124
|
|
|
125
|
+
### `slidict init`
|
|
126
|
+
|
|
127
|
+
Creates a `.env` file for `SLIDICT_LLM_*`, `SLIDICT_FRAMEWORK`, and `SLIDICT_METHOD`
|
|
128
|
+
(see [Configuration](#configuration)) and adds `.env` to `.gitignore`. Does nothing to
|
|
129
|
+
an existing `.env` or an already-updated `.gitignore`.
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
bin/slidict init
|
|
133
|
+
```
|
|
134
|
+
|
|
97
135
|
### `slidict auth`
|
|
98
136
|
|
|
99
137
|
Authenticates the CLI with your GitHub account via the device code flow and saves a
|
|
@@ -167,19 +205,55 @@ override it with `--format`. Run `bin/slidict lint -h` for the full list of opti
|
|
|
167
205
|
## Configuration
|
|
168
206
|
|
|
169
207
|
Slidict generates slides with an LLM through any OpenAI Compatible API. Configure the
|
|
170
|
-
target endpoint with environment variables or CLI flags
|
|
208
|
+
target endpoint with environment variables, a `.env` file, or CLI flags:
|
|
171
209
|
|
|
172
210
|
| Environment variable | CLI flag | Default |
|
|
173
211
|
| ------------------------ | ---------------- | -------------- |
|
|
174
212
|
| `SLIDICT_LLM_BASE_URL` | `--llm-base-url` | _(none)_ |
|
|
175
213
|
| `SLIDICT_LLM_API_KEY` | `--llm-api-key` | _(none)_ |
|
|
176
214
|
| `SLIDICT_LLM_MODEL` | `--llm-model` | `gpt-4o-mini` |
|
|
215
|
+
| `SLIDICT_FRAMEWORK` | `--framework` | `slidev` |
|
|
216
|
+
| `SLIDICT_METHOD` | `--method` | _(none)_ |
|
|
217
|
+
|
|
218
|
+
Precedence is CLI flag > real environment variable > `.env` file > built-in default (a
|
|
219
|
+
`.env` value fills in only variables that aren't already set in the environment). Run
|
|
220
|
+
`slidict init` to create a `.env` file in the current directory (with these variables
|
|
221
|
+
commented out) and add `.env` to `.gitignore`:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
bin/slidict init
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
```text
|
|
228
|
+
Created .env
|
|
229
|
+
Added .env to .gitignore
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Then edit `.env` to set the values you want, for example:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
SLIDICT_LLM_BASE_URL=http://localhost:11434/v1
|
|
236
|
+
SLIDICT_LLM_API_KEY=ollama
|
|
237
|
+
SLIDICT_LLM_MODEL=llama3
|
|
238
|
+
SLIDICT_FRAMEWORK=marp
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Since `.env` is gitignored, it's a good place for secrets like API keys that
|
|
242
|
+
shouldn't be committed. A CLI flag always overrides both `.env` and the real
|
|
243
|
+
environment for that invocation.
|
|
177
244
|
|
|
178
245
|
If no `llm-base-url` is configured, Slidict uses its built-in slide template and never
|
|
179
246
|
calls an LLM. Once a `llm-base-url` is set, Slidict always calls that endpoint; if the
|
|
180
247
|
request fails, Slidict reports the error and exits without writing a file (no fallback).
|
|
181
248
|
You can force the template even when a base URL is configured with `--no-llm`.
|
|
182
249
|
|
|
250
|
+
Use `--language LANG` (e.g. `--language Japanese`) to have the LLM write the generated
|
|
251
|
+
slide titles and bullets in a language other than English. When an `llm-base-url` is
|
|
252
|
+
also configured, Slidict asks the LLM to translate any interactive questions it still
|
|
253
|
+
needs to ask (topic, duration, audience, goal) into that language too, so you're not
|
|
254
|
+
answering Japanese-bound slides in English prompts. This only affects LLM-generated
|
|
255
|
+
slides and questions; the built-in template stays in English.
|
|
256
|
+
|
|
183
257
|
Examples:
|
|
184
258
|
|
|
185
259
|
```bash
|
data/lib/slidict/cli/app.rb
CHANGED
|
@@ -6,18 +6,60 @@ require "pathname"
|
|
|
6
6
|
module Slidict
|
|
7
7
|
module Cli
|
|
8
8
|
class App
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
include Options
|
|
10
|
+
|
|
11
|
+
# Keyed by the same symbols as the parsed CLI options (options[:topic],
|
|
12
|
+
# etc.), so a missing answer's question text can be looked up directly
|
|
13
|
+
# by `questions_for`.
|
|
14
|
+
QUESTIONS = {
|
|
15
|
+
topic: "What would you like to talk about?",
|
|
16
|
+
duration: "How long is the presentation?",
|
|
17
|
+
audience: "Who is the audience?",
|
|
18
|
+
goal: "What should the audience remember or do?"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
options input: -> { $stdin },
|
|
22
|
+
output: -> { $stdout },
|
|
23
|
+
renderer: -> { Output::Renderer.new },
|
|
24
|
+
auth_client: -> { nil },
|
|
25
|
+
credentials: -> { nil },
|
|
26
|
+
sleeper: -> { Kernel },
|
|
27
|
+
slides_command: -> { nil },
|
|
28
|
+
server: -> { nil },
|
|
29
|
+
lint_command: -> { nil }
|
|
30
|
+
|
|
31
|
+
flag "--topic", arg: "TEXT", desc: "Presentation topic"
|
|
32
|
+
flag "--duration", arg: "TEXT", desc: 'Presentation length, for example "5 minutes"'
|
|
33
|
+
flag "--audience", arg: "TEXT", desc: "Target audience"
|
|
34
|
+
flag "--goal", arg: "TEXT", desc: "Desired audience takeaway or action"
|
|
35
|
+
flag "--text", arg: "TEXT", desc: "Source text to turn into slides"
|
|
36
|
+
flag "--text-file", arg: "PATH", desc: "Read source text from a file"
|
|
37
|
+
flag "--framework", arg: "NAME",
|
|
38
|
+
desc: -> { "#{Output::Format.names.join(", ")} (default: slidev)\n(env: SLIDICT_FRAMEWORK)" }
|
|
39
|
+
flag "--method", arg: "ID", desc: "Presentation method, for example scqa, prep, or pyramid\n" \
|
|
40
|
+
"(env: SLIDICT_METHOD)"
|
|
41
|
+
flag "--language", arg: "LANG", desc: "Generate slide titles and bullets in the given language\n" \
|
|
42
|
+
"(e.g. Japanese); only affects LLM-generated slides"
|
|
43
|
+
flag "--filename", arg: "NAME", desc: "File name under public/ (default: next sequential file)"
|
|
44
|
+
flag "--llm-base-url", arg: "URL", desc: "OpenAI Compatible API base URL (env: SLIDICT_LLM_BASE_URL).\n" \
|
|
45
|
+
"When omitted, the built-in slide template is used instead."
|
|
46
|
+
flag "--llm-api-key", arg: "KEY", desc: "API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)"
|
|
47
|
+
# rubocop:disable Layout/HashAlignment -- source-only line wrap, desc stays a single displayed line
|
|
48
|
+
flag "--llm-model", arg: "NAME",
|
|
49
|
+
desc: "Model name to request (env: SLIDICT_LLM_MODEL); omit to list available models"
|
|
50
|
+
# rubocop:enable Layout/HashAlignment
|
|
51
|
+
flag "--no-llm", desc: "Skip the LLM call and use the built-in slide template"
|
|
52
|
+
flag "--publish", desc: "Publish the generated slides to slidict.io as a draft\n" \
|
|
53
|
+
"(requires `slidict auth`; creates a new slide, or edits\n" \
|
|
54
|
+
"an existing one when --slide-id is given)"
|
|
55
|
+
# rubocop:disable Layout/HashAlignment -- source-only line wrap, desc stays a single displayed line
|
|
56
|
+
flag "--slide-id", arg: "ID",
|
|
57
|
+
desc: "Edit this existing draft instead of creating a new one\n(implies --publish)"
|
|
58
|
+
# rubocop:enable Layout/HashAlignment
|
|
59
|
+
flag "--slide-title", arg: "TEXT", desc: "Title for the published slide (default: --topic)"
|
|
60
|
+
flag "--visibility", arg: "VIS", desc: "public, unlisted, or group_only (default: public)"
|
|
61
|
+
flag "-o", "--output", arg: "PATH", desc: "Output file (overrides --filename and the public/ default)"
|
|
62
|
+
flag "-h", "--help", desc: "Show this help"
|
|
21
63
|
|
|
22
64
|
def run(argv = [])
|
|
23
65
|
options = parse(argv)
|
|
@@ -28,32 +70,41 @@ module Slidict
|
|
|
28
70
|
return lint(options[:args]) if options[:command] == "lint"
|
|
29
71
|
return list_methods if options[:command] == "list-methods"
|
|
30
72
|
return show_method(options[:args]) if options[:command] == "show-method"
|
|
73
|
+
return init if options[:command] == "init"
|
|
31
74
|
|
|
32
75
|
config = build_config(options)
|
|
33
76
|
return print_available_models(config) if config.llm_enabled? && config.model.nil?
|
|
34
77
|
|
|
78
|
+
# Validate and read local input before making a network request. This
|
|
79
|
+
# keeps missing/empty source-file errors fast and deterministic.
|
|
80
|
+
options[:text] = read_source_text(options)
|
|
35
81
|
client = llm_client_for(config)
|
|
36
|
-
return
|
|
82
|
+
return FAILURE if client && !verify_connection(client)
|
|
83
|
+
|
|
84
|
+
raise ArgumentError, "--text and --text-file require an LLM endpoint" if options[:text] && !client
|
|
37
85
|
|
|
86
|
+
questions = questions_for(client, options)
|
|
38
87
|
deck = Deck.new(
|
|
39
|
-
topic:
|
|
40
|
-
duration:
|
|
41
|
-
audience:
|
|
42
|
-
goal:
|
|
88
|
+
topic: source_topic(options) || ask(questions[:topic], options[:topic]),
|
|
89
|
+
duration: options[:text] ? options[:duration] : ask(questions[:duration], options[:duration]),
|
|
90
|
+
audience: options[:text] ? options[:audience] : ask(questions[:audience], options[:audience]),
|
|
91
|
+
goal: options[:text] ? options[:goal] : ask(questions[:goal], options[:goal]),
|
|
43
92
|
framework: options[:framework],
|
|
44
|
-
presentation_method: options[:presentation_method]
|
|
93
|
+
presentation_method: options[:presentation_method],
|
|
94
|
+
source: options[:text]
|
|
45
95
|
)
|
|
46
96
|
|
|
47
97
|
if client
|
|
48
98
|
begin
|
|
49
|
-
slides = client.generate_slides(deck)
|
|
99
|
+
slides = client.generate_slides(deck, language: options[:language])
|
|
50
100
|
rescue Llm::Client::Error => e
|
|
51
101
|
@output.puts "Error: LLM request failed (#{e.message})"
|
|
52
|
-
return
|
|
102
|
+
return FAILURE
|
|
53
103
|
end
|
|
54
104
|
deck = Deck.new(
|
|
55
105
|
topic: deck.topic, duration: deck.duration, audience: deck.audience, goal: deck.goal,
|
|
56
|
-
framework: deck.framework, slides: slides, presentation_method: deck.presentation_method
|
|
106
|
+
framework: deck.framework, slides: slides, presentation_method: deck.presentation_method,
|
|
107
|
+
source: deck.source
|
|
57
108
|
)
|
|
58
109
|
end
|
|
59
110
|
|
|
@@ -65,18 +116,40 @@ module Slidict
|
|
|
65
116
|
|
|
66
117
|
return publish_to_slidict(deck, content, options) if options[:publish] || options[:slide_id]
|
|
67
118
|
|
|
68
|
-
|
|
119
|
+
SUCCESS
|
|
69
120
|
rescue ArgumentError => e
|
|
70
121
|
@output.puts "Error: #{e.message}"
|
|
71
122
|
@output.puts
|
|
72
123
|
print_help
|
|
73
|
-
|
|
124
|
+
FAILURE
|
|
74
125
|
end
|
|
75
126
|
|
|
76
127
|
private
|
|
77
128
|
|
|
129
|
+
def read_source_text(options)
|
|
130
|
+
raise ArgumentError, "specify only one of --text or --text-file" if options[:text] && options[:text_file]
|
|
131
|
+
unless options[:text_file]
|
|
132
|
+
raise ArgumentError, "source text must not be empty" if options.key?(:text) && options[:text].to_s.strip.empty?
|
|
133
|
+
|
|
134
|
+
return options[:text]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
File.read(options[:text_file]).tap do |text|
|
|
138
|
+
raise ArgumentError, "source text must not be empty" if text.strip.empty?
|
|
139
|
+
end
|
|
140
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
141
|
+
raise ArgumentError, "could not read #{options[:text_file]}: #{e.message}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def source_topic(options)
|
|
145
|
+
return options[:topic] unless options[:topic].to_s.strip.empty?
|
|
146
|
+
return unless options[:text]
|
|
147
|
+
|
|
148
|
+
options[:text].each_line.map(&:strip).find { |line| !line.empty? }&.sub(/\A#+\s*/, "")
|
|
149
|
+
end
|
|
150
|
+
|
|
78
151
|
def parse(argv)
|
|
79
|
-
options = { framework: "slidev" }
|
|
152
|
+
options = { framework: ENV["SLIDICT_FRAMEWORK"] || "slidev", method: ENV["SLIDICT_METHOD"] }
|
|
80
153
|
args = argv.dup
|
|
81
154
|
|
|
82
155
|
args.shift if args.first == "new"
|
|
@@ -125,47 +198,16 @@ module Slidict
|
|
|
125
198
|
return options
|
|
126
199
|
end
|
|
127
200
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
when "--filename"
|
|
135
|
-
options[:filename] = fetch_value!(args, arg)
|
|
136
|
-
when "--topic"
|
|
137
|
-
options[:topic] = fetch_value!(args, arg)
|
|
138
|
-
when "--duration"
|
|
139
|
-
options[:duration] = fetch_value!(args, arg)
|
|
140
|
-
when "--audience"
|
|
141
|
-
options[:audience] = fetch_value!(args, arg)
|
|
142
|
-
when "--goal"
|
|
143
|
-
options[:goal] = fetch_value!(args, arg)
|
|
144
|
-
when "--framework"
|
|
145
|
-
options[:framework] = fetch_value!(args, arg)
|
|
146
|
-
when "--method"
|
|
147
|
-
options[:method] = fetch_value!(args, arg)
|
|
148
|
-
when "--llm-base-url"
|
|
149
|
-
options[:llm_base_url] = fetch_value!(args, arg)
|
|
150
|
-
when "--llm-api-key"
|
|
151
|
-
options[:llm_api_key] = fetch_value!(args, arg)
|
|
152
|
-
when "--llm-model"
|
|
153
|
-
options[:llm_model] = fetch_value!(args, arg)
|
|
154
|
-
when "--no-llm"
|
|
155
|
-
options[:no_llm] = true
|
|
156
|
-
when "--publish"
|
|
157
|
-
options[:publish] = true
|
|
158
|
-
when "--slide-id"
|
|
159
|
-
options[:slide_id] = fetch_value!(args, arg)
|
|
160
|
-
when "--slide-title"
|
|
161
|
-
options[:slide_title] = fetch_value!(args, arg)
|
|
162
|
-
when "--visibility"
|
|
163
|
-
options[:visibility] = fetch_value!(args, arg)
|
|
164
|
-
else
|
|
165
|
-
raise ArgumentError, "unknown option #{arg}"
|
|
166
|
-
end
|
|
201
|
+
if args.first == "init"
|
|
202
|
+
args.shift
|
|
203
|
+
raise ArgumentError, "init does not accept options" unless args.empty?
|
|
204
|
+
|
|
205
|
+
options[:command] = "init"
|
|
206
|
+
return options
|
|
167
207
|
end
|
|
168
208
|
|
|
209
|
+
parse_flags!(args, options)
|
|
210
|
+
|
|
169
211
|
options[:output] ||= output_path_for(options[:framework], options[:filename])
|
|
170
212
|
options[:presentation_method] = method_for(options[:method])
|
|
171
213
|
options
|
|
@@ -189,10 +231,10 @@ module Slidict
|
|
|
189
231
|
@output.puts "Available models (specify one with --llm-model NAME or SLIDICT_LLM_MODEL=NAME):"
|
|
190
232
|
models.each { |m| @output.puts " #{m}" }
|
|
191
233
|
end
|
|
192
|
-
|
|
234
|
+
SUCCESS
|
|
193
235
|
rescue Llm::Client::Error => e
|
|
194
236
|
@output.puts "Error: LLM request failed (#{e.message})"
|
|
195
|
-
|
|
237
|
+
FAILURE
|
|
196
238
|
end
|
|
197
239
|
|
|
198
240
|
def llm_client_for(config)
|
|
@@ -228,7 +270,7 @@ module Slidict
|
|
|
228
270
|
provider: token.fetch("provider", "github")
|
|
229
271
|
)
|
|
230
272
|
@output.puts "4. Saved CLI access token to #{path}"
|
|
231
|
-
return
|
|
273
|
+
return SUCCESS
|
|
232
274
|
rescue External::SlidictIo::Auth::Pending
|
|
233
275
|
return login_expired if Time.now >= deadline
|
|
234
276
|
|
|
@@ -236,7 +278,7 @@ module Slidict
|
|
|
236
278
|
end
|
|
237
279
|
rescue External::SlidictIo::Auth::Error, KeyError => e
|
|
238
280
|
@output.puts "Error: GitHub auth failed (#{e.message})"
|
|
239
|
-
|
|
281
|
+
FAILURE
|
|
240
282
|
end
|
|
241
283
|
|
|
242
284
|
def slides(args)
|
|
@@ -258,7 +300,7 @@ module Slidict
|
|
|
258
300
|
id: method.id, name: method.name, category: method.category
|
|
259
301
|
)
|
|
260
302
|
end
|
|
261
|
-
|
|
303
|
+
SUCCESS
|
|
262
304
|
end
|
|
263
305
|
|
|
264
306
|
def show_method(args)
|
|
@@ -275,13 +317,53 @@ module Slidict
|
|
|
275
317
|
method.slides.each_with_index { |slide, i| @output.puts " #{i + 1}. #{slide.title}: #{slide.role}" }
|
|
276
318
|
@output.puts "Review checklist:"
|
|
277
319
|
method.review_checklist.each { |item| @output.puts " - #{item}" }
|
|
278
|
-
|
|
320
|
+
SUCCESS
|
|
279
321
|
end
|
|
280
322
|
|
|
281
323
|
def method_for(id)
|
|
282
324
|
id ? method_registry.fetch(id) : nil
|
|
283
325
|
end
|
|
284
326
|
|
|
327
|
+
ENV_TEMPLATE = <<~ENV
|
|
328
|
+
# Slidict configuration. CLI flags always take precedence over these
|
|
329
|
+
# values; unset ones fall back to the built-in defaults. This file is
|
|
330
|
+
# ignored by git (see .gitignore) so it's safe to put secrets here.
|
|
331
|
+
|
|
332
|
+
# OpenAI Compatible API endpoint (required to enable LLM-generated slides).
|
|
333
|
+
# SLIDICT_LLM_BASE_URL=https://api.openai.com/v1
|
|
334
|
+
# SLIDICT_LLM_API_KEY=sk-...
|
|
335
|
+
# SLIDICT_LLM_MODEL=gpt-4o-mini
|
|
336
|
+
|
|
337
|
+
# Default --framework when it is not given on the command line.
|
|
338
|
+
# SLIDICT_FRAMEWORK=slidev
|
|
339
|
+
|
|
340
|
+
# Default --method when it is not given on the command line.
|
|
341
|
+
# SLIDICT_METHOD=scqa
|
|
342
|
+
ENV
|
|
343
|
+
|
|
344
|
+
def init
|
|
345
|
+
env_created = write_env_file
|
|
346
|
+
@output.puts(env_created ? "Created .env" : ".env already exists, leaving it unchanged")
|
|
347
|
+
|
|
348
|
+
gitignore_updated = ensure_env_gitignored
|
|
349
|
+
@output.puts "Added .env to .gitignore" if gitignore_updated
|
|
350
|
+
SUCCESS
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def write_env_file
|
|
354
|
+
return false if File.exist?(".env")
|
|
355
|
+
|
|
356
|
+
File.write(".env", ENV_TEMPLATE)
|
|
357
|
+
true
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def ensure_env_gitignored
|
|
361
|
+
return false if File.exist?(".gitignore") && File.readlines(".gitignore", chomp: true).include?(".env")
|
|
362
|
+
|
|
363
|
+
File.open(".gitignore", "a") { |f| f.puts(".env") }
|
|
364
|
+
true
|
|
365
|
+
end
|
|
366
|
+
|
|
285
367
|
def method_registry
|
|
286
368
|
@method_registry ||= PresentationMethodRegistry.new
|
|
287
369
|
end
|
|
@@ -314,12 +396,15 @@ module Slidict
|
|
|
314
396
|
|
|
315
397
|
def login_expired
|
|
316
398
|
@output.puts "Error: GitHub auth timed out. Run `slidict auth` and try again."
|
|
317
|
-
|
|
399
|
+
FAILURE
|
|
318
400
|
end
|
|
319
401
|
|
|
320
402
|
def fetch_value!(args, option)
|
|
321
403
|
value = args.shift
|
|
322
|
-
|
|
404
|
+
# Source prose can legitimately start with Markdown frontmatter or a
|
|
405
|
+
# list item. Other options retain the typo-friendly flag check.
|
|
406
|
+
missing = value.nil? || (value.start_with?("-") && option != "--text")
|
|
407
|
+
raise ArgumentError, "#{option} requires a value" if missing
|
|
323
408
|
|
|
324
409
|
value
|
|
325
410
|
end
|
|
@@ -332,9 +417,30 @@ module Slidict
|
|
|
332
417
|
@input.gets&.chomp.to_s
|
|
333
418
|
end
|
|
334
419
|
|
|
335
|
-
|
|
336
|
-
|
|
420
|
+
# Translates only the questions that will actually be asked (those
|
|
421
|
+
# whose option wasn't already given on the command line). Falls back to
|
|
422
|
+
# the English questions if there's no client, no --language, nothing
|
|
423
|
+
# left to ask, or a translation call fails -- this is a nicety, not
|
|
424
|
+
# something worth aborting slide generation over.
|
|
425
|
+
def questions_for(client, options)
|
|
426
|
+
return QUESTIONS if options[:text]
|
|
427
|
+
return QUESTIONS unless client && options[:language]
|
|
428
|
+
|
|
429
|
+
missing = QUESTIONS.select { |key, _| options[key].to_s.strip.empty? }
|
|
430
|
+
return QUESTIONS if missing.empty?
|
|
431
|
+
|
|
432
|
+
translated = missing.transform_values { |text| client.translate_text(text, options[:language]) }
|
|
433
|
+
QUESTIONS.merge(translated)
|
|
434
|
+
rescue Llm::Client::Error => e
|
|
435
|
+
@output.puts "Warning: could not translate questions into #{options[:language]} " \
|
|
436
|
+
"(#{e.message}); asking in English."
|
|
437
|
+
QUESTIONS
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
usage do
|
|
441
|
+
<<~USAGE
|
|
337
442
|
Usage: slidict [new] [options]
|
|
443
|
+
Usage: slidict init
|
|
338
444
|
Usage: slidict auth
|
|
339
445
|
Usage: slidict slides <list|show|create|edit> [options]
|
|
340
446
|
Usage: slidict serve [sinatra options]
|
|
@@ -345,6 +451,7 @@ module Slidict
|
|
|
345
451
|
Generate presentation source files from a short conversation.
|
|
346
452
|
|
|
347
453
|
Commands:
|
|
454
|
+
init Create a .env file for SLIDICT_LLM_* etc. and add it to .gitignore
|
|
348
455
|
auth Authenticate the CLI with GitHub and save a CLI access token
|
|
349
456
|
slides Manage your slides on slidict.io (run `slidict slides -h` for details)
|
|
350
457
|
serve Serve slide files from ./public with Sinatra
|
|
@@ -354,29 +461,8 @@ module Slidict
|
|
|
354
461
|
show-method Show details for one presentation method
|
|
355
462
|
|
|
356
463
|
Options:
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
--audience TEXT Target audience
|
|
360
|
-
--goal TEXT Desired audience takeaway or action
|
|
361
|
-
--framework NAME #{Output::Format.names.join(", ")} (default: slidev)
|
|
362
|
-
--method ID Presentation method, for example scqa, prep, or pyramid
|
|
363
|
-
--filename NAME File name under public/ (default: next sequential file)
|
|
364
|
-
--llm-base-url URL OpenAI Compatible API base URL (env: SLIDICT_LLM_BASE_URL).
|
|
365
|
-
When omitted, the built-in slide template is used instead.
|
|
366
|
-
--llm-api-key KEY API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)
|
|
367
|
-
--llm-model NAME Model name to request (env: SLIDICT_LLM_MODEL); omit to list available models
|
|
368
|
-
--no-llm Skip the LLM call and use the built-in slide template
|
|
369
|
-
--publish Publish the generated slides to slidict.io as a draft
|
|
370
|
-
(requires `slidict auth`; creates a new slide, or edits
|
|
371
|
-
an existing one when --slide-id is given)
|
|
372
|
-
--slide-id ID Edit this existing draft instead of creating a new one
|
|
373
|
-
(implies --publish)
|
|
374
|
-
--slide-title TEXT Title for the published slide (default: --topic)
|
|
375
|
-
--visibility VIS public, unlisted, or group_only (default: public)
|
|
376
|
-
-o, --output PATH Output file (overrides --filename and the public/ default)
|
|
377
|
-
-h, --help Show this help
|
|
378
|
-
HELP
|
|
379
|
-
0
|
|
464
|
+
#{flags_help}
|
|
465
|
+
USAGE
|
|
380
466
|
end
|
|
381
467
|
|
|
382
468
|
def output_path_for(framework, filename)
|
data/lib/slidict/cli/lint.rb
CHANGED
|
@@ -8,11 +8,21 @@ module Slidict
|
|
|
8
8
|
class Lint
|
|
9
9
|
ASCIIDOC_EXTENSIONS = %w[.adoc .asciidoc].freeze
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
include Options
|
|
12
|
+
|
|
13
|
+
options output: Options::MISSING,
|
|
14
|
+
linter_factory: -> { method(:default_linter) },
|
|
15
|
+
renderer: -> { Slidict::Lint::Renderer.new }
|
|
16
|
+
|
|
17
|
+
flag "--format", arg: "FORMAT", desc: "markdown or asciidoc (default: auto-detected from extension)"
|
|
18
|
+
flag "--llm-base-url", arg: "URL", desc: "OpenAI Compatible API base URL (env: SLIDICT_LLM_BASE_URL)"
|
|
19
|
+
flag "--llm-api-key", arg: "KEY", desc: "API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)"
|
|
20
|
+
# rubocop:disable Layout/HashAlignment -- source-only line wrap, desc stays a single displayed line
|
|
21
|
+
flag "--llm-model", arg: "NAME",
|
|
22
|
+
desc: "Model name to request (env: SLIDICT_LLM_MODEL); omit to list available models"
|
|
23
|
+
# rubocop:enable Layout/HashAlignment
|
|
24
|
+
flag "--translate", arg: "LANG", desc: "Translate findings into the given language (e.g. Japanese)"
|
|
25
|
+
flag "-h", "--help", desc: "Show this help"
|
|
16
26
|
|
|
17
27
|
def run(argv)
|
|
18
28
|
options = parse(argv)
|
|
@@ -44,18 +54,18 @@ module Slidict
|
|
|
44
54
|
print_error(error)
|
|
45
55
|
@output.puts
|
|
46
56
|
print_help
|
|
47
|
-
|
|
57
|
+
FAILURE
|
|
48
58
|
end
|
|
49
59
|
|
|
50
60
|
def print_error(error)
|
|
51
61
|
@output.puts "Error: #{error.message}"
|
|
52
|
-
|
|
62
|
+
FAILURE
|
|
53
63
|
end
|
|
54
64
|
|
|
55
65
|
def parse(argv)
|
|
56
66
|
args = argv.dup
|
|
57
67
|
options = { path: extract_path!(args) }
|
|
58
|
-
|
|
68
|
+
parse_flags!(args, options)
|
|
59
69
|
options
|
|
60
70
|
end
|
|
61
71
|
|
|
@@ -63,20 +73,6 @@ module Slidict
|
|
|
63
73
|
args.shift unless args.first.to_s.start_with?("-")
|
|
64
74
|
end
|
|
65
75
|
|
|
66
|
-
def parse_options!(args, options)
|
|
67
|
-
until args.empty?
|
|
68
|
-
case (arg = args.shift)
|
|
69
|
-
when "-h", "--help" then options[:help] = true
|
|
70
|
-
when "--format" then options[:format] = fetch_value!(args, arg)
|
|
71
|
-
when "--llm-base-url" then options[:llm_base_url] = fetch_value!(args, arg)
|
|
72
|
-
when "--llm-api-key" then options[:llm_api_key] = fetch_value!(args, arg)
|
|
73
|
-
when "--llm-model" then options[:llm_model] = fetch_value!(args, arg)
|
|
74
|
-
when "--translate" then options[:translate] = fetch_value!(args, arg)
|
|
75
|
-
else raise ArgumentError, "unknown option #{arg}"
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
|
|
80
76
|
def fetch_value!(args, option)
|
|
81
77
|
value = args.shift
|
|
82
78
|
raise ArgumentError, "#{option} requires a value" if value.nil? || value.start_with?("-")
|
|
@@ -105,12 +101,12 @@ module Slidict
|
|
|
105
101
|
|
|
106
102
|
def print_findings(findings)
|
|
107
103
|
@output.puts(findings.empty? ? "No issues found." : @renderer.render(findings))
|
|
108
|
-
|
|
104
|
+
SUCCESS
|
|
109
105
|
end
|
|
110
106
|
|
|
111
107
|
def file_not_found(path)
|
|
112
108
|
@output.puts "Error: file not found: #{path}"
|
|
113
|
-
|
|
109
|
+
FAILURE
|
|
114
110
|
end
|
|
115
111
|
|
|
116
112
|
def print_available_models(config)
|
|
@@ -122,28 +118,22 @@ module Slidict
|
|
|
122
118
|
@output.puts "Available models (specify one with --llm-model NAME or SLIDICT_LLM_MODEL=NAME):"
|
|
123
119
|
models.each { |m| @output.puts " #{m}" }
|
|
124
120
|
end
|
|
125
|
-
|
|
121
|
+
SUCCESS
|
|
126
122
|
rescue Llm::Client::Error => e
|
|
127
123
|
print_error(e)
|
|
128
124
|
end
|
|
129
125
|
|
|
130
126
|
def llm_required
|
|
131
127
|
@output.puts "Error: lint requires an LLM endpoint (--llm-base-url or SLIDICT_LLM_BASE_URL)"
|
|
132
|
-
|
|
128
|
+
FAILURE
|
|
133
129
|
end
|
|
134
130
|
|
|
135
|
-
|
|
136
|
-
|
|
131
|
+
usage do
|
|
132
|
+
<<~USAGE
|
|
137
133
|
Usage: slidict lint <file> [options]
|
|
138
134
|
Diagnoses whether a slide deck's structure will land with its audience.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
--llm-api-key KEY API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)
|
|
142
|
-
--llm-model NAME Model name to request (env: SLIDICT_LLM_MODEL); omit to list available models
|
|
143
|
-
--translate LANG Translate findings into the given language (e.g. Japanese)
|
|
144
|
-
-h, --help Show this help
|
|
145
|
-
HELP
|
|
146
|
-
0
|
|
135
|
+
#{flags_help}
|
|
136
|
+
USAGE
|
|
147
137
|
end
|
|
148
138
|
end
|
|
149
139
|
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slidict
|
|
4
|
+
module Cli
|
|
5
|
+
# Concern for CLI classes with several constructor dependencies. Declaring
|
|
6
|
+
# `options name: -> { default }, ...` once generates both the attr_reader
|
|
7
|
+
# and the `initialize` assignment, instead of repeating each dependency in
|
|
8
|
+
# the method signature and again as `@name = name` in the body.
|
|
9
|
+
#
|
|
10
|
+
# Defaults are instance_exec'd lazily (only when the keyword is omitted),
|
|
11
|
+
# matching plain keyword-argument defaults, and may reference other
|
|
12
|
+
# options or instance methods (e.g. `-> { method(:default_linter) }`).
|
|
13
|
+
# Classes that need to post-process a value (e.g. expanding a path) can
|
|
14
|
+
# still override `initialize`, call `super`, and adjust the ivar.
|
|
15
|
+
#
|
|
16
|
+
# Also provides a `flag`/`parse_flags!`/`flags_help` DSL: declaring a
|
|
17
|
+
# `-x`/`--xxx` switch once (with its arg placeholder and description)
|
|
18
|
+
# drives both argv parsing and the `--help` listing, so the two can't
|
|
19
|
+
# drift apart. `usage` then only needs to hold the surrounding usage text
|
|
20
|
+
# (Usage:/Commands: lines), interpolating `flags_help` for the option list.
|
|
21
|
+
module Options
|
|
22
|
+
MISSING = Object.new.freeze
|
|
23
|
+
|
|
24
|
+
# Process exit codes (see bin/slidict, which does `exit App.new.run(ARGV)`),
|
|
25
|
+
# named so command methods don't scatter bare 0/1 literals as return values.
|
|
26
|
+
SUCCESS = 0
|
|
27
|
+
FAILURE = 1
|
|
28
|
+
|
|
29
|
+
def self.included(base)
|
|
30
|
+
base.extend(ClassMethods)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# One `-x`/`--xxx` declaration: how to parse it (arg placeholder,
|
|
34
|
+
# coercion) and how to describe it in `--help` output.
|
|
35
|
+
Flag = Struct.new(:switches, :key, :arg, :coerce, :desc, keyword_init: true) do
|
|
36
|
+
def boolean?
|
|
37
|
+
arg.nil?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def short
|
|
41
|
+
switches.find { |s| !s.start_with?("--") }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def long
|
|
45
|
+
switches.find { |s| s.start_with?("--") }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Left column of the help listing, e.g. "-o, --output PATH" or, for a
|
|
49
|
+
# long-only flag, " --topic TEXT" -- the leading 4 spaces keep the
|
|
50
|
+
# "--name" text column-aligned either way.
|
|
51
|
+
def name_column
|
|
52
|
+
column = "#{short ? "#{short}, " : " "}#{long}"
|
|
53
|
+
arg ? "#{column} #{arg}" : column
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Class-level DSL (`options name: -> { default }, ...`) mixed into any
|
|
58
|
+
# class that includes Options.
|
|
59
|
+
module ClassMethods
|
|
60
|
+
# Defines the assignment in an anonymous module (rather than directly
|
|
61
|
+
# on the including class) so a class that needs to post-process a
|
|
62
|
+
# value can still define its own `initialize`, call `super`, and have
|
|
63
|
+
# it reach this one instead of silently overwriting it.
|
|
64
|
+
def options(**defaults)
|
|
65
|
+
attr_reader(*defaults.keys)
|
|
66
|
+
|
|
67
|
+
include(Module.new do
|
|
68
|
+
define_method(:initialize) do |**overrides|
|
|
69
|
+
defaults.each do |name, default|
|
|
70
|
+
instance_variable_set(:"@#{name}", resolve_option(name, default, overrides))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Declares a flag. `group` lets a class with several argv shapes
|
|
77
|
+
# (e.g. Slides' `list` vs. `create`/`edit`) keep separate flag sets;
|
|
78
|
+
# `parse_flags!`/`flags_help` default to :default. `desc` may be a
|
|
79
|
+
# Proc (instance_exec'd lazily, like an `options` default) for text
|
|
80
|
+
# that depends on another file having loaded by call time rather
|
|
81
|
+
# than by require time (e.g. `Output::Format.names`).
|
|
82
|
+
def flag(*switches, desc:, arg: nil, coerce: nil, group: :default)
|
|
83
|
+
key = switches.last.sub(/\A-+/, "").tr("-", "_").to_sym
|
|
84
|
+
flag_groups[group] << Flag.new(switches: switches, key: key, arg: arg, coerce: coerce, desc: desc)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def flag_groups
|
|
88
|
+
@flag_groups ||= Hash.new { |h, k| h[k] = [] }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def flags(group = :default)
|
|
92
|
+
flag_groups[group]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Declares the `-h`/`--help` usage text, generating a private
|
|
96
|
+
# `print_help` that writes it to `@output` and returns SUCCESS,
|
|
97
|
+
# instead of every command spelling that out. Takes a block for the
|
|
98
|
+
# same lazy-evaluation reason as `flag`'s `desc:`.
|
|
99
|
+
def usage(&text)
|
|
100
|
+
private(define_method(:print_help) do
|
|
101
|
+
@output.puts instance_exec(&text)
|
|
102
|
+
SUCCESS
|
|
103
|
+
end)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def resolve_option(name, default, overrides)
|
|
110
|
+
return overrides[name] if overrides.key?(name)
|
|
111
|
+
raise ArgumentError, "missing keyword: :#{name}" if default.equal?(MISSING)
|
|
112
|
+
|
|
113
|
+
instance_exec(&default)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Consumes `args` against `self.class.flags(group)`: booleans are set to
|
|
117
|
+
# true, valued flags read their value via the including class's own
|
|
118
|
+
# `fetch_value!` (whose rules on e.g. a leading "-" differ between
|
|
119
|
+
# classes), then any `coerce` is applied.
|
|
120
|
+
def parse_flags!(args, options, group = :default)
|
|
121
|
+
until args.empty?
|
|
122
|
+
arg = args.shift
|
|
123
|
+
flag = self.class.flags(group).find { |f| f.switches.include?(arg) }
|
|
124
|
+
raise ArgumentError, "unknown option #{arg}" unless flag
|
|
125
|
+
|
|
126
|
+
value = flag.boolean? || fetch_value!(args, arg)
|
|
127
|
+
value = instance_exec(value, &flag.coerce) if flag.coerce
|
|
128
|
+
options[flag.key] = value
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Renders `self.class.flags(group)` as an aligned --help listing.
|
|
133
|
+
def flags_help(group = :default)
|
|
134
|
+
defs = self.class.flags(group)
|
|
135
|
+
width = defs.map { |f| f.name_column.length }.max
|
|
136
|
+
defs.map { |f| flag_doc_lines(f, width) }.join("\n")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def flag_doc_lines(flag, width)
|
|
140
|
+
desc = flag.desc.respond_to?(:call) ? instance_exec(&flag.desc) : flag.desc
|
|
141
|
+
first, *rest = desc.lines(chomp: true)
|
|
142
|
+
lines = ["#{flag.name_column.ljust(width)} #{first}"]
|
|
143
|
+
rest.each { |line| lines << "#{" " * (width + 2)}#{line}" }
|
|
144
|
+
lines.join("\n")
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/slidict/cli/serve.rb
CHANGED
|
@@ -9,9 +9,14 @@ module Slidict
|
|
|
9
9
|
class Serve
|
|
10
10
|
DEFAULT_PUBLIC_DIR = "public"
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
include Options
|
|
13
|
+
|
|
14
|
+
options public_dir: -> { DEFAULT_PUBLIC_DIR },
|
|
15
|
+
output: -> { $stdout }
|
|
16
|
+
|
|
17
|
+
def initialize(**)
|
|
18
|
+
super
|
|
19
|
+
@public_dir = File.expand_path(@public_dir)
|
|
15
20
|
end
|
|
16
21
|
|
|
17
22
|
def run(args = [])
|
|
@@ -20,7 +25,7 @@ module Slidict
|
|
|
20
25
|
ARGV.replace(args)
|
|
21
26
|
@output.puts "Serving slides from #{@public_dir}"
|
|
22
27
|
app.run!
|
|
23
|
-
|
|
28
|
+
SUCCESS
|
|
24
29
|
ensure
|
|
25
30
|
ARGV.replace(original_argv) if original_argv
|
|
26
31
|
end
|
data/lib/slidict/cli/slides.rb
CHANGED
|
@@ -4,12 +4,29 @@ module Slidict
|
|
|
4
4
|
module Cli
|
|
5
5
|
# Implements the `slidict slides <list|show|create|edit>` subcommands.
|
|
6
6
|
class Slides
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
include Options
|
|
8
|
+
|
|
9
|
+
options output: Options::MISSING,
|
|
10
|
+
credentials: -> { External::SlidictIo::Credentials.new },
|
|
11
|
+
client: -> { nil },
|
|
12
|
+
reauthenticate: -> { nil }
|
|
13
|
+
|
|
14
|
+
# rubocop:disable Layout/HashAlignment -- source-only line wraps below, kept short rather than column-aligned
|
|
15
|
+
flag "--page", arg: "N",
|
|
16
|
+
coerce: ->(v) { Integer(v) }, desc: "Page number (20 slides per page)", group: :list
|
|
17
|
+
# rubocop:enable Layout/HashAlignment
|
|
18
|
+
flag "-h", "--help", desc: "Show this help", group: :list
|
|
19
|
+
|
|
20
|
+
flag "--title", arg: "TEXT", desc: "Slide title", group: :body_options
|
|
21
|
+
flag "--body", arg: "TEXT", desc: "Slide body text", group: :body_options
|
|
22
|
+
# rubocop:disable Layout/HashAlignment -- source-only line wraps below, kept short rather than column-aligned
|
|
23
|
+
flag "--file", arg: "PATH", desc: "Read the slide body from a file (instead of --body)",
|
|
24
|
+
group: :body_options
|
|
25
|
+
flag "--body-format", arg: "FORMAT", desc: "asciidoc or markdown (default: auto-detected from body)",
|
|
26
|
+
group: :body_options
|
|
27
|
+
# rubocop:enable Layout/HashAlignment
|
|
28
|
+
flag "--visibility", arg: "VIS", desc: "public, unlisted, or group_only (default: public)", group: :body_options
|
|
29
|
+
flag "-h", "--help", desc: "Show this help", group: :body_options
|
|
13
30
|
|
|
14
31
|
def run(argv)
|
|
15
32
|
options = parse(argv)
|
|
@@ -20,7 +37,7 @@ module Slidict
|
|
|
20
37
|
@output.puts "Error: #{e.message}"
|
|
21
38
|
@output.puts
|
|
22
39
|
print_help
|
|
23
|
-
|
|
40
|
+
FAILURE
|
|
24
41
|
end
|
|
25
42
|
|
|
26
43
|
# Creates or edits a draft directly, bypassing argv parsing (and its
|
|
@@ -49,13 +66,7 @@ module Slidict
|
|
|
49
66
|
|
|
50
67
|
def parse_list(args)
|
|
51
68
|
options = { subcommand: :list }
|
|
52
|
-
|
|
53
|
-
case (arg = args.shift)
|
|
54
|
-
when "--page" then options[:page] = Integer(fetch_value!(args, arg))
|
|
55
|
-
when "-h", "--help" then options[:help] = true
|
|
56
|
-
else raise ArgumentError, "unknown option #{arg}"
|
|
57
|
-
end
|
|
58
|
-
end
|
|
69
|
+
parse_flags!(args, options, :list)
|
|
59
70
|
options
|
|
60
71
|
end
|
|
61
72
|
|
|
@@ -83,17 +94,7 @@ module Slidict
|
|
|
83
94
|
end
|
|
84
95
|
|
|
85
96
|
def parse_body_options!(args, options)
|
|
86
|
-
|
|
87
|
-
case (arg = args.shift)
|
|
88
|
-
when "--title" then options[:title] = fetch_value!(args, arg)
|
|
89
|
-
when "--body" then options[:body] = fetch_value!(args, arg)
|
|
90
|
-
when "--file" then options[:file] = fetch_value!(args, arg)
|
|
91
|
-
when "--body-format" then options[:body_format] = fetch_value!(args, arg)
|
|
92
|
-
when "--visibility" then options[:visibility] = fetch_value!(args, arg)
|
|
93
|
-
when "-h", "--help" then options[:help] = true
|
|
94
|
-
else raise ArgumentError, "unknown option #{arg}"
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
+
parse_flags!(args, options, :body_options)
|
|
97
98
|
raise ArgumentError, "specify only one of --body or --file" if options[:body] && options[:file]
|
|
98
99
|
end
|
|
99
100
|
|
|
@@ -111,17 +112,17 @@ module Slidict
|
|
|
111
112
|
def list(options)
|
|
112
113
|
with_reauth_retry do
|
|
113
114
|
print_slide_list(client.list(page: options[:page]))
|
|
114
|
-
|
|
115
|
+
SUCCESS
|
|
115
116
|
end
|
|
116
117
|
end
|
|
117
118
|
|
|
118
119
|
def show(options)
|
|
119
120
|
with_reauth_retry do
|
|
120
121
|
print_slide_detail(client.show(options[:id]))
|
|
121
|
-
|
|
122
|
+
SUCCESS
|
|
122
123
|
rescue External::SlidictIo::Client::NotFound
|
|
123
124
|
@output.puts "Error: slide not found"
|
|
124
|
-
|
|
125
|
+
FAILURE
|
|
125
126
|
end
|
|
126
127
|
end
|
|
127
128
|
|
|
@@ -147,13 +148,13 @@ module Slidict
|
|
|
147
148
|
slide = yield
|
|
148
149
|
@output.puts "#{verb} slide ##{slide["id"]} (draft)"
|
|
149
150
|
print_slide_detail(slide)
|
|
150
|
-
|
|
151
|
+
SUCCESS
|
|
151
152
|
rescue External::SlidictIo::Client::NotFound
|
|
152
153
|
@output.puts "Error: slide not found"
|
|
153
|
-
|
|
154
|
+
FAILURE
|
|
154
155
|
rescue External::SlidictIo::Client::NotEditable
|
|
155
156
|
@output.puts "Error: this slide is already published. Edit it from the Web UI instead."
|
|
156
|
-
|
|
157
|
+
FAILURE
|
|
157
158
|
rescue External::SlidictIo::Client::RateLimited
|
|
158
159
|
print_rate_limited
|
|
159
160
|
rescue External::SlidictIo::Client::Unprocessable => e
|
|
@@ -230,22 +231,22 @@ module Slidict
|
|
|
230
231
|
|
|
231
232
|
def print_rate_limited
|
|
232
233
|
@output.puts "Error: rate limited. Create/edit is limited to once per minute. Wait and try again."
|
|
233
|
-
|
|
234
|
+
FAILURE
|
|
234
235
|
end
|
|
235
236
|
|
|
236
237
|
def print_unprocessable(error)
|
|
237
238
|
@output.puts "Error: #{error.message}"
|
|
238
239
|
error.errors.each { |message| @output.puts " - #{message}" }
|
|
239
|
-
|
|
240
|
+
FAILURE
|
|
240
241
|
end
|
|
241
242
|
|
|
242
243
|
def print_client_error(error)
|
|
243
244
|
@output.puts "Error: #{error.message}"
|
|
244
|
-
|
|
245
|
+
FAILURE
|
|
245
246
|
end
|
|
246
247
|
|
|
247
|
-
|
|
248
|
-
|
|
248
|
+
usage do
|
|
249
|
+
<<~USAGE
|
|
249
250
|
Usage: slidict slides <command> [options]
|
|
250
251
|
|
|
251
252
|
Commands:
|
|
@@ -255,16 +256,10 @@ module Slidict
|
|
|
255
256
|
edit <id> [options] Edit an existing draft slide
|
|
256
257
|
|
|
257
258
|
Create/edit options:
|
|
258
|
-
|
|
259
|
-
--body TEXT Slide body text
|
|
260
|
-
--file PATH Read the slide body from a file (instead of --body)
|
|
261
|
-
--body-format FORMAT asciidoc or markdown (default: auto-detected from body)
|
|
262
|
-
--visibility VIS public, unlisted, or group_only (default: public)
|
|
263
|
-
-h, --help Show this help
|
|
259
|
+
#{flags_help(:body_options)}
|
|
264
260
|
|
|
265
261
|
Note: slides are always created/edited as drafts. Publish from the Web UI.
|
|
266
|
-
|
|
267
|
-
0
|
|
262
|
+
USAGE
|
|
268
263
|
end
|
|
269
264
|
end
|
|
270
265
|
end
|
data/lib/slidict/deck.rb
CHANGED
|
@@ -4,15 +4,17 @@ module Slidict
|
|
|
4
4
|
Slide = Struct.new(:title, :bullets, keyword_init: true)
|
|
5
5
|
|
|
6
6
|
class Deck
|
|
7
|
-
attr_reader :topic, :duration, :audience, :goal, :framework, :presentation_method
|
|
7
|
+
attr_reader :topic, :duration, :audience, :goal, :framework, :presentation_method, :source
|
|
8
8
|
|
|
9
|
-
def initialize(topic:, duration:, audience:, goal:, framework: "slidev", slides: nil, presentation_method: nil
|
|
9
|
+
def initialize(topic:, duration:, audience:, goal:, framework: "slidev", slides: nil, presentation_method: nil,
|
|
10
|
+
source: nil)
|
|
10
11
|
@topic = normalize(topic, fallback: "Untitled presentation")
|
|
11
12
|
@duration = normalize(duration, fallback: "5 minutes")
|
|
12
13
|
@audience = normalize(audience, fallback: "general audience")
|
|
13
14
|
@goal = normalize(goal, fallback: "understand the key message")
|
|
14
15
|
@framework = normalize(framework, fallback: "slidev").downcase
|
|
15
16
|
@presentation_method = presentation_method
|
|
17
|
+
@source = source.to_s.strip
|
|
16
18
|
@slides = slides
|
|
17
19
|
end
|
|
18
20
|
|
data/lib/slidict/env.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slidict
|
|
4
|
+
# Loads KEY=VALUE pairs from a .env file (if present) into ENV, so
|
|
5
|
+
# SLIDICT_LLM_BASE_URL, SLIDICT_FRAMEWORK, etc. can be set once per project
|
|
6
|
+
# instead of on every invocation. Only keys not already present in ENV are
|
|
7
|
+
# populated -- a real environment variable always wins over .env, and a CLI
|
|
8
|
+
# flag (read afterwards, in Cli::App#parse) wins over both. See `slidict
|
|
9
|
+
# init` for generating a starter file.
|
|
10
|
+
module Env
|
|
11
|
+
LINE = /\A(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/
|
|
12
|
+
|
|
13
|
+
def self.load!(path = ".env", env = ENV)
|
|
14
|
+
return unless File.exist?(path)
|
|
15
|
+
|
|
16
|
+
File.foreach(path) do |line|
|
|
17
|
+
key, value = parse_line(line)
|
|
18
|
+
env[key] = value if key && !env.key?(key)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.parse_line(line)
|
|
23
|
+
line = line.strip
|
|
24
|
+
return [nil, nil] if line.empty? || line.start_with?("#")
|
|
25
|
+
|
|
26
|
+
match = LINE.match(line)
|
|
27
|
+
return [nil, nil] unless match
|
|
28
|
+
|
|
29
|
+
[match[1], unquote(match[2].strip)]
|
|
30
|
+
end
|
|
31
|
+
private_class_method :parse_line
|
|
32
|
+
|
|
33
|
+
# A quoted value keeps everything up to its closing quote verbatim (so a
|
|
34
|
+
# "#" inside it isn't mistaken for a comment); an unquoted value has any
|
|
35
|
+
# trailing "# comment" stripped.
|
|
36
|
+
def self.unquote(value)
|
|
37
|
+
if value.start_with?('"', "'")
|
|
38
|
+
quote = value[0]
|
|
39
|
+
closing = value.index(quote, 1)
|
|
40
|
+
return closing ? value[1...closing] : value[1..]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
value.sub(/\s+#.*\z/, "")
|
|
44
|
+
end
|
|
45
|
+
private_class_method :unquote
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slidict
|
|
4
|
+
# Framework-independent application API for integrations such as slidict.io.
|
|
5
|
+
# It turns source prose into both a Deck and presentation source without
|
|
6
|
+
# involving CLI input/output or the filesystem.
|
|
7
|
+
class Generator
|
|
8
|
+
Result = Struct.new(:deck, :content, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
def initialize(client:, renderer: Output::Renderer.new)
|
|
11
|
+
@client = client
|
|
12
|
+
@renderer = renderer
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def generate(text:, topic: nil, duration: nil, audience: nil, goal: nil,
|
|
16
|
+
framework: "slidev", language: nil, presentation_method: nil)
|
|
17
|
+
source = text.to_s.strip
|
|
18
|
+
raise ArgumentError, "text must not be empty" if source.empty?
|
|
19
|
+
|
|
20
|
+
deck = Deck.new(
|
|
21
|
+
topic: topic || title_from(source),
|
|
22
|
+
duration: duration,
|
|
23
|
+
audience: audience,
|
|
24
|
+
goal: goal || "communicate the source text clearly",
|
|
25
|
+
framework: framework,
|
|
26
|
+
presentation_method: presentation_method,
|
|
27
|
+
source: source
|
|
28
|
+
)
|
|
29
|
+
slides = @client.generate_slides(deck, language: language)
|
|
30
|
+
generated_deck = Deck.new(
|
|
31
|
+
topic: deck.topic, duration: deck.duration, audience: deck.audience, goal: deck.goal,
|
|
32
|
+
framework: deck.framework, slides: slides, presentation_method: deck.presentation_method,
|
|
33
|
+
source: source
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
Result.new(deck: generated_deck, content: @renderer.render(generated_deck))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def title_from(source)
|
|
42
|
+
source.each_line.map(&:strip).find { |line| !line.empty? }&.sub(/\A#+\s*/, "")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/slidict/llm/client.rb
CHANGED
|
@@ -35,8 +35,8 @@ module Slidict
|
|
|
35
35
|
raise Error, "could not parse models response: #{e.message}"
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
def generate_slides(deck)
|
|
39
|
-
content = chat_completion(prompt_for(deck))
|
|
38
|
+
def generate_slides(deck, language: nil)
|
|
39
|
+
content = chat_completion(prompt_for(deck, language: language))
|
|
40
40
|
slides_from(content)
|
|
41
41
|
end
|
|
42
42
|
|
|
@@ -48,6 +48,16 @@ module Slidict
|
|
|
48
48
|
findings_from(content)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# Translates a single line of text into language. Unlike
|
|
52
|
+
# generate_slides/lint_slides, the response is a plain string rather
|
|
53
|
+
# than parsed JSON: asking a (often small, local) model to translate
|
|
54
|
+
# one line is a task it can follow reliably, whereas asking it to also
|
|
55
|
+
# return a JSON array of a specific length is exactly the kind of
|
|
56
|
+
# structured-output instruction weaker models tend to get wrong.
|
|
57
|
+
def translate_text(text, language)
|
|
58
|
+
strip_quotes(chat_completion(translate_prompt_for(text, language)).strip)
|
|
59
|
+
end
|
|
60
|
+
|
|
51
61
|
private
|
|
52
62
|
|
|
53
63
|
def get_models_response
|
|
@@ -57,7 +67,7 @@ module Slidict
|
|
|
57
67
|
perform_request(uri, request)
|
|
58
68
|
end
|
|
59
69
|
|
|
60
|
-
def prompt_for(deck)
|
|
70
|
+
def prompt_for(deck, language: nil)
|
|
61
71
|
<<~PROMPT
|
|
62
72
|
You are an assistant that designs presentation slide outlines.
|
|
63
73
|
Topic: #{deck.topic}
|
|
@@ -65,15 +75,35 @@ module Slidict
|
|
|
65
75
|
Audience: #{deck.audience}
|
|
66
76
|
Goal: #{deck.goal}
|
|
67
77
|
#{method_prompt_for(deck)}
|
|
78
|
+
#{source_prompt_for(deck)}
|
|
68
79
|
|
|
69
80
|
Return one slide for each required slide role when a presentation method is
|
|
70
81
|
provided; otherwise return exactly 5 slides. Each item must be an object with
|
|
71
82
|
a "title" string and a "bullets" array of 2-4 short strings.
|
|
72
|
-
Respond with the JSON array only: no commentary, no markdown code
|
|
73
|
-
and no reasoning or thinking content before or after it.
|
|
83
|
+
#{language_instruction_for(language)}Respond with the JSON array only: no commentary, no markdown code
|
|
84
|
+
fences, and no reasoning or thinking content before or after it.
|
|
74
85
|
PROMPT
|
|
75
86
|
end
|
|
76
87
|
|
|
88
|
+
def source_prompt_for(deck)
|
|
89
|
+
return "" if deck.source.empty?
|
|
90
|
+
|
|
91
|
+
<<~SOURCE
|
|
92
|
+
Source text:
|
|
93
|
+
<source>
|
|
94
|
+
#{deck.source}
|
|
95
|
+
</source>
|
|
96
|
+
Base the presentation on the source text. Identify its central message,
|
|
97
|
+
preserve important facts and examples, and do not invent unsupported claims.
|
|
98
|
+
SOURCE
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def language_instruction_for(language)
|
|
102
|
+
return "" unless language
|
|
103
|
+
|
|
104
|
+
"Write the \"title\" and \"bullets\" text in #{language}.\n"
|
|
105
|
+
end
|
|
106
|
+
|
|
77
107
|
def method_prompt_for(deck)
|
|
78
108
|
method = deck.presentation_method
|
|
79
109
|
return "" unless method
|
|
@@ -132,6 +162,23 @@ module Slidict
|
|
|
132
162
|
translate ? "#{prompt}\nTranslate only the \"message\" field of each finding into #{translate}. Keep \"slide\" as an integer and \"severity\" as exactly \"warning\" or \"info\" — do not translate those values." : prompt
|
|
133
163
|
end
|
|
134
164
|
|
|
165
|
+
def translate_prompt_for(text, language)
|
|
166
|
+
<<~PROMPT
|
|
167
|
+
Translate the following text into #{language}. Respond with only the
|
|
168
|
+
translation: no commentary, no surrounding quotation marks, no
|
|
169
|
+
markdown formatting, and no reasoning or thinking content before or
|
|
170
|
+
after it.
|
|
171
|
+
|
|
172
|
+
#{text}
|
|
173
|
+
PROMPT
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Small/local models often ignore the "no quotation marks" instruction
|
|
177
|
+
# above and wrap the translation anyway.
|
|
178
|
+
def strip_quotes(text)
|
|
179
|
+
text.sub(/\A["'“”‘’「」]+/, "").sub(/["'“”‘’「」]+\z/, "")
|
|
180
|
+
end
|
|
181
|
+
|
|
135
182
|
def findings_from(content)
|
|
136
183
|
parsed = JSON.parse(extract_json_array(content))
|
|
137
184
|
raise Error, "expected a JSON array of findings" unless parsed.is_a?(Array)
|
data/lib/slidict/version.rb
CHANGED
data/lib/slidict.rb
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
4
|
|
|
5
|
+
require_relative "slidict/cli/options"
|
|
5
6
|
require_relative "slidict/cli/app"
|
|
6
7
|
require_relative "slidict/cli/lint"
|
|
7
8
|
require_relative "slidict/cli/serve"
|
|
8
9
|
require_relative "slidict/cli/slides"
|
|
9
10
|
require_relative "slidict/config"
|
|
10
11
|
require_relative "slidict/deck"
|
|
12
|
+
require_relative "slidict/env"
|
|
11
13
|
require_relative "slidict/external/slidict_io/auth"
|
|
12
14
|
require_relative "slidict/external/slidict_io/client"
|
|
13
15
|
require_relative "slidict/external/slidict_io/credentials"
|
|
@@ -18,6 +20,7 @@ require_relative "slidict/lint/slide_parser"
|
|
|
18
20
|
require_relative "slidict/llm/client"
|
|
19
21
|
require_relative "slidict/output/format"
|
|
20
22
|
require_relative "slidict/output/renderer"
|
|
23
|
+
require_relative "slidict/generator"
|
|
21
24
|
require_relative "slidict/presentation_method"
|
|
22
25
|
require_relative "slidict/version"
|
|
23
26
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: slidict
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yusuke Abe
|
|
@@ -83,13 +83,16 @@ files:
|
|
|
83
83
|
- lib/slidict.rb
|
|
84
84
|
- lib/slidict/cli/app.rb
|
|
85
85
|
- lib/slidict/cli/lint.rb
|
|
86
|
+
- lib/slidict/cli/options.rb
|
|
86
87
|
- lib/slidict/cli/serve.rb
|
|
87
88
|
- lib/slidict/cli/slides.rb
|
|
88
89
|
- lib/slidict/config.rb
|
|
89
90
|
- lib/slidict/deck.rb
|
|
91
|
+
- lib/slidict/env.rb
|
|
90
92
|
- lib/slidict/external/slidict_io/auth.rb
|
|
91
93
|
- lib/slidict/external/slidict_io/client.rb
|
|
92
94
|
- lib/slidict/external/slidict_io/credentials.rb
|
|
95
|
+
- lib/slidict/generator.rb
|
|
93
96
|
- lib/slidict/lint/finding.rb
|
|
94
97
|
- lib/slidict/lint/linter.rb
|
|
95
98
|
- lib/slidict/lint/renderer.rb
|