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.
Files changed (158) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +224 -0
  4. data/THIRD_PARTY_LICENSES.md +45 -0
  5. data/bin/chocomint +37 -0
  6. data/bin/chocomint-console-worker +35 -0
  7. data/bin/chocomint-server +43 -0
  8. data/config/config.yml +37 -0
  9. data/lib/chocomint/config.rb +74 -0
  10. data/lib/chocomint/console/conpty.rb +296 -0
  11. data/lib/chocomint/console/server.rb +164 -0
  12. data/lib/chocomint/console/worker.rb +88 -0
  13. data/lib/chocomint/console/worker_process.rb +254 -0
  14. data/lib/chocomint/edit_git_handlers.rb +397 -0
  15. data/lib/chocomint/edit_handlers.rb +696 -0
  16. data/lib/chocomint/edit_html.rb +9 -0
  17. data/lib/chocomint/errors.rb +18 -0
  18. data/lib/chocomint/factory.rb +145 -0
  19. data/lib/chocomint/llm/base_client.rb +113 -0
  20. data/lib/chocomint/llm/chat_client.rb +206 -0
  21. data/lib/chocomint/llm/primary_client.rb +320 -0
  22. data/lib/chocomint/llm/verifier_client.rb +127 -0
  23. data/lib/chocomint/logger/sqlite_logger.rb +245 -0
  24. data/lib/chocomint/orchestrator.rb +175 -0
  25. data/lib/chocomint/planner.rb +241 -0
  26. data/lib/chocomint/security/path_guard.rb +84 -0
  27. data/lib/chocomint/server.rb +863 -0
  28. data/lib/chocomint/tools/append_file.rb +50 -0
  29. data/lib/chocomint/tools/base.rb +88 -0
  30. data/lib/chocomint/tools/delete_file.rb +42 -0
  31. data/lib/chocomint/tools/edit.rb +65 -0
  32. data/lib/chocomint/tools/fetch_url.rb +156 -0
  33. data/lib/chocomint/tools/file_info.rb +54 -0
  34. data/lib/chocomint/tools/glob.rb +58 -0
  35. data/lib/chocomint/tools/grep.rb +78 -0
  36. data/lib/chocomint/tools/list_dir.rb +41 -0
  37. data/lib/chocomint/tools/ls.rb +51 -0
  38. data/lib/chocomint/tools/make_dir.rb +44 -0
  39. data/lib/chocomint/tools/read_file.rb +45 -0
  40. data/lib/chocomint/tools/registry.rb +29 -0
  41. data/lib/chocomint/tools/run_command.rb +96 -0
  42. data/lib/chocomint/tools/shell.rb +169 -0
  43. data/lib/chocomint/tools/write_file.rb +54 -0
  44. data/lib/chocomint/validator/machine_validator.rb +65 -0
  45. data/lib/chocomint/validator/semantic_validator.rb +52 -0
  46. data/lib/chocomint.rb +51 -0
  47. data/public/edit/edit.css +414 -0
  48. data/public/edit/edit.js +2137 -0
  49. data/public/edit/index.html +251 -0
  50. data/public/vendor/monaco/LICENSE.txt +23 -0
  51. data/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  52. data/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  53. data/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  54. data/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  55. data/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  56. data/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  57. data/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  58. data/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  59. data/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  60. data/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  61. data/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  62. data/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  63. data/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  64. data/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  65. data/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  66. data/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  67. data/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  68. data/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  69. data/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  70. data/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  71. data/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  72. data/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  73. data/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  74. data/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  75. data/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  76. data/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  77. data/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  78. data/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  79. data/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  80. data/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  81. data/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  82. data/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  83. data/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  84. data/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  85. data/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  86. data/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  87. data/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  88. data/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  89. data/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  90. data/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  91. data/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  92. data/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  93. data/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  94. data/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  95. data/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  96. data/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  97. data/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  98. data/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  99. data/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  100. data/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  101. data/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  102. data/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  103. data/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  104. data/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  105. data/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  106. data/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  107. data/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  108. data/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  109. data/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  110. data/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  111. data/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  112. data/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  113. data/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  114. data/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  115. data/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  116. data/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  117. data/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  118. data/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  119. data/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  120. data/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  121. data/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  122. data/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  123. data/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  124. data/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  125. data/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  126. data/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  127. data/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  128. data/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  129. data/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  130. data/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  131. data/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  132. data/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  133. data/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  134. data/public/vendor/monaco/vs/editor/editor.main.css +8 -0
  135. data/public/vendor/monaco/vs/editor/editor.main.js +798 -0
  136. data/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
  137. data/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  138. data/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  139. data/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  140. data/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  141. data/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  142. data/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  143. data/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  144. data/public/vendor/monaco/vs/loader.js +11 -0
  145. data/public/vendor/monaco/vs/nls.messages.de.js +21 -0
  146. data/public/vendor/monaco/vs/nls.messages.es.js +21 -0
  147. data/public/vendor/monaco/vs/nls.messages.fr.js +19 -0
  148. data/public/vendor/monaco/vs/nls.messages.it.js +19 -0
  149. data/public/vendor/monaco/vs/nls.messages.ja.js +21 -0
  150. data/public/vendor/monaco/vs/nls.messages.ko.js +19 -0
  151. data/public/vendor/monaco/vs/nls.messages.ru.js +21 -0
  152. data/public/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
  153. data/public/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
  154. data/public/vendor/xterm/LICENSE +25 -0
  155. data/public/vendor/xterm/addon-fit.js +2 -0
  156. data/public/vendor/xterm/xterm.css +218 -0
  157. data/public/vendor/xterm/xterm.js +2 -0
  158. 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