scout-ai 1.2.3 → 2.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 +4 -4
- data/.vimproject +138 -50
- data/README.md +171 -290
- data/Rakefile +17 -1
- data/VERSION +1 -1
- data/doc/Improvements.md +325 -0
- data/doc/StartHere.md +110 -0
- data/doc/developer/Architecture.md +126 -0
- data/doc/developer/Backends.md +199 -0
- data/doc/developer/ChatLifecycle.md +183 -0
- data/doc/developer/DelegationInternals.md +295 -0
- data/doc/developer/DesignPrinciples.md +245 -0
- data/doc/developer/PromptProcessing.md +292 -0
- data/doc/developer/Provenance.md +317 -0
- data/doc/user/BuildingAgents.md +345 -0
- data/doc/user/Cookbook.md +333 -0
- data/doc/user/CoreConcepts.md +181 -0
- data/doc/user/Delegation.md +191 -0
- data/doc/user/GettingStarted.md +159 -0
- data/doc/user/ManagingContext.md +163 -0
- data/doc/user/MultiAgentWorkflows.md +256 -0
- data/doc/user/Python.md +159 -0
- data/doc/user/RunningInference.md +200 -0
- data/doc/user/ToolCalling.md +193 -0
- data/doc/user/WritingChats.md +197 -0
- data/lib/scout/llm/agent/chat.rb +61 -11
- data/lib/scout/llm/agent/delegate.rb +274 -65
- data/lib/scout/llm/agent/iterate.rb +2 -2
- data/lib/scout/llm/agent/save.rb +273 -0
- data/lib/scout/llm/agent/workflow.rb +164 -0
- data/lib/scout/llm/agent.rb +86 -61
- data/lib/scout/llm/ask.rb +62 -17
- data/lib/scout/llm/backends/anthropic.rb +9 -2
- data/lib/scout/llm/backends/bedrock.rb +15 -3
- data/lib/scout/llm/backends/default.rb +183 -99
- data/lib/scout/llm/backends/glm.rb +58 -0
- data/lib/scout/llm/backends/huggingface.rb +196 -26
- data/lib/scout/llm/backends/ollama.rb +13 -1
- data/lib/scout/llm/backends/openai.rb +0 -2
- data/lib/scout/llm/backends/openwebui.rb +20 -13
- data/lib/scout/llm/backends/relay.rb +22 -22
- data/lib/scout/llm/backends/responses.rb +1 -1
- data/lib/scout/llm/chat/agent_meta.rb +264 -0
- data/lib/scout/llm/chat/annotation.rb +39 -10
- data/lib/scout/llm/chat/parse.rb +28 -6
- data/lib/scout/llm/chat/persist.rb +25 -0
- data/lib/scout/llm/chat/process/clear.rb +41 -6
- data/lib/scout/llm/chat/process/files.rb +21 -6
- data/lib/scout/llm/chat/process/meta.rb +421 -34
- data/lib/scout/llm/chat/process/options.rb +21 -1
- data/lib/scout/llm/chat/process/tools.rb +56 -15
- data/lib/scout/llm/chat/process.rb +4 -0
- data/lib/scout/llm/chat/prompt/shorten_tools.rb +125 -0
- data/lib/scout/llm/chat/prompt/shorten_tools_epoch.rb +365 -0
- data/lib/scout/llm/chat/prompt.rb +48 -0
- data/lib/scout/llm/chat/provenance.rb +775 -0
- data/lib/scout/llm/chat/tool_calls.rb +76 -0
- data/lib/scout/llm/chat.rb +18 -2
- data/lib/scout/llm/embed.rb +11 -3
- data/lib/scout/llm/image.rb +86 -0
- data/lib/scout/llm/mcp.rb +10 -2
- data/lib/scout/llm/rag.rb +3 -3
- data/lib/scout/llm/tools/call.rb +160 -11
- data/lib/scout/llm/tools/knowledge_base.rb +1 -1
- data/lib/scout/llm/tools/workflow.rb +32 -16
- data/lib/scout/model/python/huggingface/causal.rb +23 -5
- data/lib/scout/model/python/huggingface.rb +2 -1
- data/lib/scout-ai.rb +1 -0
- data/python/README.md +197 -14
- data/python/scout_ai/huggingface/eval.py +245 -34
- data/python/tests/test_huggingface_eval.py +58 -0
- data/research/ChatAnalyst-required-changes.md +167 -0
- data/research/agent-delegation-analysis.md +810 -0
- data/research/agent-meta-provenance-integration-plan.md +622 -0
- data/research/agent-workflow-analysis.md +1120 -0
- data/research/backends-analysis.md +836 -0
- data/research/chat-core-analysis.md +946 -0
- data/research/chatanalyst-provenance/00-baseline.md +30 -0
- data/research/chatanalyst-provenance/01-repo-map.md +60 -0
- data/research/chatanalyst-provenance/02-event-reconstruction.md +55 -0
- data/research/chatanalyst-provenance/03-duplication-evidence.md +45 -0
- data/research/chatanalyst-provenance/04-tooling-root-cause.md +57 -0
- data/research/chatanalyst-provenance/05-fix-plan.md +46 -0
- data/research/chatanalyst-provenance/07-critic-review.md +25 -0
- data/research/chatanalyst-provenance/final-report.md +45 -0
- data/research/chatanalyst-provenance/resumption.md +37 -0
- data/research/coding-philosophy-analysis.md +928 -0
- data/research/commands-analysis.md +947 -0
- data/research/multi-agent-patterns-analysis.md +853 -0
- data/research/prompt-strategies-analysis.md +630 -0
- data/research/prov-verbosity-fix-notes.md +77 -0
- data/research/provenance-analysis.md +469 -0
- data/research/provenance-navigation-design.md +640 -0
- data/research/synthesis-report.md +487 -0
- data/research/tools-system-analysis.md +779 -0
- data/scout-ai.gemspec +100 -11
- data/scout_commands/agent/ask +13 -3
- data/scout_commands/agent/kb +2 -0
- data/scout_commands/llm/ask +11 -4
- data/scout_commands/llm/md +76 -0
- data/scout_commands/llm/process_queries +48 -0
- data/scout_commands/llm/prov +602 -0
- data/scout_commands/llm/word +71 -0
- data/scout_commands/workflow/mcp +43 -0
- data/share/word/reference.docx +0 -0
- data/test/etc/AI/mock.yaml +11 -0
- data/test/fixtures/backends/anthropic.json +19 -0
- data/test/fixtures/backends/anthropic_tool_use.json +24 -0
- data/test/fixtures/backends/bedrock.json +8 -0
- data/test/fixtures/backends/bedrock_embedding.json +3 -0
- data/test/fixtures/backends/bedrock_tool_use.json +17 -0
- data/test/fixtures/backends/ollama.json +16 -0
- data/test/fixtures/backends/ollama_tool_call.json +27 -0
- data/test/fixtures/backends/openai_chat.json +21 -0
- data/test/fixtures/backends/openai_chat_tool_call.json +31 -0
- data/test/fixtures/backends/responses.json +33 -0
- data/test/fixtures/backends/responses_tool_call.json +28 -0
- data/test/integration/README.md +32 -0
- data/test/integration/scout/llm/backends/test_endpoints.rb +34 -0
- data/test/integration/scout/llm/backends/test_openwebui.rb +61 -0
- data/test/integration/scout/llm/backends/test_relay.rb +52 -0
- data/test/integration/scout/llm/test_infrastructure.rb +74 -0
- data/test/{scout → integration/scout}/llm/test_mcp.rb +1 -1
- data/test/integration/scout/llm/tools/test_mcp.rb +42 -0
- data/test/integration/scout/model/test_base.rb +91 -0
- data/test/scout/llm/agent/test_chat.rb +8 -2
- data/test/scout/llm/agent/test_save.rb +413 -0
- data/test/scout/llm/agent/test_workflow.rb +110 -0
- data/test/scout/llm/backends/test_anthropic.rb +93 -10
- data/test/scout/llm/backends/test_bedrock.rb +118 -2
- data/test/scout/llm/backends/test_huggingface.rb +137 -42
- data/test/scout/llm/backends/test_ollama.rb +70 -20
- data/test/scout/llm/backends/test_openwebui.rb +42 -40
- data/test/scout/llm/backends/test_relay.rb +4 -2
- data/test/scout/llm/chat/agent_meta_fixtures.rb +131 -0
- data/test/scout/llm/chat/process/test_meta.rb +518 -0
- data/test/scout/llm/chat/process/test_normalize_usage.rb +183 -0
- data/test/scout/llm/chat/test_agent_meta.rb +357 -0
- data/test/scout/llm/chat/test_agent_meta_provenance.rb +467 -0
- data/test/scout/llm/chat/test_agent_meta_tokens.rb +594 -0
- data/test/scout/llm/chat/test_parse.rb +70 -15
- data/test/scout/llm/chat/test_prov_cli.rb +274 -0
- data/test/scout/llm/chat/test_provenance.rb +240 -0
- data/test/scout/llm/chat/test_tool_calls.rb +38 -0
- data/test/scout/llm/test_agent.rb +13 -36
- data/test/scout/llm/test_ask.rb +75 -52
- data/test/scout/llm/test_chat.rb +107 -13
- data/test/scout/llm/test_embed.rb +48 -0
- data/test/scout/llm/test_rag.rb +23 -16
- data/test/scout/llm/test_tools.rb +12 -1
- data/test/scout/llm/tools/test_knowledge_base.rb +0 -1
- data/test/scout/llm/tools/test_mcp.rb +5 -3
- data/test/scout/llm/tools/test_workflow.rb +23 -2
- data/test/scout/model/python/huggingface/causal/test_next_token.rb +11 -5
- data/test/scout/model/python/huggingface/test_causal.rb +9 -3
- data/test/scout/model/python/huggingface/test_classification.rb +11 -2
- data/test/scout/model/python/test_torch.rb +2 -0
- data/test/scout/model/python/torch/test_helpers.rb +4 -0
- data/test/scout/model/test_base.rb +4 -2
- data/test/support/availability.rb +231 -0
- data/test/support/fake_clients.rb +138 -0
- data/test/support/fixtures.rb +21 -0
- data/test/support/infrastructure_probes.rb +136 -0
- data/test/support/mock_backend.rb +215 -0
- data/test/test_helper.rb +32 -2
- metadata +99 -10
- data/doc/Agent.md +0 -327
- data/doc/Chat.md +0 -458
- data/doc/LLM.md +0 -340
- data/doc/RAG.md +0 -129
- data/scout_commands/documenter +0 -148
- data/test/scout/llm/backends/test_openai.rb +0 -192
- data/test/scout/llm/backends/test_responses.rb +0 -238
- data/test/scout/llm/test_parse.rb +0 -98
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'scout-ai'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'set'
|
|
7
|
+
require 'tempfile'
|
|
8
|
+
|
|
9
|
+
cmd = $previous_commands ?
|
|
10
|
+
"scout #{$previous_commands.any? ? "#{$previous_commands * ' '} " : ''}#{File.basename(__FILE__)}" :
|
|
11
|
+
$PROGRAM_NAME
|
|
12
|
+
|
|
13
|
+
options = SOPT.setup <<~EOF
|
|
14
|
+
|
|
15
|
+
Examine the provenance of a chat or workflow job
|
|
16
|
+
|
|
17
|
+
$ #{cmd} [<options>] <filename>
|
|
18
|
+
|
|
19
|
+
-h--help Print this help
|
|
20
|
+
-c--component Show per-component direct costs instead of aggregate totals
|
|
21
|
+
-f--flow Print a compact provenance flow
|
|
22
|
+
-l--long Show full filesystem paths instead of abbreviated names
|
|
23
|
+
-e--evidence List the deduplicated direct inference events and their evidence
|
|
24
|
+
--dot* Write the flow as Graphviz DOT
|
|
25
|
+
-p--plot* Render the flow as svg, png, or pdf
|
|
26
|
+
EOF
|
|
27
|
+
if options[:help]
|
|
28
|
+
defined?(scout_usage) ? scout_usage : puts(SOPT.doc)
|
|
29
|
+
exit 0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
component = options.delete(:component)
|
|
33
|
+
flow = options.delete(:flow)
|
|
34
|
+
dot_file = options.delete(:dot)
|
|
35
|
+
plot_file = options.delete(:plot)
|
|
36
|
+
long = options.delete(:long)
|
|
37
|
+
evidence = options.delete(:evidence)
|
|
38
|
+
filename = ARGV.first
|
|
39
|
+
raise MissingParameterException, :filename if filename.nil?
|
|
40
|
+
|
|
41
|
+
# A persisted Step always has an info sidecar. A .files directory alone is NOT
|
|
42
|
+
# evidence: saved agent chats carry one too. Explicitly loading the Step after
|
|
43
|
+
# this evidence check avoids treating every readable chat as a job.
|
|
44
|
+
job_evidence = Open.exists?(filename + '.info')
|
|
45
|
+
root_type = job_evidence ? :job : :chat
|
|
46
|
+
root = root_type == :job ? Step.load(filename) : Path.setup(File.expand_path(filename))
|
|
47
|
+
warnings = []
|
|
48
|
+
records = Chat.traverse_provenance(
|
|
49
|
+
root,
|
|
50
|
+
root_type: root_type,
|
|
51
|
+
on_error: lambda do |error, kind, object, relation, reference|
|
|
52
|
+
warnings << {
|
|
53
|
+
error: error,
|
|
54
|
+
kind: kind,
|
|
55
|
+
object: object,
|
|
56
|
+
relation: relation,
|
|
57
|
+
reference: reference
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
).to_a
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
# Build graph: nodes keyed by [kind, path], edges as {from, to, relation}
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
nodes = {}
|
|
66
|
+
edges = []
|
|
67
|
+
records.each do |kind, object, parent_kind, parent, relation, first_visit, _detail|
|
|
68
|
+
key = Chat.provenance_key(kind, object)
|
|
69
|
+
nodes[key] ||= { kind: kind, object: object, path: Chat.provenance_path(kind, object) }
|
|
70
|
+
next unless parent
|
|
71
|
+
|
|
72
|
+
parent_key = Chat.provenance_key(parent_kind, parent)
|
|
73
|
+
nodes[parent_key] ||= {
|
|
74
|
+
kind: parent_kind,
|
|
75
|
+
object: parent,
|
|
76
|
+
path: Chat.provenance_path(parent_kind, parent)
|
|
77
|
+
}
|
|
78
|
+
edge = { from: parent_key, to: key, relation: relation, first_visit: first_visit }
|
|
79
|
+
edges << edge unless edges.any? do |other|
|
|
80
|
+
other[:from] == edge[:from] && other[:to] == edge[:to] && other[:relation] == relation
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
root_key = Chat.provenance_key(root_type, root)
|
|
84
|
+
|
|
85
|
+
# Adjacency: parent -> children, sorted for readability
|
|
86
|
+
adjacency = Hash.new { |h, k| h[k] = [] }
|
|
87
|
+
edges.each do |edge|
|
|
88
|
+
adjacency[edge[:from]] << edge
|
|
89
|
+
end
|
|
90
|
+
RELATION_ORDER = { dependency: 0, log: 1, job: 2, agent_job: 3, result: 4 }
|
|
91
|
+
adjacency.each_value do |children|
|
|
92
|
+
children.sort_by! { |e| RELATION_ORDER[e[:relation]] || 99 }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
# Token computation
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
chat_cache = {}
|
|
99
|
+
|
|
100
|
+
# Direct tokens for a single node:
|
|
101
|
+
# chat -> its own direct inference tokens
|
|
102
|
+
# job -> sum of tokens from its direct log chats (agent.chat + society chats)
|
|
103
|
+
direct_tokens = lambda do |key|
|
|
104
|
+
node = nodes[key]
|
|
105
|
+
if node[:kind] == :chat
|
|
106
|
+
chat = chat_cache[node[:path]] ||= Chat.load(node[:path])
|
|
107
|
+
Chat.token_totals([chat])
|
|
108
|
+
else
|
|
109
|
+
logs = edges.select { |e| e[:from] == key && e[:relation] == :log }
|
|
110
|
+
.collect { |e| nodes[e[:to]][:path] }.uniq
|
|
111
|
+
chats = logs.collect { |path| chat_cache[path] ||= Chat.load(path) }
|
|
112
|
+
Chat.token_totals(chats)
|
|
113
|
+
end
|
|
114
|
+
rescue => error
|
|
115
|
+
warnings << { error: error, kind: node[:kind], object: node[:object], relation: :tokens }
|
|
116
|
+
Chat::TOKEN_KEYS.each_with_object({}) { |name, totals| totals[name.to_sym] = 0 }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# Receipt (agent_meta) evidence: delegated inference events embedded in
|
|
121
|
+
# function_call_output envelopes. Problems are collected, never fatal.
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
receipt_warnings = []
|
|
124
|
+
token_events = nil
|
|
125
|
+
token_events_for = lambda do
|
|
126
|
+
return token_events unless token_events.nil?
|
|
127
|
+
token_events = begin
|
|
128
|
+
Chat.provenance_token_events(root, warnings: receipt_warnings)
|
|
129
|
+
rescue => error
|
|
130
|
+
warnings << { error: error, kind: root_type, object: root, relation: :tokens }
|
|
131
|
+
[]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def receipt_evidence(event)
|
|
136
|
+
event[:evidence].select { |evidence| evidence[:origin] == :agent_meta }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Receipt evidence grouped by the chat file that carries the envelope; used
|
|
140
|
+
# for the one-line delegated annotations and to decide whether scope lines
|
|
141
|
+
# are needed in component mode.
|
|
142
|
+
receipt_events_by_source = Hash.new { |hash, key| hash[key] = [] }
|
|
143
|
+
has_receipt_evidence = false
|
|
144
|
+
token_events_for.call.each do |event|
|
|
145
|
+
next if receipt_evidence(event).empty?
|
|
146
|
+
has_receipt_evidence = true
|
|
147
|
+
receipt_evidence(event).collect { |evidence| evidence[:source] }.compact.uniq.each do |source|
|
|
148
|
+
receipt_events_by_source[source] << event
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Aggregate tokens: provenance-aware totals of the subtree reachable from the
|
|
153
|
+
# node. Direct inference events are deduplicated by identity, so usage that
|
|
154
|
+
# exists only inside agent_meta receipts (delegated children whose logs were
|
|
155
|
+
# never saved) is included without double counting.
|
|
156
|
+
aggregate_cache = {}
|
|
157
|
+
aggregate_tokens = lambda do |key|
|
|
158
|
+
return aggregate_cache[key] if aggregate_cache.key?(key)
|
|
159
|
+
node = nodes[key]
|
|
160
|
+
subtree = node[:kind] == :job ? node[:object] : node[:path]
|
|
161
|
+
aggregate_cache[key] = Chat.provenance_token_totals(subtree, warnings: receipt_warnings)
|
|
162
|
+
rescue => error
|
|
163
|
+
Chat::TOKEN_KEYS.each_with_object({}) { |name, totals| totals[name.to_sym] = 0 }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Whole-tree totals per scope; only meaningful when receipts exist.
|
|
167
|
+
scope_totals = lambda do |scope|
|
|
168
|
+
Chat.provenance_token_totals(root, scope: scope, warnings: receipt_warnings)
|
|
169
|
+
rescue => error
|
|
170
|
+
Chat::TOKEN_KEYS.each_with_object({}) { |name, totals| totals[name.to_sym] = 0 }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Select mode: aggregate (default) or component (direct)
|
|
174
|
+
node_tokens = component ? direct_tokens : aggregate_tokens
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
# Visibility: which nodes should be shown?
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
# Hidden nodes are skipped entirely — not printed and not traversed.
|
|
180
|
+
# - result-relation chats: they duplicate the job node and create cycles
|
|
181
|
+
# - top-level agent.chat log file: its cost is subsumed by the parent job.
|
|
182
|
+
# ScoutCoder: DUAL-LAYOUT. The root copy of the agent conversation sits
|
|
183
|
+
# directly under the files dir in BOTH layouts:
|
|
184
|
+
# new <job>.files/agent.chat
|
|
185
|
+
# legacy <job>.files/log/agent.chat
|
|
186
|
+
# Any basename agent.chat whose parent is a `<something>.files` directory
|
|
187
|
+
# (top level of the files dir) or the legacy `.../log` directory is such a
|
|
188
|
+
# copy. NESTED society chats (agent.society/<agent>/<conv>/agent.chat,
|
|
189
|
+
# log/society/<agent>/<conv>/agent.chat) are real independent inferences
|
|
190
|
+
# and must be shown.
|
|
191
|
+
def top_level_agent_chat?(path)
|
|
192
|
+
return false unless File.basename(path) == 'agent.chat'
|
|
193
|
+
parent = File.dirname(path)
|
|
194
|
+
parent.end_with?('/log') || parent.end_with?('/log/') ||
|
|
195
|
+
parent.end_with?('.files')
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def hidden_node?(node, relation)
|
|
199
|
+
return true if relation == :result
|
|
200
|
+
return true if relation == :log && top_level_agent_chat?(node[:path])
|
|
201
|
+
false
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
# Formatting helpers
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
def token_str(tokens)
|
|
208
|
+
parts = []
|
|
209
|
+
parts << "total=#{Misc.human_number(tokens[:tt])}" if tokens[:tt] && tokens[:tt].to_i > 0
|
|
210
|
+
parts << "prompt=#{Misc.human_number(tokens[:pt])}" if tokens[:pt] && tokens[:pt].to_i > 0
|
|
211
|
+
parts << "cont=#{Misc.human_number(tokens[:ct])}" if tokens[:ct] && tokens[:ct].to_i > 0
|
|
212
|
+
parts << "cache=#{Misc.human_number(tokens[:cct])}" if tokens[:cct] && tokens[:cct].to_i > 0
|
|
213
|
+
parts << "reason=#{Misc.human_number(tokens[:rt])}" if tokens[:rt] && tokens[:rt].to_i > 0
|
|
214
|
+
parts * ' '
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def report_line(kind, label, tokens, offset: 0)
|
|
218
|
+
color = kind == :job ? :yellow : :green
|
|
219
|
+
parts = [' ' * (offset * 2)]
|
|
220
|
+
parts << Log.color(color, kind.to_s)
|
|
221
|
+
tok = token_str(tokens)
|
|
222
|
+
parts << tok unless tok.empty?
|
|
223
|
+
parts << Log.color(:blue, label.to_s) unless label.to_s.empty?
|
|
224
|
+
parts * ' '
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def job_short_id(path)
|
|
228
|
+
basename = File.basename(path)
|
|
229
|
+
|
|
230
|
+
parts = basename.split('_')
|
|
231
|
+
if parts.length == 2 && parts.last.length > 10
|
|
232
|
+
parts.last[0,8]
|
|
233
|
+
else
|
|
234
|
+
basename
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def node_label(node, parent_node = nil, root_key = nil, key = nil, long = false)
|
|
239
|
+
path = node[:path]
|
|
240
|
+
return path if long
|
|
241
|
+
if node[:kind] == :job
|
|
242
|
+
job = node[:object]
|
|
243
|
+
workflow = job.info[:workflow] || job.info['workflow']
|
|
244
|
+
task = job.info[:task_name] || job.info['task_name']
|
|
245
|
+
short_id = job_short_id(path)
|
|
246
|
+
workflow && task ? "#{workflow}/#{task} #{short_id}" : short_id
|
|
247
|
+
elsif parent_node && parent_node[:kind] == :job && path.include?('.files/log/')
|
|
248
|
+
# Log chat under a job: show path relative to the job's log directory.
|
|
249
|
+
# Derive log dir from parent_node path (which is the realpath) to avoid
|
|
250
|
+
# symlink-resolution mismatches between the Step object and node path.
|
|
251
|
+
parent_log_dir = File.join(parent_node[:path] + ".files", "log")
|
|
252
|
+
rel = Misc.path_relative_to(parent_log_dir, path)
|
|
253
|
+
rel.nil? || rel.empty? ? File.basename(path) : rel
|
|
254
|
+
elsif parent_node && parent_node[:kind] == :job && path.include?(parent_node[:path] + '.files/')
|
|
255
|
+
# ScoutCoder: DUAL-LAYOUT sibling of the legacy branch above. Log chat
|
|
256
|
+
# under a job in the NEW layout (no log/ component): show the path
|
|
257
|
+
# relative to the job's files dir, so
|
|
258
|
+
# agent.society/Direct/harness_test/agent.chat is shown shortened.
|
|
259
|
+
parent_files_dir = parent_node[:path] + '.files'
|
|
260
|
+
rel = Misc.path_relative_to(parent_files_dir, path)
|
|
261
|
+
rel.nil? || rel.empty? ? File.basename(path) : rel
|
|
262
|
+
elsif key == root_key && node[:kind] == :chat
|
|
263
|
+
# Root chat: show basename
|
|
264
|
+
File.basename(path)
|
|
265
|
+
else
|
|
266
|
+
path
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# ------------------------------------------------------------------
|
|
271
|
+
# Tree rendering (default mode)
|
|
272
|
+
# ------------------------------------------------------------------
|
|
273
|
+
unless flow || dot_file || plot_file
|
|
274
|
+
printed = Set.new
|
|
275
|
+
print_tree = lambda do |key, offset, relation, parent_key|
|
|
276
|
+
# Skip already-visited nodes entirely (no "(seen)" line)
|
|
277
|
+
return if printed.include?(key)
|
|
278
|
+
|
|
279
|
+
node = nodes[key]
|
|
280
|
+
parent_node = parent_key ? nodes[parent_key] : nil
|
|
281
|
+
|
|
282
|
+
# Skip hidden nodes entirely — don't print, don't traverse children
|
|
283
|
+
return if hidden_node?(node, relation)
|
|
284
|
+
|
|
285
|
+
printed << key
|
|
286
|
+
label = node_label(node, parent_node, root_key, key, long)
|
|
287
|
+
# A job reached through an agent_meta receipt is a delegated producer.
|
|
288
|
+
label = "delegated-job #{label}" if node[:kind] == :job && relation == :agent_job
|
|
289
|
+
puts report_line(node[:kind], label, node_tokens.call(key), offset: offset)
|
|
290
|
+
|
|
291
|
+
# One compact annotation line for the delegated usage embedded in this
|
|
292
|
+
# chat's own outputs; never one line per receipt.
|
|
293
|
+
if node[:kind] == :chat
|
|
294
|
+
events = receipt_events_by_source.key?(node[:path]) ? receipt_events_by_source[node[:path]] : []
|
|
295
|
+
if events && events.any?
|
|
296
|
+
delegated_tt = events.inject(0) { |sum, event| sum + event[:tokens][:tt].to_i }
|
|
297
|
+
tools = events.flat_map do |event|
|
|
298
|
+
receipt_evidence(event).collect { |evidence| evidence[:tool_name] }
|
|
299
|
+
end.compact.uniq.sort
|
|
300
|
+
annotation = "delegated receipt: #{events.length} events, total=#{Misc.human_number(delegated_tt)}"
|
|
301
|
+
annotation += ", #{tools * ','}" unless tools.empty?
|
|
302
|
+
puts [(' ' * ((offset + 1) * 2)), Log.color(:cyan, annotation)] * ' '
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
(adjacency[key] || []).each do |edge|
|
|
307
|
+
print_tree.call(edge[:to], offset + 1, edge[:relation], key)
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
print_tree.call(root_key, 0, nil, nil)
|
|
311
|
+
|
|
312
|
+
# Component mode: make the evidence coverage behind the numbers explicit
|
|
313
|
+
# once receipts are present; chats without receipts keep the previous output
|
|
314
|
+
# exactly. These are COVERAGE figures, not additive cost categories:
|
|
315
|
+
# chat_evidence + receipt_evidence double counts every event that exists in
|
|
316
|
+
# both a saved child log and a receipt. Only deduplicated_total is the
|
|
317
|
+
# cost, and receipt_only is the disjoint delegated contribution.
|
|
318
|
+
if component && has_receipt_evidence
|
|
319
|
+
coverage = [[:deduplicated_total, 'total'],
|
|
320
|
+
[:chat_evidence, 'events with saved chat/log evidence'],
|
|
321
|
+
[:receipt_evidence, 'events inside agent_meta receipts (overlaps chat_evidence)'],
|
|
322
|
+
[:receipt_only, 'receipt evidence with no saved chat/log']]
|
|
323
|
+
coverage.each do |scope, note|
|
|
324
|
+
totals = scope_totals.call(scope)
|
|
325
|
+
rendered = token_str(totals)
|
|
326
|
+
rendered = 'total=0' if rendered.empty?
|
|
327
|
+
puts Log.color(:cyan, "evidence #{scope}:") + ' ' + rendered + ' ' + Log.color(:cyan, "(#{note})")
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
conflict_info = {}
|
|
331
|
+
Chat.provenance_token_totals(root, warnings: [], conflicts: conflict_info)
|
|
332
|
+
unless conflict_info[:authoritative]
|
|
333
|
+
puts Log.color(:red, 'unresolved identity conflicts: ') +
|
|
334
|
+
"#{conflict_info[:events]} conflicting event(s); totals above are best-effort (canonical evidence only), not exact"
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# ------------------------------------------------------------------
|
|
340
|
+
# Flow / DOT / SVG mode
|
|
341
|
+
# ------------------------------------------------------------------
|
|
342
|
+
# Convert root-outward discovery relations into natural data-flow arrows.
|
|
343
|
+
flow_edges = edges.collect do |edge|
|
|
344
|
+
case edge[:relation]
|
|
345
|
+
when :job
|
|
346
|
+
{ from: edge[:to], to: edge[:from], relation: :result }
|
|
347
|
+
when :agent_job
|
|
348
|
+
{ from: edge[:to], to: edge[:from], relation: :delegated_result }
|
|
349
|
+
when :dependency
|
|
350
|
+
{ from: edge[:to], to: edge[:from], relation: :dependency }
|
|
351
|
+
else
|
|
352
|
+
edge.slice(:from, :to, :relation)
|
|
353
|
+
end
|
|
354
|
+
end.uniq
|
|
355
|
+
|
|
356
|
+
if flow || dot_file || plot_file
|
|
357
|
+
# Determine visible nodes using same rules as tree mode.
|
|
358
|
+
hidden_keys = Set.new
|
|
359
|
+
nodes.each do |key, node|
|
|
360
|
+
# top-level agent.chat is hidden; nested ones are visible
|
|
361
|
+
hidden_keys << key if node[:kind] == :chat && top_level_agent_chat?(node[:path])
|
|
362
|
+
# result-relation chats that duplicate a job (same path)
|
|
363
|
+
if node[:kind] == :chat
|
|
364
|
+
has_job_same_path = nodes.any? do |k, n|
|
|
365
|
+
n[:kind] == :job && n[:path] == node[:path]
|
|
366
|
+
end
|
|
367
|
+
hidden_keys << key if has_job_same_path
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
visible_keys = nodes.keys.reject { |k| hidden_keys.include?(k) }
|
|
371
|
+
visible_set = Set.new(visible_keys)
|
|
372
|
+
|
|
373
|
+
# Jobs in dependency order, then chat files
|
|
374
|
+
job_keys = visible_keys.select { |kind, _path| kind == :job }
|
|
375
|
+
predecessors = Hash.new { |hash, k| hash[k] = [] }
|
|
376
|
+
flow_edges.each do |edge|
|
|
377
|
+
predecessors[edge[:to]] << edge[:from] if edge[:relation] == :dependency
|
|
378
|
+
end
|
|
379
|
+
ordered_jobs = []
|
|
380
|
+
visited_jobs = Set.new
|
|
381
|
+
visit_job = lambda do |key|
|
|
382
|
+
next if visited_jobs.include?(key)
|
|
383
|
+
visited_jobs << key
|
|
384
|
+
predecessors[key].sort_by(&:last).each { |dep| visit_job.call(dep) }
|
|
385
|
+
ordered_jobs << key
|
|
386
|
+
end
|
|
387
|
+
job_keys.sort_by(&:last).each { |key| visit_job.call(key) }
|
|
388
|
+
chat_keys = (visible_keys - job_keys).sort_by(&:last)
|
|
389
|
+
ordered_keys = ordered_jobs + chat_keys
|
|
390
|
+
indexes = ordered_keys.each_with_index.to_h { |key, index| [key, index + 1] }
|
|
391
|
+
|
|
392
|
+
def short_number(number)
|
|
393
|
+
number = number.to_i
|
|
394
|
+
return format('%.1fM', number / 1_000_000.0) if number >= 1_000_000
|
|
395
|
+
return format('%.1fk', number / 1_000.0) if number >= 1_000
|
|
396
|
+
number.to_s
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
if flow
|
|
400
|
+
puts 'Flow'
|
|
401
|
+
puts '===='
|
|
402
|
+
ordered_keys.each do |key|
|
|
403
|
+
node = nodes[key]
|
|
404
|
+
name = node_label(node, nil, nil, key, long)
|
|
405
|
+
tokens = short_number(node_tokens.call(key)[:tt])
|
|
406
|
+
id = node[:kind] == :job ? job_short_id(node[:path]) : Misc.digest(node[:path])[0, 8]
|
|
407
|
+
puts format('[%2d] %-5s %-52s %8s %s', indexes[key], node[:kind].to_s.capitalize, name, tokens, id)
|
|
408
|
+
end
|
|
409
|
+
puts
|
|
410
|
+
flow_edges.each do |edge|
|
|
411
|
+
next unless visible_set.include?(edge[:from]) && visible_set.include?(edge[:to])
|
|
412
|
+
puts format('[%2d] --%-10s--> [%2d]', indexes[edge[:from]], edge[:relation], indexes[edge[:to]])
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
if dot_file || plot_file
|
|
417
|
+
ids = ordered_keys.each_with_index.to_h { |key, index| [key, "n#{index}"] }
|
|
418
|
+
lines = [
|
|
419
|
+
'digraph scout_ai_provenance {',
|
|
420
|
+
' graph [rankdir=LR, bgcolor="white", pad=0.2, nodesep=0.35, ranksep=0.6];',
|
|
421
|
+
' node [fontname="Helvetica", fontsize=10];',
|
|
422
|
+
' edge [fontname="Helvetica", fontsize=8, arrowsize=0.7];'
|
|
423
|
+
]
|
|
424
|
+
ordered_keys.each do |key|
|
|
425
|
+
node = nodes[key]
|
|
426
|
+
label = "#{node[:kind].to_s.capitalize}\n#{node_label(node, nil, nil, key, long)}\n#{short_number(node_tokens.call(key)[:tt])} tokens"
|
|
427
|
+
shape = node[:kind] == :job ? 'box' : 'note'
|
|
428
|
+
lines << %( #{ids[key]} [shape=#{shape}, style="rounded,filled", fillcolor="#f7f7f7", label=#{JSON.generate(label)}];)
|
|
429
|
+
end
|
|
430
|
+
styles = {
|
|
431
|
+
result: 'color="#39804a", penwidth=1.5, label="result"',
|
|
432
|
+
delegated_result: 'color="#8a6d3b", penwidth=1.2, label="delegated_result"',
|
|
433
|
+
dependency: 'color="#333333", label="dependency"',
|
|
434
|
+
log: 'color="#2864a5", style="dashed", label="log"'
|
|
435
|
+
}
|
|
436
|
+
flow_edges.each do |edge|
|
|
437
|
+
next unless visible_set.include?(edge[:from]) && visible_set.include?(edge[:to])
|
|
438
|
+
lines << %( #{ids[edge[:from]]} -> #{ids[edge[:to]]} [#{styles[edge[:relation]] || "label=#{JSON.generate(edge[:relation].to_s)}"}];)
|
|
439
|
+
end
|
|
440
|
+
lines << '}'
|
|
441
|
+
dot = lines * "\n"
|
|
442
|
+
Open.write(dot_file, dot) if dot_file
|
|
443
|
+
|
|
444
|
+
if plot_file
|
|
445
|
+
extension = File.extname(plot_file).sub(/^\./, '').downcase
|
|
446
|
+
extension = 'svg' if extension.empty?
|
|
447
|
+
raise ParameterException, "Unsupported plot format: #{extension}" unless %w[svg png pdf].include?(extension)
|
|
448
|
+
Tempfile.create(['scout-ai-provenance', '.dot']) do |file|
|
|
449
|
+
file.write(dot)
|
|
450
|
+
file.flush
|
|
451
|
+
raise ScoutException, 'Graphviz dot failed' unless system('dot', "-T#{extension}", file.path, '-o', plot_file.to_s)
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# ------------------------------------------------------------------
|
|
458
|
+
# Evidence mode: the deduplicated direct inference events behind the totals
|
|
459
|
+
# ------------------------------------------------------------------
|
|
460
|
+
if evidence
|
|
461
|
+
def evidence_location_str(address)
|
|
462
|
+
return '' unless Array === address && address.any?
|
|
463
|
+
rest = address[1..-1]
|
|
464
|
+
base = address[0].nil? ? '?' : File.basename(address[0].to_s)
|
|
465
|
+
if rest.length >= 3 && (rest[1] == :agent_meta || rest[1] == :meta)
|
|
466
|
+
"#{base}:#{rest[0]}[#{rest[1]},#{rest[2]}]"
|
|
467
|
+
else
|
|
468
|
+
"#{base}:#{rest[0]}"
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def event_id_str(event)
|
|
473
|
+
return event[:inference_id] if event[:inference_id]
|
|
474
|
+
identity = event[:identity]
|
|
475
|
+
# Legacy receipts have no stable id: show the receipt location instead of
|
|
476
|
+
# the raw address array.
|
|
477
|
+
return "receipt=#{evidence_location_str(identity[1])}" if identity.first == :receipt
|
|
478
|
+
identity.length == 2 ? "#{identity[0]}=#{identity[1]}" : identity * '='
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def event_status(event)
|
|
482
|
+
if event[:conflict]
|
|
483
|
+
'conflict (counted once, not authoritative)'
|
|
484
|
+
elsif event[:incomplete_evidence]
|
|
485
|
+
'incomplete evidence'
|
|
486
|
+
elsif event[:deduplication] == :receipt_unresolved
|
|
487
|
+
'legacy unresolved'
|
|
488
|
+
elsif event[:evidence].all? { |item| item[:origin] == :agent_meta }
|
|
489
|
+
'receipt-only'
|
|
490
|
+
else
|
|
491
|
+
'counted once'
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
events = token_events_for.call
|
|
496
|
+
puts
|
|
497
|
+
puts 'Direct inference events'
|
|
498
|
+
puts '======================='
|
|
499
|
+
if events.empty?
|
|
500
|
+
puts '(none)'
|
|
501
|
+
else
|
|
502
|
+
# Raw integers here: every number must stay directly comparable with the
|
|
503
|
+
# meta records at the evidence addresses.
|
|
504
|
+
rows = events.collect do |event|
|
|
505
|
+
tokens = event[:tokens]
|
|
506
|
+
token_parts = ["total=#{tokens[:tt].to_i}"]
|
|
507
|
+
token_parts << "prompt=#{tokens[:pt].to_i}" if tokens[:pt].to_i > 0
|
|
508
|
+
token_parts << "cont=#{tokens[:ct].to_i}" if tokens[:ct].to_i > 0
|
|
509
|
+
token_parts << "cache=#{tokens[:cct].to_i}" if tokens[:cct].to_i > 0
|
|
510
|
+
token_parts << "reason=#{tokens[:rt].to_i}" if tokens[:rt].to_i > 0
|
|
511
|
+
evidence_parts = event[:evidence].collect do |item|
|
|
512
|
+
location = evidence_location_str(item[:evidence_address] || item[:meta_address])
|
|
513
|
+
call = item[:call_id] ? " call=#{item[:call_id]}" : ''
|
|
514
|
+
"#{item[:origin]} #{location}#{call}"
|
|
515
|
+
end
|
|
516
|
+
[event_id_str(event), token_parts * ' ', evidence_parts * '; ', event_status(event)]
|
|
517
|
+
end
|
|
518
|
+
puts format('%-26s %-36s %-56s %s', 'Inference ID', 'Tokens', 'Evidence', 'Status')
|
|
519
|
+
rows.each { |row| puts format('%-26s %-36s %-56s %s', *row) }
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
receipt_only = events.select { |event| event_status(event) == 'receipt-only' }
|
|
523
|
+
legacy_unresolved = events.select { |event| event[:deduplication] == :receipt_unresolved }
|
|
524
|
+
|
|
525
|
+
unless receipt_only.empty?
|
|
526
|
+
puts
|
|
527
|
+
puts "Receipt-only events (no saved child log): #{receipt_only.collect { |event| event_id_str(event) } * ', '}"
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
unless legacy_unresolved.empty?
|
|
531
|
+
puts
|
|
532
|
+
puts 'Legacy unresolved events (no inference_id; one per receipt, may overcount):'
|
|
533
|
+
legacy_unresolved.each do |event|
|
|
534
|
+
location = evidence_location_str(event[:evidence].first[:evidence_address])
|
|
535
|
+
puts " #{event_id_str(event)} at #{location}"
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
seen_conflicts = Set.new
|
|
540
|
+
conflicts = receipt_warnings.select do |warning|
|
|
541
|
+
warning[:reason] == :identity_conflict && seen_conflicts.add?(warning[:identity])
|
|
542
|
+
end
|
|
543
|
+
unless conflicts.empty?
|
|
544
|
+
puts
|
|
545
|
+
puts 'Identity conflicts (counted once from canonical evidence; totals containing these are NOT authoritative):'
|
|
546
|
+
conflicts.each do |conflict|
|
|
547
|
+
values = conflict[:values].collect { |field, list| "#{field}=#{Array(list).uniq * '|'}" } * ' '
|
|
548
|
+
puts " #{conflict[:identity] * '='}: disputed #{conflict[:fields] * ','} (#{values})"
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
incomplete = events.select { |event| event[:incomplete_evidence] }
|
|
553
|
+
unless incomplete.empty?
|
|
554
|
+
puts
|
|
555
|
+
puts 'Incomplete evidence (one copy lacks provider_response_id; no conflict):'
|
|
556
|
+
incomplete.each do |event|
|
|
557
|
+
puts " #{event_id_str(event)}"
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# Agent-job references come straight from the traversal edge details, so no
|
|
562
|
+
# parent chat has to be re-parsed here: the edge already carries the receipt
|
|
563
|
+
# record (call id, tool name, output address, receipt address, reference).
|
|
564
|
+
agent_job_edges = records.select do |kind, _object, parent_kind, _parent, relation, _first|
|
|
565
|
+
relation == :agent_job && parent_kind == :chat && kind == :job
|
|
566
|
+
end
|
|
567
|
+
unless agent_job_edges.empty?
|
|
568
|
+
puts
|
|
569
|
+
puts 'Job projection references (no direct tokens):'
|
|
570
|
+
agent_job_edges.each do |_kind, object, _parent_kind, _parent, _relation, _first, detail|
|
|
571
|
+
detail ||= {}
|
|
572
|
+
location = evidence_location_str(detail[:evidence_address])
|
|
573
|
+
tool = detail[:tool_name] || '?'
|
|
574
|
+
puts " job=#{object.path} at #{location} call=#{detail[:call_id]} tool=#{tool} (delegated_result edge)"
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
warnings.each do |warning|
|
|
580
|
+
error = warning[:error]
|
|
581
|
+
# Receipt problems are reported in detail by the receipt_warnings block
|
|
582
|
+
# below (reason, location, call id); skip the generic line for them. They
|
|
583
|
+
# are recognized by their structured reference, not by a class: agent_meta
|
|
584
|
+
# diagnostics travel as ordinary ScoutException plus a reference Hash.
|
|
585
|
+
next if warning[:relation] == :agent_job && Hash === warning[:reference] && warning[:reference][:reason]
|
|
586
|
+
Log.warn "Incomplete provenance at #{warning[:kind]} #{warning[:object]} (#{warning[:relation]}): #{error.message}"
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# Receipt problems and identity conflicts collected by the token collector.
|
|
590
|
+
# The same problem may be reported once per aggregate subtree, so printed
|
|
591
|
+
# lines are deduplicated by their location.
|
|
592
|
+
seen_receipt_warnings = Set.new
|
|
593
|
+
receipt_warnings.each do |warning|
|
|
594
|
+
key = warning.values_at(:reason, :source, :output_address, :call_id, :agent_meta_index, :identity)
|
|
595
|
+
next unless seen_receipt_warnings.add?(key)
|
|
596
|
+
if warning[:reason] == :identity_conflict
|
|
597
|
+
Log.warn "identity conflict: #{warning[:identity] * '='} disagrees on #{warning[:fields] * ','}"
|
|
598
|
+
else
|
|
599
|
+
location = warning[:evidence_address] || warning[:output_address]
|
|
600
|
+
Log.warn "agent_meta receipt: #{warning[:reason]} in #{warning[:source]} at #{location.inspect} call=#{warning[:call_id]}#{warning[:message] ? ' - ' + warning[:message] : ''}"
|
|
601
|
+
end
|
|
602
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'scout'
|
|
5
|
+
|
|
6
|
+
cmd = $previous_commands ?
|
|
7
|
+
"scout #{$previous_commands.any? ? "#{$previous_commands * ' '} " : ''}#{File.basename(__FILE__)}" :
|
|
8
|
+
$PROGRAM_NAME
|
|
9
|
+
|
|
10
|
+
options = SOPT.setup <<~EOF
|
|
11
|
+
|
|
12
|
+
Format a chat in word
|
|
13
|
+
|
|
14
|
+
$ #{cmd} [<options>] <chat> <word>
|
|
15
|
+
|
|
16
|
+
-h--help Print this help
|
|
17
|
+
-r--reference* Use a different reference.docx
|
|
18
|
+
-l--last Format only the last interaction
|
|
19
|
+
EOF
|
|
20
|
+
if options[:help]
|
|
21
|
+
if defined? scout_usage
|
|
22
|
+
scout_usage
|
|
23
|
+
else
|
|
24
|
+
puts SOPT.doc
|
|
25
|
+
end
|
|
26
|
+
exit 0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
help, reference, last = IndiferentHash.process_options options, :help, :reference, :last
|
|
30
|
+
|
|
31
|
+
reference ||= Scout.share.word["reference.docx"].find
|
|
32
|
+
|
|
33
|
+
chat, word, _ = ARGV
|
|
34
|
+
|
|
35
|
+
raise MissingParameterException, :chat if chat.nil?
|
|
36
|
+
|
|
37
|
+
if word.nil?
|
|
38
|
+
word = chat.sub(/\.(txt|chat|md)$/i, '') + '.docx'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
messages = Chat.parse Open.read(chat)
|
|
42
|
+
|
|
43
|
+
if last
|
|
44
|
+
messages = messages.select{|m| %w(user assistant).include? m[:role].to_s }[-2..-1]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
out = []
|
|
48
|
+
messages.each do |message|
|
|
49
|
+
IndiferentHash.setup(message)
|
|
50
|
+
case message['role']
|
|
51
|
+
when 'user'
|
|
52
|
+
out.push <<-EOF
|
|
53
|
+
|
|
54
|
+
::: {custom-style="UserBlock"}
|
|
55
|
+
|
|
56
|
+
#{message['content']}
|
|
57
|
+
|
|
58
|
+
:::
|
|
59
|
+
|
|
60
|
+
EOF
|
|
61
|
+
when 'assistant'
|
|
62
|
+
out.push message['content']
|
|
63
|
+
else
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
TmpFile.with_file out * "\n", extension: :md do |markdown|
|
|
69
|
+
CMD.cmd(:pandoc, "'#{markdown}' --reference-doc=#{reference} -o #{word}")
|
|
70
|
+
end
|
|
71
|
+
puts word
|