ruby-laya 0.1.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.
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Laya
6
+ # How this process loads and runs checkpoints.
7
+ #
8
+ # Laya.configure do |config|
9
+ # config.device = "coreml"
10
+ # config.preload = true
11
+ # end
12
+ #
13
+ # Settings apply to the shared client that {Laya.ask} and {Laya::Decision} use. Change them
14
+ # before the first decision; afterwards, call {Laya.reset!} to rebuild the client.
15
+ class Configuration
16
+ # "cpu", "coreml", "cuda", or nil to let the runtime decide.
17
+ attr_accessor :device
18
+ # ONNX Runtime providers, overriding `device` when set.
19
+ attr_accessor :providers
20
+ # Intra-op threads. Nil lets ONNX Runtime choose.
21
+ attr_accessor :threads
22
+ # Hugging Face token, for a private or gated export.
23
+ attr_accessor :token
24
+ # Pin every decision to one checkpoint instead of routing per request.
25
+ attr_accessor :model
26
+ # How many checkpoints stay resident.
27
+ attr_accessor :max_loaded
28
+ # Build every checkpoint at startup rather than on first use.
29
+ attr_accessor :preload
30
+ # A language code, or a callable taking the state, used ahead of the built-in detection.
31
+ attr_accessor :lang_guess
32
+
33
+ def initialize
34
+ @max_loaded = Router::DEFAULT_MAX_LOADED
35
+ @preload = false
36
+ end
37
+
38
+ # The options a Router is built from.
39
+ def router_options
40
+ { device: device, providers: providers, threads: threads, token: token,
41
+ max_loaded: max_loaded, preload: preload, lang_guess: lang_guess }.compact
42
+ end
43
+ end
44
+
45
+ class << self
46
+ # The shared configuration.
47
+ def config
48
+ @config ||= Configuration.new
49
+ end
50
+
51
+ def configure
52
+ yield config
53
+ reset!
54
+ config
55
+ end
56
+
57
+ # The router every decision goes through unless it was given its own.
58
+ def client
59
+ @client_lock ||= Monitor.new
60
+ @client_lock.synchronize { @client ||= Router.new(**config.router_options) }
61
+ end
62
+
63
+ # Drop the shared client, closing whatever it had resident. The next decision rebuilds it.
64
+ def reset!
65
+ @client_lock ||= Monitor.new
66
+ @client_lock.synchronize do
67
+ @client&.close
68
+ @client = nil
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "questions"
4
+
5
+ module Laya
6
+ # A named set of typed questions, declared once and asked many times.
7
+ #
8
+ # class TicketTriage < Laya::Decision
9
+ # choice :department, "Which team should handle this?",
10
+ # billing: "invoices, payments, refunds",
11
+ # technical: "bugs, outages, system errors",
12
+ # other: "everything else"
13
+ #
14
+ # score :urgency, "How urgent is this?",
15
+ # levels: ["not urgent", "soon", "critical deadline"]
16
+ #
17
+ # noul :churn_risk, "Does the customer threaten to cancel?"
18
+ # end
19
+ #
20
+ # triage = TicketTriage.decide(email)
21
+ # triage.department # => #<Laya::Answer::Choice billing 95.9%>
22
+ # triage.department == :billing # => true
23
+ # triage.urgency.score # => 1.36
24
+ # triage.churn_risk? # => true
25
+ #
26
+ # Every question becomes a reader named after it, and every noul also gets a predicate. The
27
+ # underlying {Result}, with its routing and token usage, is on `#result`.
28
+ class Decision
29
+ class << self
30
+ # The questions this decision asks, in declaration order, in the shape the runtime takes.
31
+ def questions
32
+ @questions ||= superclass.respond_to?(:questions) ? superclass.questions.dup : {}
33
+ end
34
+
35
+ # Pin this decision to one checkpoint instead of routing per request.
36
+ #
37
+ # model "multilingual"
38
+ def model(name = nil)
39
+ @model = name unless name.nil?
40
+ @model || (superclass.respond_to?(:model) ? superclass.model : nil)
41
+ end
42
+
43
+ # Register a question already in the runtime's shape. This is what {define} uses, and the
44
+ # way to declare a question whose text is built at load time rather than written out.
45
+ # The key is kept as given, so a question set with string ids answers with string ids and
46
+ # matches what the raw path would return.
47
+ def question(name, definition)
48
+ declare(name, definition)
49
+ define_method("#{name}?") { self[name].true? } if Util.get(definition, "type").to_s == "noul"
50
+ name
51
+ end
52
+
53
+ # A decision class from a question hash, for question sets that are generated or shipped.
54
+ #
55
+ # Triage = Laya::Decision.define(Laya.triage_questions)
56
+ def define(questions, model: nil)
57
+ Class.new(self) do
58
+ self.model(model) if model
59
+ questions.each { |name, definition| question(name, definition) }
60
+ end
61
+ end
62
+
63
+ def choice(name, instructions, criteria = nil, **labels)
64
+ declare(name, Questions.choice(instructions, criteria, **labels))
65
+ end
66
+
67
+ def score(name, instructions, levels:)
68
+ declare(name, Questions.score(instructions, levels))
69
+ end
70
+
71
+ def noul(name, instructions, yes: nil, no: nil)
72
+ declare(name, Questions.noul(instructions, yes: yes, no: no))
73
+ define_method("#{name}?") { self[name].true? }
74
+ end
75
+
76
+ # Ask every question about `state`.
77
+ #
78
+ # `client` is anything answering `predict(state, questions, **options)`: an {Agent}, a
79
+ # {Router}, or your own double in a test. It defaults to the shared client.
80
+ def decide(state, client: nil, **options)
81
+ options[:model] ||= model if model && (client || Laya.client).is_a?(Router)
82
+ result = (client || Laya.client).predict(state, questions, **options)
83
+ new(result)
84
+ end
85
+
86
+ private
87
+
88
+ def declare(name, question)
89
+ questions[name] = question
90
+ define_method(name.to_s) { self[name] }
91
+ name
92
+ end
93
+
94
+ def inherited(subclass)
95
+ super
96
+ subclass.instance_variable_set(:@questions, questions.dup)
97
+ end
98
+ end
99
+
100
+ attr_reader :result
101
+
102
+ def initialize(result)
103
+ @result = result
104
+ end
105
+
106
+ # The answer to one question, by the name it was declared with.
107
+ def [](name)
108
+ result[name]
109
+ end
110
+
111
+ def to_h = result.to_h
112
+ def usage = result.usage
113
+ def routing = result.routing
114
+
115
+ def inspect
116
+ answered = self.class.questions.keys.map { |name| "#{name}=#{self[name]}" }
117
+ "#<#{self.class.name || 'Decision'} #{answered.join(' ')}>"
118
+ end
119
+ end
120
+ end
data/lib/laya/email.rb ADDED
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laya
4
+ # Cleaning and structuring email before a checkpoint reads it.
5
+ #
6
+ # The markers cover English, Portuguese and Spanish mail clients. Quoted history is often a
7
+ # different request than the new message, and it weighs on the answer just as heavily, so it
8
+ # has to go; a footer that survives is harmless by comparison, which is why the cleaning errs
9
+ # towards keeping text.
10
+ module Email
11
+ QUOTE_HEADERS = [
12
+ /\A\s*On .{0,300}wrote:\s*\z/i,
13
+ # "Em resposta ao que você escreveu:" is body text; a client's attribution carries a date.
14
+ /\A\s*Em (?=.*\d).{0,300}escreveu:\s*\z/i,
15
+ /\A\s*El (?=.*\d).{0,300}escribi[óo]:\s*\z/i,
16
+ /\A\s*-{2,}\s*(Original|Forwarded) Message\s*-{2,}/i,
17
+ /\A\s*-{2,}\s*(Mensagem (original|encaminhada)|Mensaje (original|reenviado))\s*-{2,}/i,
18
+ /\A\s*_{8,}\s*\z/,
19
+ /\A\s*From:\s.+\z/i,
20
+ # `De:` also opens ordinary Portuguese and Spanish lines ("De: 10/09 a 15/09"), so this one
21
+ # is only a header when it carries an address.
22
+ /\A\s*De:\s.*[@<]/i
23
+ ].freeze
24
+
25
+ # Gmail wraps a long attribution, leaving `someone@x.com> escreveu:` alone on the next line.
26
+ # That tail cuts too, and takes the `On/Em/El ...` head it belongs to with it.
27
+ ATTRIBUTION_TAIL = /\A.{0,120}\S@\S+\s+(wrote|escreveu|escribi[óo]):\s*\z/i
28
+ ATTRIBUTION_HEAD = /\A\s*(On|Em|El) (?=.*\d)/i
29
+
30
+ # Exchange often leaves the address out of Outlook's reply header ("De: Maria Souza"), so a
31
+ # bare `De:` only cuts when the header's own `Enviado:` or a dated `Data:` line follows it.
32
+ HEADER_FROM_NAME = /\A\s*De:\s+\S/i
33
+ HEADER_NEXT = /\A\s*(Enviad[oa]( em| el)?:\s|(Data|Fecha):\s.*\d{4})/i
34
+
35
+ SIGNATURE_MARKERS = [
36
+ /\A\s*--\s*\z/,
37
+ /\A\s*(best|kind|warm|many thanks|thanks|thank you|regards|cheers|sincerely)[\w ,!.]*\z/i,
38
+ /\A\s*sent from my (iphone|android|mobile|ipad)/i,
39
+ # Portuguese and Spanish sign-offs match only on their own: "Obrigado pelo retorno, mas ..."
40
+ # is a request, so unlike the English marker no trailing words are allowed.
41
+ /\A\s*(atenciosamente|att|abraços?|abs|um abraço|cordialmente|grat[oa]|(muito )?obrigad[oa]s?
42
+ ( desde já| pela atenção)?|(com os melhores )?cumprimentos|saudações|
43
+ (un )?saludos?( cordiales)?|atentamente|(muchas )?gracias( de antemano)?)[\s,!.]*\z/xi
44
+ ].freeze
45
+
46
+ # Mobile and mail-app footers. Only a line that is nothing but the footer matches, and such a
47
+ # line may run to 60 characters: Samsung's default is longer than any sign-off.
48
+ DEVICE = "iphone|ipad|android|ios|celular|telemóvel|móvil|galaxy|smartphone|samsung|tablet|" \
49
+ "outlook|yahoo|mail|e-?mail|gmail|windows"
50
+ DEVICE_FOOTER = Regexp.new(
51
+ "\\A\\s*((enviad[oa] (do|pelo|pela|via|desde|a partir do)( meu| minha| mi)?|sent from( my)?) " \
52
+ "(#{DEVICE})( (#{DEVICE}|para|for|no|na|\\d+))*|(obter o|get) outlook (para|for) (ios|android))[\\s.!]*\\z",
53
+ Regexp::IGNORECASE
54
+ )
55
+
56
+ # Confidentiality footers. The Portuguese and Spanish patterns are tied to "this message"
57
+ # rather than to the bare word `confidencial`, which a sender's own request uses just as
58
+ # often ("preciso do contrato confidencial").
59
+ DISCLAIMER_PARTS = [
60
+ "confidential",
61
+ "intended (solely )?for the (use of the )?(named )?(addressee|recipient)",
62
+ "if you (have )?received this (e-?mail|message) in error",
63
+ "\\b(esta|este) (mensagem|e-?mail|mensaje|correo)\\b[^.]{0,80}(confidencia|sigilos|privilegiad)",
64
+ "\\b(uso exclusivo|exclusivamente|únicamente|unicamente)\\b[^.]{0,30}" \
65
+ "(destinatári|destinatari|pessoa|persona|entidade|entidad)",
66
+ "\\b(recebeu|recebido|receber) (esta|este) (mensagem|e-?mail)\\b[^.]{0,20} por (engano|erro)",
67
+ "\\b(ha recibido|recibió|recibe) (este|esta) (mensaje|correo)\\b[^.]{0,20} por error",
68
+ # the "think before printing" footer, tied to its environmental ending rather than to
69
+ # `antes de imprimir`, which a request uses too ("antes de imprimir o boleto, confira")
70
+ "\\bantes de imprimir\\b[^.]{0,100}(meio ambiente|medio ambiente|natureza|planeta|realmente necess)",
71
+ "\\b(meio|medio) ambiente\\b[^.]{0,30}antes de imprimir"
72
+ ].freeze
73
+ DISCLAIMER = Regexp.new("(#{DISCLAIMER_PARTS.join('|')})", Regexp::IGNORECASE)
74
+
75
+ SENTENCE = /(?<=[.!?])\s+/
76
+
77
+ # A signature marker only counts on a short line; a device footer gets more room.
78
+ SIGNATURE_MAX = 40
79
+ DEVICE_FOOTER_MAX = 60
80
+
81
+ module_function
82
+
83
+ # Remove quoted history, signatures and disclaimers, keeping the sender's own request.
84
+ def clean_email_body(body, max_chars: 3000)
85
+ lines = strip_quoted_history(normalise(body).split("\n", -1))
86
+ lines = lines.first(signature_cut(lines))
87
+ paragraphs = lines.join("\n").split(/\n\s*\n/).map { |paragraph| strip_disclaimer(paragraph) }
88
+ paragraphs.map(&:strip).reject(&:empty?).join("\n\n").gsub(/[ \t]+/, " ")[0, max_chars].to_s
89
+ end
90
+
91
+ def normalise(body)
92
+ (body || "").gsub("\r\n", "\n").gsub("\r", "\n").gsub("\\n", "\n")
93
+ end
94
+
95
+ # Everything up to the first quote header, minus the quoted lines themselves.
96
+ def strip_quoted_history(source)
97
+ kept = []
98
+ source.each_with_index do |line, i|
99
+ break if !kept.empty? && QUOTE_HEADERS.any? { |pattern| line.match?(pattern) }
100
+ break if !kept.empty? && outlook_header?(line, source[i + 1])
101
+
102
+ if ATTRIBUTION_TAIL.match?(line) && !kept.empty?
103
+ kept.pop if ATTRIBUTION_HEAD.match?(kept.last)
104
+ break
105
+ end
106
+ next if line.lstrip.start_with?(">")
107
+
108
+ kept << line.rstrip
109
+ end
110
+ kept
111
+ end
112
+
113
+ def outlook_header?(line, following)
114
+ HEADER_FROM_NAME.match?(line) && following && HEADER_NEXT.match?(following)
115
+ end
116
+
117
+ # Where the signature starts, or the end of the message when there is none. Only the last
118
+ # part of a message is considered, so a "Thanks" opening line is never mistaken for a sign-off.
119
+ def signature_cut(lines)
120
+ first = (lines.length * 0.6).to_i.clamp(1, [lines.length - 8, 1].max)
121
+ (first...lines.length).each do |i|
122
+ length = lines[i].strip.length
123
+ signature = length <= SIGNATURE_MAX && SIGNATURE_MARKERS.any? { |pattern| lines[i].match?(pattern) }
124
+ return i if signature || (length <= DEVICE_FOOTER_MAX && DEVICE_FOOTER.match?(lines[i]))
125
+ end
126
+ lines.length
127
+ end
128
+
129
+ # Drop boilerplate from one paragraph, sentence by sentence.
130
+ #
131
+ # The whole paragraph goes only when every sentence in it is boilerplate. A footer that runs
132
+ # on without a blank line used to take the sender's actual request with it, which is worse
133
+ # than leaving a boilerplate line behind.
134
+ def strip_disclaimer(paragraph)
135
+ return paragraph unless paragraph.match?(DISCLAIMER) # keep the original line structure
136
+
137
+ sentences = paragraph.split(SENTENCE).map(&:strip).reject(&:empty?)
138
+ pieces = sentences.flat_map { |s| s.match?(DISCLAIMER) ? split_fused_lines(s) : [s] }
139
+ pieces.grep_v(DISCLAIMER).join(" ")
140
+ end
141
+
142
+ # Split a boilerplate sentence where a new sentence starts on a new line.
143
+ #
144
+ # An unpunctuated request glued to a disclaimer ("locked\nThis email is...") splits, because
145
+ # the next line starts with a capital; a wrapped continuation ("are\nconfidential") does not,
146
+ # so a wrapped footer still drops whole.
147
+ def split_fused_lines(sentence)
148
+ return [sentence] unless sentence.include?("\n")
149
+
150
+ pieces = []
151
+ current = +""
152
+ sentence.split("\n").each do |line|
153
+ if current.empty? || !starts_new_sentence?(line)
154
+ current << "\n" unless current.empty?
155
+ current << line
156
+ else
157
+ pieces << current
158
+ current = +line
159
+ end
160
+ end
161
+ pieces << current unless current.empty?
162
+ pieces.map(&:strip).reject(&:empty?)
163
+ end
164
+
165
+ # True when the first letter is a capital: a fresh sentence rather than a wrapped line. Lines
166
+ # in uncased scripts never start a new piece, so wrapped boilerplate there drops whole.
167
+ def starts_new_sentence?(line)
168
+ letter = line.each_char.find { |char| char.match?(/\p{L}/) }
169
+ return false unless letter
170
+
171
+ letter == letter.upcase && letter != letter.downcase
172
+ end
173
+
174
+ # A state ready for the email presets: subject, cleaned body, and whatever else you pass.
175
+ def email_state(subject, body, sender: nil, clean: true, **extra)
176
+ state = { "subject" => (subject || "").strip,
177
+ "body" => clean ? clean_email_body(body) : (body || "") }
178
+ state["from"] = sender if sender && !sender.to_s.empty?
179
+ extra.each { |key, value| state[key.to_s] = value unless value.nil? }
180
+ state
181
+ end
182
+
183
+ def email_questions(categories = nil)
184
+ Presets.email_questions(categories)
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laya
4
+ # Base class for every error raised by Laya.
5
+ class Error < StandardError; end
6
+
7
+ # A model path, subfolder or required checkpoint file could not be found.
8
+ class ModelNotFoundError < Error; end
9
+
10
+ # The checkpoint on disk is not a Laya decision model (missing config, weights or a
11
+ # shape that does not match the architecture).
12
+ class IncompatibleModelError < Error; end
13
+
14
+ # A network download from the Hugging Face Hub failed.
15
+ class DownloadError < Error; end
16
+ end
data/lib/laya/hub.rb ADDED
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "fileutils"
7
+
8
+ module Laya
9
+ # Downloads checkpoint files from the Hugging Face Hub into the standard Hugging Face cache, so
10
+ # `HF_HOME`, `HF_HUB_CACHE`, `HF_HUB_OFFLINE` and `HF_TOKEN` all behave as they do for any other
11
+ # Hub client, and a cached snapshot keeps working without a network.
12
+ #
13
+ # Files land in `<cache>/models--<org>--<name>/snapshots/<revision sha>/<path>`, the layout the
14
+ # Hub's own tooling reads.
15
+ module Hub
16
+ MAX_REDIRECTS = 5
17
+ DEFAULT_REVISION = "main"
18
+
19
+ # Reports download progress. Replace it to drive your own progress bar, or set it to nil.
20
+ #
21
+ # Laya::Hub.progress = ->(path, done, total) { ... }
22
+ class << self
23
+ attr_writer :progress
24
+
25
+ def progress
26
+ return @progress if defined?(@progress)
27
+
28
+ @progress = method(:report_progress)
29
+ end
30
+ end
31
+
32
+ module_function
33
+
34
+ def endpoint
35
+ ENV.fetch("HF_ENDPOINT", "https://huggingface.co").sub(%r{/+\z}, "")
36
+ end
37
+
38
+ def offline?
39
+ %w[1 true yes on].include?(ENV.fetch("HF_HUB_OFFLINE", "").downcase)
40
+ end
41
+
42
+ # An empty variable counts as unset, so exporting HF_TOKEN= does not hide the other name.
43
+ def token
44
+ [ENV.fetch("HF_TOKEN", nil), ENV.fetch("HUGGING_FACE_HUB_TOKEN", nil)].find { |value| present?(value) }
45
+ end
46
+
47
+ def cache_dir
48
+ return File.expand_path(ENV["HF_HUB_CACHE"]) if present?(ENV["HF_HUB_CACHE"])
49
+ return File.join(File.expand_path(ENV["HF_HOME"]), "hub") if present?(ENV["HF_HOME"])
50
+
51
+ File.join(Dir.home, ".cache", "huggingface", "hub")
52
+ end
53
+
54
+ def present?(value)
55
+ value && !value.empty?
56
+ end
57
+
58
+ def repo_dir(repo_id, cache_dir: nil)
59
+ File.join(cache_dir || Hub.cache_dir, "models--#{repo_id.gsub('/', '--')}")
60
+ end
61
+
62
+ # Download every file under `subfolder` matching `allow_patterns` and return the local
63
+ # directory holding them.
64
+ #
65
+ # Files already present are not fetched again. When the Hub cannot be reached, the newest
66
+ # cached snapshot is used instead, so an offline process keeps working.
67
+ def snapshot(repo_id, subfolder: nil, allow_patterns: nil, revision: DEFAULT_REVISION,
68
+ token: nil, cache_dir: nil)
69
+ token ||= Hub.token
70
+ root = repo_dir(repo_id, cache_dir: cache_dir)
71
+ sha = resolve_revision(repo_id, revision, token: token, root: root)
72
+ snapshot_dir = File.join(root, "snapshots", sha)
73
+ target = subfolder ? File.join(snapshot_dir, subfolder) : snapshot_dir
74
+
75
+ if sha == cached_revision(root, revision) && offline?
76
+ raise DownloadError, "HF_HUB_OFFLINE is set and #{repo_id} is not cached" unless File.directory?(target)
77
+
78
+ return target
79
+ end
80
+
81
+ prefix = subfolder ? "#{subfolder}/" : ""
82
+ wanted = Array(allow_patterns).map { |pattern| prefix + pattern }
83
+ files = filter(list_files(repo_id, revision: sha, token: token), wanted)
84
+ raise DownloadError, "no files in #{repo_id.inspect} match #{wanted.inspect}" if files.empty?
85
+
86
+ files.each do |path|
87
+ local = File.join(snapshot_dir, path)
88
+ next if File.file?(local) && File.size(local).positive?
89
+
90
+ download(repo_id, path, local, revision: sha, token: token)
91
+ end
92
+ write_ref(root, revision, sha)
93
+ target
94
+ end
95
+
96
+ # The commit the revision points at, or the cached one when the Hub is unreachable.
97
+ def resolve_revision(repo_id, revision, token: nil, root: nil)
98
+ return cached_revision!(root, revision, repo_id) if offline?
99
+
100
+ uri = URI("#{endpoint}/api/models/#{repo_id}/revision/#{URI.encode_www_form_component(revision)}")
101
+ sha = JSON.parse(get(uri, token: token)).fetch("sha")
102
+ raise DownloadError, "#{repo_id} revision #{revision.inspect} has no commit sha" unless sha
103
+
104
+ sha
105
+ rescue JSON::ParserError, KeyError => e
106
+ raise DownloadError, "unexpected response resolving #{repo_id.inspect}: #{e.message}"
107
+ rescue DownloadError => e
108
+ cached = cached_revision(root, revision)
109
+ raise e unless cached
110
+
111
+ warn "[laya] #{e.message}; using the cached snapshot #{cached[0, 7]}"
112
+ cached
113
+ end
114
+
115
+ def cached_revision(root, revision)
116
+ ref = File.join(root.to_s, "refs", revision.to_s)
117
+ File.file?(ref) ? File.read(ref).strip : nil
118
+ end
119
+
120
+ def cached_revision!(root, revision, repo_id)
121
+ cached_revision(root, revision) ||
122
+ raise(DownloadError, "HF_HUB_OFFLINE is set and #{repo_id} is not cached in #{root}")
123
+ end
124
+
125
+ def write_ref(root, revision, sha)
126
+ ref = File.join(root, "refs", revision.to_s)
127
+ FileUtils.mkdir_p(File.dirname(ref))
128
+ File.write(ref, sha)
129
+ end
130
+
131
+ def list_files(repo_id, revision: DEFAULT_REVISION, token: nil)
132
+ uri = URI("#{endpoint}/api/models/#{repo_id}/revision/#{URI.encode_www_form_component(revision)}")
133
+ JSON.parse(get(uri, token: token)).fetch("siblings", []).map { |sibling| sibling["rfilename"] }
134
+ rescue JSON::ParserError => e
135
+ raise DownloadError, "unexpected response listing #{repo_id.inspect}: #{e.message}"
136
+ end
137
+
138
+ # Hub glob semantics: `*` matches across path separators too.
139
+ def filter(files, patterns)
140
+ return files if patterns.nil? || patterns.empty?
141
+
142
+ files.select { |file| patterns.any? { |pattern| File.fnmatch(pattern, file, File::FNM_DOTMATCH) } }
143
+ end
144
+
145
+ def download(repo_id, path, local, revision: DEFAULT_REVISION, token: nil)
146
+ uri = URI("#{endpoint}/#{repo_id}/resolve/#{URI.encode_www_form_component(revision)}/#{path}")
147
+ FileUtils.mkdir_p(File.dirname(local))
148
+ partial = "#{local}.incomplete"
149
+ done = 0
150
+ File.open(partial, "wb") do |file|
151
+ get(uri, token: token) do |chunk, total|
152
+ file.write(chunk)
153
+ done += chunk.bytesize
154
+ Hub.progress&.call(path, done, total)
155
+ end
156
+ end
157
+ File.rename(partial, local)
158
+ local
159
+ rescue StandardError => e
160
+ FileUtils.rm_f(partial.to_s)
161
+ raise e
162
+ end
163
+
164
+ def report_progress(path, done, total)
165
+ return unless $stderr.tty?
166
+
167
+ percent = total.to_i.positive? ? format(" %3d%%", 100 * done / total) : ""
168
+ $stderr.print(format("\r[laya] %s%s %.0f MB", File.basename(path), percent, done / 1e6))
169
+ $stderr.print("\n") if total.to_i.positive? && done >= total
170
+ end
171
+
172
+ # GET with redirects, streaming to the block when one is given.
173
+ def get(uri, token: nil, redirects: 0, &block)
174
+ raise DownloadError, "too many redirects for #{uri}" if redirects > MAX_REDIRECTS
175
+
176
+ request = Net::HTTP::Get.new(uri)
177
+ request["User-Agent"] = "ruby-laya/#{Laya::VERSION}"
178
+ request["Authorization"] = "Bearer #{token}" if token && uri.host == URI(endpoint).host
179
+
180
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
181
+ open_timeout: 30, read_timeout: 300) do |http|
182
+ http.request(request) do |response|
183
+ case response
184
+ when Net::HTTPRedirection
185
+ return get(URI.join(uri, response["location"]), token: token, redirects: redirects + 1, &block)
186
+ when Net::HTTPSuccess
187
+ return response.body unless block
188
+
189
+ total = response["content-length"].to_i
190
+ response.read_body { |chunk| block.call(chunk, total) }
191
+ return nil
192
+ when Net::HTTPUnauthorized, Net::HTTPForbidden
193
+ raise DownloadError, "access denied for #{uri} (HTTP #{response.code}); " \
194
+ "set HF_TOKEN for gated or private repositories"
195
+ when Net::HTTPNotFound
196
+ raise DownloadError, "not found: #{uri} (HTTP 404)"
197
+ else
198
+ raise DownloadError, "HTTP #{response.code} fetching #{uri}"
199
+ end
200
+ end
201
+ end
202
+ rescue SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, IOError => e
203
+ raise DownloadError, "could not fetch #{uri}: #{e.class}: #{e.message}"
204
+ end
205
+ end
206
+ end