slidict 0.2.0 → 0.4.0
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/workflows/changelog.yml +6 -23
- data/.github/workflows/gem-push.yml +38 -6
- data/AGENTS.md +2 -2
- data/README.md +53 -6
- data/lib/slidict/cli/app.rb +336 -0
- data/lib/slidict/cli/lint.rb +133 -0
- data/lib/slidict/cli/serve.rb +113 -0
- data/lib/slidict/cli/slides.rb +271 -0
- data/lib/slidict/external/slidict_io/auth.rb +80 -0
- data/lib/slidict/external/slidict_io/client.rb +126 -0
- data/lib/slidict/external/slidict_io/credentials.rb +43 -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 +214 -0
- data/lib/slidict/output/format.rb +44 -0
- data/lib/slidict/output/renderer.rb +36 -0
- data/lib/slidict/version.rb +1 -1
- data/lib/slidict.rb +14 -7
- metadata +58 -9
- data/lib/slidict/auth_client.rb +0 -75
- data/lib/slidict/cli.rb +0 -266
- data/lib/slidict/credentials.rb +0 -39
- data/lib/slidict/llm_client.rb +0 -144
- data/lib/slidict/markdown_renderer.rb +0 -39
- data/lib/slidict/slides_client.rb +0 -120
- data/lib/slidict/slides_command.rb +0 -253
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Slidict
|
|
8
|
+
module External
|
|
9
|
+
module SlidictIo
|
|
10
|
+
# Talks to the slidict.io CLI slides API using a CLI access token
|
|
11
|
+
# (obtained via Slidict::External::SlidictIo::Auth / `slidict auth`).
|
|
12
|
+
class Client
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
class Unauthorized < Error; end
|
|
15
|
+
class Forbidden < Error; end
|
|
16
|
+
class NotFound < Error; end
|
|
17
|
+
class NotEditable < Error; end
|
|
18
|
+
class RateLimited < Error; end
|
|
19
|
+
|
|
20
|
+
# Raised for 422 responses other than "not_editable", carrying the API's validation errors.
|
|
21
|
+
class Unprocessable < Error
|
|
22
|
+
attr_reader :errors
|
|
23
|
+
|
|
24
|
+
def initialize(message, errors: [])
|
|
25
|
+
super(message)
|
|
26
|
+
@errors = errors
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
DEFAULT_BASE_URL = "https://slidict.io"
|
|
31
|
+
|
|
32
|
+
attr_reader :base_url
|
|
33
|
+
|
|
34
|
+
def initialize(access_token:, token_type: "Bearer",
|
|
35
|
+
base_url: ENV.fetch("SLIDICT_AUTH_BASE_URL", DEFAULT_BASE_URL))
|
|
36
|
+
@access_token = access_token
|
|
37
|
+
@token_type = token_type
|
|
38
|
+
@base_url = base_url
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def list(page: nil)
|
|
42
|
+
get_json("/api/cli/slides", query: page ? { page: page } : {})
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def show(id)
|
|
46
|
+
get_json("/api/cli/slides/#{id}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def create(body:, title: nil, body_format: nil, visibility: nil)
|
|
50
|
+
post_json("/api/cli/slides",
|
|
51
|
+
slide_payload(title: title, body: body, body_format: body_format, visibility: visibility))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def update(id, body: nil, title: nil, body_format: nil, visibility: nil)
|
|
55
|
+
patch_json("/api/cli/slides/#{id}",
|
|
56
|
+
slide_payload(title: title, body: body, body_format: body_format, visibility: visibility))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def slide_payload(title:, body:, body_format:, visibility:)
|
|
62
|
+
{ title: title, body: body, body_format: body_format, visibility: visibility }.compact
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def get_json(path, query: {})
|
|
66
|
+
uri = URI.join(base_url, path)
|
|
67
|
+
uri.query = URI.encode_www_form(query) unless query.empty?
|
|
68
|
+
perform(uri, Net::HTTP::Get.new(uri))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def post_json(path, payload)
|
|
72
|
+
uri = URI.join(base_url, path)
|
|
73
|
+
perform(uri, build_request(Net::HTTP::Post.new(uri), payload))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def patch_json(path, payload)
|
|
77
|
+
uri = URI.join(base_url, path)
|
|
78
|
+
perform(uri, build_request(Net::HTTP::Patch.new(uri), payload))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_request(request, payload)
|
|
82
|
+
request["Content-Type"] = "application/json"
|
|
83
|
+
request.body = JSON.generate(payload)
|
|
84
|
+
request
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def perform(uri, request)
|
|
88
|
+
request["Accept"] = "application/json"
|
|
89
|
+
request["Authorization"] = "#{@token_type} #{@access_token}"
|
|
90
|
+
|
|
91
|
+
response = Net::HTTP.start(uri.hostname, uri.port,
|
|
92
|
+
use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 30) do |http|
|
|
93
|
+
http.request(request)
|
|
94
|
+
end
|
|
95
|
+
body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
|
|
96
|
+
handle_response(response, body)
|
|
97
|
+
rescue JSON::ParserError => e
|
|
98
|
+
raise Error, "invalid JSON response: #{e.message}"
|
|
99
|
+
rescue SystemCallError, Timeout::Error => e
|
|
100
|
+
raise Error, e.message
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
ERROR_CLASSES_BY_STATUS = {
|
|
104
|
+
"401" => Unauthorized,
|
|
105
|
+
"403" => Forbidden,
|
|
106
|
+
"404" => NotFound,
|
|
107
|
+
"429" => RateLimited
|
|
108
|
+
}.freeze
|
|
109
|
+
|
|
110
|
+
def handle_response(response, body)
|
|
111
|
+
return body if response.is_a?(Net::HTTPSuccess)
|
|
112
|
+
return raise_unprocessable(body) if response.code == "422"
|
|
113
|
+
|
|
114
|
+
error_class = ERROR_CLASSES_BY_STATUS.fetch(response.code, Error)
|
|
115
|
+
raise error_class, body["error_description"] || body["error"] || "HTTP #{response.code}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def raise_unprocessable(body)
|
|
119
|
+
raise NotEditable, "not_editable" if body["error"] == "not_editable"
|
|
120
|
+
|
|
121
|
+
raise Unprocessable.new(body["error"] || "unprocessable", errors: Array(body["errors"]))
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Slidict
|
|
7
|
+
module External
|
|
8
|
+
module SlidictIo
|
|
9
|
+
class Credentials
|
|
10
|
+
DEFAULT_PATH = File.expand_path("~/.config/slidict/credentials.json")
|
|
11
|
+
|
|
12
|
+
def initialize(path: DEFAULT_PATH)
|
|
13
|
+
@path = path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def write_cli_token!(access_token:, token_type: "Bearer", provider: "github")
|
|
17
|
+
FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
|
|
18
|
+
json = JSON.pretty_generate(
|
|
19
|
+
"access_token" => access_token,
|
|
20
|
+
"token_type" => token_type,
|
|
21
|
+
"provider" => provider,
|
|
22
|
+
"kind" => "cli_access_token"
|
|
23
|
+
)
|
|
24
|
+
File.write(@path, "#{json}\n", mode: "w", perm: 0o600)
|
|
25
|
+
File.chmod(0o600, @path)
|
|
26
|
+
@path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns { access_token:, token_type: } or nil if no CLI access token is saved.
|
|
30
|
+
def read_cli_token
|
|
31
|
+
return nil unless File.exist?(@path)
|
|
32
|
+
|
|
33
|
+
data = JSON.parse(File.read(@path))
|
|
34
|
+
return nil unless data["kind"] == "cli_access_token" && data["access_token"]
|
|
35
|
+
|
|
36
|
+
{ access_token: data["access_token"], token_type: data.fetch("token_type", "Bearer") }
|
|
37
|
+
rescue JSON::ParserError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
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")
|
|
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)
|
|
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
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Slidict
|
|
8
|
+
module Llm
|
|
9
|
+
# Talks to any OpenAI Compatible API (OpenAI, Ollama, LM Studio, vLLM, etc.)
|
|
10
|
+
# via the standard /chat/completions endpoint. Configure the target with
|
|
11
|
+
# Slidict::Config (base_url, api_key, model).
|
|
12
|
+
class Client
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
|
|
15
|
+
def initialize(base_url:, api_key:, model:)
|
|
16
|
+
@base_url = base_url
|
|
17
|
+
@api_key = api_key
|
|
18
|
+
@model = model
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Checks that the endpoint is reachable before the (slower, more
|
|
22
|
+
# expensive) chat completion request is made. Raises Error on failure.
|
|
23
|
+
def verify_connection!
|
|
24
|
+
uri = endpoint_uri("models")
|
|
25
|
+
request = Net::HTTP::Get.new(uri)
|
|
26
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
27
|
+
response = perform_request(uri, request)
|
|
28
|
+
|
|
29
|
+
raise Error, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def generate_slides(deck)
|
|
33
|
+
content = chat_completion(prompt_for(deck))
|
|
34
|
+
slides_from(content)
|
|
35
|
+
end
|
|
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)
|
|
41
|
+
content = chat_completion(lint_prompt_for(slide_texts))
|
|
42
|
+
findings_from(content)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def prompt_for(deck)
|
|
48
|
+
<<~PROMPT
|
|
49
|
+
You are an assistant that designs presentation slide outlines.
|
|
50
|
+
Topic: #{deck.topic}
|
|
51
|
+
Duration: #{deck.duration}
|
|
52
|
+
Audience: #{deck.audience}
|
|
53
|
+
Goal: #{deck.goal}
|
|
54
|
+
|
|
55
|
+
Return exactly 5 slides as a JSON array. Each item must be an object with
|
|
56
|
+
a "title" string and a "bullets" array of 2-4 short strings.
|
|
57
|
+
Respond with the JSON array only: no commentary, no markdown code fences,
|
|
58
|
+
and no reasoning or thinking content before or after it.
|
|
59
|
+
PROMPT
|
|
60
|
+
end
|
|
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)
|
|
97
|
+
numbered = slide_texts.each_with_index.map { |text, i| "--- Slide #{i + 1} ---\n#{text}" }.join("\n\n")
|
|
98
|
+
format(LINT_PROMPT_TEMPLATE, numbered: numbered)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def findings_from(content)
|
|
102
|
+
parsed = JSON.parse(extract_json_array(content))
|
|
103
|
+
raise Error, "expected a JSON array of findings" unless parsed.is_a?(Array)
|
|
104
|
+
|
|
105
|
+
parsed.map { |item| finding_from(item) }
|
|
106
|
+
rescue JSON::ParserError, KeyError, ArgumentError, TypeError => e
|
|
107
|
+
raise Error, "could not parse model response: #{e.message}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def finding_from(item)
|
|
111
|
+
Lint::Finding.new(
|
|
112
|
+
slide: Integer(item.fetch("slide")),
|
|
113
|
+
severity: normalize_severity(item["severity"]),
|
|
114
|
+
message: item.fetch("message")
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def normalize_severity(value)
|
|
119
|
+
%w[warning info].include?(value) ? value : "info"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def chat_completion(prompt)
|
|
123
|
+
response = JSON.parse(post_chat_completion(prompt))
|
|
124
|
+
content = response.dig("choices", 0, "message", "content")
|
|
125
|
+
raise Error, "empty response from model" if content.to_s.strip.empty?
|
|
126
|
+
|
|
127
|
+
content
|
|
128
|
+
rescue JSON::ParserError => e
|
|
129
|
+
raise Error, "could not parse model response: #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def post_chat_completion(prompt)
|
|
133
|
+
uri = endpoint_uri("chat/completions")
|
|
134
|
+
request = build_request(uri, prompt)
|
|
135
|
+
response = perform_request(uri, request)
|
|
136
|
+
|
|
137
|
+
raise Error, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
138
|
+
|
|
139
|
+
response.body
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def perform_request(uri, request)
|
|
143
|
+
Net::HTTP.start(uri.host, uri.port,
|
|
144
|
+
use_ssl: uri.scheme == "https",
|
|
145
|
+
open_timeout: 5,
|
|
146
|
+
read_timeout: 30,
|
|
147
|
+
write_timeout: 30) do |http|
|
|
148
|
+
http.request(request)
|
|
149
|
+
end
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
raise Error, e.message
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def endpoint_uri(path)
|
|
155
|
+
base = @base_url.to_s.sub(%r{/+\z}, "")
|
|
156
|
+
URI.join("#{base}/", path)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def build_request(uri, prompt)
|
|
160
|
+
request = Net::HTTP::Post.new(uri)
|
|
161
|
+
request["Content-Type"] = "application/json"
|
|
162
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
163
|
+
request.body = JSON.generate(
|
|
164
|
+
model: @model,
|
|
165
|
+
messages: [{ role: "user", content: prompt }],
|
|
166
|
+
temperature: 0.7
|
|
167
|
+
)
|
|
168
|
+
request
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def slides_from(content)
|
|
172
|
+
parsed = JSON.parse(extract_json_array(content))
|
|
173
|
+
raise Error, "expected a JSON array of slides" unless parsed.is_a?(Array)
|
|
174
|
+
|
|
175
|
+
parsed.map do |item|
|
|
176
|
+
Slide.new(title: item.fetch("title"), bullets: Array(item.fetch("bullets")))
|
|
177
|
+
end
|
|
178
|
+
rescue JSON::ParserError, KeyError => e
|
|
179
|
+
raise Error, "could not parse model response: #{e.message}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Some models (especially reasoning models served through LM Studio or
|
|
183
|
+
# Ollama) prepend or append thinking/reasoning text around the JSON
|
|
184
|
+
# answer instead of returning it verbatim, so the array is extracted from
|
|
185
|
+
# within the raw content rather than parsed as-is.
|
|
186
|
+
def extract_json_array(content)
|
|
187
|
+
content.enum_for(:scan, /\[/).each do
|
|
188
|
+
start = Regexp.last_match.begin(0)
|
|
189
|
+
candidate = json_array_from(content, start)
|
|
190
|
+
return candidate if candidate
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
raise Error, "no JSON array found in model response"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def json_array_from(content, start)
|
|
197
|
+
finish = start
|
|
198
|
+
while (finish = content.index("]", finish))
|
|
199
|
+
candidate = content[start..finish]
|
|
200
|
+
return candidate if parses_to_array?(candidate)
|
|
201
|
+
|
|
202
|
+
finish += 1
|
|
203
|
+
end
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def parses_to_array?(candidate)
|
|
208
|
+
JSON.parse(candidate).is_a?(Array)
|
|
209
|
+
rescue JSON::ParserError
|
|
210
|
+
false
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slidict
|
|
4
|
+
module Output
|
|
5
|
+
# Single source of truth for everything that varies per output framework:
|
|
6
|
+
# the output file extension, the body_format sent to the slidict.io API,
|
|
7
|
+
# and (for markdown-based frameworks) the frontmatter block to render.
|
|
8
|
+
# Add a new framework by adding one entry to REGISTRY.
|
|
9
|
+
class Format
|
|
10
|
+
Definition = Struct.new(:name, :extension, :body_format, :frontmatter, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
DEFAULT_NAME = "slidev"
|
|
13
|
+
|
|
14
|
+
REGISTRY = {
|
|
15
|
+
"slidev" => Definition.new(
|
|
16
|
+
name: "slidev",
|
|
17
|
+
extension: ".md",
|
|
18
|
+
body_format: "markdown",
|
|
19
|
+
frontmatter: "theme: default\nclass: text-center"
|
|
20
|
+
),
|
|
21
|
+
"marp" => Definition.new(
|
|
22
|
+
name: "marp",
|
|
23
|
+
extension: ".md",
|
|
24
|
+
body_format: "markdown",
|
|
25
|
+
frontmatter: "marp: true\ntheme: default"
|
|
26
|
+
),
|
|
27
|
+
"asciidoctor-revealjs" => Definition.new(
|
|
28
|
+
name: "asciidoctor-revealjs",
|
|
29
|
+
extension: ".adoc",
|
|
30
|
+
body_format: "asciidoc",
|
|
31
|
+
frontmatter: nil
|
|
32
|
+
)
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
def self.fetch(name)
|
|
36
|
+
REGISTRY.fetch(name.to_s.downcase, REGISTRY.fetch(DEFAULT_NAME))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.names
|
|
40
|
+
REGISTRY.keys
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slidict
|
|
4
|
+
module Output
|
|
5
|
+
class Renderer
|
|
6
|
+
def render(deck)
|
|
7
|
+
return render_asciidoctor_revealjs(deck) if Format.fetch(deck.framework).body_format == "asciidoc"
|
|
8
|
+
|
|
9
|
+
[frontmatter(deck.framework), deck.slides.map { |slide| render_slide(slide) }.join("\n---\n\n")].join("\n")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def frontmatter(framework)
|
|
15
|
+
body = Format.fetch(framework).frontmatter
|
|
16
|
+
"---\n#{body}\ngenerated: #{Time.now.utc.iso8601}\n---\n"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render_slide(slide)
|
|
20
|
+
lines = ["# #{slide.title}", ""]
|
|
21
|
+
lines.concat(slide.bullets.map { |bullet| "- #{bullet}" })
|
|
22
|
+
lines.join("\n")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def render_asciidoctor_revealjs(deck)
|
|
26
|
+
lines = ["= #{deck.topic}", ":revealjs_theme: white", ":slidict_generated: #{Time.now.utc.iso8601}", ""]
|
|
27
|
+
lines.concat(deck.slides.flat_map { |slide| render_asciidoctor_slide(slide) })
|
|
28
|
+
lines.join("\n")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def render_asciidoctor_slide(slide)
|
|
32
|
+
["== #{slide.title}", "", *slide.bullets.map { |bullet| "* #{bullet}" }, ""]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/slidict/version.rb
CHANGED
data/lib/slidict.rb
CHANGED
|
@@ -2,15 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
4
|
|
|
5
|
-
require_relative "slidict/
|
|
6
|
-
require_relative "slidict/cli"
|
|
5
|
+
require_relative "slidict/cli/app"
|
|
6
|
+
require_relative "slidict/cli/lint"
|
|
7
|
+
require_relative "slidict/cli/serve"
|
|
8
|
+
require_relative "slidict/cli/slides"
|
|
7
9
|
require_relative "slidict/config"
|
|
8
|
-
require_relative "slidict/credentials"
|
|
9
10
|
require_relative "slidict/deck"
|
|
10
|
-
require_relative "slidict/
|
|
11
|
-
require_relative "slidict/
|
|
12
|
-
require_relative "slidict/
|
|
13
|
-
require_relative "slidict/
|
|
11
|
+
require_relative "slidict/external/slidict_io/auth"
|
|
12
|
+
require_relative "slidict/external/slidict_io/client"
|
|
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"
|
|
18
|
+
require_relative "slidict/llm/client"
|
|
19
|
+
require_relative "slidict/output/format"
|
|
20
|
+
require_relative "slidict/output/renderer"
|
|
14
21
|
require_relative "slidict/version"
|
|
15
22
|
|
|
16
23
|
module Slidict
|