chocomint 1.0.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/LICENSE +21 -0
- data/README.md +224 -0
- data/THIRD_PARTY_LICENSES.md +45 -0
- data/bin/chocomint +37 -0
- data/bin/chocomint-console-worker +35 -0
- data/bin/chocomint-server +43 -0
- data/config/config.yml +37 -0
- data/lib/chocomint/config.rb +74 -0
- data/lib/chocomint/console/conpty.rb +296 -0
- data/lib/chocomint/console/server.rb +164 -0
- data/lib/chocomint/console/worker.rb +88 -0
- data/lib/chocomint/console/worker_process.rb +254 -0
- data/lib/chocomint/edit_git_handlers.rb +397 -0
- data/lib/chocomint/edit_handlers.rb +696 -0
- data/lib/chocomint/edit_html.rb +9 -0
- data/lib/chocomint/errors.rb +18 -0
- data/lib/chocomint/factory.rb +145 -0
- data/lib/chocomint/llm/base_client.rb +113 -0
- data/lib/chocomint/llm/chat_client.rb +206 -0
- data/lib/chocomint/llm/primary_client.rb +320 -0
- data/lib/chocomint/llm/verifier_client.rb +127 -0
- data/lib/chocomint/logger/sqlite_logger.rb +245 -0
- data/lib/chocomint/orchestrator.rb +175 -0
- data/lib/chocomint/planner.rb +241 -0
- data/lib/chocomint/security/path_guard.rb +84 -0
- data/lib/chocomint/server.rb +863 -0
- data/lib/chocomint/tools/append_file.rb +50 -0
- data/lib/chocomint/tools/base.rb +88 -0
- data/lib/chocomint/tools/delete_file.rb +42 -0
- data/lib/chocomint/tools/edit.rb +65 -0
- data/lib/chocomint/tools/fetch_url.rb +156 -0
- data/lib/chocomint/tools/file_info.rb +54 -0
- data/lib/chocomint/tools/glob.rb +58 -0
- data/lib/chocomint/tools/grep.rb +78 -0
- data/lib/chocomint/tools/list_dir.rb +41 -0
- data/lib/chocomint/tools/ls.rb +51 -0
- data/lib/chocomint/tools/make_dir.rb +44 -0
- data/lib/chocomint/tools/read_file.rb +45 -0
- data/lib/chocomint/tools/registry.rb +29 -0
- data/lib/chocomint/tools/run_command.rb +96 -0
- data/lib/chocomint/tools/shell.rb +169 -0
- data/lib/chocomint/tools/write_file.rb +54 -0
- data/lib/chocomint/validator/machine_validator.rb +65 -0
- data/lib/chocomint/validator/semantic_validator.rb +52 -0
- data/lib/chocomint.rb +51 -0
- data/public/edit/edit.css +414 -0
- data/public/edit/edit.js +2137 -0
- data/public/edit/index.html +251 -0
- data/public/vendor/monaco/LICENSE.txt +23 -0
- data/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- data/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
- data/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- data/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- data/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- data/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- data/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- data/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- data/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- data/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- data/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- data/public/vendor/monaco/vs/editor/editor.main.css +8 -0
- data/public/vendor/monaco/vs/editor/editor.main.js +798 -0
- data/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
- data/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
- data/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- data/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
- data/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
- data/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
- data/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- data/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
- data/public/vendor/monaco/vs/loader.js +11 -0
- data/public/vendor/monaco/vs/nls.messages.de.js +21 -0
- data/public/vendor/monaco/vs/nls.messages.es.js +21 -0
- data/public/vendor/monaco/vs/nls.messages.fr.js +19 -0
- data/public/vendor/monaco/vs/nls.messages.it.js +19 -0
- data/public/vendor/monaco/vs/nls.messages.ja.js +21 -0
- data/public/vendor/monaco/vs/nls.messages.ko.js +19 -0
- data/public/vendor/monaco/vs/nls.messages.ru.js +21 -0
- data/public/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
- data/public/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
- data/public/vendor/xterm/LICENSE +25 -0
- data/public/vendor/xterm/addon-fit.js +2 -0
- data/public/vendor/xterm/xterm.css +218 -0
- data/public/vendor/xterm/xterm.js +2 -0
- metadata +318 -0
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "webrick"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "cgi"
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
require_relative "logger/sqlite_logger"
|
|
9
|
+
require_relative "edit_handlers"
|
|
10
|
+
require_relative "edit_git_handlers"
|
|
11
|
+
|
|
12
|
+
module Chocomint
|
|
13
|
+
# 監督完結型の Anthropic Messages API 互換プロキシサーバー。
|
|
14
|
+
#
|
|
15
|
+
# POST /v1/messages でユーザー要求を受け取り、Orchestrator に委譲して
|
|
16
|
+
# ツール実行・機械検証・意味検証・再試行・監査ログまで完結させ、
|
|
17
|
+
# 保証済みの結果を Anthropic 形式のレスポンスとして返す。
|
|
18
|
+
#
|
|
19
|
+
# 通常の LLM API との違い: 「提案」ではなく「実行が保証された結果」を返す。
|
|
20
|
+
class Server
|
|
21
|
+
include EditHandlers
|
|
22
|
+
include EditGitHandlers
|
|
23
|
+
|
|
24
|
+
# planner: Planner インスタンス (run(request, expectations:) -> Planner::Result)。
|
|
25
|
+
# edit UI 用に PathGuard / base_dir / vendor / console 情報も受け取る (任意)。
|
|
26
|
+
# chat_client: 会話 (ツール不要の質問) を判定・応答する ChatClient (任意)。
|
|
27
|
+
# 未指定なら /edit チャットは従来どおり全指示を Planner に流す。
|
|
28
|
+
def initialize(planner, host: "127.0.0.1", port: 9210, logger: $stderr,
|
|
29
|
+
sqlite_path: nil, path_guard: nil, base_dir: Dir.pwd,
|
|
30
|
+
max_file_bytes: 10_485_760, ws_port: nil, ws_token: nil,
|
|
31
|
+
chat_client: nil)
|
|
32
|
+
@planner = planner
|
|
33
|
+
@chat_client = chat_client
|
|
34
|
+
@host = host
|
|
35
|
+
@port = port
|
|
36
|
+
@logger = logger
|
|
37
|
+
@sqlite_path = sqlite_path
|
|
38
|
+
@path_guard = path_guard
|
|
39
|
+
@base_dir = base_dir
|
|
40
|
+
@max_file_bytes = max_file_bytes
|
|
41
|
+
@ws_port = ws_port
|
|
42
|
+
@ws_token = ws_token
|
|
43
|
+
@vendor_dir = File.expand_path("../../public/vendor", __dir__)
|
|
44
|
+
@edit_static_dir = File.expand_path("../../public/edit", __dir__)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# 指定ポートが使用中なら、この範囲内で連番の空きポートを探す (0 なら無制限に探索しない)。
|
|
48
|
+
PORT_FALLBACK_RANGE = 20
|
|
49
|
+
|
|
50
|
+
def start
|
|
51
|
+
server = bind_server_with_fallback
|
|
52
|
+
|
|
53
|
+
server.mount_proc("/v1/messages") { |req, res| handle_messages(req, res) }
|
|
54
|
+
server.mount_proc("/health") { |_req, res| ok_json(res, { "status" => "ok" }) }
|
|
55
|
+
server.mount_proc("/logs") { |req, res| handle_logs(req, res) }
|
|
56
|
+
server.mount_proc("/logs/detail") { |req, res| handle_log_detail(req, res) }
|
|
57
|
+
server.mount_proc("/logs/llm") { |req, res| handle_llm_logs(req, res) }
|
|
58
|
+
|
|
59
|
+
# AI エディタ (/edit)。PathGuard が注入されているときのみ有効化する。
|
|
60
|
+
if @path_guard
|
|
61
|
+
server.mount_proc("/edit") { |req, res| handle_edit(req, res) }
|
|
62
|
+
server.mount_proc("/edit/assets") { |req, res| handle_edit_asset(req, res) }
|
|
63
|
+
server.mount_proc("/edit/static") { |req, res| handle_edit_static(req, res) }
|
|
64
|
+
server.mount_proc("/edit/files") { |req, res| handle_edit_files(req, res) }
|
|
65
|
+
server.mount_proc("/edit/file") { |req, res| handle_edit_file(req, res) }
|
|
66
|
+
server.mount_proc("/edit/save") { |req, res| handle_edit_save(req, res) }
|
|
67
|
+
server.mount_proc("/edit/chat") { |req, res| handle_edit_chat(req, res) }
|
|
68
|
+
server.mount_proc("/edit/compact") { |req, res| handle_edit_compact(req, res) }
|
|
69
|
+
# ファイル操作 (右クリックメニュー): 削除 / 名前変更 / 追加。
|
|
70
|
+
server.mount_proc("/edit/fs/delete") { |req, res| handle_edit_fs_delete(req, res) }
|
|
71
|
+
server.mount_proc("/edit/fs/rename") { |req, res| handle_edit_fs_rename(req, res) }
|
|
72
|
+
server.mount_proc("/edit/fs/mkdir") { |req, res| handle_edit_fs_mkdir(req, res) }
|
|
73
|
+
server.mount_proc("/edit/fs/touch") { |req, res| handle_edit_fs_touch(req, res) }
|
|
74
|
+
# Git 操作 (ステータスバー / WORKING DIRECTORY パネル)。
|
|
75
|
+
server.mount_proc("/edit/git/status") { |req, res| handle_edit_git_status(req, res) }
|
|
76
|
+
server.mount_proc("/edit/git/init") { |req, res| handle_edit_git_init(req, res) }
|
|
77
|
+
server.mount_proc("/edit/git/stage") { |req, res| handle_edit_git_stage(req, res) }
|
|
78
|
+
server.mount_proc("/edit/git/unstage") { |req, res| handle_edit_git_unstage(req, res) }
|
|
79
|
+
server.mount_proc("/edit/git/stage_all") { |req, res| handle_edit_git_stage_all(req, res) }
|
|
80
|
+
server.mount_proc("/edit/git/unstage_all") { |req, res| handle_edit_git_unstage_all(req, res) }
|
|
81
|
+
server.mount_proc("/edit/git/discard") { |req, res| handle_edit_git_discard(req, res) }
|
|
82
|
+
server.mount_proc("/edit/git/commit") { |req, res| handle_edit_git_commit(req, res) }
|
|
83
|
+
server.mount_proc("/edit/git/push") { |req, res| handle_edit_git_push(req, res) }
|
|
84
|
+
server.mount_proc("/edit/git/remote") { |req, res| handle_edit_git_remote_set(req, res) }
|
|
85
|
+
server.mount_proc("/edit/git/branches") { |req, res| handle_edit_git_branches(req, res) }
|
|
86
|
+
server.mount_proc("/edit/git/checkout") { |req, res| handle_edit_git_checkout(req, res) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
trap("INT") { server.shutdown }
|
|
90
|
+
@logger.puts("chocomint proxy listening on http://#{@host}:#{@port}/v1/messages")
|
|
91
|
+
@logger.puts("chocomint editor available at http://#{@host}:#{@port}/edit") if @path_guard
|
|
92
|
+
server.start
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# テスト・組み込み用: Rack 非依存で 1 リクエストを処理する純粋関数。
|
|
96
|
+
# body_hash(パース済み) -> [status, response_hash]
|
|
97
|
+
def process(body_hash)
|
|
98
|
+
request = extract_request(body_hash)
|
|
99
|
+
return [400, error_body("invalid_request_error", "no user message found")] if request.nil?
|
|
100
|
+
|
|
101
|
+
expectations = body_hash["expectations"] || body_hash.dig("metadata", "expectations")
|
|
102
|
+
result = @planner.run(request, expectations: expectations)
|
|
103
|
+
[200, success_body(result)]
|
|
104
|
+
rescue Chocomint::InvalidProposalError, Chocomint::UnknownToolError => e
|
|
105
|
+
[422, error_body("invalid_request_error", e.message)]
|
|
106
|
+
rescue Chocomint::RetryLimitExceededError => e
|
|
107
|
+
[502, error_body("execution_failed", e.message)]
|
|
108
|
+
rescue Chocomint::Error => e
|
|
109
|
+
[500, error_body("api_error", e.message)]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
PAGE_SIZE = 100
|
|
113
|
+
|
|
114
|
+
# 監査ログをブラウザ表示するための HTML を組み立てる純粋関数。
|
|
115
|
+
# rows: SqliteLogger#recent 相当の Hash 配列(当該ページ分)
|
|
116
|
+
# total: 全件数(trace_id 指定時はその trace の件数)
|
|
117
|
+
# page: 現在のページ(0 始まり)
|
|
118
|
+
# trace_id: フィルタ中の trace_id(無ければ nil)
|
|
119
|
+
# -> [status, content_type, body]
|
|
120
|
+
def render_logs(rows, total: nil, page: 0, trace_id: nil)
|
|
121
|
+
total ||= rows.size
|
|
122
|
+
[200, "text/html; charset=utf-8",
|
|
123
|
+
logs_html(rows, total: total, page: page, trace_id: trace_id)]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# @port で Bind を試み、使用中なら @port+1, +2... と PORT_FALLBACK_RANGE 回まで試す。
|
|
129
|
+
# 成功したポートで @port を更新する。
|
|
130
|
+
# Windows は使用中ポートへの bind で EADDRINUSE ではなく EACCES を返すことがあるため両方を捕捉する。
|
|
131
|
+
def bind_server_with_fallback
|
|
132
|
+
candidate = @port
|
|
133
|
+
attempts = 0
|
|
134
|
+
begin
|
|
135
|
+
server = WEBrick::HTTPServer.new(
|
|
136
|
+
BindAddress: @host,
|
|
137
|
+
Port: candidate,
|
|
138
|
+
Logger: WEBrick::Log.new(@logger, WEBrick::Log::WARN),
|
|
139
|
+
AccessLog: []
|
|
140
|
+
)
|
|
141
|
+
rescue Errno::EADDRINUSE, Errno::EACCES
|
|
142
|
+
attempts += 1
|
|
143
|
+
raise if attempts > PORT_FALLBACK_RANGE
|
|
144
|
+
|
|
145
|
+
@logger.puts("chocomint: port #{candidate} is in use, trying #{candidate + 1}...")
|
|
146
|
+
candidate += 1
|
|
147
|
+
retry
|
|
148
|
+
end
|
|
149
|
+
@port = candidate
|
|
150
|
+
server
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def handle_logs(req, res)
|
|
154
|
+
unless req.request_method == "GET"
|
|
155
|
+
return error_json(res, 405, "invalid_request_error", "method not allowed")
|
|
156
|
+
end
|
|
157
|
+
if @sqlite_path.nil?
|
|
158
|
+
return error_json(res, 503, "api_error", "log store not configured")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# 空文字は「フィルタなし」扱い。エンコーディング正規化は SqliteLogger 側で行う。
|
|
162
|
+
trace_id = req.query["trace_id"]
|
|
163
|
+
trace_id = nil if trace_id.nil? || trace_id.empty?
|
|
164
|
+
page = [(req.query["page"] || "0").to_i, 0].max
|
|
165
|
+
|
|
166
|
+
logger = Logger::SqliteLogger.new(@sqlite_path)
|
|
167
|
+
begin
|
|
168
|
+
total = logger.count(trace_id: trace_id)
|
|
169
|
+
rows = logger.recent(limit: PAGE_SIZE, offset: page * PAGE_SIZE, trace_id: trace_id)
|
|
170
|
+
ensure
|
|
171
|
+
logger.close
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
_status, content_type, body =
|
|
175
|
+
render_logs(rows, total: total, page: page, trace_id: trace_id)
|
|
176
|
+
res.status = 200
|
|
177
|
+
res["content-type"] = content_type
|
|
178
|
+
res.body = body
|
|
179
|
+
rescue Chocomint::Error, SQLite3::Exception => e
|
|
180
|
+
error_json(res, 500, "api_error", e.message)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# 監査ログ 1 件の詳細ページ (GET /logs/detail?id=N)。
|
|
184
|
+
def handle_log_detail(req, res)
|
|
185
|
+
unless req.request_method == "GET"
|
|
186
|
+
return error_json(res, 405, "invalid_request_error", "method not allowed")
|
|
187
|
+
end
|
|
188
|
+
if @sqlite_path.nil?
|
|
189
|
+
return error_json(res, 503, "api_error", "log store not configured")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
id = req.query["id"].to_i
|
|
193
|
+
logger = Logger::SqliteLogger.new(@sqlite_path)
|
|
194
|
+
begin
|
|
195
|
+
row = logger.execution(id)
|
|
196
|
+
ensure
|
|
197
|
+
logger.close
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
unless row
|
|
201
|
+
res.status = 404
|
|
202
|
+
res["content-type"] = "text/html; charset=utf-8"
|
|
203
|
+
res.body = log_detail_not_found(id)
|
|
204
|
+
return
|
|
205
|
+
end
|
|
206
|
+
res.status = 200
|
|
207
|
+
res["content-type"] = "text/html; charset=utf-8"
|
|
208
|
+
res.body = log_detail_html(row)
|
|
209
|
+
rescue Chocomint::Error, SQLite3::Exception => e
|
|
210
|
+
error_json(res, 500, "api_error", e.message)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# LLM ペイロード一覧 (表)。?id=N があれば 1 件の詳細を表示する。
|
|
214
|
+
def handle_llm_logs(req, res)
|
|
215
|
+
unless req.request_method == "GET"
|
|
216
|
+
return error_json(res, 405, "invalid_request_error", "method not allowed")
|
|
217
|
+
end
|
|
218
|
+
if @sqlite_path.nil?
|
|
219
|
+
return error_json(res, 503, "api_error", "log store not configured")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
id = req.query["id"]
|
|
223
|
+
return handle_llm_log_detail(res, id.to_i) if id && !id.empty?
|
|
224
|
+
|
|
225
|
+
trace_id = req.query["trace_id"]
|
|
226
|
+
trace_id = nil if trace_id.nil? || trace_id.empty?
|
|
227
|
+
page = [(req.query["page"] || "0").to_i, 0].max
|
|
228
|
+
|
|
229
|
+
logger = Logger::SqliteLogger.new(@sqlite_path)
|
|
230
|
+
begin
|
|
231
|
+
total = logger.llm_payload_count(trace_id: trace_id)
|
|
232
|
+
rows = logger.recent_llm_payloads(
|
|
233
|
+
limit: PAGE_SIZE, offset: page * PAGE_SIZE, trace_id: trace_id
|
|
234
|
+
)
|
|
235
|
+
ensure
|
|
236
|
+
logger.close
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
res.status = 200
|
|
240
|
+
res["content-type"] = "text/html; charset=utf-8"
|
|
241
|
+
res.body = llm_logs_html(rows, total: total, page: page, trace_id: trace_id)
|
|
242
|
+
rescue Chocomint::Error, SQLite3::Exception => e
|
|
243
|
+
error_json(res, 500, "api_error", e.message)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# ペイロード 1 件の詳細 (リクエスト + レスポンス全体)。
|
|
247
|
+
def handle_llm_log_detail(res, id)
|
|
248
|
+
logger = Logger::SqliteLogger.new(@sqlite_path)
|
|
249
|
+
begin
|
|
250
|
+
row = logger.llm_payload(id)
|
|
251
|
+
# 同じ trace のツール正常性判定 (status / verifier) を併記する。
|
|
252
|
+
outcomes = row ? logger.tool_outcomes(row["trace_id"]) : []
|
|
253
|
+
ensure
|
|
254
|
+
logger.close
|
|
255
|
+
end
|
|
256
|
+
unless row
|
|
257
|
+
res.status = 404
|
|
258
|
+
res["content-type"] = "text/html; charset=utf-8"
|
|
259
|
+
res.body = llm_detail_not_found(id)
|
|
260
|
+
return
|
|
261
|
+
end
|
|
262
|
+
res.status = 200
|
|
263
|
+
res["content-type"] = "text/html; charset=utf-8"
|
|
264
|
+
res.body = llm_detail_html(row, outcomes)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# 一覧の表列。id は詳細リンク、trace_id はフィルタリンクにする。
|
|
268
|
+
# content はレスポンスの返答要約 (text 冒頭 / tool_use:名前)。
|
|
269
|
+
LLM_COLUMNS = %w[no trace_id role model response_status content created_at].freeze
|
|
270
|
+
|
|
271
|
+
def llm_logs_html(rows, total:, page:, trace_id:)
|
|
272
|
+
base = trace_id ? "/logs/llm?trace_id=#{CGI.escape(trace_id)}&" : "/logs/llm?"
|
|
273
|
+
body_rows = rows.map { |row| llm_logs_row(row) }.join("\n")
|
|
274
|
+
<<~HTML
|
|
275
|
+
<!DOCTYPE html>
|
|
276
|
+
<html lang="ja">
|
|
277
|
+
<head>
|
|
278
|
+
<meta charset="utf-8">
|
|
279
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
280
|
+
<title>chocomint LLM ペイロード</title>
|
|
281
|
+
<style>
|
|
282
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
283
|
+
body { font-family: system-ui, sans-serif; margin: 1.5rem; color: #222; }
|
|
284
|
+
h1 { font-size: 1.25rem; }
|
|
285
|
+
.meta { color: #666; margin-bottom: 1rem; }
|
|
286
|
+
.filter { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;
|
|
287
|
+
flex-wrap: wrap; }
|
|
288
|
+
.filter code { background: #eef; padding: 0.1rem 0.4rem; border-radius: 3px; }
|
|
289
|
+
.btn { display: inline-block; padding: 0.3rem 0.7rem; border: 1px solid #ccc;
|
|
290
|
+
border-radius: 4px; background: #fff; color: #222; cursor: pointer;
|
|
291
|
+
text-decoration: none; font-size: 0.85rem; }
|
|
292
|
+
.btn:hover { background: #f0f0f0; }
|
|
293
|
+
.btn[aria-disabled="true"] { opacity: 0.4; pointer-events: none; }
|
|
294
|
+
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch;
|
|
295
|
+
border: 1px solid #ddd; border-radius: 4px; }
|
|
296
|
+
table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
|
|
297
|
+
th, td { border: 1px solid #ddd; padding: 0.4rem 0.5rem; text-align: left;
|
|
298
|
+
white-space: nowrap; }
|
|
299
|
+
th { background: #f5f5f5; position: sticky; top: 0; }
|
|
300
|
+
td.no a, td.trace a { color: #1155cc; text-decoration: none; }
|
|
301
|
+
td.no a:hover, td.trace a:hover { text-decoration: underline; }
|
|
302
|
+
td.content { white-space: normal; max-width: 28rem; color: #444; }
|
|
303
|
+
tr:nth-child(even) { background: #fafafa; }
|
|
304
|
+
.role-primary { color: #1155cc; font-weight: 600; }
|
|
305
|
+
.role-verifier { color: #7b1fa2; font-weight: 600; }
|
|
306
|
+
.empty { color: #888; }
|
|
307
|
+
.pager { display: flex; align-items: center; gap: 0.75rem; margin-top: 1rem; }
|
|
308
|
+
</style>
|
|
309
|
+
</head>
|
|
310
|
+
<body>
|
|
311
|
+
<h1>chocomint LLM ペイロード(実際に LLM へ送受信した全内容)</h1>
|
|
312
|
+
<div class="filter"><a class="btn" href="/logs">監査ログへ</a></div>
|
|
313
|
+
#{trace_id ? %(<p class="meta">trace_id で絞り込み中: <code>#{CGI.escapeHTML(trace_id)}</code> <a class="btn" href="/logs/llm">解除</a></p>) : ''}
|
|
314
|
+
<p class="meta">#{page_range_label(total, page)}</p>
|
|
315
|
+
<div class="table-wrap">
|
|
316
|
+
<table>
|
|
317
|
+
<thead>
|
|
318
|
+
<tr>#{LLM_COLUMNS.map { |c| "<th>#{CGI.escapeHTML(c)}</th>" }.join}</tr>
|
|
319
|
+
</thead>
|
|
320
|
+
<tbody>
|
|
321
|
+
#{rows.empty? ? %(<tr><td colspan="#{LLM_COLUMNS.size}" class="empty">ペイロードはありません</td></tr>) : body_rows}
|
|
322
|
+
</tbody>
|
|
323
|
+
</table>
|
|
324
|
+
</div>
|
|
325
|
+
#{llm_pager(total, page, base)}
|
|
326
|
+
</body>
|
|
327
|
+
</html>
|
|
328
|
+
HTML
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def llm_logs_row(row)
|
|
332
|
+
role = row["role"].to_s
|
|
333
|
+
tid = row["trace_id"].to_s
|
|
334
|
+
id = row["id"].to_s
|
|
335
|
+
no_link = %(<a href="/logs/llm?id=#{CGI.escape(id)}">##{CGI.escapeHTML(id)}</a>)
|
|
336
|
+
trace_cell = tid.empty? ? "(なし)" : %(<a href="/logs/llm?trace_id=#{CGI.escape(tid)}">#{CGI.escapeHTML(tid)}</a>)
|
|
337
|
+
status = row["response_status"].nil? ? "-" : row["response_status"].to_s
|
|
338
|
+
content = content_summary(row["response_body"])
|
|
339
|
+
<<~ROW
|
|
340
|
+
<tr>
|
|
341
|
+
<td class="no">#{no_link}</td>
|
|
342
|
+
<td class="trace">#{trace_cell}</td>
|
|
343
|
+
<td class="role-#{CGI.escapeHTML(role)}">#{CGI.escapeHTML(role)}</td>
|
|
344
|
+
<td>#{CGI.escapeHTML(row['model'].to_s)}</td>
|
|
345
|
+
<td>#{CGI.escapeHTML(status)}</td>
|
|
346
|
+
<td class="content">#{CGI.escapeHTML(content)}</td>
|
|
347
|
+
<td>#{CGI.escapeHTML(row['created_at'].to_s)}</td>
|
|
348
|
+
</tr>
|
|
349
|
+
ROW
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# ペイロード詳細ページ (リクエスト + レスポンス + ツール正常性判定)。
|
|
353
|
+
def llm_detail_html(row, outcomes = [])
|
|
354
|
+
role = row["role"].to_s
|
|
355
|
+
tid = row["trace_id"].to_s
|
|
356
|
+
trace_link = tid.empty? ? "(なし)" : %(<a href="/logs/llm?trace_id=#{CGI.escape(tid)}">#{CGI.escapeHTML(tid)}</a>)
|
|
357
|
+
status = row["response_status"].nil? ? "(未記録)" : row["response_status"].to_s
|
|
358
|
+
<<~HTML
|
|
359
|
+
<!DOCTYPE html>
|
|
360
|
+
<html lang="ja">
|
|
361
|
+
<head>
|
|
362
|
+
<meta charset="utf-8">
|
|
363
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
364
|
+
<title>chocomint LLM ペイロード ##{CGI.escapeHTML(row['id'].to_s)}</title>
|
|
365
|
+
<style>
|
|
366
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
367
|
+
body { font-family: system-ui, sans-serif; margin: 1.5rem; color: #222; }
|
|
368
|
+
h1 { font-size: 1.2rem; }
|
|
369
|
+
.btn { display: inline-block; padding: 0.3rem 0.7rem; border: 1px solid #ccc;
|
|
370
|
+
border-radius: 4px; background: #fff; color: #222; text-decoration: none;
|
|
371
|
+
font-size: 0.85rem; }
|
|
372
|
+
.btn:hover { background: #f0f0f0; }
|
|
373
|
+
.head { background: #f5f5f5; padding: 0.5rem 0.75rem; border-radius: 4px;
|
|
374
|
+
display: flex; gap: 1rem; flex-wrap: wrap; align-items: center;
|
|
375
|
+
font-size: 0.85rem; margin: 0.75rem 0 1rem; }
|
|
376
|
+
.role-primary { color: #1155cc; font-weight: 600; }
|
|
377
|
+
.role-verifier { color: #7b1fa2; font-weight: 600; }
|
|
378
|
+
.head a { color: #1155cc; text-decoration: none; }
|
|
379
|
+
.lbl { color: #555; font-size: 0.85rem; font-weight: 600; margin: 1rem 0 0.3rem; }
|
|
380
|
+
pre { margin: 0; padding: 0.75rem; background: #fbfbfb; border: 1px solid #eee;
|
|
381
|
+
border-radius: 4px; font-size: 0.8rem; overflow-x: auto;
|
|
382
|
+
white-space: pre-wrap; word-break: break-word; }
|
|
383
|
+
table.outcomes { border-collapse: collapse; font-size: 0.83rem; }
|
|
384
|
+
table.outcomes th, table.outcomes td { border: 1px solid #ddd;
|
|
385
|
+
padding: 0.35rem 0.6rem; text-align: left; white-space: nowrap; }
|
|
386
|
+
table.outcomes th { background: #f5f5f5; }
|
|
387
|
+
.status-PASS { color: #137333; font-weight: 600; }
|
|
388
|
+
.status-RETRY { color: #b06000; font-weight: 600; }
|
|
389
|
+
.status-STEP { color: #1155cc; font-weight: 600; }
|
|
390
|
+
.status-FAIL { color: #c5221f; font-weight: 600; }
|
|
391
|
+
.note { color: #888; font-size: 0.85rem; }
|
|
392
|
+
.cblock { margin-bottom: 0.6rem; }
|
|
393
|
+
.ctag { display: inline-block; font-size: 0.72rem; color: #fff;
|
|
394
|
+
background: #555; padding: 0.1rem 0.5rem; border-radius: 3px;
|
|
395
|
+
margin-bottom: 0.25rem; }
|
|
396
|
+
</style>
|
|
397
|
+
</head>
|
|
398
|
+
<body>
|
|
399
|
+
<div><a class="btn" href="/logs/llm">一覧へ</a></div>
|
|
400
|
+
<h1>LLM ペイロード ##{CGI.escapeHTML(row['id'].to_s)}</h1>
|
|
401
|
+
<div class="head">
|
|
402
|
+
<span class="role-#{CGI.escapeHTML(role)}">#{CGI.escapeHTML(role)}</span>
|
|
403
|
+
<span>model: #{CGI.escapeHTML(row['model'].to_s)}</span>
|
|
404
|
+
<span>trace: #{trace_link}</span>
|
|
405
|
+
<span>response_status: #{CGI.escapeHTML(status)}</span>
|
|
406
|
+
<span>#{CGI.escapeHTML(row['created_at'].to_s)}</span>
|
|
407
|
+
</div>
|
|
408
|
+
<div class="lbl">request messages(LLM への送信メッセージ)</div>
|
|
409
|
+
#{request_messages_block(row['request_body'])}
|
|
410
|
+
<div class="lbl">request_body(生データ)</div>
|
|
411
|
+
<pre>#{CGI.escapeHTML(pretty_json(row['request_body']))}</pre>
|
|
412
|
+
<div class="lbl">request_headers</div>
|
|
413
|
+
<pre>#{CGI.escapeHTML(pretty_json(row['request_headers']))}</pre>
|
|
414
|
+
<div class="lbl">response content(LLM の返答)</div>
|
|
415
|
+
#{response_content_block(row['response_body'])}
|
|
416
|
+
<div class="lbl">response_body(生データ)</div>
|
|
417
|
+
<pre>#{CGI.escapeHTML(row['response_body'].nil? ? '(未記録)' : pretty_json(row['response_body']))}</pre>
|
|
418
|
+
<div class="lbl">ツール正常性判定(同一 trace の全試行)</div>
|
|
419
|
+
#{tool_outcomes_table(outcomes)}
|
|
420
|
+
</body>
|
|
421
|
+
</html>
|
|
422
|
+
HTML
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# 一覧表向けに content を 1 行の短い要約にする (text 冒頭 / tool_use:名前)。
|
|
426
|
+
CONTENT_SUMMARY_LIMIT = 80
|
|
427
|
+
|
|
428
|
+
def content_summary(response_body)
|
|
429
|
+
return "-" if response_body.nil?
|
|
430
|
+
|
|
431
|
+
parsed = begin
|
|
432
|
+
JSON.parse(response_body)
|
|
433
|
+
rescue JSON::ParserError
|
|
434
|
+
nil
|
|
435
|
+
end
|
|
436
|
+
content = parsed.is_a?(Hash) ? parsed["content"] : nil
|
|
437
|
+
return "-" unless content.is_a?(Array) && !content.empty?
|
|
438
|
+
|
|
439
|
+
parts = content.filter_map do |b|
|
|
440
|
+
next unless b.is_a?(Hash)
|
|
441
|
+
|
|
442
|
+
case b["type"]
|
|
443
|
+
when "text" then b["text"].to_s.strip
|
|
444
|
+
when "tool_use" then "[tool_use: #{b['name']}]"
|
|
445
|
+
else "[#{b['type']}]"
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
summary = parts.join(" ").gsub(/\s+/, " ").strip
|
|
449
|
+
summary = "-" if summary.empty?
|
|
450
|
+
summary.length > CONTENT_SUMMARY_LIMIT ? "#{summary[0, CONTENT_SUMMARY_LIMIT]}…" : summary
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# response_body(JSON) の content 配列を抽出し、text / tool_use を読みやすく表示する。
|
|
454
|
+
# パース不能・content 無しなら注記を返す。
|
|
455
|
+
def response_content_block(response_body)
|
|
456
|
+
return %(<p class="note">(未記録)</p>) if response_body.nil?
|
|
457
|
+
|
|
458
|
+
parsed = begin
|
|
459
|
+
JSON.parse(response_body)
|
|
460
|
+
rescue JSON::ParserError
|
|
461
|
+
nil
|
|
462
|
+
end
|
|
463
|
+
content = parsed.is_a?(Hash) ? parsed["content"] : nil
|
|
464
|
+
return %(<p class="note">content がありません。</p>) unless content.is_a?(Array) && !content.empty?
|
|
465
|
+
|
|
466
|
+
blocks = content.map do |b|
|
|
467
|
+
next unless b.is_a?(Hash)
|
|
468
|
+
|
|
469
|
+
case b["type"]
|
|
470
|
+
when "text"
|
|
471
|
+
%(<div class="cblock"><span class="ctag">text</span>) +
|
|
472
|
+
%(<pre>#{CGI.escapeHTML(b['text'].to_s)}</pre></div>)
|
|
473
|
+
when "tool_use"
|
|
474
|
+
input = pretty_json(JSON.generate(b["input"]))
|
|
475
|
+
%(<div class="cblock"><span class="ctag">tool_use: #{CGI.escapeHTML(b['name'].to_s)}</span>) +
|
|
476
|
+
%(<pre>#{CGI.escapeHTML(input)}</pre></div>)
|
|
477
|
+
else
|
|
478
|
+
%(<div class="cblock"><span class="ctag">#{CGI.escapeHTML(b['type'].to_s)}</span>) +
|
|
479
|
+
%(<pre>#{CGI.escapeHTML(pretty_json(JSON.generate(b)))}</pre></div>)
|
|
480
|
+
end
|
|
481
|
+
end.compact
|
|
482
|
+
blocks.empty? ? %(<p class="note">表示可能な content がありません。</p>) : blocks.join
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# request_body(JSON) の messages 配列を抽出し、role ごとに読みやすく表示する。
|
|
486
|
+
# content は文字列 / text ブロック配列のどちらも扱う。
|
|
487
|
+
# パース不能・messages 無しなら注記を返す。
|
|
488
|
+
def request_messages_block(request_body)
|
|
489
|
+
return %(<p class="note">(未記録)</p>) if request_body.nil?
|
|
490
|
+
|
|
491
|
+
parsed = begin
|
|
492
|
+
JSON.parse(request_body)
|
|
493
|
+
rescue JSON::ParserError
|
|
494
|
+
nil
|
|
495
|
+
end
|
|
496
|
+
messages = parsed.is_a?(Hash) ? parsed["messages"] : nil
|
|
497
|
+
return %(<p class="note">messages がありません。</p>) unless messages.is_a?(Array) && !messages.empty?
|
|
498
|
+
|
|
499
|
+
blocks = messages.filter_map do |m|
|
|
500
|
+
next unless m.is_a?(Hash)
|
|
501
|
+
|
|
502
|
+
%(<div class="cblock"><span class="ctag">#{CGI.escapeHTML(m['role'].to_s)}</span>) +
|
|
503
|
+
%(<pre>#{CGI.escapeHTML(message_content_text(m['content']))}</pre></div>)
|
|
504
|
+
end
|
|
505
|
+
blocks.empty? ? %(<p class="note">表示可能な messages がありません。</p>) : blocks.join
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# messages[].content は文字列、または text ブロック配列のどちらも取り得る。
|
|
509
|
+
def message_content_text(content)
|
|
510
|
+
case content
|
|
511
|
+
when String then content
|
|
512
|
+
when Array
|
|
513
|
+
content.filter_map { |b| b["text"] if b.is_a?(Hash) && b["type"] == "text" }
|
|
514
|
+
.join("\n")
|
|
515
|
+
else
|
|
516
|
+
""
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# 同一 trace のツール実行判定を表にする。空なら注記のみ。
|
|
521
|
+
def tool_outcomes_table(outcomes)
|
|
522
|
+
return %(<p class="note">判定結果はありません(trace 未紐付け、または未実行)。</p>) if outcomes.empty?
|
|
523
|
+
|
|
524
|
+
rows = outcomes.map do |o|
|
|
525
|
+
status = o["status"].to_s
|
|
526
|
+
verifier = o["verifier_output"].nil? ? "-" : o["verifier_output"].to_s
|
|
527
|
+
%(<tr>) +
|
|
528
|
+
%(<td>#{CGI.escapeHTML(o['attempt'].to_s)}</td>) +
|
|
529
|
+
%(<td>#{CGI.escapeHTML(tool_name(o['proposal']))}</td>) +
|
|
530
|
+
%(<td class="status-#{CGI.escapeHTML(status)}">#{CGI.escapeHTML(status)}</td>) +
|
|
531
|
+
%(<td>#{CGI.escapeHTML(verifier)}</td>) +
|
|
532
|
+
%(</tr>)
|
|
533
|
+
end.join
|
|
534
|
+
%(<table class="outcomes"><thead><tr>) +
|
|
535
|
+
%(<th>attempt</th><th>tool</th><th>判定(status)</th><th>verifier</th>) +
|
|
536
|
+
%(</tr></thead><tbody>#{rows}</tbody></table>)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# 監査ログ 1 件の詳細ページ (request / proposal / result / verifier_output を整形表示)。
|
|
540
|
+
def log_detail_html(row)
|
|
541
|
+
tid = row["trace_id"].to_s
|
|
542
|
+
trace_link = tid.empty? ? "(なし)" : %(<a href="/logs?trace_id=#{CGI.escape(tid)}">#{CGI.escapeHTML(tid)}</a>)
|
|
543
|
+
llm_link = tid.empty? ? "(なし)" : %(<a href="/logs/llm?trace_id=#{CGI.escape(tid)}">LLM ペイロードを見る</a>)
|
|
544
|
+
status = row["status"].to_s
|
|
545
|
+
<<~HTML
|
|
546
|
+
<!DOCTYPE html>
|
|
547
|
+
<html lang="ja">
|
|
548
|
+
<head>
|
|
549
|
+
<meta charset="utf-8">
|
|
550
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
551
|
+
<title>chocomint 監査ログ ##{CGI.escapeHTML(row['id'].to_s)}</title>
|
|
552
|
+
<style>
|
|
553
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
554
|
+
body { font-family: system-ui, sans-serif; margin: 1.5rem; color: #222; }
|
|
555
|
+
h1 { font-size: 1.2rem; }
|
|
556
|
+
.btn { display: inline-block; padding: 0.3rem 0.7rem; border: 1px solid #ccc;
|
|
557
|
+
border-radius: 4px; background: #fff; color: #222; text-decoration: none;
|
|
558
|
+
font-size: 0.85rem; }
|
|
559
|
+
.btn:hover { background: #f0f0f0; }
|
|
560
|
+
.head { background: #f5f5f5; padding: 0.5rem 0.75rem; border-radius: 4px;
|
|
561
|
+
display: flex; gap: 1rem; flex-wrap: wrap; align-items: center;
|
|
562
|
+
font-size: 0.85rem; margin: 0.75rem 0 1rem; }
|
|
563
|
+
.head a { color: #1155cc; text-decoration: none; }
|
|
564
|
+
.lbl { color: #555; font-size: 0.85rem; font-weight: 600; margin: 1rem 0 0.3rem; }
|
|
565
|
+
pre { margin: 0; padding: 0.75rem; background: #fbfbfb; border: 1px solid #eee;
|
|
566
|
+
border-radius: 4px; font-size: 0.8rem; overflow-x: auto;
|
|
567
|
+
white-space: pre-wrap; word-break: break-word; }
|
|
568
|
+
.status-PASS { color: #137333; font-weight: 600; }
|
|
569
|
+
.status-RETRY { color: #b06000; font-weight: 600; }
|
|
570
|
+
.status-STEP { color: #1155cc; font-weight: 600; }
|
|
571
|
+
.status-FAIL { color: #c5221f; font-weight: 600; }
|
|
572
|
+
</style>
|
|
573
|
+
</head>
|
|
574
|
+
<body>
|
|
575
|
+
<div><a class="btn" href="/logs">一覧へ</a></div>
|
|
576
|
+
<h1>監査ログ ##{CGI.escapeHTML(row['id'].to_s)}</h1>
|
|
577
|
+
<div class="head">
|
|
578
|
+
<span>trace: #{trace_link}</span>
|
|
579
|
+
<span>attempt: #{CGI.escapeHTML(row['attempt'].to_s)}</span>
|
|
580
|
+
<span class="status-#{CGI.escapeHTML(status)}">status: #{CGI.escapeHTML(status)}</span>
|
|
581
|
+
<span>#{CGI.escapeHTML(row['created_at'].to_s)}</span>
|
|
582
|
+
<span>#{llm_link}</span>
|
|
583
|
+
</div>
|
|
584
|
+
<div class="lbl">request</div>
|
|
585
|
+
<pre>#{CGI.escapeHTML(row['request'].to_s)}</pre>
|
|
586
|
+
<div class="lbl">proposal</div>
|
|
587
|
+
<pre>#{CGI.escapeHTML(pretty_json(row['proposal']))}</pre>
|
|
588
|
+
<div class="lbl">result</div>
|
|
589
|
+
<pre>#{CGI.escapeHTML(pretty_json(row['result']))}</pre>
|
|
590
|
+
<div class="lbl">verifier_output</div>
|
|
591
|
+
<pre>#{CGI.escapeHTML(row['verifier_output'].nil? ? '(なし)' : row['verifier_output'].to_s)}</pre>
|
|
592
|
+
</body>
|
|
593
|
+
</html>
|
|
594
|
+
HTML
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def log_detail_not_found(id)
|
|
598
|
+
<<~HTML
|
|
599
|
+
<!DOCTYPE html>
|
|
600
|
+
<html lang="ja"><head><meta charset="utf-8">
|
|
601
|
+
<title>not found</title></head>
|
|
602
|
+
<body style="font-family:system-ui,sans-serif;margin:1.5rem">
|
|
603
|
+
<p>監査ログ ##{CGI.escapeHTML(id.to_s)} は見つかりませんでした。</p>
|
|
604
|
+
<p><a href="/logs">一覧へ</a></p>
|
|
605
|
+
</body></html>
|
|
606
|
+
HTML
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def llm_detail_not_found(id)
|
|
610
|
+
<<~HTML
|
|
611
|
+
<!DOCTYPE html>
|
|
612
|
+
<html lang="ja"><head><meta charset="utf-8">
|
|
613
|
+
<title>not found</title></head>
|
|
614
|
+
<body style="font-family:system-ui,sans-serif;margin:1.5rem">
|
|
615
|
+
<p>ペイロード ##{CGI.escapeHTML(id.to_s)} は見つかりませんでした。</p>
|
|
616
|
+
<p><a href="/logs/llm">一覧へ</a></p>
|
|
617
|
+
</body></html>
|
|
618
|
+
HTML
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# JSON 文字列を整形。パースできなければ原文をそのまま返す。
|
|
622
|
+
def pretty_json(text)
|
|
623
|
+
JSON.pretty_generate(JSON.parse(text.to_s))
|
|
624
|
+
rescue JSON::ParserError
|
|
625
|
+
text.to_s
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def llm_pager(total, page, base)
|
|
629
|
+
last_page = total.zero? ? 0 : (total - 1) / PAGE_SIZE
|
|
630
|
+
prev_attr = page <= 0 ? %( aria-disabled="true") : ""
|
|
631
|
+
next_attr = page >= last_page ? %( aria-disabled="true") : ""
|
|
632
|
+
%(<div class="pager">) +
|
|
633
|
+
%(<a class="btn" href="#{base}page=#{page - 1}"#{prev_attr}>前へ</a>) +
|
|
634
|
+
%(<span>#{page + 1} / #{last_page + 1} ページ</span>) +
|
|
635
|
+
%(<a class="btn" href="#{base}page=#{page + 1}"#{next_attr}>次へ</a>) +
|
|
636
|
+
%(</div>)
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
# tool は proposal から抽出する派生列。id は表示上 "no" と見出しを付ける。
|
|
640
|
+
# llm_payloads / related は実カラムを持たない派生列 (行生成側で個別に組み立てる)。
|
|
641
|
+
COLUMNS = %w[id trace_id tool attempt status verifier_output request proposal
|
|
642
|
+
created_at llm_payloads related result].freeze
|
|
643
|
+
COLUMN_LABELS = { "id" => "no", "llm_payloads" => "LLM ペイロード", "related" => "関連" }.freeze
|
|
644
|
+
|
|
645
|
+
def logs_html(rows, total:, page:, trace_id:)
|
|
646
|
+
body_rows = rows.map { |row| logs_row(row) }.join("\n")
|
|
647
|
+
<<~HTML
|
|
648
|
+
<!DOCTYPE html>
|
|
649
|
+
<html lang="ja">
|
|
650
|
+
<head>
|
|
651
|
+
<meta charset="utf-8">
|
|
652
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
653
|
+
<title>chocomint 監査ログ</title>
|
|
654
|
+
<style>
|
|
655
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
656
|
+
body { font-family: system-ui, sans-serif; margin: 1.5rem; color: #222; }
|
|
657
|
+
h1 { font-size: 1.25rem; }
|
|
658
|
+
.meta { color: #666; margin-bottom: 1rem; }
|
|
659
|
+
.filter { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;
|
|
660
|
+
flex-wrap: wrap; }
|
|
661
|
+
.filter code { background: #eef; padding: 0.1rem 0.4rem; border-radius: 3px; }
|
|
662
|
+
.btn { display: inline-block; padding: 0.3rem 0.7rem; border: 1px solid #ccc;
|
|
663
|
+
border-radius: 4px; background: #fff; color: #222; cursor: pointer;
|
|
664
|
+
text-decoration: none; font-size: 0.85rem; }
|
|
665
|
+
.btn:hover { background: #f0f0f0; }
|
|
666
|
+
.btn[aria-disabled="true"] { opacity: 0.4; pointer-events: none; }
|
|
667
|
+
/* 横スクロール: 各セルは 1 行に固定して溢れたらスクロール */
|
|
668
|
+
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch;
|
|
669
|
+
border: 1px solid #ddd; border-radius: 4px; }
|
|
670
|
+
table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
|
|
671
|
+
th, td { border: 1px solid #ddd; padding: 0.4rem 0.5rem; text-align: left;
|
|
672
|
+
white-space: nowrap; }
|
|
673
|
+
th { background: #f5f5f5; position: sticky; top: 0; }
|
|
674
|
+
td.no a, td.trace a { color: #1155cc; text-decoration: none; }
|
|
675
|
+
td.no a:hover, td.trace a:hover { text-decoration: underline; }
|
|
676
|
+
tr:nth-child(even) { background: #fafafa; }
|
|
677
|
+
.status-PASS { color: #137333; font-weight: 600; }
|
|
678
|
+
.status-RETRY { color: #b06000; font-weight: 600; }
|
|
679
|
+
.status-STEP { color: #1155cc; font-weight: 600; }
|
|
680
|
+
.status-FAIL { color: #c5221f; font-weight: 600; }
|
|
681
|
+
.empty { color: #888; }
|
|
682
|
+
.pager { display: flex; align-items: center; gap: 0.75rem; margin-top: 1rem; }
|
|
683
|
+
@media (max-width: 768px) {
|
|
684
|
+
body { margin: 0.75rem; }
|
|
685
|
+
table { font-size: 0.78rem; }
|
|
686
|
+
th, td { padding: 0.3rem 0.35rem; }
|
|
687
|
+
}
|
|
688
|
+
</style>
|
|
689
|
+
</head>
|
|
690
|
+
<body>
|
|
691
|
+
<h1>chocomint 監査ログ</h1>
|
|
692
|
+
<div class="filter"><a class="btn" href="/logs/llm#{trace_id ? "?trace_id=#{CGI.escape(trace_id)}" : ''}">LLM ペイロードを見る</a></div>
|
|
693
|
+
#{filter_bar(trace_id)}
|
|
694
|
+
<p class="meta">#{page_range_label(total, page)}</p>
|
|
695
|
+
<div class="table-wrap">
|
|
696
|
+
<table>
|
|
697
|
+
<thead>
|
|
698
|
+
<tr>#{COLUMNS.map { |c| "<th>#{CGI.escapeHTML(COLUMN_LABELS[c] || c)}</th>" }.join}</tr>
|
|
699
|
+
</thead>
|
|
700
|
+
<tbody>
|
|
701
|
+
#{rows.empty? ? %(<tr><td colspan="#{COLUMNS.size}" class="empty">ログはありません</td></tr>) : body_rows}
|
|
702
|
+
</tbody>
|
|
703
|
+
</table>
|
|
704
|
+
</div>
|
|
705
|
+
#{pager(total, page, trace_id)}
|
|
706
|
+
</body>
|
|
707
|
+
</html>
|
|
708
|
+
HTML
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# trace_id フィルタ中はその ID と解除ボタンを表示する。
|
|
712
|
+
def filter_bar(trace_id)
|
|
713
|
+
return "" if trace_id.nil?
|
|
714
|
+
|
|
715
|
+
%(<div class="filter">trace_id で絞り込み中: ) +
|
|
716
|
+
%(<code>#{CGI.escapeHTML(trace_id)}</code>) +
|
|
717
|
+
%(<a class="btn" href="/logs">フィルタ解除</a></div>)
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
def page_range_label(total, page)
|
|
721
|
+
return "0 件" if total.zero?
|
|
722
|
+
|
|
723
|
+
from = page * PAGE_SIZE + 1
|
|
724
|
+
to = [from + PAGE_SIZE - 1, total].min
|
|
725
|
+
"#{from}〜#{to} 件 / 全 #{total} 件(新しい順)"
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
# ページャ。trace_id フィルタはリンク間で維持する。
|
|
729
|
+
def pager(total, page, trace_id)
|
|
730
|
+
last_page = total.zero? ? 0 : (total - 1) / PAGE_SIZE
|
|
731
|
+
base = trace_id ? "/logs?trace_id=#{CGI.escape(trace_id)}&" : "/logs?"
|
|
732
|
+
prev_attr = page <= 0 ? %( aria-disabled="true") : ""
|
|
733
|
+
next_attr = page >= last_page ? %( aria-disabled="true") : ""
|
|
734
|
+
|
|
735
|
+
%(<div class="pager">) +
|
|
736
|
+
%(<a class="btn" href="#{base}page=#{page - 1}"#{prev_attr}>前へ</a>) +
|
|
737
|
+
%(<span>#{page + 1} / #{last_page + 1} ページ</span>) +
|
|
738
|
+
%(<a class="btn" href="#{base}page=#{page + 1}"#{next_attr}>次へ</a>) +
|
|
739
|
+
%(</div>)
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
def logs_row(row)
|
|
743
|
+
tid = row["trace_id"].to_s
|
|
744
|
+
cells = COLUMNS.map do |col|
|
|
745
|
+
# tool は proposal(JSON) から抽出する派生列。
|
|
746
|
+
value = col == "tool" ? tool_name(row["proposal"]) : row[col]
|
|
747
|
+
text = value.nil? ? "" : value.to_s
|
|
748
|
+
label = %(data-label="#{CGI.escapeHTML(col)}")
|
|
749
|
+
case col
|
|
750
|
+
when "id"
|
|
751
|
+
# クリックでこの行の詳細ページを別途開く
|
|
752
|
+
link = %(<a href="/logs/detail?id=#{CGI.escape(text)}">##{CGI.escapeHTML(text)}</a>)
|
|
753
|
+
%(<td #{label} class="no">#{link}</td>)
|
|
754
|
+
when "status"
|
|
755
|
+
%(<td #{label} class="status-#{CGI.escapeHTML(text)}">#{CGI.escapeHTML(text)}</td>)
|
|
756
|
+
when "trace_id"
|
|
757
|
+
# クリックでその trace_id のみにフィルタ
|
|
758
|
+
link = %(<a href="/logs?trace_id=#{CGI.escape(text)}">#{CGI.escapeHTML(text)}</a>)
|
|
759
|
+
%(<td #{label} class="trace">#{link}</td>)
|
|
760
|
+
when "llm_payloads"
|
|
761
|
+
content = tid.empty? ? "-" : %(<a href="/logs/llm?trace_id=#{CGI.escape(tid)}">LLM ペイロード</a>)
|
|
762
|
+
%(<td #{label}>#{content}</td>)
|
|
763
|
+
when "related"
|
|
764
|
+
content = tid.empty? ? "-" : %(<a href="/logs?trace_id=#{CGI.escape(tid)}">同一 trace の試行</a>)
|
|
765
|
+
%(<td #{label}>#{content}</td>)
|
|
766
|
+
else
|
|
767
|
+
%(<td #{label}>#{CGI.escapeHTML(text)}</td>)
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
"<tr>#{cells.join}</tr>"
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
# proposal(JSON 文字列) から tool 名を取り出す。抽出不能なら空文字。
|
|
774
|
+
def tool_name(proposal)
|
|
775
|
+
parsed = JSON.parse(proposal.to_s)
|
|
776
|
+
parsed.is_a?(Hash) ? parsed["tool"].to_s : ""
|
|
777
|
+
rescue JSON::ParserError
|
|
778
|
+
""
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
def handle_messages(req, res)
|
|
782
|
+
unless req.request_method == "POST"
|
|
783
|
+
return error_json(res, 405, "invalid_request_error", "method not allowed")
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
body = JSON.parse(req.body.to_s)
|
|
787
|
+
status, payload = process(body)
|
|
788
|
+
res.status = status
|
|
789
|
+
res["content-type"] = "application/json"
|
|
790
|
+
res.body = JSON.generate(payload)
|
|
791
|
+
rescue JSON::ParserError => e
|
|
792
|
+
error_json(res, 400, "invalid_request_error", "invalid JSON: #{e.message}")
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
# Anthropic 形式 messages から最後の user テキストを取り出す。
|
|
796
|
+
def extract_request(body)
|
|
797
|
+
messages = body["messages"]
|
|
798
|
+
return nil unless messages.is_a?(Array)
|
|
799
|
+
|
|
800
|
+
user = messages.reverse.find { |m| m["role"] == "user" }
|
|
801
|
+
return nil unless user
|
|
802
|
+
|
|
803
|
+
case user["content"]
|
|
804
|
+
when String then user["content"]
|
|
805
|
+
when Array
|
|
806
|
+
user["content"].filter_map { |b| b["text"] if b.is_a?(Hash) && b["type"] == "text" }
|
|
807
|
+
.join("\n")
|
|
808
|
+
end
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
# 監督結果を Anthropic Messages レスポンスとして組み立てる。
|
|
812
|
+
# マルチステップに対応し、実行した全ステップの tool_use(提案) と
|
|
813
|
+
# tool_result(実行結果) を順に content へ並べる。
|
|
814
|
+
def success_body(result)
|
|
815
|
+
{
|
|
816
|
+
"id" => "msg_#{SecureRandom.hex(12)}",
|
|
817
|
+
"type" => "message",
|
|
818
|
+
"role" => "assistant",
|
|
819
|
+
"model" => "chocomint-supervisor",
|
|
820
|
+
"stop_reason" => "tool_use",
|
|
821
|
+
"chocomint" => {
|
|
822
|
+
"status" => result.status,
|
|
823
|
+
"trace_id" => result.trace_id,
|
|
824
|
+
"attempts" => result.attempts,
|
|
825
|
+
"steps" => result.steps.size
|
|
826
|
+
},
|
|
827
|
+
"content" => result.steps.flat_map { |step| step_content(step) }
|
|
828
|
+
}
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
# 1 ステップを tool_use + tool_result の 2 ブロックに展開する。
|
|
832
|
+
def step_content(step)
|
|
833
|
+
[
|
|
834
|
+
{
|
|
835
|
+
"type" => "tool_use",
|
|
836
|
+
"id" => "toolu_#{SecureRandom.hex(12)}",
|
|
837
|
+
"name" => step[:tool],
|
|
838
|
+
"input" => step[:arguments]
|
|
839
|
+
},
|
|
840
|
+
{
|
|
841
|
+
"type" => "tool_result",
|
|
842
|
+
"content" => [{ "type" => "text", "text" => JSON.generate(step[:result]) }]
|
|
843
|
+
}
|
|
844
|
+
]
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
def error_body(type, message)
|
|
848
|
+
{ "type" => "error", "error" => { "type" => type, "message" => message } }
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
def ok_json(res, hash)
|
|
852
|
+
res.status = 200
|
|
853
|
+
res["content-type"] = "application/json"
|
|
854
|
+
res.body = JSON.generate(hash)
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def error_json(res, status, type, message)
|
|
858
|
+
res.status = status
|
|
859
|
+
res["content-type"] = "application/json"
|
|
860
|
+
res.body = JSON.generate(error_body(type, message))
|
|
861
|
+
end
|
|
862
|
+
end
|
|
863
|
+
end
|