tina4ruby 3.13.99 → 3.13.101
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/CHANGELOG.md +34 -0
- data/README.md +12 -0
- data/lib/tina4/ai_client.rb +310 -0
- data/lib/tina4/cli.rb +0 -106
- data/lib/tina4/dev_admin.rb +0 -2
- data/lib/tina4/frond.rb +253 -25
- data/lib/tina4/metrics.rb +65 -374
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9102cf347865953d60e7d47b1ead3b87e25f139f1439a19e5f230ec31594c32a
|
|
4
|
+
data.tar.gz: 7a5323f6a9d26e41852269c63d76fac619a59b549e634aad6a56bdc887dcb780
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b0d72050d8db6db2ca5a1c621c6212848ef094ab84a3718b32aa06d53912c74c0324973374b1dafc6dd43d64b52dd34cb574a806fe79f034ac8757c0400db1cb
|
|
7
|
+
data.tar.gz: b13a9d5139f751391a684e28858bc894268fbb511ca192615eb48181dda59784faddcebcbdf6ae74aefb08736a53e9e356d432bc0ec4f6b69b50132c75a6e3f3
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,40 @@ number means the same thing everywhere.
|
|
|
6
6
|
**The authoritative release notes for every shipped version live in the documentation:**
|
|
7
7
|
https://tina4.com/ruby/36-releases
|
|
8
8
|
|
|
9
|
+
## 3.13.101
|
|
10
|
+
|
|
11
|
+
### Breaking: metrics has one owner
|
|
12
|
+
|
|
13
|
+
- Remove the framework `metrics` command and local quick census. Use the native `tina4 metrics` CLI.
|
|
14
|
+
- Keep dev-admin metrics as a thin `/metrics/full` and `/metrics/file` JSON handoff to that CLI.
|
|
15
|
+
|
|
16
|
+
### App-facing AI client
|
|
17
|
+
|
|
18
|
+
- Add zero-dependency `Tina4::Ai.chat`, `Tina4::Ai.complete`, and `Tina4::Ai.embed`.
|
|
19
|
+
- Support local/OpenAI-compatible, OpenAI, and Anthropic chat providers.
|
|
20
|
+
- Normalize chat responses, stream ordered deltas, and preserve embedding cardinality.
|
|
21
|
+
- Fail closed on missing hosted-provider keys, verify TLS, redact sensitive failures, and
|
|
22
|
+
distinguish bounded connection and total-request timeouts.
|
|
23
|
+
- Retry only transient connection, HTTP 429, and HTTP 5xx failures, never a partial stream.
|
|
24
|
+
|
|
25
|
+
## 3.13.100
|
|
26
|
+
|
|
27
|
+
### Breaking: Frond instance extensions stay local
|
|
28
|
+
|
|
29
|
+
Calling `add_filter`, `add_global`, or `add_test` on a Frond instance now changes
|
|
30
|
+
that renderer only. Register on `Frond` itself when every later instance must
|
|
31
|
+
inherit the extension.
|
|
32
|
+
|
|
33
|
+
- Reject a second `{% extends %}` tag instead of replacing the first parent without warning.
|
|
34
|
+
- Resolve multi-level inheritance without recursing through the same child template.
|
|
35
|
+
- Preserve nested root blocks through a depth-aware final substitution pass.
|
|
36
|
+
- Bound template, fragment, and expression caches, with TTL sweeps for stale entries.
|
|
37
|
+
- Retry transient AI skill-download failures.
|
|
38
|
+
- Activate the tina4-js skill for `tina4js` and `Tina4 JS` spellings as well as `tina4-js`.
|
|
39
|
+
- Keep `Tina4::VERSION` and both AI-facing guide markers on one version.
|
|
40
|
+
|
|
41
|
+
## 3.13.99
|
|
42
|
+
|
|
9
43
|
### Breaking: `request.params` is route-params-only, and `path_params` is renamed `params`
|
|
10
44
|
|
|
11
45
|
Ruby had the worst version of the param-pollution bug: `params` used to merge the query
|
data/README.md
CHANGED
|
@@ -68,6 +68,18 @@ db = Tina4::Database.new("sqlite://app.db")
|
|
|
68
68
|
|
|
69
69
|
**2,508 tests. Zero runtime dependencies. Full parity across Python, PHP, Ruby, and Node.js.**
|
|
70
70
|
|
|
71
|
+
### AI Client
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
reply = Tina4::Ai.chat([{ role: "user", content: "Summarise this text" }])
|
|
75
|
+
text = Tina4::Ai.complete("Give me a title")
|
|
76
|
+
vector = Tina4::Ai.embed("semantic search text")
|
|
77
|
+
|
|
78
|
+
Tina4::Ai.chat([{ role: "user", content: "Stream this" }], stream: true).each { |delta| print delta }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Configure `TINA4_AI_PROVIDER` as `local`, `openai`, or `anthropic`. Hosted providers require `TINA4_AI_KEY`; local OpenAI-compatible endpoints do not.
|
|
82
|
+
|
|
71
83
|
---
|
|
72
84
|
|
|
73
85
|
## CLI Reference
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Tina4
|
|
8
|
+
class AiError < StandardError; end
|
|
9
|
+
class AiConfigError < AiError; end
|
|
10
|
+
class AiTimeoutError < AiError; end
|
|
11
|
+
class AiParseError < AiError; end
|
|
12
|
+
|
|
13
|
+
class AiHTTPError < AiError
|
|
14
|
+
attr_reader :status
|
|
15
|
+
|
|
16
|
+
def initialize(message, status = nil)
|
|
17
|
+
super(message)
|
|
18
|
+
@status = status
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
ChatResponse = Struct.new(:text, :model, :usage, :finish_reason, :raw, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
# Zero-dependency app-facing AI client (ADR-0053).
|
|
25
|
+
class Ai
|
|
26
|
+
PROVIDERS = %w[local openai anthropic].freeze
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
def chat(messages, model: nil, temperature: nil, max_tokens: nil, stream: false, timeout: nil, provider: nil)
|
|
30
|
+
validate_messages(messages)
|
|
31
|
+
config = resolve_config("chat", model, timeout, provider)
|
|
32
|
+
body = chat_body(config, messages, temperature, max_tokens, stream)
|
|
33
|
+
return stream_request(config, headers(config), body) if stream
|
|
34
|
+
|
|
35
|
+
normalize_chat(config[:provider], request_json(config, headers(config), body))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def complete(prompt, **options)
|
|
39
|
+
raise AiConfigError, "AI prompt must be a string" unless prompt.is_a?(String)
|
|
40
|
+
|
|
41
|
+
options.delete(:stream)
|
|
42
|
+
chat([{ role: "user", content: prompt }], **options, stream: false).text
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def embed(text_or_texts, model: nil, timeout: nil, provider: nil)
|
|
46
|
+
single = text_or_texts.is_a?(String)
|
|
47
|
+
valid_batch = text_or_texts.is_a?(Array) && !text_or_texts.empty? && text_or_texts.all? { |item| item.is_a?(String) }
|
|
48
|
+
raise AiConfigError, "AI embedding input must be a string or a non-empty list of strings" unless single || valid_batch
|
|
49
|
+
|
|
50
|
+
config = resolve_config("embed", model, timeout, provider)
|
|
51
|
+
raise AiConfigError, "Anthropic does not provide the embedding endpoint in this contract" if config[:provider] == "anthropic"
|
|
52
|
+
|
|
53
|
+
raw = request_json(config, headers(config), { model: config[:model], input: text_or_texts })
|
|
54
|
+
begin
|
|
55
|
+
data = raw.fetch("data").sort_by { |item| item.fetch("index", 0) }
|
|
56
|
+
vectors = data.map { |item| item.fetch("embedding") }
|
|
57
|
+
expected = single ? 1 : text_or_texts.length
|
|
58
|
+
valid = vectors.length == expected && vectors.all? do |vector|
|
|
59
|
+
vector.is_a?(Array) && !vector.empty? && vector.all? { |value| value.is_a?(Numeric) }
|
|
60
|
+
end
|
|
61
|
+
raise KeyError unless valid
|
|
62
|
+
rescue KeyError, TypeError
|
|
63
|
+
raise AiParseError, "AI provider returned a malformed embedding response"
|
|
64
|
+
end
|
|
65
|
+
single ? vectors.first : vectors
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def validate_messages(messages)
|
|
71
|
+
valid = messages.is_a?(Array) && !messages.empty? && messages.all? do |message|
|
|
72
|
+
message.is_a?(Hash) && %w[system user assistant].include?((message[:role] || message["role"]).to_s) &&
|
|
73
|
+
(message.key?(:content) ? message[:content] : message["content"]).is_a?(String)
|
|
74
|
+
end
|
|
75
|
+
raise AiConfigError, "AI messages must contain supported roles and string content" unless valid
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def number(name, default, minimum)
|
|
79
|
+
value = Float(ENV.fetch(name, default.to_s))
|
|
80
|
+
raise AiConfigError, "#{name} must be at least #{minimum}" if value < minimum
|
|
81
|
+
|
|
82
|
+
value
|
|
83
|
+
rescue ArgumentError, TypeError
|
|
84
|
+
raise AiConfigError, "#{name} must be numeric"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def resolve_config(capability, model, timeout, provider)
|
|
88
|
+
selected = (provider || ENV["TINA4_AI_PROVIDER"] || "local").strip.downcase
|
|
89
|
+
raise AiConfigError, "TINA4_AI_PROVIDER must be local, openai, or anthropic" unless PROVIDERS.include?(selected)
|
|
90
|
+
|
|
91
|
+
key = ENV["TINA4_AI_KEY"]
|
|
92
|
+
if %w[openai anthropic].include?(selected) && (key.nil? || key.empty?)
|
|
93
|
+
raise AiConfigError, "TINA4_AI_KEY is required for the #{selected} provider"
|
|
94
|
+
end
|
|
95
|
+
defaults = {
|
|
96
|
+
"local" => ["http://localhost:11437", "llama3.2"],
|
|
97
|
+
"openai" => ["https://api.openai.com/v1", "gpt-4o-mini"],
|
|
98
|
+
"anthropic" => ["https://api.anthropic.com/v1", "claude-3-5-haiku-latest"]
|
|
99
|
+
}
|
|
100
|
+
value = capability == "embed" && ENV["TINA4_EMBED_URL"] ? ENV["TINA4_EMBED_URL"] : (ENV["TINA4_AI_URL"] || defaults[selected][0])
|
|
101
|
+
total = timeout.nil? ? number("TINA4_AI_TIMEOUT", 60, 0.001) : Float(timeout)
|
|
102
|
+
raise AiConfigError, "AI timeout must be greater than zero" unless total.positive?
|
|
103
|
+
|
|
104
|
+
chosen_model = (model || ENV["TINA4_AI_MODEL"] || defaults[selected][1]).to_s.strip
|
|
105
|
+
raise AiConfigError, "AI model must be a non-empty string" if chosen_model.empty?
|
|
106
|
+
|
|
107
|
+
{
|
|
108
|
+
provider: selected,
|
|
109
|
+
url: endpoint(value, capability, selected),
|
|
110
|
+
model: chosen_model,
|
|
111
|
+
key: key,
|
|
112
|
+
total_timeout: total,
|
|
113
|
+
connect_timeout: number("TINA4_AI_CONNECT_TIMEOUT", 10, 0.001),
|
|
114
|
+
max_retries: number("TINA4_AI_MAX_RETRIES", 2, 0).to_i
|
|
115
|
+
}
|
|
116
|
+
rescue ArgumentError, TypeError
|
|
117
|
+
raise AiConfigError, "AI timeout must be numeric"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def endpoint(value, capability, provider)
|
|
121
|
+
uri = URI.parse(value)
|
|
122
|
+
raise AiConfigError, "AI URL must be an http or https URL" unless %w[http https].include?(uri.scheme) && uri.host
|
|
123
|
+
|
|
124
|
+
path = uri.path.to_s.sub(%r{/+$}, "")
|
|
125
|
+
if ["", "/v1", "/api"].include?(path)
|
|
126
|
+
suffix = provider == "anthropic" ? "/messages" : (capability == "embed" ? "/embeddings" : "/chat/completions")
|
|
127
|
+
uri.path = (path.empty? ? "/v1" : path) + suffix
|
|
128
|
+
end
|
|
129
|
+
uri.to_s
|
|
130
|
+
rescue URI::InvalidURIError
|
|
131
|
+
raise AiConfigError, "AI URL must be an http or https URL"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def headers(config)
|
|
135
|
+
result = { "Content-Type" => "application/json", "Accept" => "application/json" }
|
|
136
|
+
if config[:provider] == "openai"
|
|
137
|
+
result["Authorization"] = "Bearer #{config[:key]}"
|
|
138
|
+
elsif config[:provider] == "anthropic"
|
|
139
|
+
result["x-api-key"] = config[:key]
|
|
140
|
+
result["anthropic-version"] = "2023-06-01"
|
|
141
|
+
end
|
|
142
|
+
result
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def chat_body(config, messages, temperature, max_tokens, stream)
|
|
146
|
+
normalized = messages.map { |message| { role: (message[:role] || message["role"]).to_s, content: message.key?(:content) ? message[:content] : message["content"] } }
|
|
147
|
+
body = { model: config[:model], messages: normalized, stream: stream }
|
|
148
|
+
body[:temperature] = temperature unless temperature.nil?
|
|
149
|
+
body[:max_tokens] = max_tokens unless max_tokens.nil?
|
|
150
|
+
if config[:provider] == "anthropic"
|
|
151
|
+
system = normalized.select { |message| message[:role] == "system" }.map { |message| message[:content] }
|
|
152
|
+
body[:messages] = normalized.reject { |message| message[:role] == "system" }
|
|
153
|
+
body[:max_tokens] = max_tokens || 1024
|
|
154
|
+
body[:system] = system.join("\n\n") unless system.empty?
|
|
155
|
+
end
|
|
156
|
+
body
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def http_request(config, deadline, request_headers, body)
|
|
160
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
161
|
+
raise AiTimeoutError, "AI total request timeout expired" unless remaining.positive?
|
|
162
|
+
|
|
163
|
+
uri = URI.parse(config[:url])
|
|
164
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
165
|
+
http.use_ssl = uri.scheme == "https"
|
|
166
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
|
|
167
|
+
http.open_timeout = [config[:connect_timeout], remaining].min
|
|
168
|
+
http.read_timeout = remaining
|
|
169
|
+
http.write_timeout = remaining if http.respond_to?(:write_timeout=)
|
|
170
|
+
request = Net::HTTP::Post.new(uri.request_uri, request_headers)
|
|
171
|
+
request.body = JSON.generate(body)
|
|
172
|
+
[http, request]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def request_json(config, request_headers, body)
|
|
176
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + config[:total_timeout]
|
|
177
|
+
(config[:max_retries] + 1).times do |attempt|
|
|
178
|
+
begin
|
|
179
|
+
http, request = http_request(config, deadline, request_headers, body)
|
|
180
|
+
response = http.request(request)
|
|
181
|
+
raise AiTimeoutError, "AI total request timeout expired" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
182
|
+
|
|
183
|
+
status = response.code.to_i
|
|
184
|
+
unless status.between?(200, 299)
|
|
185
|
+
if (status == 429 || status >= 500) && attempt < config[:max_retries]
|
|
186
|
+
retry_delay(response, deadline)
|
|
187
|
+
next
|
|
188
|
+
end
|
|
189
|
+
raise AiHTTPError.new("AI provider returned HTTP #{status}", status)
|
|
190
|
+
end
|
|
191
|
+
parsed = JSON.parse(response.body)
|
|
192
|
+
raise AiParseError, "AI provider returned a non-object JSON response" unless parsed.is_a?(Hash)
|
|
193
|
+
|
|
194
|
+
return parsed
|
|
195
|
+
rescue Net::OpenTimeout
|
|
196
|
+
raise AiTimeoutError, "AI connection timeout expired" if attempt >= config[:max_retries]
|
|
197
|
+
rescue Net::ReadTimeout, Timeout::Error
|
|
198
|
+
raise AiTimeoutError, "AI total request timeout expired" if attempt >= config[:max_retries]
|
|
199
|
+
rescue AiHTTPError => e
|
|
200
|
+
raise if e.status || attempt >= config[:max_retries]
|
|
201
|
+
rescue SocketError, EOFError, IOError, SystemCallError => e
|
|
202
|
+
raise AiHTTPError, "AI transport failed (#{e.class.name})" if attempt >= config[:max_retries]
|
|
203
|
+
rescue JSON::ParserError
|
|
204
|
+
raise AiParseError, "AI provider returned malformed JSON"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
raise AiHTTPError, "AI request failed"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def retry_delay(response, deadline)
|
|
211
|
+
requested = Float(response["retry-after"] || 0.1) rescue 0.1
|
|
212
|
+
delay = [requested.positive? ? requested : 0, deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)].min
|
|
213
|
+
sleep(delay) if delay.positive?
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def normalize_chat(provider, raw)
|
|
217
|
+
if provider == "anthropic"
|
|
218
|
+
parts = raw.fetch("content").select { |item| item.fetch("type", "text") == "text" }.map { |item| item.fetch("text") }
|
|
219
|
+
raise KeyError if parts.empty?
|
|
220
|
+
prompt = raw.fetch("usage", {}).fetch("input_tokens", 0).to_i
|
|
221
|
+
completion = raw.fetch("usage", {}).fetch("output_tokens", 0).to_i
|
|
222
|
+
return ChatResponse.new(text: parts.join, model: raw.fetch("model", "").to_s,
|
|
223
|
+
usage: { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion },
|
|
224
|
+
finish_reason: raw["stop_reason"], raw: raw)
|
|
225
|
+
end
|
|
226
|
+
choice = raw.fetch("choices").fetch(0)
|
|
227
|
+
text = choice.fetch("message").fetch("content")
|
|
228
|
+
raise TypeError unless text.is_a?(String)
|
|
229
|
+
usage = raw.fetch("usage", {})
|
|
230
|
+
ChatResponse.new(text: text, model: raw.fetch("model", "").to_s,
|
|
231
|
+
usage: { prompt_tokens: usage.fetch("prompt_tokens", 0).to_i,
|
|
232
|
+
completion_tokens: usage.fetch("completion_tokens", 0).to_i,
|
|
233
|
+
total_tokens: usage.fetch("total_tokens", 0).to_i },
|
|
234
|
+
finish_reason: choice["finish_reason"], raw: raw)
|
|
235
|
+
rescue KeyError, IndexError, TypeError
|
|
236
|
+
raise AiParseError, "AI provider returned a malformed chat response"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def stream_delta(provider, data)
|
|
240
|
+
return [true, nil] if data == "[DONE]"
|
|
241
|
+
|
|
242
|
+
event = JSON.parse(data)
|
|
243
|
+
text = if provider == "anthropic"
|
|
244
|
+
event["type"] == "content_block_delta" ? event.dig("delta", "text") : nil
|
|
245
|
+
else
|
|
246
|
+
event.dig("choices", 0, "delta", "content")
|
|
247
|
+
end
|
|
248
|
+
raise AiParseError, "AI provider returned malformed stream data" unless text.nil? || text.is_a?(String)
|
|
249
|
+
|
|
250
|
+
[false, text]
|
|
251
|
+
rescue JSON::ParserError
|
|
252
|
+
raise AiParseError, "AI provider returned malformed stream data"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def each_stream_data(response, deadline)
|
|
256
|
+
buffer = +""
|
|
257
|
+
response.read_body do |chunk|
|
|
258
|
+
raise AiTimeoutError, "AI total request timeout expired" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
259
|
+
buffer << chunk
|
|
260
|
+
while (index = buffer.index("\n"))
|
|
261
|
+
line = buffer.slice!(0..index).strip
|
|
262
|
+
yield line.delete_prefix("data:").strip if line.start_with?("data:")
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def stream_request(config, request_headers, body)
|
|
268
|
+
Enumerator.new do |yielder|
|
|
269
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + config[:total_timeout]
|
|
270
|
+
yielded = false
|
|
271
|
+
(config[:max_retries] + 1).times do |attempt|
|
|
272
|
+
begin
|
|
273
|
+
http, request = http_request(config, deadline, request_headers.merge("Accept" => "text/event-stream"), body)
|
|
274
|
+
retry_response = false
|
|
275
|
+
completed = false
|
|
276
|
+
http.request(request) do |response|
|
|
277
|
+
status = response.code.to_i
|
|
278
|
+
unless status.between?(200, 299)
|
|
279
|
+
response.read_body { |_chunk| nil }
|
|
280
|
+
if (status == 429 || status >= 500) && attempt < config[:max_retries]
|
|
281
|
+
retry_delay(response, deadline)
|
|
282
|
+
retry_response = true
|
|
283
|
+
next
|
|
284
|
+
end
|
|
285
|
+
raise AiHTTPError.new("AI provider returned HTTP #{status}", status)
|
|
286
|
+
end
|
|
287
|
+
each_stream_data(response, deadline) do |data|
|
|
288
|
+
completed, text = stream_delta(config[:provider], data)
|
|
289
|
+
break if completed
|
|
290
|
+
next if text.nil?
|
|
291
|
+
yielded = true
|
|
292
|
+
yielder << text
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
next if retry_response
|
|
296
|
+
raise AiParseError, "AI provider stream ended before [DONE]" unless completed
|
|
297
|
+
break
|
|
298
|
+
rescue Net::OpenTimeout
|
|
299
|
+
raise AiTimeoutError, "AI connection timeout expired" if yielded || attempt >= config[:max_retries]
|
|
300
|
+
rescue Net::ReadTimeout, Timeout::Error
|
|
301
|
+
raise AiTimeoutError, "AI total request timeout expired" if yielded || attempt >= config[:max_retries]
|
|
302
|
+
rescue SocketError, EOFError, IOError, SystemCallError => e
|
|
303
|
+
raise AiHTTPError, "AI transport failed (#{e.class.name})" if yielded || attempt >= config[:max_retries]
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
data/lib/tina4/cli.rb
CHANGED
|
@@ -71,7 +71,6 @@ module Tina4
|
|
|
71
71
|
"console" => { handler: :cmd_console, summary: "Start an interactive console" },
|
|
72
72
|
"generate" => { handler: :cmd_generate, usage: "<what> <name> [options]", subcommands: GENERATORS.keys, summary: "Generate scaffolding (see Generators below)" },
|
|
73
73
|
"ai" => { handler: :cmd_ai, usage: "[--all]", summary: "Detect AI tools and install context files" },
|
|
74
|
-
"metrics" => { handler: :cmd_metrics, usage: "[--top N] [--json] [--fail-on warn|error] [--path DIR]", summary: "Rank top code-quality offenders" },
|
|
75
74
|
"commands" => { handler: :cmd_commands, usage: "[--json]", summary: "List available commands (add --json for machine form)" },
|
|
76
75
|
"help" => { handler: :cmd_help, summary: "Show this help message" },
|
|
77
76
|
}.freeze
|
|
@@ -1060,104 +1059,6 @@ module Tina4
|
|
|
1060
1059
|
end
|
|
1061
1060
|
end
|
|
1062
1061
|
|
|
1063
|
-
# ── metrics ───────────────────────────────────────────────────────────
|
|
1064
|
-
|
|
1065
|
-
# Report top code-quality offenders (complexity, size, maintainability,
|
|
1066
|
-
# tests). Mirrors the Python-master `tina4python metrics` command.
|
|
1067
|
-
#
|
|
1068
|
-
# tina4ruby metrics # human report, scans src/ (or framework)
|
|
1069
|
-
# tina4ruby metrics --top 10 # only the worst 10
|
|
1070
|
-
# tina4ruby metrics --path lib # scan a specific directory
|
|
1071
|
-
# tina4ruby metrics --json # machine-readable for CI
|
|
1072
|
-
# tina4ruby metrics --fail-on warn # exit 1 if any warn/error offender
|
|
1073
|
-
# tina4ruby metrics --fail-on error # exit 1 only on error-severity
|
|
1074
|
-
def cmd_metrics(argv)
|
|
1075
|
-
require "json"
|
|
1076
|
-
require "set"
|
|
1077
|
-
require_relative "metrics"
|
|
1078
|
-
|
|
1079
|
-
flags, _positional = parse_flags(argv)
|
|
1080
|
-
|
|
1081
|
-
top = (flags["top"].to_s =~ /\A\d+\z/) ? flags["top"].to_i : 20
|
|
1082
|
-
as_json = flags.key?("json")
|
|
1083
|
-
path = flags["path"].is_a?(String) ? flags["path"] : "src"
|
|
1084
|
-
fail_on = flags["fail-on"].is_a?(String) ? flags["fail-on"] : nil
|
|
1085
|
-
|
|
1086
|
-
unless [nil, "warn", "error"].include?(fail_on)
|
|
1087
|
-
puts " invalid --fail-on '#{fail_on}' (use warn or error)"
|
|
1088
|
-
exit 2
|
|
1089
|
-
end
|
|
1090
|
-
|
|
1091
|
-
# ONE engine run. Ask for every offender and slice for display: the gate
|
|
1092
|
-
# must read the FULL set, not the printed top-N, and the old second call
|
|
1093
|
-
# re-ran the whole analysis (its "full_analysis is cached" comment stopped
|
|
1094
|
-
# being true when the in-process analyzer and its cache were deleted).
|
|
1095
|
-
# An Integer, not Float::INFINITY -- Array#first demands an Integer.
|
|
1096
|
-
every_offender = 2**31
|
|
1097
|
-
begin
|
|
1098
|
-
result = Tina4::Metrics.offenders(path, every_offender)
|
|
1099
|
-
rescue Tina4::MetricsEngineError => e
|
|
1100
|
-
warn " metrics error: #{e.message}"
|
|
1101
|
-
exit 2
|
|
1102
|
-
end
|
|
1103
|
-
summary = result["summary"]
|
|
1104
|
-
all_offenders = result["offenders"]
|
|
1105
|
-
found = all_offenders.first(top)
|
|
1106
|
-
|
|
1107
|
-
severities = all_offenders.map { |o| o["severity"] }.to_set
|
|
1108
|
-
exit_code = 0
|
|
1109
|
-
if fail_on == "warn" && !(severities & %w[warn error]).empty?
|
|
1110
|
-
exit_code = 1
|
|
1111
|
-
elsif fail_on == "error" && severities.include?("error")
|
|
1112
|
-
exit_code = 1
|
|
1113
|
-
end
|
|
1114
|
-
|
|
1115
|
-
if as_json
|
|
1116
|
-
puts JSON.pretty_generate({ "summary" => summary, "offenders" => found })
|
|
1117
|
-
exit exit_code
|
|
1118
|
-
end
|
|
1119
|
-
|
|
1120
|
-
# ── Human report ──────────────────────────────────────────────────
|
|
1121
|
-
use_color = $stdout.tty?
|
|
1122
|
-
colorize = lambda do |text, code|
|
|
1123
|
-
use_color ? "\e[#{code}m#{text}\e[0m" : text
|
|
1124
|
-
end
|
|
1125
|
-
sev_color = { "error" => "31", "warn" => "33", "info" => "2" } # red / yellow / dim
|
|
1126
|
-
|
|
1127
|
-
puts
|
|
1128
|
-
puts " Tina4 Metrics — #{summary['scan_mode']} scan (#{summary['scan_root']})"
|
|
1129
|
-
puts " files: #{summary['files_analyzed']} " \
|
|
1130
|
-
"functions: #{summary['total_functions']} " \
|
|
1131
|
-
"avg complexity: #{summary['avg_complexity']} " \
|
|
1132
|
-
"avg maintainability: #{summary['avg_maintainability']}"
|
|
1133
|
-
showing = found.empty? ? "" : " (showing top #{found.length})"
|
|
1134
|
-
puts " offenders: #{summary['total_offenders']} total#{showing}"
|
|
1135
|
-
puts
|
|
1136
|
-
|
|
1137
|
-
if found.empty?
|
|
1138
|
-
puts " " + colorize.call("✓ no offenders — clean", "32")
|
|
1139
|
-
puts
|
|
1140
|
-
exit exit_code
|
|
1141
|
-
end
|
|
1142
|
-
|
|
1143
|
-
# Compute column widths so the table lines up.
|
|
1144
|
-
locs = found.map { |o| "#{o['file']}:#{o['line']}" }
|
|
1145
|
-
loc_w = [("FILE:LINE".length)].concat(locs.map(&:length)).max
|
|
1146
|
-
kind_w = [("KIND".length)].concat(found.map { |o| o["kind"].length }).max
|
|
1147
|
-
|
|
1148
|
-
header = format(" %3s %-8s %-#{kind_w}s %-#{loc_w}s DETAIL", "#", "SEVERITY", "KIND", "FILE:LINE")
|
|
1149
|
-
puts colorize.call(header, "1")
|
|
1150
|
-
puts " " + ("-" * (header.length - 2))
|
|
1151
|
-
found.each_with_index do |o, i|
|
|
1152
|
-
sev = o["severity"]
|
|
1153
|
-
sev_cell = colorize.call(format("%-8s", sev), sev_color[sev])
|
|
1154
|
-
puts format(" %3d %s %-#{kind_w}s %-#{loc_w}s %s",
|
|
1155
|
-
i + 1, sev_cell, o["kind"], locs[i], o["detail"])
|
|
1156
|
-
end
|
|
1157
|
-
puts
|
|
1158
|
-
exit exit_code
|
|
1159
|
-
end
|
|
1160
|
-
|
|
1161
1062
|
# ── generate ────────────────────────────────────────────────────────
|
|
1162
1063
|
|
|
1163
1064
|
def cmd_generate(argv)
|
|
@@ -2939,13 +2840,6 @@ module Tina4
|
|
|
2939
2840
|
"shaped ones emit working code. Writes are secure by default; use --public",
|
|
2940
2841
|
"to open them.",
|
|
2941
2842
|
"",
|
|
2942
|
-
"Metrics:",
|
|
2943
|
-
" metrics [--top N] [--json] [--fail-on warn|error] [--path DIR]",
|
|
2944
|
-
" --top N Show only the worst N offenders (default: 20)",
|
|
2945
|
-
" --json Print machine-readable JSON ({summary, offenders}) for CI",
|
|
2946
|
-
" --fail-on Exit 1 if any offender at/above this severity (warn|error)",
|
|
2947
|
-
" --path DIR Scan DIR (default: src/, auto-resolves to the framework)",
|
|
2948
|
-
"",
|
|
2949
2843
|
"Field types: string, int, float, bool, text, datetime, blob",
|
|
2950
2844
|
"Table names: singular by default (Product -> product)",
|
|
2951
2845
|
"",
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -632,8 +632,6 @@ module Tina4
|
|
|
632
632
|
json_response(gallery_deploy(name))
|
|
633
633
|
when ["GET", "/__dev/api/version-check"]
|
|
634
634
|
json_response(version_check_payload)
|
|
635
|
-
when ["GET", "/__dev/api/metrics"]
|
|
636
|
-
json_response(Tina4::Metrics.quick_metrics)
|
|
637
635
|
when ["GET", "/__dev/api/metrics/full"]
|
|
638
636
|
# No fallback (ADR-0002). A missing or stale CLI is a 503 naming the
|
|
639
637
|
# install command, never zeros that read as a healthy codebase.
|