slidict 0.3.1 → 0.4.1
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/.github/release-drafter.yml +1 -1
- data/.github/workflows/changelog.yml +6 -23
- data/.github/workflows/gem-push.yml +38 -6
- data/AGENTS.md +2 -2
- data/README.md +30 -0
- data/lib/slidict/cli/app.rb +21 -1
- data/lib/slidict/cli/lint.rb +135 -0
- data/lib/slidict/lint/finding.rb +10 -0
- data/lib/slidict/lint/linter.rb +22 -0
- data/lib/slidict/lint/renderer.rb +19 -0
- data/lib/slidict/lint/slide_parser.rb +68 -0
- data/lib/slidict/llm/client.rb +69 -0
- data/lib/slidict/version.rb +1 -1
- data/lib/slidict.rb +5 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72ffe395b54c50043126018602fe50cde4e7e9ffdc666dd4c403b76b0fc97753
|
|
4
|
+
data.tar.gz: 1f8fd030ddb53433a334af861bcb1bf4fdb2a191de50657d900a74936e4ca767
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b33d3e8fde16b58ace800bc590edd63cd67bdb20f595dff60367182c440b8f57fa779632a8da4f72a885061bd400a576b888f4c555684fd2e2c615e14fb589f6
|
|
7
|
+
data.tar.gz: 61cc90648bd97afd6dbda04aed0406f7f7601553852319f05a05905187752057d951805f03facac2003efebadbf97625092e814b952ec347c08000130c3ddf37
|
data/.github/release-drafter.yml
CHANGED
|
@@ -5,36 +5,19 @@ on:
|
|
|
5
5
|
branches:
|
|
6
6
|
- main
|
|
7
7
|
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
pull-requests: read
|
|
11
|
+
|
|
8
12
|
jobs:
|
|
9
13
|
changelog:
|
|
10
14
|
runs-on: ubuntu-latest
|
|
11
15
|
|
|
12
16
|
steps:
|
|
13
|
-
- name:
|
|
14
|
-
uses: actions/checkout@v3
|
|
15
|
-
with:
|
|
16
|
-
fetch-depth: 0
|
|
17
|
-
|
|
18
|
-
- name: Extract version
|
|
19
|
-
id: version
|
|
20
|
-
run: |
|
|
21
|
-
version=$(grep -Eo "[0-9]+\.[0-9]+\.[0-9]+" lib/slidict/version.rb | head -n 1)
|
|
22
|
-
echo "version=v$version" >> $GITHUB_ENV
|
|
23
|
-
echo "VERSION_TAG=v$version" >> $GITHUB_ENV
|
|
24
|
-
|
|
25
|
-
- name: Check if version file changed
|
|
26
|
-
id: version_file
|
|
27
|
-
run: |
|
|
28
|
-
if git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" | grep -qx "lib/slidict/version.rb"; then
|
|
29
|
-
echo "changed=true" >> "$GITHUB_OUTPUT"
|
|
30
|
-
else
|
|
31
|
-
echo "changed=false" >> "$GITHUB_OUTPUT"
|
|
32
|
-
fi
|
|
33
|
-
|
|
34
|
-
- name: Release with release-drafter
|
|
17
|
+
- name: Update draft release notes
|
|
35
18
|
uses: release-drafter/release-drafter@v5
|
|
36
19
|
with:
|
|
37
20
|
config-name: release-drafter.yml
|
|
38
|
-
publish:
|
|
21
|
+
publish: false
|
|
39
22
|
env:
|
|
40
23
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -1,31 +1,51 @@
|
|
|
1
1
|
name: Ruby Gem
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
-
|
|
7
|
-
|
|
8
|
-
-
|
|
4
|
+
workflow_run:
|
|
5
|
+
workflows:
|
|
6
|
+
- Changelog
|
|
7
|
+
types:
|
|
8
|
+
- completed
|
|
9
9
|
|
|
10
10
|
jobs:
|
|
11
11
|
build:
|
|
12
12
|
name: Build + Publish
|
|
13
13
|
runs-on: ubuntu-latest
|
|
14
|
+
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main'
|
|
14
15
|
permissions:
|
|
15
|
-
contents:
|
|
16
|
+
contents: write
|
|
16
17
|
packages: write
|
|
17
18
|
|
|
18
19
|
steps:
|
|
19
20
|
- uses: actions/checkout@v3
|
|
20
21
|
with:
|
|
22
|
+
ref: ${{ github.event.workflow_run.head_sha }}
|
|
21
23
|
fetch-depth: 0
|
|
22
24
|
|
|
25
|
+
- name: Check if version file changed
|
|
26
|
+
id: version_file
|
|
27
|
+
run: |
|
|
28
|
+
if git diff --name-only HEAD~1 HEAD | grep -qx "lib/slidict/version.rb"; then
|
|
29
|
+
echo "changed=true" >> "$GITHUB_OUTPUT"
|
|
30
|
+
else
|
|
31
|
+
echo "changed=false" >> "$GITHUB_OUTPUT"
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
- name: Extract version
|
|
35
|
+
if: steps.version_file.outputs.changed == 'true'
|
|
36
|
+
id: version
|
|
37
|
+
run: |
|
|
38
|
+
version=$(grep -Eo "[0-9]+\.[0-9]+\.[0-9]+" lib/slidict/version.rb | head -n 1)
|
|
39
|
+
echo "tag=v$version" >> "$GITHUB_OUTPUT"
|
|
40
|
+
|
|
23
41
|
- name: Set up Ruby 3.4
|
|
42
|
+
if: steps.version_file.outputs.changed == 'true'
|
|
24
43
|
uses: ruby/setup-ruby@v1
|
|
25
44
|
with:
|
|
26
45
|
ruby-version: 3.4.3
|
|
27
46
|
|
|
28
47
|
- name: Publish to GPR
|
|
48
|
+
if: steps.version_file.outputs.changed == 'true'
|
|
29
49
|
run: |
|
|
30
50
|
mkdir -p $HOME/.gem
|
|
31
51
|
touch $HOME/.gem/credentials
|
|
@@ -38,6 +58,7 @@ jobs:
|
|
|
38
58
|
OWNER: ${{ github.repository_owner }}
|
|
39
59
|
|
|
40
60
|
- name: Publish to RubyGems
|
|
61
|
+
if: steps.version_file.outputs.changed == 'true'
|
|
41
62
|
run: |
|
|
42
63
|
mkdir -p $HOME/.gem
|
|
43
64
|
touch $HOME/.gem/credentials
|
|
@@ -47,3 +68,14 @@ jobs:
|
|
|
47
68
|
gem push *.gem
|
|
48
69
|
env:
|
|
49
70
|
GEM_HOST_API_KEY: "${{ secrets.RUBYGEMS_AUTH_TOKEN }}"
|
|
71
|
+
|
|
72
|
+
- name: Publish release notes
|
|
73
|
+
if: steps.version_file.outputs.changed == 'true'
|
|
74
|
+
uses: release-drafter/release-drafter@v5
|
|
75
|
+
with:
|
|
76
|
+
config-name: release-drafter.yml
|
|
77
|
+
publish: true
|
|
78
|
+
tag: ${{ steps.version.outputs.tag }}
|
|
79
|
+
name: ${{ steps.version.outputs.tag }}
|
|
80
|
+
env:
|
|
81
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
data/AGENTS.md
CHANGED
|
@@ -21,8 +21,8 @@ OpenAI-compatible chat API.
|
|
|
21
21
|
|
|
22
22
|
Run both tests and lint before considering a change complete.
|
|
23
23
|
|
|
24
|
-
When you add or change a CLI command
|
|
25
|
-
|
|
24
|
+
When you add or change a feature (a CLI command, its options, or any other
|
|
25
|
+
user-facing behavior), update `README.md` to document it.
|
|
26
26
|
|
|
27
27
|
## Commit conventions
|
|
28
28
|
|
data/README.md
CHANGED
|
@@ -13,6 +13,7 @@ Unlike traditional slide generators, Slidict focuses on communication before sli
|
|
|
13
13
|
- Local-first MVP implemented in Ruby
|
|
14
14
|
- Built-in Sinatra server for browsing generated slides from `public/`
|
|
15
15
|
- OpenAI Compatible API support, so you can point Slidict at OpenAI, Ollama, LM Studio, vLLM, or any other server implementing the same `/chat/completions` endpoint
|
|
16
|
+
- Presentation structure linter: checks whether a Markdown/Asciidoc deck's *structure* (not its looks) will land with an audience
|
|
16
17
|
|
|
17
18
|
## Requirements
|
|
18
19
|
|
|
@@ -127,6 +128,34 @@ bin/slidict serve -p 4567 -o 0.0.0.0
|
|
|
127
128
|
`bin/slidict --publish` and `--slide-id` (see [Usage](#usage)) wrap this same `create`/`edit`
|
|
128
129
|
behavior so you can save the slides you just generated straight to slidict.io.
|
|
129
130
|
|
|
131
|
+
### `slidict lint`
|
|
132
|
+
|
|
133
|
+
Diagnoses whether a Markdown or Asciidoc slide deck has a structure an audience can
|
|
134
|
+
actually follow -- not whether it looks nice. Diagnosis only; it does not rewrite the
|
|
135
|
+
file. Requires an LLM endpoint (see [Configuration](#configuration)).
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
bin/slidict lint talk.md --llm-base-url http://localhost:11434/v1 --llm-api-key ollama --llm-model llama3
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
[warning] Slide 3: this slide's main point is unclear
|
|
143
|
+
[warning] Slide 5: "MCP" is used without first explaining it
|
|
144
|
+
[info] Slide 8: the closing slide is weak; consider adding a one-sentence takeaway for the audience
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
It checks six things across the whole deck:
|
|
148
|
+
|
|
149
|
+
1. Is the audience clear?
|
|
150
|
+
2. Can the overall point be stated in one sentence?
|
|
151
|
+
3. Does the deck flow background → problem → solution → result/learning?
|
|
152
|
+
4. Is any single slide overloaded with information?
|
|
153
|
+
5. Is jargon used without first explaining it?
|
|
154
|
+
6. Does the closing slide give the audience a concrete takeaway?
|
|
155
|
+
|
|
156
|
+
The deck format (`markdown` or `asciidoc`) is auto-detected from the file extension;
|
|
157
|
+
override it with `--format`. Run `bin/slidict lint -h` for the full list of options.
|
|
158
|
+
|
|
130
159
|
## Configuration
|
|
131
160
|
|
|
132
161
|
Slidict generates slides with an LLM through any OpenAI Compatible API. Configure the
|
|
@@ -191,6 +220,7 @@ We optimize for communication, not decoration.
|
|
|
191
220
|
- [x] Interactive CLI
|
|
192
221
|
- [x] Slide generation
|
|
193
222
|
- [x] OpenAI Compatible API support (configurable base URL, so Ollama, LM Studio, and other compatible servers work out of the box)
|
|
223
|
+
- [x] Presentation structure linter (`slidict lint`)
|
|
194
224
|
|
|
195
225
|
## License
|
|
196
226
|
|
data/lib/slidict/cli/app.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Slidict
|
|
|
7
7
|
module Cli
|
|
8
8
|
class App
|
|
9
9
|
def initialize(input: $stdin, output: $stdout, renderer: Output::Renderer.new, auth_client: nil,
|
|
10
|
-
credentials: nil, sleeper: Kernel, slides_command: nil, server: nil)
|
|
10
|
+
credentials: nil, sleeper: Kernel, slides_command: nil, server: nil, lint_command: nil)
|
|
11
11
|
@input = input
|
|
12
12
|
@output = output
|
|
13
13
|
@renderer = renderer
|
|
@@ -16,6 +16,7 @@ module Slidict
|
|
|
16
16
|
@sleeper = sleeper
|
|
17
17
|
@slides_command = slides_command
|
|
18
18
|
@server = server
|
|
19
|
+
@lint_command = lint_command
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def run(argv = [])
|
|
@@ -24,6 +25,7 @@ module Slidict
|
|
|
24
25
|
return auth if options[:command] == "auth"
|
|
25
26
|
return slides(options[:args]) if options[:command] == "slides"
|
|
26
27
|
return serve(options[:args]) if options[:command] == "serve"
|
|
28
|
+
return lint(options[:args]) if options[:command] == "lint"
|
|
27
29
|
|
|
28
30
|
config = build_config(options)
|
|
29
31
|
client = llm_client_for(config)
|
|
@@ -94,6 +96,13 @@ module Slidict
|
|
|
94
96
|
return options
|
|
95
97
|
end
|
|
96
98
|
|
|
99
|
+
if args.first == "lint"
|
|
100
|
+
args.shift
|
|
101
|
+
options[:command] = "lint"
|
|
102
|
+
options[:args] = args
|
|
103
|
+
return options
|
|
104
|
+
end
|
|
105
|
+
|
|
97
106
|
until args.empty?
|
|
98
107
|
case (arg = args.shift)
|
|
99
108
|
when "-h", "--help"
|
|
@@ -198,6 +207,10 @@ module Slidict
|
|
|
198
207
|
server.run(args)
|
|
199
208
|
end
|
|
200
209
|
|
|
210
|
+
def lint(args)
|
|
211
|
+
lint_command.run(args)
|
|
212
|
+
end
|
|
213
|
+
|
|
201
214
|
def publish_to_slidict(deck, content, options)
|
|
202
215
|
slides_command.publish(
|
|
203
216
|
id: options[:slide_id],
|
|
@@ -216,6 +229,10 @@ module Slidict
|
|
|
216
229
|
@server ||= Serve.new(output: @output)
|
|
217
230
|
end
|
|
218
231
|
|
|
232
|
+
def lint_command
|
|
233
|
+
@lint_command ||= Lint.new(output: @output)
|
|
234
|
+
end
|
|
235
|
+
|
|
219
236
|
def body_format_for(framework)
|
|
220
237
|
Output::Format.fetch(framework).body_format
|
|
221
238
|
end
|
|
@@ -246,6 +263,7 @@ module Slidict
|
|
|
246
263
|
Usage: slidict auth
|
|
247
264
|
Usage: slidict slides <list|show|create|edit> [options]
|
|
248
265
|
Usage: slidict serve [sinatra options]
|
|
266
|
+
Usage: slidict lint <file> [options]
|
|
249
267
|
|
|
250
268
|
Generate presentation source files from a short conversation.
|
|
251
269
|
|
|
@@ -253,6 +271,8 @@ module Slidict
|
|
|
253
271
|
auth Authenticate the CLI with GitHub and save a CLI access token
|
|
254
272
|
slides Manage your slides on slidict.io (run `slidict slides -h` for details)
|
|
255
273
|
serve Serve slide files from ./public with Sinatra
|
|
274
|
+
lint Check whether a slide deck's structure will land with its audience
|
|
275
|
+
(run `slidict lint -h` for details)
|
|
256
276
|
|
|
257
277
|
Options:
|
|
258
278
|
--topic TEXT Presentation topic
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slidict
|
|
4
|
+
module Cli
|
|
5
|
+
# Implements `slidict lint <file>`: diagnoses whether a Markdown/Asciidoc
|
|
6
|
+
# slide deck has a structure that an audience can actually follow (not
|
|
7
|
+
# whether it looks nice). Diagnosis only -- it does not rewrite the file.
|
|
8
|
+
class Lint
|
|
9
|
+
ASCIIDOC_EXTENSIONS = %w[.adoc .asciidoc].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(output:, linter_factory: nil, renderer: Slidict::Lint::Renderer.new)
|
|
12
|
+
@output = output
|
|
13
|
+
@linter_factory = linter_factory || method(:default_linter)
|
|
14
|
+
@renderer = renderer
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run(argv)
|
|
18
|
+
options = parse(argv)
|
|
19
|
+
return print_help if options[:help] || options[:path].nil?
|
|
20
|
+
return file_not_found(options[:path]) unless File.exist?(options[:path])
|
|
21
|
+
|
|
22
|
+
run_lint(options)
|
|
23
|
+
rescue ArgumentError => e
|
|
24
|
+
print_usage_error(e)
|
|
25
|
+
rescue Slidict::Lint::Linter::Error, Llm::Client::Error => e
|
|
26
|
+
print_error(e)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def run_lint(options)
|
|
32
|
+
config = build_config(options)
|
|
33
|
+
return llm_required unless config.llm_enabled?
|
|
34
|
+
|
|
35
|
+
print_findings(lint(options, config))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def lint(options, config)
|
|
39
|
+
@linter_factory.call(config).lint(File.read(options[:path]), format: format_for(options), translate: options[:translate])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def print_usage_error(error)
|
|
43
|
+
print_error(error)
|
|
44
|
+
@output.puts
|
|
45
|
+
print_help
|
|
46
|
+
1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def print_error(error)
|
|
50
|
+
@output.puts "Error: #{error.message}"
|
|
51
|
+
1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse(argv)
|
|
55
|
+
args = argv.dup
|
|
56
|
+
options = { path: extract_path!(args) }
|
|
57
|
+
parse_options!(args, options)
|
|
58
|
+
options
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def extract_path!(args)
|
|
62
|
+
args.shift unless args.first.to_s.start_with?("-")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parse_options!(args, options)
|
|
66
|
+
until args.empty?
|
|
67
|
+
case (arg = args.shift)
|
|
68
|
+
when "-h", "--help" then options[:help] = true
|
|
69
|
+
when "--format" then options[:format] = fetch_value!(args, arg)
|
|
70
|
+
when "--llm-base-url" then options[:llm_base_url] = fetch_value!(args, arg)
|
|
71
|
+
when "--llm-api-key" then options[:llm_api_key] = fetch_value!(args, arg)
|
|
72
|
+
when "--llm-model" then options[:llm_model] = fetch_value!(args, arg)
|
|
73
|
+
when "--translate" then options[:translate] = fetch_value!(args, arg)
|
|
74
|
+
else raise ArgumentError, "unknown option #{arg}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def fetch_value!(args, option)
|
|
80
|
+
value = args.shift
|
|
81
|
+
raise ArgumentError, "#{option} requires a value" if value.nil? || value.start_with?("-")
|
|
82
|
+
|
|
83
|
+
value
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_config(options)
|
|
87
|
+
Config.from_env.merge(
|
|
88
|
+
base_url: options[:llm_base_url],
|
|
89
|
+
api_key: options[:llm_api_key],
|
|
90
|
+
model: options[:llm_model]
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def default_linter(config)
|
|
95
|
+
client = Llm::Client.new(base_url: config.base_url, api_key: config.api_key, model: config.model)
|
|
96
|
+
Slidict::Lint::Linter.new(client: client)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def format_for(options)
|
|
100
|
+
return options[:format] if options[:format]
|
|
101
|
+
|
|
102
|
+
ASCIIDOC_EXTENSIONS.include?(File.extname(options[:path]).downcase) ? "asciidoc" : "markdown"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def print_findings(findings)
|
|
106
|
+
@output.puts(findings.empty? ? "No issues found." : @renderer.render(findings))
|
|
107
|
+
0
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def file_not_found(path)
|
|
111
|
+
@output.puts "Error: file not found: #{path}"
|
|
112
|
+
1
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def llm_required
|
|
116
|
+
@output.puts "Error: lint requires an LLM endpoint (--llm-base-url or SLIDICT_LLM_BASE_URL)"
|
|
117
|
+
1
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def print_help
|
|
121
|
+
@output.puts <<~HELP
|
|
122
|
+
Usage: slidict lint <file> [options]
|
|
123
|
+
Diagnoses whether a slide deck's structure will land with its audience.
|
|
124
|
+
--format FORMAT markdown or asciidoc (default: auto-detected from extension)
|
|
125
|
+
--llm-base-url URL OpenAI Compatible API base URL (env: SLIDICT_LLM_BASE_URL)
|
|
126
|
+
--llm-api-key KEY API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)
|
|
127
|
+
--llm-model NAME Model name to request (env: SLIDICT_LLM_MODEL, default: gpt-4o-mini)
|
|
128
|
+
--translate LANG Translate findings into the given language (e.g. Japanese)
|
|
129
|
+
-h, --help Show this help
|
|
130
|
+
HELP
|
|
131
|
+
0
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slidict
|
|
4
|
+
module Lint
|
|
5
|
+
# A single diagnostic produced by the linter: which slide it concerns,
|
|
6
|
+
# how severe it is ("warning" blocks understanding, "info" is a
|
|
7
|
+
# suggestion), and the human-readable message to show the user.
|
|
8
|
+
Finding = Struct.new(:slide, :severity, :message, keyword_init: true)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slidict
|
|
4
|
+
module Lint
|
|
5
|
+
# Orchestrates a lint run: splits the raw deck source into slides and
|
|
6
|
+
# asks the LLM client to diagnose the presentation's structure.
|
|
7
|
+
class Linter
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
def initialize(client:)
|
|
11
|
+
@client = client
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def lint(content, format: "markdown", translate: nil)
|
|
15
|
+
slides = SlideParser.parse(content, format: format)
|
|
16
|
+
raise Error, "no slides found in the given file" if slides.empty?
|
|
17
|
+
|
|
18
|
+
@client.lint_slides(slides, translate: translate)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slidict
|
|
4
|
+
module Lint
|
|
5
|
+
# Formats findings as one line per finding:
|
|
6
|
+
# [warning] Slide 3: this slide's main point is unclear
|
|
7
|
+
class Renderer
|
|
8
|
+
def render(findings)
|
|
9
|
+
findings.sort_by(&:slide).map { |finding| line_for(finding) }.join("\n")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def line_for(finding)
|
|
15
|
+
"[#{finding.severity}] Slide #{finding.slide}: #{finding.message}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slidict
|
|
4
|
+
module Lint
|
|
5
|
+
# Splits raw Markdown/Asciidoc slide source into one text block per
|
|
6
|
+
# slide, so the linter can refer to slides by number. Frontmatter
|
|
7
|
+
# (Markdown) and the document title/preamble (Asciidoc) are dropped
|
|
8
|
+
# since they are not slides a reader sees.
|
|
9
|
+
class SlideParser
|
|
10
|
+
MARKDOWN_SEPARATOR = /\A-{3,}\s*\z/
|
|
11
|
+
ASCIIDOC_SLIDE_HEADING = /\A==\s+/
|
|
12
|
+
MARKDOWN_HEADING = /\A#\s+/
|
|
13
|
+
|
|
14
|
+
def self.parse(content, format: "markdown")
|
|
15
|
+
format == "asciidoc" ? parse_asciidoc(content) : parse_markdown(content)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.parse_markdown(content)
|
|
19
|
+
blocks = split_lines(content, MARKDOWN_SEPARATOR)
|
|
20
|
+
blocks = blocks.drop(2) if content.lines.first&.match?(MARKDOWN_SEPARATOR)
|
|
21
|
+
|
|
22
|
+
if blocks.size <= 1
|
|
23
|
+
by_heading = split_lines(content, MARKDOWN_HEADING, keep_separator: true, drop_preamble: true)
|
|
24
|
+
blocks = by_heading unless by_heading.empty?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
clean(blocks)
|
|
28
|
+
end
|
|
29
|
+
private_class_method :parse_markdown
|
|
30
|
+
|
|
31
|
+
def self.parse_asciidoc(content)
|
|
32
|
+
clean(split_lines(content, ASCIIDOC_SLIDE_HEADING, keep_separator: true, drop_preamble: true))
|
|
33
|
+
end
|
|
34
|
+
private_class_method :parse_asciidoc
|
|
35
|
+
|
|
36
|
+
# Splits `content` into blocks every time a line matches `separator`.
|
|
37
|
+
# With keep_separator: true the matching line starts a new block
|
|
38
|
+
# instead of being discarded (used for heading-based splitting).
|
|
39
|
+
# With drop_preamble: true, any lines before the first match are
|
|
40
|
+
# discarded rather than kept as a leading block.
|
|
41
|
+
def self.split_lines(content, separator, keep_separator: false, drop_preamble: false)
|
|
42
|
+
blocks = []
|
|
43
|
+
current = drop_preamble ? nil : []
|
|
44
|
+
content.each_line do |line|
|
|
45
|
+
current = append_line(blocks, current, line, separator, keep_separator)
|
|
46
|
+
end
|
|
47
|
+
blocks << current.join if current
|
|
48
|
+
blocks
|
|
49
|
+
end
|
|
50
|
+
private_class_method :split_lines
|
|
51
|
+
|
|
52
|
+
def self.append_line(blocks, current, line, separator, keep_separator)
|
|
53
|
+
return current << line if current && !line.match?(separator)
|
|
54
|
+
|
|
55
|
+
blocks << current.join if current
|
|
56
|
+
return current unless line.match?(separator)
|
|
57
|
+
|
|
58
|
+
keep_separator ? [line] : []
|
|
59
|
+
end
|
|
60
|
+
private_class_method :append_line
|
|
61
|
+
|
|
62
|
+
def self.clean(blocks)
|
|
63
|
+
blocks.map(&:strip).reject(&:empty?)
|
|
64
|
+
end
|
|
65
|
+
private_class_method :clean
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/slidict/llm/client.rb
CHANGED
|
@@ -34,6 +34,14 @@ module Slidict
|
|
|
34
34
|
slides_from(content)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# slide_texts is an array of slide bodies (1-indexed by position) as
|
|
38
|
+
# produced by Slidict::Lint::SlideParser. Returns an array of
|
|
39
|
+
# Slidict::Lint::Finding.
|
|
40
|
+
def lint_slides(slide_texts, translate: nil)
|
|
41
|
+
content = chat_completion(lint_prompt_for(slide_texts, translate: translate))
|
|
42
|
+
findings_from(content)
|
|
43
|
+
end
|
|
44
|
+
|
|
37
45
|
private
|
|
38
46
|
|
|
39
47
|
def prompt_for(deck)
|
|
@@ -51,6 +59,67 @@ module Slidict
|
|
|
51
59
|
PROMPT
|
|
52
60
|
end
|
|
53
61
|
|
|
62
|
+
LINT_PROMPT_TEMPLATE = <<~PROMPT
|
|
63
|
+
You are a presentation structure linter for tech talks and lightning talks.
|
|
64
|
+
Your goal is not to judge how the slides look, but to diagnose whether the
|
|
65
|
+
structure will actually land with an audience.
|
|
66
|
+
|
|
67
|
+
Evaluate the deck as a whole against these six checks:
|
|
68
|
+
|
|
69
|
+
1. Is the audience clear (who is this talk for)?
|
|
70
|
+
2. Can the overall point be stated in one sentence?
|
|
71
|
+
3. Does the deck flow background -> problem -> solution -> result/learning
|
|
72
|
+
(call out where the flow breaks down)?
|
|
73
|
+
4. Is any single slide overloaded with information?
|
|
74
|
+
5. Is jargon used without first explaining it?
|
|
75
|
+
6. Does the closing slide give the audience one concrete takeaway?
|
|
76
|
+
|
|
77
|
+
For each finding, name the single slide it relates to most. For deck-wide
|
|
78
|
+
findings (unclear audience, unclear thesis, etc.), point to the slide where
|
|
79
|
+
the problem is most visible, or slide 1 if you cannot tell.
|
|
80
|
+
|
|
81
|
+
Decide severity using these rules:
|
|
82
|
+
- warning: likely to block audience understanding (checks 1-5)
|
|
83
|
+
- info: a suggestion that would make things better (check 6, or minor polish)
|
|
84
|
+
|
|
85
|
+
Here is the slide content ("--- Slide N ---" marks each slide boundary):
|
|
86
|
+
|
|
87
|
+
%<numbered>s
|
|
88
|
+
|
|
89
|
+
Respond with a JSON array of findings only. Each item must be an object of
|
|
90
|
+
the form {"slide": <integer>, "severity": "warning" or "info", "message": "<one sentence>"}.
|
|
91
|
+
|
|
92
|
+
Do not include any commentary, markdown code fences, or text other than the
|
|
93
|
+
JSON array. Return an empty array [] if you find no issues.
|
|
94
|
+
PROMPT
|
|
95
|
+
|
|
96
|
+
def lint_prompt_for(slide_texts, translate: nil)
|
|
97
|
+
numbered = slide_texts.each_with_index.map { |text, i| "--- Slide #{i + 1} ---\n#{text}" }.join("\n\n")
|
|
98
|
+
prompt = format(LINT_PROMPT_TEMPLATE, numbered: numbered)
|
|
99
|
+
translate ? "#{prompt}\nWrite each message field in #{translate}." : prompt
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def findings_from(content)
|
|
103
|
+
parsed = JSON.parse(extract_json_array(content))
|
|
104
|
+
raise Error, "expected a JSON array of findings" unless parsed.is_a?(Array)
|
|
105
|
+
|
|
106
|
+
parsed.map { |item| finding_from(item) }
|
|
107
|
+
rescue JSON::ParserError, KeyError, ArgumentError, TypeError => e
|
|
108
|
+
raise Error, "could not parse model response: #{e.message}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def finding_from(item)
|
|
112
|
+
Lint::Finding.new(
|
|
113
|
+
slide: Integer(item.fetch("slide")),
|
|
114
|
+
severity: normalize_severity(item["severity"]),
|
|
115
|
+
message: item.fetch("message")
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def normalize_severity(value)
|
|
120
|
+
%w[warning info].include?(value) ? value : "info"
|
|
121
|
+
end
|
|
122
|
+
|
|
54
123
|
def chat_completion(prompt)
|
|
55
124
|
response = JSON.parse(post_chat_completion(prompt))
|
|
56
125
|
content = response.dig("choices", 0, "message", "content")
|
data/lib/slidict/version.rb
CHANGED
data/lib/slidict.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "time"
|
|
4
4
|
|
|
5
5
|
require_relative "slidict/cli/app"
|
|
6
|
+
require_relative "slidict/cli/lint"
|
|
6
7
|
require_relative "slidict/cli/serve"
|
|
7
8
|
require_relative "slidict/cli/slides"
|
|
8
9
|
require_relative "slidict/config"
|
|
@@ -10,6 +11,10 @@ require_relative "slidict/deck"
|
|
|
10
11
|
require_relative "slidict/external/slidict_io/auth"
|
|
11
12
|
require_relative "slidict/external/slidict_io/client"
|
|
12
13
|
require_relative "slidict/external/slidict_io/credentials"
|
|
14
|
+
require_relative "slidict/lint/finding"
|
|
15
|
+
require_relative "slidict/lint/linter"
|
|
16
|
+
require_relative "slidict/lint/renderer"
|
|
17
|
+
require_relative "slidict/lint/slide_parser"
|
|
13
18
|
require_relative "slidict/llm/client"
|
|
14
19
|
require_relative "slidict/output/format"
|
|
15
20
|
require_relative "slidict/output/renderer"
|
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.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yusuke Abe
|
|
@@ -74,6 +74,7 @@ files:
|
|
|
74
74
|
- Rakefile
|
|
75
75
|
- lib/slidict.rb
|
|
76
76
|
- lib/slidict/cli/app.rb
|
|
77
|
+
- lib/slidict/cli/lint.rb
|
|
77
78
|
- lib/slidict/cli/serve.rb
|
|
78
79
|
- lib/slidict/cli/slides.rb
|
|
79
80
|
- lib/slidict/config.rb
|
|
@@ -81,6 +82,10 @@ files:
|
|
|
81
82
|
- lib/slidict/external/slidict_io/auth.rb
|
|
82
83
|
- lib/slidict/external/slidict_io/client.rb
|
|
83
84
|
- lib/slidict/external/slidict_io/credentials.rb
|
|
85
|
+
- lib/slidict/lint/finding.rb
|
|
86
|
+
- lib/slidict/lint/linter.rb
|
|
87
|
+
- lib/slidict/lint/renderer.rb
|
|
88
|
+
- lib/slidict/lint/slide_parser.rb
|
|
84
89
|
- lib/slidict/llm/client.rb
|
|
85
90
|
- lib/slidict/output/format.rb
|
|
86
91
|
- lib/slidict/output/renderer.rb
|