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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +44 -0
- data/LICENSE +176 -0
- data/NOTICE +7 -0
- data/README.md +399 -0
- data/lib/laya/agent.rb +324 -0
- data/lib/laya/ask.rb +73 -0
- data/lib/laya/checkpoints.rb +77 -0
- data/lib/laya/common.rb +175 -0
- data/lib/laya/configuration.rb +72 -0
- data/lib/laya/decision.rb +120 -0
- data/lib/laya/email.rb +187 -0
- data/lib/laya/errors.rb +16 -0
- data/lib/laya/hub.rb +206 -0
- data/lib/laya/lang.rb +301 -0
- data/lib/laya/presets.rb +197 -0
- data/lib/laya/py_json.rb +144 -0
- data/lib/laya/question.rb +171 -0
- data/lib/laya/questions.rb +43 -0
- data/lib/laya/result.rb +263 -0
- data/lib/laya/router.rb +353 -0
- data/lib/laya/runtime.rb +79 -0
- data/lib/laya/shortlist.rb +197 -0
- data/lib/laya/tokenizer.rb +101 -0
- data/lib/laya/training.rb +88 -0
- data/lib/laya/util.rb +57 -0
- data/lib/laya/version.rb +8 -0
- data/lib/laya.rb +129 -0
- metadata +110 -0
|
@@ -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
|
data/lib/laya/errors.rb
ADDED
|
@@ -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
|