Skip to content

Commit 6ee8498

Browse files
committed
feat(compression): support grouped search output
1 parent f839b41 commit 6ee8498

5 files changed

Lines changed: 373 additions & 25 deletions

File tree

lib/codex_pooler/gateway/request_compression/content_detector.ex

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ defmodule CodexPooler.Gateway.RequestCompression.ContentDetector do
3535
@search_heading_regex ~r/\b(?:search results|results for|matches for|found\s+\d+\s+(?:results|matches))\b/i
3636
@numbered_result_regex ~r/^\s*(?:\d+[\).]\s+|[-*]\s+).+\s-\s.+$/m
3737
@path_match_regex ~r/^\s*[\w.\/-]+:\d+(?::\d+)?:\s*\S/m
38+
@nul_path_match_regex ~r/^[^\x00\r\n]+\x00\d+(?::\d+)?:\s*\S/m
39+
@grouped_line_match_regex ~r/^\s*\d+(?::\d+)?:\s*\S/
40+
@grouped_heading_path_regex ~r/^[\w.\/-]+$/u
41+
@grouped_heading_extension_regex ~r/(?:^|\/)[\w.-]+\.[A-Za-z0-9][A-Za-z0-9_-]*$/u
42+
@grouped_heading_sentence_punctuation_regex ~r/[!?;]|\.\s*$/
43+
@max_grouped_heading_bytes 240
3844
@url_regex ~r/https?:\/\/\S+/i
3945
@snippet_separator_regex ~r/\s-\s/
4046

@@ -167,14 +173,18 @@ defmodule CodexPooler.Gateway.RequestCompression.ContentDetector do
167173
defp search_points(content) do
168174
numbered_results = scan_count(@numbered_result_regex, content)
169175
path_matches = scan_count(@path_match_regex, content)
176+
nul_matches = scan_count(@nul_path_match_regex, content)
177+
grouped_matches = grouped_search_match_count(content)
178+
structural_matches = path_matches + nul_matches + grouped_matches
170179
urls = scan_count(@url_regex, content)
171180
separators = scan_count(@snippet_separator_regex, content)
172181

173182
[
174183
score(regex_match?(@search_heading_regex, content), 30),
175184
cond_score(numbered_results, [{2, 30}, {1, 15}]),
176-
cond_score(path_matches, [{3, 45}, {2, 35}, {1, 15}]),
177-
score(path_matches >= 2, 15),
185+
cond_score(structural_matches, [{3, 45}, {2, 35}, {1, 15}]),
186+
score(structural_matches >= 2, 15),
187+
score(nul_matches >= 2 or grouped_matches >= 2, 10),
178188
cond_score(urls, [{2, 20}, {1, 10}]),
179189
score(separators >= 2, 10)
180190
]
@@ -223,6 +233,40 @@ defmodule CodexPooler.Gateway.RequestCompression.ContentDetector do
223233
|> min(100)
224234
end
225235

236+
defp grouped_search_match_count(content) do
237+
content
238+
|> lines()
239+
|> Enum.with_index()
240+
|> Enum.map(fn {line, index} ->
241+
path = String.trim(line)
242+
243+
if grouped_heading?(path) do
244+
count_grouped_lines_after(content, index)
245+
else
246+
0
247+
end
248+
end)
249+
|> Enum.sum()
250+
end
251+
252+
defp count_grouped_lines_after(content, heading_index) do
253+
matches =
254+
content
255+
|> lines()
256+
|> Enum.drop(heading_index + 1)
257+
|> Enum.take_while(&Regex.match?(@grouped_line_match_regex, &1))
258+
|> length()
259+
260+
if matches >= 2, do: matches, else: 0
261+
end
262+
263+
defp grouped_heading?(path) do
264+
byte_size(path) in 1..@max_grouped_heading_bytes and
265+
Regex.match?(@grouped_heading_path_regex, path) and
266+
(String.contains?(path, "/") or Regex.match?(@grouped_heading_extension_regex, path)) and
267+
not Regex.match?(@grouped_heading_sentence_punctuation_regex, path)
268+
end
269+
226270
defp repeated_line?(lines) do
227271
lines
228272
|> Enum.map(&String.trim/1)

lib/codex_pooler/gateway/request_compression/strategies/search_results.ex

Lines changed: 119 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,14 @@ defmodule CodexPooler.Gateway.RequestCompression.Strategies.SearchResults do
99
@default_max_files 20
1010
@default_max_matches 60
1111
@default_max_matches_per_file 3
12+
@default_model "gpt-4o"
13+
@max_heading_bytes 240
1214

1315
@match_line_regex ~r/^\s*(?<path>[\w.\/-][\w.\/-]*):(?<line>\d+)(?::(?<column>\d+))?:\s*(?<text>\S.*)$/u
16+
@line_match_regex ~r/^\s*(?<line>\d+)(?::(?<column>\d+))?:\s*(?<text>\S.*)$/u
17+
@heading_path_regex ~r/^[\w.\/-]+$/u
18+
@heading_extension_regex ~r/(?:^|\/)[\w.-]+\.[A-Za-z0-9][A-Za-z0-9_-]*$/u
19+
@heading_sentence_punctuation_regex ~r/[!?;]|\.\s*$/
1420

1521
@spec compress(term(), Strategies.opts()) :: Strategies.result()
1622
def compress(content, opts \\ [])
@@ -30,7 +36,7 @@ defmodule CodexPooler.Gateway.RequestCompression.Strategies.SearchResults do
3036
compressed_lines = render_groups(groups, selected_groups, compressed_match_count)
3137
compressed = Strategies.join_lines(compressed_lines)
3238

33-
Strategies.finalize(
39+
finalize(
3440
@strategy,
3541
content,
3642
compressed,
@@ -66,41 +72,115 @@ defmodule CodexPooler.Gateway.RequestCompression.Strategies.SearchResults do
6672
def compress(_content, _opts), do: :skip
6773

6874
defp parse_matches(lines) do
69-
lines
70-
|> Enum.with_index()
71-
|> Enum.reduce([], fn {line, index}, matches ->
72-
case parse_match(line, index) do
73-
{:ok, match} -> [match | matches]
74-
:skip -> matches
75-
end
76-
end)
77-
|> Enum.reverse()
75+
direct_matches =
76+
lines
77+
|> Enum.with_index()
78+
|> Enum.reduce([], fn {line, index}, matches ->
79+
case parse_match(line, index) do
80+
{:ok, match} -> [match | matches]
81+
:skip -> matches
82+
end
83+
end)
84+
85+
grouped_matches = parse_grouped_matches(lines)
86+
87+
(direct_matches ++ grouped_matches)
88+
|> Enum.sort_by(& &1.index)
7889
end
7990

8091
defp parse_match(line, index) do
92+
if String.contains?(line, <<0>>) do
93+
parse_nul_match(line, index)
94+
else
95+
parse_classic_match(line, index)
96+
end
97+
end
98+
99+
defp parse_nul_match(line, index) do
100+
case String.split(line, <<0>>) do
101+
[path, fragment] ->
102+
parse_line_fragment(path, fragment, index)
103+
104+
_malformed ->
105+
:skip
106+
end
107+
end
108+
109+
defp parse_classic_match(line, index) do
81110
case Regex.named_captures(@match_line_regex, line) do
82111
%{"path" => path, "line" => line_number, "column" => column, "text" => text} ->
83-
path = String.trim(path)
84-
text = String.trim(text)
112+
build_match(path, line_number, column, text, index)
85113

86-
if path == "" or text == "" do
87-
:skip
88-
else
89-
{:ok,
90-
%{
91-
path: path,
92-
line: line_number,
93-
column: column,
94-
text: text,
95-
index: index
96-
}}
114+
_no_match ->
115+
:skip
116+
end
117+
end
118+
119+
defp parse_grouped_matches(lines) do
120+
lines
121+
|> Enum.with_index()
122+
|> Enum.flat_map(fn {line, index} ->
123+
path = String.trim(line)
124+
125+
if path_like_heading?(path) do
126+
grouped_matches_after(lines, path, index)
127+
else
128+
[]
129+
end
130+
end)
131+
end
132+
133+
defp grouped_matches_after(lines, path, heading_index) do
134+
matches =
135+
lines
136+
|> Enum.drop(heading_index + 1)
137+
|> Enum.with_index(heading_index + 1)
138+
|> Enum.reduce_while([], fn {line, index}, matches ->
139+
case parse_line_fragment(path, line, index) do
140+
{:ok, match} -> {:cont, [match | matches]}
141+
:skip -> {:halt, matches}
97142
end
143+
end)
144+
|> Enum.reverse()
145+
146+
if length(matches) >= 2, do: matches, else: []
147+
end
148+
149+
defp parse_line_fragment(path, fragment, index) do
150+
case Regex.named_captures(@line_match_regex, fragment) do
151+
%{"line" => line_number, "column" => column, "text" => text} ->
152+
build_match(path, line_number, column, text, index)
98153

99154
_no_match ->
100155
:skip
101156
end
102157
end
103158

159+
defp build_match(path, line_number, column, text, index) do
160+
path = String.trim(path)
161+
text = String.trim(text)
162+
163+
if path == "" or text == "" or String.contains?(path, <<0>>) do
164+
:skip
165+
else
166+
{:ok,
167+
%{
168+
path: path,
169+
line: line_number,
170+
column: column,
171+
text: text,
172+
index: index
173+
}}
174+
end
175+
end
176+
177+
defp path_like_heading?(path) do
178+
byte_size(path) in 1..@max_heading_bytes and
179+
Regex.match?(@heading_path_regex, path) and
180+
(String.contains?(path, "/") or Regex.match?(@heading_extension_regex, path)) and
181+
not Regex.match?(@heading_sentence_punctuation_regex, path)
182+
end
183+
104184
defp group_matches(matches) do
105185
{paths, groups} =
106186
Enum.reduce(matches, {[], %{}}, fn match, {paths, groups} ->
@@ -202,4 +282,20 @@ defmodule CodexPooler.Gateway.RequestCompression.Strategies.SearchResults do
202282
|> Enum.map(&length(&1.matches))
203283
|> Enum.sum()
204284
end
285+
286+
defp finalize(strategy, original, compressed, counts, opts) do
287+
Strategies.finalize(strategy, original, compressed, counts, default_model_opts(opts))
288+
end
289+
290+
defp default_model_opts(opts) when is_list(opts) do
291+
if Keyword.has_key?(opts, :model), do: opts, else: Keyword.put(opts, :model, @default_model)
292+
end
293+
294+
defp default_model_opts(opts) when is_map(opts) do
295+
if Map.has_key?(opts, :model) or Map.has_key?(opts, "model") do
296+
opts
297+
else
298+
Map.put(opts, :model, @default_model)
299+
end
300+
end
205301
end

test/codex_pooler/gateway/request_compression/content_detector_test.exs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,50 @@ defmodule CodexPooler.Gateway.RequestCompression.ContentDetectorTest do
186186
assert_noop(:text, ContentDetector.detect(negative))
187187
end
188188

189+
test "detects classic grouped and nul-delimited search output" do
190+
classic =
191+
1..3
192+
|> Enum.map_join("\n", &"lib/example_#{&1}.ex:#{&1}: found match #{&1}")
193+
194+
grouped = """
195+
lib/grouped.ex
196+
12: first grouped match
197+
14:3: second grouped match
198+
"""
199+
200+
nul_delimited =
201+
1..3
202+
|> Enum.map_join("\n", &"lib/nul_result.ex\0#{&1}: nul match #{&1}")
203+
204+
for body <- [classic, grouped, nul_delimited] do
205+
assert %{
206+
kind: :search,
207+
confidence: confidence,
208+
compressible: true,
209+
strategy: :search_results
210+
} = ContentDetector.detect(body)
211+
212+
assert confidence >= 0.6
213+
end
214+
end
215+
216+
test "keeps prose headings and malformed nul fragments out of search detection" do
217+
prose_heading = """
218+
Search results from the last review.
219+
1: this is prose, not a grouped file match
220+
2: this is also prose
221+
"""
222+
223+
malformed_nul = """
224+
lib/example.ex\0not-a-match-line
225+
lib/example.ex\0: missing line number
226+
lib/example.ex\0one: missing numeric line
227+
"""
228+
229+
assert_noop(:text, ContentDetector.detect(prose_heading))
230+
assert_noop(:text, ContentDetector.detect(malformed_nul))
231+
end
232+
189233
test "detects build or log output at and above request-compression confidence" do
190234
positive = """
191235
example command output line

test/codex_pooler/gateway/request_compression/maybe_compress_test.exs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,54 @@ defmodule CodexPooler.Gateway.RequestCompression.MaybeCompressTest do
172172
refute inspect(metadata) =~ "call_json_document_compression"
173173
end
174174

175+
test "rewrites nul-delimited search-result tool-output strings" do
176+
omitted_sentinel = "nul search omitted sentinel"
177+
original_output = compression_nul_search_fixture(omitted_sentinel)
178+
179+
body =
180+
Jason.encode!(%{
181+
"model" => @supported_model,
182+
"input" => [
183+
%{
184+
"type" => "function_call_output",
185+
"call_id" => "call_nul_search_compression",
186+
"output" => original_output
187+
}
188+
]
189+
})
190+
191+
{context, request_options} = request_context(body)
192+
193+
assert {compressed_body, compressed_options} =
194+
RequestCompression.maybe_compress(body, context, request_options)
195+
196+
assert compressed_body != body
197+
198+
compressed_output =
199+
compressed_body
200+
|> Jason.decode!()
201+
|> Map.fetch!("input")
202+
|> List.first()
203+
|> Map.fetch!("output")
204+
205+
assert compressed_output =~ "[compressed search results:"
206+
assert compressed_output =~ "lib/nul_result.ex"
207+
refute compressed_output =~ <<0>>
208+
refute compressed_output =~ omitted_sentinel
209+
210+
assert %{
211+
"status" => "compressed",
212+
"candidate_count" => 1,
213+
"compressed_count" => 1,
214+
"skipped_count" => 0
215+
} = metadata = compressed_options.runtime.payload_compression
216+
217+
assert "search_results" in metadata["strategies"]
218+
assert metadata["original_tokens"] > metadata["compressed_tokens"]
219+
refute inspect(metadata) =~ omitted_sentinel
220+
refute inspect(metadata) =~ "call_nul_search_compression"
221+
end
222+
175223
test "skips when no route model is available" do
176224
body =
177225
Jason.encode!(%{
@@ -289,4 +337,15 @@ defmodule CodexPooler.Gateway.RequestCompression.MaybeCompressTest do
289337
])
290338
|> Enum.join("\n")
291339
end
340+
341+
defp compression_nul_search_fixture(omitted_sentinel) do
342+
1..24
343+
|> Enum.map_join("\n", fn
344+
11 ->
345+
"lib/nul_result.ex\011: nul search result 11 #{omitted_sentinel} with extra context"
346+
347+
index ->
348+
"lib/nul_result.ex\0#{index}: nul search result #{index} with enough context to compress"
349+
end)
350+
end
292351
end

0 commit comments

Comments
 (0)