agent-dump 1.2.0__tar.gz → 1.2.2__tar.gz
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.
- {agent_dump-1.2.0 → agent_dump-1.2.2}/Cargo.lock +1 -1
- {agent_dump-1.2.0 → agent_dump-1.2.2}/Cargo.toml +1 -1
- {agent_dump-1.2.0 → agent_dump-1.2.2}/PKG-INFO +2 -2
- {agent_dump-1.2.0 → agent_dump-1.2.2}/README.md +1 -1
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/Cargo.toml +1 -1
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/compat/json.rs +2 -2
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/output/render.rs +15 -1
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/claude/mod.rs +12 -1
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/codex/mod.rs +16 -1
- agent_dump-1.2.2/crates/agent-dump-core/src/providers/jsonl.rs +275 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/pi/mod.rs +1 -1
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/context.rs +28 -10
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/index/tests.rs +27 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/index.rs +25 -26
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/text.rs +72 -17
- {agent_dump-1.2.0 → agent_dump-1.2.2}/pyproject.toml +2 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/locales/en.json +0 -1
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/locales/zh.json +0 -1
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/insight-handoff.txt +2 -2
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/pm-handoff.txt +2 -2
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/events.rs +6 -10
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/handoff.rs +1 -2
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/sessions.rs +3 -10
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/terminal/reader.rs +50 -26
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/collect.rs +3 -31
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/interactive.rs +116 -58
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/list.rs +1 -1
- agent_dump-1.2.0/crates/agent-dump-core/src/providers/jsonl.rs +0 -139
- {agent_dump-1.2.0 → agent_dump-1.2.2}/LICENSE +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/compat/json/diagnostic.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/compat/json/encoding.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/compat/mod.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/compat/value.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/config.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/lib.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/output/diagnostics.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/output/export.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/output/formats.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/output/i18n.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/output/mod.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/parallel.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/claude/transcript.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/codex/enrichment.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/codex/patch.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/codex/transcript.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/contract.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/cursor/mod.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/cursor/transcript.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/desktop/cherry.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/desktop/deepchat.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/desktop/minimax.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/desktop/mod.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/desktop/tests.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/error.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/files.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/kimi/mod.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/kimi/transcript.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/kimi/wire.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/mod.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/pi/transcript.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/registry.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/source_tests.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/sqlite/connection.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/sqlite/legacy.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/sqlite/mod.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/sqlite/v2.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/title.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/filter.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/mod.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/read.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/scanner.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/transcript.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/session/assembly.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/session/cache/tests.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/session/cache.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/session/mod.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/session/timestamp.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/storage/mod.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/storage/private_files.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/storage/source_io.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/.gitattributes +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/README.md +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/insight-chunk.txt +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/insight-merge.txt +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/insight-report.txt +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/insight-retry.txt +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/pm-chunk.txt +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/pm-merge.txt +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/pm-report.txt +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/pm-retry.txt +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/read-session-en.txt +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/read-session-zh.txt +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/system.txt +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/uri.txt +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/rust-toolchain.toml +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/rustfmt.toml +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/cli_args.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/llm.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/log.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/mod.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/model.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/progress.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/prompts.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/reduction.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/summary.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/command.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/date_input.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/main.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/shortcut.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/terminal/mod.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/terminal/selector.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/terminal/tui.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/config.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/machine.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/maintenance.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/mod.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/read.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/reader.rs +0 -0
- {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/uri.rs +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-dump
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.2
|
|
4
4
|
Classifier: Environment :: Console
|
|
5
5
|
Classifier: License :: OSI Approved :: MIT License
|
|
6
6
|
Classifier: Programming Language :: Rust
|
|
@@ -462,7 +462,7 @@ File discovery failures include partially unreadable providers. Valid sessions r
|
|
|
462
462
|
|
|
463
463
|
When some session reads or summaries fail, collection continues with successful sessions. The saved Markdown includes a fixed incomplete-report notice with failure and included-session counts; an entirely failed run still fails.
|
|
464
464
|
|
|
465
|
-
Collect discovers candidate sessions without a creation-time cutoff and processes user/assistant visible text only within the requested local dates. A session spanning several days is split into separate daily units before summarization; session counts remain unique URI counts. Text without a reliable timestamp
|
|
465
|
+
Collect discovers candidate sessions without a creation-time cutoff and processes user/assistant visible text only within the requested local dates. A session spanning several days is split into separate daily units before summarization; session counts remain unique URI counts. Text without a reliable timestamp (including undated Kimi context and inferred Cursor times) belongs to no date and is excluded without being reported as a coverage gap. No session creation/update time is substituted. Query filters select candidates; limits apply to unique sessions after text-date filtering. Events, including long individual messages, are split into chunks of at most 3,200 Unicode characters including labels. Chunk summaries are merged in groups of at most eight; large final reports are rendered in separate source-preserving groups. Model inputs are limited to 64,000 characters per request. Oversized metadata or derived summaries fail explicitly; source text is never silently truncated. Partial reports list omitted session URIs. Full input coverage does not mean a summary preserves every detail.
|
|
466
466
|
|
|
467
467
|
PM summaries merge sessions only within the same date and known working directory. Sessions without a working directory retain separate attribution.
|
|
468
468
|
|
|
@@ -438,7 +438,7 @@ File discovery failures include partially unreadable providers. Valid sessions r
|
|
|
438
438
|
|
|
439
439
|
When some session reads or summaries fail, collection continues with successful sessions. The saved Markdown includes a fixed incomplete-report notice with failure and included-session counts; an entirely failed run still fails.
|
|
440
440
|
|
|
441
|
-
Collect discovers candidate sessions without a creation-time cutoff and processes user/assistant visible text only within the requested local dates. A session spanning several days is split into separate daily units before summarization; session counts remain unique URI counts. Text without a reliable timestamp
|
|
441
|
+
Collect discovers candidate sessions without a creation-time cutoff and processes user/assistant visible text only within the requested local dates. A session spanning several days is split into separate daily units before summarization; session counts remain unique URI counts. Text without a reliable timestamp (including undated Kimi context and inferred Cursor times) belongs to no date and is excluded without being reported as a coverage gap. No session creation/update time is substituted. Query filters select candidates; limits apply to unique sessions after text-date filtering. Events, including long individual messages, are split into chunks of at most 3,200 Unicode characters including labels. Chunk summaries are merged in groups of at most eight; large final reports are rendered in separate source-preserving groups. Model inputs are limited to 64,000 characters per request. Oversized metadata or derived summaries fail explicitly; source text is never silently truncated. Partial reports list omitted session URIs. Full input coverage does not mean a summary preserves every detail.
|
|
442
442
|
|
|
443
443
|
PM summaries merge sessions only within the same date and known working directory. Sessions without a working directory retain separate attribution.
|
|
444
444
|
|
|
@@ -15,7 +15,7 @@ workspace = true
|
|
|
15
15
|
jiff.workspace = true
|
|
16
16
|
md-5.workspace = true
|
|
17
17
|
serde.workspace = true
|
|
18
|
-
serde_json
|
|
18
|
+
serde_json = { workspace = true, features = ["raw_value"] }
|
|
19
19
|
regex.workspace = true
|
|
20
20
|
sha2.workspace = true
|
|
21
21
|
tempfile.workspace = true
|
|
@@ -32,7 +32,7 @@ pub fn nonfinite(number: &Number) -> Option<f64> {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
fn normalize(value: &mut Value) {
|
|
35
|
+
pub(crate) fn normalize(value: &mut Value) {
|
|
36
36
|
match value {
|
|
37
37
|
Value::Number(number) if number.as_str().contains(['.', 'e', 'E']) => {
|
|
38
38
|
if let Ok(value) = number.as_str().parse::<f64>()
|
|
@@ -50,7 +50,7 @@ fn normalize(value: &mut Value) {
|
|
|
50
50
|
pub fn from_slice(bytes: &[u8]) -> crate::Result<Value> {
|
|
51
51
|
let text = std::str::from_utf8(bytes)
|
|
52
52
|
.map_err(|error| diagnostic::invalid_utf8(bytes, error))?;
|
|
53
|
-
let original = serde_json::
|
|
53
|
+
let original = serde_json::from_str::<Value>(text);
|
|
54
54
|
if let Ok(mut value) = original {
|
|
55
55
|
normalize(&mut value);
|
|
56
56
|
return Ok(value);
|
|
@@ -5,7 +5,10 @@ const fn unsafe_char(c: char) -> bool {
|
|
|
5
5
|
matches!(c, '\u{0}'..='\u{8}' | '\u{b}'..='\u{1f}' | '\u{7f}'..='\u{9f}' | '\u{61c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
|
|
6
6
|
}
|
|
7
7
|
pub fn safe_body(text: &str) -> String {
|
|
8
|
-
text.
|
|
8
|
+
let mut safe = String::with_capacity(text.len());
|
|
9
|
+
text.split(unsafe_char)
|
|
10
|
+
.for_each(|piece| safe.push_str(piece));
|
|
11
|
+
safe
|
|
9
12
|
}
|
|
10
13
|
pub fn safe_line(text: &str) -> String {
|
|
11
14
|
let replaced: String = text
|
|
@@ -458,3 +461,14 @@ pub fn metadata_summary(session: &Session, scheme: &str, zh: bool) -> String {
|
|
|
458
461
|
fields.push(format!("uri={scheme}://{}", session.id));
|
|
459
462
|
safe_line(&fields.join(" | "))
|
|
460
463
|
}
|
|
464
|
+
|
|
465
|
+
#[cfg(test)]
|
|
466
|
+
mod tests {
|
|
467
|
+
#[test]
|
|
468
|
+
fn safe_body_drops_terminal_controls_and_keeps_layout() {
|
|
469
|
+
assert_eq!(
|
|
470
|
+
super::safe_body("\x1b[2J测试\u{202e}\n\tmore\rtext\u{7f}\u{85}"),
|
|
471
|
+
"[2J测试\n\tmoretext"
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
@@ -13,6 +13,17 @@ use serde_json::Value;
|
|
|
13
13
|
use std::collections::HashMap;
|
|
14
14
|
use std::path::{Path, PathBuf};
|
|
15
15
|
|
|
16
|
+
static RECORD: jsonl::Shape = jsonl::Shape::Fields(&[
|
|
17
|
+
("timestamp", jsonl::Shape::Leaf),
|
|
18
|
+
(
|
|
19
|
+
"message",
|
|
20
|
+
jsonl::Shape::Fields(&[
|
|
21
|
+
("role", jsonl::Shape::Leaf),
|
|
22
|
+
("model", jsonl::Shape::Leaf),
|
|
23
|
+
]),
|
|
24
|
+
),
|
|
25
|
+
]);
|
|
26
|
+
|
|
16
27
|
pub struct Claude {
|
|
17
28
|
roots: SourceRoots,
|
|
18
29
|
titles: HashMap<PathBuf, HashMap<String, Value>>,
|
|
@@ -117,7 +128,7 @@ impl Claude {
|
|
|
117
128
|
}
|
|
118
129
|
|
|
119
130
|
fn scan(path: &Path) -> crate::Result<jsonl::Metadata> {
|
|
120
|
-
jsonl::metadata(path, 20)
|
|
131
|
+
jsonl::metadata(path, 20, Some(&RECORD))
|
|
121
132
|
}
|
|
122
133
|
|
|
123
134
|
fn parse(
|
|
@@ -14,6 +14,21 @@ use crate::session::{Session, SessionData, parse_timestamp};
|
|
|
14
14
|
use std::collections::HashMap;
|
|
15
15
|
use std::path::{Path, PathBuf};
|
|
16
16
|
|
|
17
|
+
static RECORD: jsonl::Shape = jsonl::Shape::Fields(&[
|
|
18
|
+
("timestamp", jsonl::Shape::Leaf),
|
|
19
|
+
(
|
|
20
|
+
"payload",
|
|
21
|
+
jsonl::Shape::Fields(&[
|
|
22
|
+
("type", jsonl::Shape::Leaf),
|
|
23
|
+
("model", jsonl::Shape::Leaf),
|
|
24
|
+
(
|
|
25
|
+
"arguments",
|
|
26
|
+
jsonl::Shape::Fields(&[("model", jsonl::Shape::Leaf)]),
|
|
27
|
+
),
|
|
28
|
+
]),
|
|
29
|
+
),
|
|
30
|
+
]);
|
|
31
|
+
|
|
17
32
|
pub struct Codex {
|
|
18
33
|
roots: SourceRoots,
|
|
19
34
|
titles: Option<HashMap<String, String>>,
|
|
@@ -79,7 +94,7 @@ impl Codex {
|
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
fn scan(path: &Path) -> crate::Result<jsonl::Metadata> {
|
|
82
|
-
jsonl::metadata(path, 10)
|
|
97
|
+
jsonl::metadata(path, 10, Some(&RECORD))
|
|
83
98
|
}
|
|
84
99
|
|
|
85
100
|
fn parse(
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
use crate::providers::contract::{DiagnosticSink, RecoverableDiagnostic};
|
|
2
|
+
use serde::de::{Deserializer, IgnoredAny, MapAccess, Visitor};
|
|
3
|
+
use serde_json::value::RawValue;
|
|
4
|
+
use serde_json::{Map, Value};
|
|
5
|
+
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
|
|
6
|
+
use std::path::Path;
|
|
7
|
+
|
|
8
|
+
pub const FULL_SCAN_LIMIT: u64 = 256 * 1024;
|
|
9
|
+
const WINDOW: u64 = 64 * 1024;
|
|
10
|
+
|
|
11
|
+
pub struct Metadata {
|
|
12
|
+
pub header: Option<Value>,
|
|
13
|
+
pub records: Vec<Value>,
|
|
14
|
+
pub tail: Option<Value>,
|
|
15
|
+
pub complete: bool,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// Fields a provider reads from a record. `Fields` positions are only
|
|
19
|
+
/// indexed, so a non-object there is kept as null.
|
|
20
|
+
pub enum Shape {
|
|
21
|
+
Leaf,
|
|
22
|
+
Fields(&'static [(&'static str, Shape)]),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
struct Pruned(&'static [(&'static str, Shape)]);
|
|
26
|
+
|
|
27
|
+
impl<'de> Visitor<'de> for Pruned {
|
|
28
|
+
type Value = Map<String, Value>;
|
|
29
|
+
|
|
30
|
+
fn expecting(
|
|
31
|
+
&self,
|
|
32
|
+
formatter: &mut std::fmt::Formatter<'_>,
|
|
33
|
+
) -> std::fmt::Result {
|
|
34
|
+
formatter.write_str("a JSON object")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fn visit_map<A: MapAccess<'de>>(
|
|
38
|
+
self,
|
|
39
|
+
mut map: A,
|
|
40
|
+
) -> Result<Self::Value, A::Error> {
|
|
41
|
+
let mut object = Map::new();
|
|
42
|
+
while let Some(key) = map.next_key::<String>()? {
|
|
43
|
+
if let Some((_, shape)) =
|
|
44
|
+
self.0.iter().find(|(name, _)| *name == key)
|
|
45
|
+
{
|
|
46
|
+
let raw = map.next_value::<&RawValue>()?;
|
|
47
|
+
let value = project(raw.get(), shape)
|
|
48
|
+
.map_err(serde::de::Error::custom)?;
|
|
49
|
+
object.insert(key, value);
|
|
50
|
+
} else {
|
|
51
|
+
map.next_value::<IgnoredAny>()?;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
Ok(object)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fn project(text: &str, shape: &'static Shape) -> serde_json::Result<Value> {
|
|
59
|
+
match shape {
|
|
60
|
+
Shape::Leaf => serde_json::from_str(text),
|
|
61
|
+
Shape::Fields(fields) if text.starts_with('{') => {
|
|
62
|
+
let mut deserializer = serde_json::Deserializer::from_str(text);
|
|
63
|
+
let object = deserializer.deserialize_map(Pruned(fields))?;
|
|
64
|
+
deserializer.end()?;
|
|
65
|
+
Ok(Value::Object(object))
|
|
66
|
+
}
|
|
67
|
+
Shape::Fields(_) => Ok(Value::Null),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fn object(bytes: &[u8]) -> Option<Value> {
|
|
72
|
+
crate::compat::json::from_slice(bytes)
|
|
73
|
+
.ok()
|
|
74
|
+
.filter(Value::is_object)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fn pruned(bytes: &[u8], shape: &'static Shape) -> Option<Value> {
|
|
78
|
+
let text = std::str::from_utf8(bytes).ok()?;
|
|
79
|
+
let text = text.trim_start_matches([' ', '\t', '\n', '\r']);
|
|
80
|
+
if !text.starts_with('{') {
|
|
81
|
+
return None;
|
|
82
|
+
}
|
|
83
|
+
// Inputs serde_json rejects, such as NaN, take the compatible path.
|
|
84
|
+
let Ok(mut value) = project(text, shape) else {
|
|
85
|
+
return object(bytes);
|
|
86
|
+
};
|
|
87
|
+
crate::compat::json::normalize(&mut value);
|
|
88
|
+
Some(value)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
fn nonempty(bytes: &[u8]) -> bool {
|
|
92
|
+
bytes.iter().any(|byte| !byte.is_ascii_whitespace())
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// Records after the first `head_lines` keep only the fields in `shape`.
|
|
96
|
+
pub fn metadata(
|
|
97
|
+
path: &Path,
|
|
98
|
+
head_lines: usize,
|
|
99
|
+
shape: Option<&'static Shape>,
|
|
100
|
+
) -> crate::Result<Metadata> {
|
|
101
|
+
let mut file = crate::storage::source_io::open(path)?;
|
|
102
|
+
let size = file.metadata()?.len();
|
|
103
|
+
let mut bytes = Vec::new();
|
|
104
|
+
if size <= FULL_SCAN_LIMIT {
|
|
105
|
+
crate::storage::source_io::at(path, file.read_to_end(&mut bytes))?;
|
|
106
|
+
let lines: Vec<_> = bytes
|
|
107
|
+
.split(|byte| *byte == b'\n')
|
|
108
|
+
.filter(|line| nonempty(line))
|
|
109
|
+
.collect();
|
|
110
|
+
let mut records = Vec::new();
|
|
111
|
+
for line in &lines {
|
|
112
|
+
records.extend(match shape {
|
|
113
|
+
Some(shape) if records.len() >= head_lines => {
|
|
114
|
+
pruned(line, shape)
|
|
115
|
+
}
|
|
116
|
+
_ => object(line),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return Ok(Metadata {
|
|
120
|
+
header: lines.first().and_then(|line| object(line)),
|
|
121
|
+
records,
|
|
122
|
+
tail: None,
|
|
123
|
+
complete: true,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
crate::storage::source_io::at(
|
|
127
|
+
path,
|
|
128
|
+
file.by_ref().take(WINDOW).read_to_end(&mut bytes),
|
|
129
|
+
)?;
|
|
130
|
+
let end = bytes.iter().rposition(|byte| *byte == b'\n');
|
|
131
|
+
let lines: Vec<_> = end
|
|
132
|
+
.map(|end| {
|
|
133
|
+
bytes[..=end]
|
|
134
|
+
.split(|byte| *byte == b'\n')
|
|
135
|
+
.take(head_lines)
|
|
136
|
+
.filter(|line| nonempty(line))
|
|
137
|
+
.collect()
|
|
138
|
+
})
|
|
139
|
+
.unwrap_or_default();
|
|
140
|
+
let header = if end.is_none() {
|
|
141
|
+
Some(serde_json::json!({}))
|
|
142
|
+
} else {
|
|
143
|
+
lines.first().and_then(|line| object(line))
|
|
144
|
+
};
|
|
145
|
+
let records = lines.iter().filter_map(|line| object(line)).collect();
|
|
146
|
+
file.seek(SeekFrom::Start(size - WINDOW))?;
|
|
147
|
+
let mut tail = Vec::new();
|
|
148
|
+
file.take(WINDOW).read_to_end(&mut tail)?;
|
|
149
|
+
let tail_record =
|
|
150
|
+
tail.iter()
|
|
151
|
+
.position(|byte| *byte == b'\n')
|
|
152
|
+
.and_then(|start| {
|
|
153
|
+
let end = tail.iter().rposition(|byte| *byte == b'\n')?;
|
|
154
|
+
tail[start + 1..=end]
|
|
155
|
+
.split(|byte| *byte == b'\n')
|
|
156
|
+
.rev()
|
|
157
|
+
.find(|line| nonempty(line))
|
|
158
|
+
.and_then(object)
|
|
159
|
+
});
|
|
160
|
+
Ok(Metadata {
|
|
161
|
+
header,
|
|
162
|
+
records,
|
|
163
|
+
tail: tail_record,
|
|
164
|
+
complete: false,
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
pub fn scan(
|
|
169
|
+
path: &Path,
|
|
170
|
+
diagnostics: &mut DiagnosticSink<'_>,
|
|
171
|
+
mut append: impl FnMut(Value, &mut DiagnosticSink<'_>) -> crate::Result<()>,
|
|
172
|
+
) -> crate::Result<()> {
|
|
173
|
+
scan_numbered(path, diagnostics, |_, record, diagnostics| {
|
|
174
|
+
append(record, diagnostics)
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
pub fn scan_numbered(
|
|
179
|
+
path: &Path,
|
|
180
|
+
diagnostics: &mut DiagnosticSink<'_>,
|
|
181
|
+
mut append: impl FnMut(
|
|
182
|
+
usize,
|
|
183
|
+
Value,
|
|
184
|
+
&mut DiagnosticSink<'_>,
|
|
185
|
+
) -> crate::Result<()>,
|
|
186
|
+
) -> crate::Result<()> {
|
|
187
|
+
let mut reader = BufReader::new(crate::storage::source_io::open(path)?);
|
|
188
|
+
let mut line = Vec::new();
|
|
189
|
+
let mut number = 0;
|
|
190
|
+
let mut skipped = Vec::new();
|
|
191
|
+
let mut count = 0;
|
|
192
|
+
loop {
|
|
193
|
+
line.clear();
|
|
194
|
+
if crate::storage::source_io::at(
|
|
195
|
+
path,
|
|
196
|
+
reader.read_until(b'\n', &mut line),
|
|
197
|
+
)? == 0
|
|
198
|
+
{
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
number += 1;
|
|
202
|
+
if !nonempty(&line) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if let Some(value) = object(&line) {
|
|
206
|
+
append(number, value, diagnostics)?;
|
|
207
|
+
} else if matches!(line.last(), Some(b'\n' | b'\r')) {
|
|
208
|
+
count += 1;
|
|
209
|
+
if skipped.len() < 5 {
|
|
210
|
+
skipped.push(number);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if count > 0 {
|
|
215
|
+
diagnostics(RecoverableDiagnostic::JsonlRecordsSkipped {
|
|
216
|
+
path: path.to_owned(),
|
|
217
|
+
count,
|
|
218
|
+
lines: skipped,
|
|
219
|
+
})?;
|
|
220
|
+
}
|
|
221
|
+
Ok(())
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#[cfg(test)]
|
|
225
|
+
mod tests {
|
|
226
|
+
use super::*;
|
|
227
|
+
|
|
228
|
+
static SHAPE: Shape = Shape::Fields(&[
|
|
229
|
+
("timestamp", Shape::Leaf),
|
|
230
|
+
(
|
|
231
|
+
"payload",
|
|
232
|
+
Shape::Fields(&[
|
|
233
|
+
("type", Shape::Leaf),
|
|
234
|
+
("arguments", Shape::Fields(&[("model", Shape::Leaf)])),
|
|
235
|
+
]),
|
|
236
|
+
),
|
|
237
|
+
]);
|
|
238
|
+
|
|
239
|
+
#[test]
|
|
240
|
+
fn pruned_records_read_like_full_records() {
|
|
241
|
+
let lines: [&[u8]; 12] = [
|
|
242
|
+
br#"{"timestamp":"t","payload":{"type":"message","content":"x"}}"#,
|
|
243
|
+
br#"{"timestamp":"t","payload":[{"type":"message"}]}"#,
|
|
244
|
+
br#"{"payload":{"type":["message"]}}"#,
|
|
245
|
+
br#"{"payload":{"type":{"kind":"message"}}}"#,
|
|
246
|
+
br#"{"timestamp":"t","payload":{"type":"message","n":NaN}}"#,
|
|
247
|
+
br#"{"timestamp":"a","timestamp":"b","payload":{"type":1}}"#,
|
|
248
|
+
br#" {"timestamp":"c","payload":{"arguments":"{}"}}"#,
|
|
249
|
+
br#"{"payload":{"arguments":{"model":"m"}},"timestamp":1e999}"#,
|
|
250
|
+
br#"[{"timestamp":"t"}]"#,
|
|
251
|
+
br#"{"timestamp":"t"} trailing"#,
|
|
252
|
+
b"{\"timestamp\":\"\xff\"}",
|
|
253
|
+
br#"{"timestamp":"t","payload":"text"}"#,
|
|
254
|
+
];
|
|
255
|
+
for line in lines {
|
|
256
|
+
let full = object(line);
|
|
257
|
+
let pruned = pruned(line, &SHAPE);
|
|
258
|
+
assert_eq!(full.is_some(), pruned.is_some(), "{line:?}");
|
|
259
|
+
let (Some(full), Some(pruned)) = (full, pruned) else {
|
|
260
|
+
continue;
|
|
261
|
+
};
|
|
262
|
+
for path in [
|
|
263
|
+
&["timestamp"][..],
|
|
264
|
+
&["payload", "type"],
|
|
265
|
+
&["payload", "arguments", "model"],
|
|
266
|
+
] {
|
|
267
|
+
let read = |value: &Value| {
|
|
268
|
+
path.iter()
|
|
269
|
+
.fold(value.clone(), |value, key| value[key].clone())
|
|
270
|
+
};
|
|
271
|
+
assert_eq!(read(&full), read(&pruned), "{line:?} {path:?}");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -45,17 +45,41 @@ pub fn locate(
|
|
|
45
45
|
query: &Query,
|
|
46
46
|
) -> crate::Result<Vec<Location>> {
|
|
47
47
|
let revision = revision(data)?;
|
|
48
|
+
Ok(evidence(data, query)
|
|
49
|
+
.into_iter()
|
|
50
|
+
.map(|(index, snippet)| {
|
|
51
|
+
let position = index + 1;
|
|
52
|
+
Location {
|
|
53
|
+
position,
|
|
54
|
+
locator: format!("{revision}:{position}"),
|
|
55
|
+
role: data.messages[index].role.clone(),
|
|
56
|
+
snippet,
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
.collect())
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
pub fn hits(data: &SessionData, query: &Query) -> Vec<usize> {
|
|
63
|
+
evidence(data, query)
|
|
64
|
+
.into_iter()
|
|
65
|
+
.map(|(index, _)| index)
|
|
66
|
+
.collect()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fn evidence(data: &SessionData, query: &Query) -> Vec<(usize, String)> {
|
|
48
70
|
let text = TextQuery::new(
|
|
49
71
|
query.keyword.as_deref().unwrap_or(""),
|
|
50
72
|
crate::query::text::Mode::Terms,
|
|
51
73
|
);
|
|
74
|
+
if text.literals.is_empty() {
|
|
75
|
+
return Vec::new();
|
|
76
|
+
}
|
|
52
77
|
let terms: Vec<_> = text
|
|
53
78
|
.literals
|
|
54
79
|
.iter()
|
|
55
80
|
.map(|term| TextQuery::new(term, crate::query::text::Mode::Phrase))
|
|
56
81
|
.collect();
|
|
57
|
-
|
|
58
|
-
.messages
|
|
82
|
+
data.messages
|
|
59
83
|
.iter()
|
|
60
84
|
.enumerate()
|
|
61
85
|
.filter_map(|(index, message)| {
|
|
@@ -69,15 +93,9 @@ pub fn locate(
|
|
|
69
93
|
let content = searchable_message(message);
|
|
70
94
|
let evidence =
|
|
71
95
|
terms.iter().find_map(|term| term.find(&[&content]))?;
|
|
72
|
-
|
|
73
|
-
Some(Location {
|
|
74
|
-
position,
|
|
75
|
-
locator: format!("{revision}:{position}"),
|
|
76
|
-
role: message.role.clone(),
|
|
77
|
-
snippet: evidence.snippet,
|
|
78
|
-
})
|
|
96
|
+
Some((index, evidence.snippet))
|
|
79
97
|
})
|
|
80
|
-
.collect()
|
|
98
|
+
.collect()
|
|
81
99
|
}
|
|
82
100
|
|
|
83
101
|
pub fn window(
|
|
@@ -356,3 +356,30 @@ fn existing_normalized_text_copies_remain_usable() {
|
|
|
356
356
|
.unwrap();
|
|
357
357
|
assert_eq!(stored, "after 认 证");
|
|
358
358
|
}
|
|
359
|
+
|
|
360
|
+
#[test]
|
|
361
|
+
fn updates_refresh_last_seen_only_after_a_day() {
|
|
362
|
+
let directory = tempfile::tempdir().unwrap();
|
|
363
|
+
let mut index =
|
|
364
|
+
SearchIndex::at(&directory.path().join("index.db")).unwrap();
|
|
365
|
+
let a = session(directory.path(), "a", 0);
|
|
366
|
+
let provider = Reader(|_: &Session| Ok("body".into()));
|
|
367
|
+
let seen = |index: &mut SearchIndex, value: Option<f64>| -> f64 {
|
|
368
|
+
if let Some(value) = value {
|
|
369
|
+
index
|
|
370
|
+
.connection
|
|
371
|
+
.execute("UPDATE index_state SET last_seen_at = ?", [value])
|
|
372
|
+
.unwrap();
|
|
373
|
+
update(index, &provider, std::slice::from_ref(&a));
|
|
374
|
+
}
|
|
375
|
+
index
|
|
376
|
+
.connection
|
|
377
|
+
.query_row("SELECT last_seen_at FROM index_state", [], |r| r.get(0))
|
|
378
|
+
.unwrap()
|
|
379
|
+
};
|
|
380
|
+
update(&mut index, &provider, std::slice::from_ref(&a));
|
|
381
|
+
let stale = 2.0f64.mul_add(-86400.0, now());
|
|
382
|
+
assert!(seen(&mut index, Some(stale)) > stale + 86400.0);
|
|
383
|
+
let recent = now() - 3600.0;
|
|
384
|
+
assert!((seen(&mut index, Some(recent)) - recent).abs() < f64::EPSILON);
|
|
385
|
+
}
|
|
@@ -213,11 +213,10 @@ impl SearchIndex {
|
|
|
213
213
|
) -> crate::Result<(usize, Vec<String>)> {
|
|
214
214
|
let observed = now();
|
|
215
215
|
let indexed: HashMap<String, IndexedRow> = self.connection.prepare("SELECT session_id, updated_signature, fts_rowid, indexed_at FROM index_state WHERE agent = ?")?.query_map([info.name], |r| Ok((r.get(0)?, IndexedRow { signature: r.get(1)?, rowid: r.get(2)?, observed: r.get(3)? })))?.collect::<Result<_, _>>()?;
|
|
216
|
-
let
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
transaction.commit()?;
|
|
216
|
+
let ids: Vec<_> = sessions.iter().map(|session| &session.id).collect();
|
|
217
|
+
// Rows expire after 30 unseen days. Refreshing at most daily lets
|
|
218
|
+
// repeated searches skip the write and its journal sync.
|
|
219
|
+
self.connection.execute("UPDATE index_state SET last_seen_at = ? WHERE agent = ? AND last_seen_at < ? AND session_id IN (SELECT value FROM json_each(?))", params![observed, info.name, observed - 86400.0, serde_json::to_string(&ids)?])?;
|
|
221
220
|
let pending: Vec<_> = sessions
|
|
222
221
|
.iter()
|
|
223
222
|
.filter_map(|session| {
|
|
@@ -371,20 +370,10 @@ impl SearchIndex {
|
|
|
371
370
|
let Some(table) = fts_table(query) else {
|
|
372
371
|
return self.literal_search(query, keys);
|
|
373
372
|
};
|
|
374
|
-
// The contentless normalized table stores no text; its matches read
|
|
375
|
-
// the raw trigram row.
|
|
376
|
-
let (raw, fields) = if table == "sessions_fts" {
|
|
377
|
-
(
|
|
378
|
-
"JOIN sessions_fts_trigram raw ON raw.rowid = f.rowid",
|
|
379
|
-
"raw.title, raw.content",
|
|
380
|
-
)
|
|
381
|
-
} else {
|
|
382
|
-
("", "f.title, f.content")
|
|
383
|
-
};
|
|
384
373
|
// Unary + keeps MATCH as the FTS scan instead of repeating it per key.
|
|
385
374
|
// Filtering returned rows retains the global BM25 corpus.
|
|
386
375
|
let sql = format!(
|
|
387
|
-
"SELECT s.agent, s.session_id,
|
|
376
|
+
"SELECT s.agent, s.session_id, f.rowid, bm25({table}) FROM {table} f JOIN index_state s ON s.fts_rowid = f.rowid WHERE {table} MATCH ? AND +f.rowid IN (SELECT scoped.fts_rowid FROM json_each(?) scope JOIN index_state scoped ON scoped.agent = json_extract(scope.value, '$[0]') AND scoped.session_id = json_extract(scope.value, '$[1]')) ORDER BY bm25({table}), s.session_updated_at DESC, s.session_created_at DESC, s.agent, s.session_id"
|
|
388
377
|
);
|
|
389
378
|
let expression = query
|
|
390
379
|
.literals
|
|
@@ -399,23 +388,33 @@ impl SearchIndex {
|
|
|
399
388
|
})
|
|
400
389
|
.collect::<Vec<_>>()
|
|
401
390
|
.join(" ");
|
|
402
|
-
let mut
|
|
403
|
-
let mut rows =
|
|
404
|
-
.query(params![expression, serde_json::to_string(keys)?])?;
|
|
391
|
+
let mut ranked = self.connection.prepare(&sql)?;
|
|
392
|
+
let mut rows =
|
|
393
|
+
ranked.query(params![expression, serde_json::to_string(keys)?])?;
|
|
394
|
+
// Ranked rows carry no text, so sorting never spills whole bodies to
|
|
395
|
+
// temporary files. Both tables share rowids; the trigram row holds
|
|
396
|
+
// the text.
|
|
397
|
+
let mut text = self.connection.prepare(
|
|
398
|
+
"SELECT title, content FROM sessions_fts_trigram WHERE rowid = ?",
|
|
399
|
+
)?;
|
|
405
400
|
let mut results = Vec::new();
|
|
406
401
|
while let Some(row) = rows.next()? {
|
|
407
|
-
let
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
402
|
+
let Some((title, content)) = text
|
|
403
|
+
.query_row([row.get::<_, i64>(2)?], |r| {
|
|
404
|
+
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
|
|
405
|
+
})
|
|
406
|
+
.optional()?
|
|
407
|
+
else {
|
|
408
|
+
continue;
|
|
409
|
+
};
|
|
411
410
|
let Some(evidence) = query.find(&[&title, &content]) else {
|
|
412
411
|
continue;
|
|
413
412
|
};
|
|
414
413
|
results.push(SearchResult {
|
|
415
|
-
provider
|
|
416
|
-
id
|
|
414
|
+
provider: row.get(0)?,
|
|
415
|
+
id: row.get(1)?,
|
|
417
416
|
snippet: evidence.snippet,
|
|
418
|
-
rank: -row.get::<_, f64>(
|
|
417
|
+
rank: -row.get::<_, f64>(3)?,
|
|
419
418
|
});
|
|
420
419
|
}
|
|
421
420
|
Ok(results)
|