substrate 0.1.0 → 0.3.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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/lib/substrate/classify.rb +65 -0
  3. data/lib/substrate/cli/availability.rb +11 -0
  4. data/lib/substrate/cli/error.rb +7 -0
  5. data/lib/substrate/cli/runner.rb +31 -0
  6. data/lib/substrate/command.rb +24 -0
  7. data/lib/substrate/configuration.rb +21 -0
  8. data/lib/substrate/detection/path_resolver.rb +33 -0
  9. data/lib/substrate/distill.rb +31 -0
  10. data/lib/substrate/extract_pdf.rb +133 -0
  11. data/lib/substrate/handlers/article.rb +39 -0
  12. data/lib/substrate/handlers/pdf.rb +29 -0
  13. data/lib/substrate/handlers/raw_text.rb +22 -0
  14. data/lib/substrate/handlers/text_file.rb +26 -0
  15. data/lib/substrate/handlers/transcript.rb +48 -0
  16. data/lib/substrate/mediums/pdf/box.rb +9 -0
  17. data/lib/substrate/mediums/pdf/chapter.rb +9 -0
  18. data/lib/substrate/mediums/pdf/content.rb +9 -0
  19. data/lib/substrate/mediums/pdf/destination.rb +9 -0
  20. data/lib/substrate/mediums/pdf/destination_resolver.rb +131 -0
  21. data/lib/substrate/mediums/pdf/document.rb +46 -0
  22. data/lib/substrate/mediums/pdf/encrypted_pdf.rb +9 -0
  23. data/lib/substrate/mediums/pdf/entry.rb +9 -0
  24. data/lib/substrate/mediums/pdf/invalid_pdf.rb +9 -0
  25. data/lib/substrate/mediums/pdf/invalid_text.rb +9 -0
  26. data/lib/substrate/mediums/pdf/limit_exceeded.rb +17 -0
  27. data/lib/substrate/mediums/pdf/limits.rb +21 -0
  28. data/lib/substrate/mediums/pdf/named_destinations.rb +94 -0
  29. data/lib/substrate/mediums/pdf/no_selectable_text.rb +9 -0
  30. data/lib/substrate/mediums/pdf/outline.rb +9 -0
  31. data/lib/substrate/mediums/pdf/outline_facts.rb +115 -0
  32. data/lib/substrate/mediums/pdf/outline_parser.rb +67 -0
  33. data/lib/substrate/mediums/pdf/page.rb +9 -0
  34. data/lib/substrate/mediums/pdf/page_extractor.rb +28 -0
  35. data/lib/substrate/mediums/pdf/reader.rb +40 -0
  36. data/lib/substrate/mediums/pdf/text_extractor.rb +36 -0
  37. data/lib/substrate/primitives/article_content.rb +10 -0
  38. data/lib/substrate/primitives/extraction.rb +5 -0
  39. data/lib/substrate/primitives/metadata.rb +21 -0
  40. data/lib/substrate/primitives/page_collection.rb +5 -0
  41. data/lib/substrate/primitives/pdf_file.rb +5 -0
  42. data/lib/substrate/primitives/raw_text.rb +5 -0
  43. data/lib/substrate/primitives/source.rb +5 -0
  44. data/lib/substrate/primitives/text_content.rb +5 -0
  45. data/lib/substrate/primitives/text_file.rb +5 -0
  46. data/lib/substrate/primitives/transcripts/content.rb +11 -0
  47. data/lib/substrate/primitives/transcripts/metadata.rb +25 -0
  48. data/lib/substrate/primitives/transcripts/segment.rb +25 -0
  49. data/lib/substrate/primitives/transcripts/word.rb +23 -0
  50. data/lib/substrate/primitives/url_candidate.rb +13 -0
  51. data/lib/substrate/source_ref.rb +5 -0
  52. data/lib/substrate/sources/article.rb +34 -0
  53. data/lib/substrate/sources/audio.rb +57 -0
  54. data/lib/substrate/sources/pdf.rb +35 -0
  55. data/lib/substrate/sources/text.rb +23 -0
  56. data/lib/substrate/sources/url.rb +36 -0
  57. data/lib/substrate/transcribers/deepgram/client.rb +70 -0
  58. data/lib/substrate/transcribers/deepgram/configuration.rb +41 -0
  59. data/lib/substrate/transcribers/deepgram/response_parser.rb +102 -0
  60. data/lib/substrate/transcribers/deepgram.rb +45 -0
  61. data/lib/substrate/transcribers/whisper_cpp/response_parser.rb +66 -0
  62. data/lib/substrate/transcribers/whisper_cpp.rb +87 -0
  63. data/lib/substrate/transcripts/select_time_range.rb +59 -0
  64. data/lib/substrate/url_resolvers/generic.rb +71 -0
  65. data/lib/substrate/url_resolvers/resolver_for.rb +35 -0
  66. data/lib/substrate/version.rb +1 -1
  67. data/lib/substrate.rb +16 -3
  68. metadata +107 -1
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ Metadata = Data.define(
5
+ :requested_url,
6
+ :final_url,
7
+ :redirected,
8
+ :content_type,
9
+ :extractor
10
+ ) do
11
+ def initialize(
12
+ requested_url:,
13
+ final_url: nil,
14
+ redirected: false,
15
+ content_type: nil,
16
+ extractor: nil
17
+ )
18
+ super
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ PageCollection = Data.define(:source_ref, :pages)
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ PdfFile = Data.define(:path)
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ RawText = Data.define(:text)
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ Source = Data.define(:kind, :locator, :metadata)
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ TextContent = Data.define(:source_ref, :text)
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ TextFile = Data.define(:path)
5
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Transcripts
5
+ Content = Data.define(
6
+ :source_ref,
7
+ :segments,
8
+ :metadata
9
+ )
10
+ end
11
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Transcripts
5
+ Metadata = Data.define(
6
+ :provider,
7
+ :model,
8
+ :language,
9
+ :duration_seconds,
10
+ :diarized,
11
+ :word_timestamps
12
+ ) do
13
+ def initialize(
14
+ provider:,
15
+ model:,
16
+ language: nil,
17
+ duration_seconds: nil,
18
+ diarized: false,
19
+ word_timestamps: false
20
+ )
21
+ super
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Transcripts
5
+ Segment = Data.define(
6
+ :text,
7
+ :start,
8
+ :end,
9
+ :confidence,
10
+ :speaker,
11
+ :words
12
+ ) do
13
+ def initialize(
14
+ text:,
15
+ start:,
16
+ end:,
17
+ confidence: nil,
18
+ speaker: nil,
19
+ words: []
20
+ )
21
+ super
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Transcripts
5
+ Word = Data.define(
6
+ :text,
7
+ :start,
8
+ :end,
9
+ :confidence,
10
+ :speaker
11
+ ) do
12
+ def initialize(
13
+ text:,
14
+ start:,
15
+ end:,
16
+ confidence: nil,
17
+ speaker: nil
18
+ )
19
+ super
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ UrlCandidate = Data.define(
5
+ :id,
6
+ :kind,
7
+ :source,
8
+ :rank,
9
+ :confidence,
10
+ :label,
11
+ :metadata
12
+ )
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ SourceRef = Data.define(:kind, :locator)
5
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Sources
5
+ class Article
6
+ def initialize(url:, text:, title:, metadata:)
7
+ @url = url
8
+ @text = text
9
+ @title = title
10
+ @metadata = metadata
11
+ end
12
+
13
+ def source_ref
14
+ @source_ref ||= SourceRef.new(
15
+ kind: :article,
16
+ locator: metadata.final_url || url
17
+ )
18
+ end
19
+
20
+ def body
21
+ ArticleContent.new(
22
+ source_ref: source_ref,
23
+ text: text,
24
+ title: title,
25
+ metadata: metadata
26
+ )
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :url, :text, :title, :metadata
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Sources
5
+ class Audio
6
+ class TranscriptionError < StandardError; end
7
+
8
+ DEFAULT_PROVIDER = :deepgram
9
+
10
+ def initialize(path, provider: DEFAULT_PROVIDER, **options)
11
+ @path = path
12
+ @provider = provider
13
+ @options = options
14
+ end
15
+
16
+ def source_ref
17
+ @source_ref ||= SourceRef.new(kind: :audio, locator: path)
18
+ end
19
+
20
+ def body
21
+ @body ||= begin
22
+ transcribe
23
+ rescue TranscriptionError => e
24
+ raise TranscriptionError, e.message
25
+ end
26
+ end
27
+
28
+ def select_time_range(range)
29
+ result = Transcripts::SelectTimeRange.call(content: body, range: range)
30
+ raise result.error if result.failure?
31
+
32
+ result.payload
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :path, :provider, :options
38
+
39
+ def transcribe
40
+ provider_class = resolve_provider(provider)
41
+ result = provider_class.call(path: path, **options)
42
+ raise TranscriptionError, result.error.message unless result.success?
43
+
44
+ result.payload
45
+ end
46
+
47
+ def resolve_provider(name)
48
+ case name
49
+ when :deepgram then Transcribers::Deepgram
50
+ when :whisper_cpp then Transcribers::WhisperCpp
51
+ else
52
+ raise ArgumentError, "Unknown transcription provider: #{name}"
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Sources
5
+ class Pdf
6
+ def initialize(path)
7
+ @path = path
8
+ end
9
+
10
+ def source_ref
11
+ @source_ref ||= SourceRef.new(kind: :pdf, locator: path)
12
+ end
13
+
14
+ def chapters
15
+ document.chapters
16
+ end
17
+
18
+ def page_count
19
+ document.page_count
20
+ end
21
+
22
+ def select_pages(range)
23
+ PageCollection.new(source_ref: source_ref, pages: range)
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :path
29
+
30
+ def document
31
+ @document ||= Mediums::Pdf::Document.new(path, source_ref: source_ref)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Sources
5
+ class Text
6
+ def initialize(path)
7
+ @path = path
8
+ end
9
+
10
+ def source_ref
11
+ @source_ref ||= SourceRef.new(kind: :text, locator: path)
12
+ end
13
+
14
+ def body
15
+ TextContent.new(source_ref: source_ref, text: File.read(path))
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :path
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Substrate
6
+ module Sources
7
+ class Url
8
+ class ResolutionError < StandardError; end
9
+
10
+ def initialize(url)
11
+ @url = url
12
+ end
13
+
14
+ def source_ref
15
+ @source_ref ||= SourceRef.new(kind: :url, locator: url)
16
+ end
17
+
18
+ def candidates
19
+ @candidates ||= begin
20
+ result = UrlResolvers::ResolverFor.call(url: url)
21
+ raise ResolutionError, result.error.message unless result.success?
22
+
23
+ result.payload
24
+ end
25
+ end
26
+
27
+ def primary_candidate
28
+ candidates.min_by(&:rank)
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :url
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+
6
+ module Substrate
7
+ module Transcribers
8
+ class Deepgram
9
+ class Client
10
+ class Error < StandardError; end
11
+
12
+ BASE_URL = "https://api.deepgram.com"
13
+ RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
14
+ MAX_RETRIES = 3
15
+ DEFAULT_TIMEOUT = 120
16
+
17
+ def initialize(api_key:, timeout: DEFAULT_TIMEOUT, retry_interval: 1)
18
+ @api_key = api_key
19
+ @connection = build_connection(timeout: timeout, retry_interval: retry_interval)
20
+ end
21
+
22
+ def transcribe(path:, params:)
23
+ response = connection.post("/v1/listen", File.binread(path)) do |req|
24
+ req.headers["Content-Type"] = content_type(path)
25
+ req.params = params
26
+ end
27
+
28
+ raise Error, "API error: #{response.status} #{response.body}" unless response.success?
29
+
30
+ response.body
31
+ rescue Faraday::Error => e
32
+ raise Error, "Request failed: #{e.message}"
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :connection, :api_key
38
+
39
+ def build_connection(timeout:, retry_interval:)
40
+ Faraday.new(url: BASE_URL) do |f|
41
+ f.response :raise_error
42
+ f.response :json, content_type: /\bjson$/
43
+
44
+ f.request :retry,
45
+ max: MAX_RETRIES,
46
+ interval: retry_interval,
47
+ backoff_factor: 2,
48
+ retry_statuses: RETRYABLE_STATUSES
49
+
50
+ f.headers["Authorization"] = "Token #{api_key}"
51
+
52
+ f.options.timeout = timeout
53
+ f.options.open_timeout = timeout / 2
54
+ end
55
+ end
56
+
57
+ def content_type(path)
58
+ case File.extname(path).downcase
59
+ when ".mp3" then "audio/mp3"
60
+ when ".wav" then "audio/wav"
61
+ when ".m4a" then "audio/m4a"
62
+ when ".ogg" then "audio/ogg"
63
+ when ".flac" then "audio/flac"
64
+ else "audio/mp3"
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Transcribers
5
+ class Deepgram
6
+ class Configuration
7
+ attr_reader :api_key, :language, :diarize, :punctuate, :model
8
+
9
+ DEFAULT_MODEL = "nova-2"
10
+
11
+ def initialize(
12
+ language: nil,
13
+ diarize: false,
14
+ punctuate: true,
15
+ model: DEFAULT_MODEL
16
+ )
17
+ @api_key = Substrate::Configuration.deepgram_api_key
18
+ @language = language
19
+ @diarize = !!diarize
20
+ @punctuate = !!punctuate
21
+ @model = model
22
+ end
23
+
24
+ def valid?
25
+ !api_key.to_s.empty?
26
+ end
27
+
28
+ def to_params
29
+ {
30
+ model: model,
31
+ smart_format: true,
32
+ punctuate: punctuate,
33
+ utterances: true,
34
+ language: language,
35
+ diarize: diarize || nil
36
+ }.compact
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Transcribers
5
+ class Deepgram
6
+ class ResponseParser
7
+ class MalformedResponseError < StandardError; end
8
+
9
+ def initialize(response, path:, diarized: false)
10
+ @response = response
11
+ @path = path
12
+ @diarized = diarized
13
+ end
14
+
15
+ def parse
16
+ Transcripts::Content.new(
17
+ source_ref: source_ref,
18
+ segments: segments,
19
+ metadata: metadata
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :response, :path, :diarized
26
+
27
+ def alternatives
28
+ response.dig("results", "channels", 0, "alternatives", 0)
29
+ end
30
+
31
+ def metadata
32
+ Transcripts::Metadata.new(
33
+ provider: :deepgram,
34
+ model: model_name,
35
+ language: response.dig("metadata", "language"),
36
+ duration_seconds: response.dig("metadata", "duration"),
37
+ diarized: diarized,
38
+ word_timestamps: words.any?
39
+ )
40
+ end
41
+
42
+ def model_name
43
+ model_info.values.first&.fetch("arch", nil) || Configuration::DEFAULT_MODEL
44
+ end
45
+
46
+ def segments
47
+ return utterances.map { |utterance| utterance_to_segment(utterance) } if utterances.any?
48
+
49
+ [words_to_segment(alternatives["transcript"], words)]
50
+ end
51
+
52
+ def words
53
+ alternatives["words"] || []
54
+ end
55
+
56
+ def utterance_to_segment(utterance)
57
+ Transcripts::Segment.new(
58
+ text: utterance["transcript"],
59
+ start: utterance["start"],
60
+ end: utterance["end"],
61
+ confidence: utterance["confidence"],
62
+ speaker: utterance["speaker"],
63
+ words: utterance.fetch("words", []).map { |w| word_data_to_word(w) }
64
+ )
65
+ end
66
+
67
+ def words_to_segment(transcript, word_list)
68
+ Transcripts::Segment.new(
69
+ text: transcript,
70
+ start: word_list.first["start"],
71
+ end: word_list.last["end"],
72
+ confidence: nil,
73
+ speaker: nil,
74
+ words: word_list.map { |w| word_data_to_word(w) }
75
+ )
76
+ end
77
+
78
+ def word_data_to_word(word_data)
79
+ Transcripts::Word.new(
80
+ text: word_data["punctuated_word"] || word_data["word"],
81
+ start: word_data["start"],
82
+ end: word_data["end"],
83
+ confidence: word_data["confidence"],
84
+ speaker: word_data["speaker"]
85
+ )
86
+ end
87
+
88
+ def utterances
89
+ response.dig("results", "utterances") || []
90
+ end
91
+
92
+ def model_info
93
+ response.dig("metadata", "model_info") || {}
94
+ end
95
+
96
+ def source_ref
97
+ @source_ref ||= SourceRef.new(kind: :audio, locator: path)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Transcribers
5
+ class Deepgram < Command
6
+ class TranscriptionError < StandardError; end
7
+
8
+ def initialize(path:, **options)
9
+ @path = path
10
+ @config = Configuration.new(**options)
11
+ end
12
+
13
+ def call
14
+ return failure(missing_key_error) unless config.valid?
15
+
16
+ response = client.transcribe(path: path, params: config.to_params)
17
+ content = parser(response).parse
18
+
19
+ success(payload: content)
20
+ rescue Client::Error => e
21
+ failure(transcription_error(e.message))
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :path, :config
27
+
28
+ def client
29
+ @client ||= Client.new(api_key: config.api_key)
30
+ end
31
+
32
+ def parser(response)
33
+ ResponseParser.new(response, path: path, diarized: config.diarize)
34
+ end
35
+
36
+ def missing_key_error
37
+ ArgumentError.new("Deepgram API key required. Configure with Substrate::Configuration.deepgram_api_key = 'your_key'")
38
+ end
39
+
40
+ def transcription_error(message)
41
+ TranscriptionError.new(message)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Transcribers
5
+ class WhisperCpp
6
+ class ResponseParser < Command
7
+ def initialize(output:, original_path:)
8
+ @output = output
9
+ @original_path = original_path
10
+ end
11
+
12
+ def call
13
+ success(payload: parse)
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :output, :original_path
19
+
20
+ def parse
21
+ Transcripts::Content.new(
22
+ source_ref: source_ref,
23
+ segments: segments,
24
+ metadata: metadata
25
+ )
26
+ end
27
+
28
+ def segments
29
+ transcription.map do |segment|
30
+ Transcripts::Segment.new(
31
+ text: segment.fetch("text"),
32
+ start: segment["offsets"]["from"] / 1000.0,
33
+ end: segment["offsets"]["to"] / 1000.0,
34
+ confidence: nil,
35
+ speaker: nil,
36
+ words: []
37
+ )
38
+ end
39
+ end
40
+
41
+ def metadata
42
+ Transcripts::Metadata.new(
43
+ provider: :whisper_cpp,
44
+ model: params.fetch("model", "unknown"),
45
+ language: output.fetch("result", {})["language"] || params["language"],
46
+ duration_seconds: nil,
47
+ diarized: false,
48
+ word_timestamps: false
49
+ )
50
+ end
51
+
52
+ def transcription
53
+ output.fetch("transcription")
54
+ end
55
+
56
+ def params
57
+ output.fetch("params", {})
58
+ end
59
+
60
+ def source_ref
61
+ SourceRef.new(kind: :audio, locator: original_path)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end