Skip to content

Commit 65a24de

Browse files
committed
feat(llm): add Temporal intent, date_range on OrchestrationResult, heuristic + LLM detection
1 parent 6b3ae69 commit 65a24de

1 file changed

Lines changed: 92 additions & 2 deletions

File tree

src/llm.rs

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ pub enum QueryIntent {
9494
Relationship,
9595
/// User is browsing without a clear target.
9696
Exploratory,
97+
/// User is asking about a specific time period.
98+
Temporal,
9799
}
98100

99101
/// Output produced by an orchestrator model for a query.
@@ -103,6 +105,9 @@ pub struct OrchestrationResult {
103105
pub intent: QueryIntent,
104106
/// Query string(s) to actually run (original + any expansions).
105107
pub expansions: Vec<String>,
108+
/// Optional unix-timestamp range for temporal queries (start, end).
109+
#[serde(default)]
110+
pub date_range: Option<(i64, i64)>,
106111
}
107112

108113
/// Per-lane weights for the RRF fusion step.
@@ -112,6 +117,7 @@ pub struct LaneWeights {
112117
pub fts: f64,
113118
pub graph: f64,
114119
pub rerank: f64,
120+
pub temporal: f64,
115121
}
116122

117123
impl LaneWeights {
@@ -123,24 +129,35 @@ impl LaneWeights {
123129
semantic: 0.6,
124130
graph: 0.6,
125131
rerank: 0.8,
132+
temporal: 0.0,
126133
},
127134
QueryIntent::Conceptual => Self {
128135
semantic: 1.2,
129136
fts: 0.8,
130137
graph: 1.0,
131138
rerank: 1.2,
139+
temporal: 0.0,
132140
},
133141
QueryIntent::Relationship => Self {
134142
graph: 1.5,
135143
semantic: 0.8,
136144
fts: 0.8,
137145
rerank: 1.0,
146+
temporal: 0.0,
138147
},
139148
QueryIntent::Exploratory => Self {
140149
semantic: 1.0,
141150
fts: 1.0,
142151
graph: 0.8,
143152
rerank: 1.0,
153+
temporal: 0.0,
154+
},
155+
QueryIntent::Temporal => Self {
156+
semantic: 0.6,
157+
fts: 0.8,
158+
graph: 0.5,
159+
rerank: 0.8,
160+
temporal: 1.5,
144161
},
145162
}
146163
}
@@ -152,6 +169,7 @@ impl LaneWeights {
152169
fts: 1.0,
153170
graph: 0.8,
154171
rerank: 0.0,
172+
temporal: 0.0,
155173
}
156174
}
157175
}
@@ -311,6 +329,7 @@ impl OrchestratorModel for MockLlm {
311329
Ok(OrchestrationResult {
312330
intent: QueryIntent::Exploratory,
313331
expansions: vec![query.to_owned()],
332+
date_range: None,
314333
})
315334
}
316335
}
@@ -776,11 +795,22 @@ impl EmbedModel for LlamaEmbed {
776795
pub fn heuristic_orchestrate(query: &str) -> OrchestrationResult {
777796
let trimmed = query.trim();
778797

798+
// Temporal: detect date/time references in the query
799+
let date_range = crate::temporal::parse_date_range_heuristic(query);
800+
if date_range.is_some() {
801+
return OrchestrationResult {
802+
intent: QueryIntent::Temporal,
803+
expansions: vec![trimmed.to_string()],
804+
date_range,
805+
};
806+
}
807+
779808
// Exact: docids (#abc123) or ticket IDs (ABC-1234)
780809
if trimmed.starts_with('#') && trimmed.len() <= 8 {
781810
return OrchestrationResult {
782811
intent: QueryIntent::Exact,
783812
expansions: vec![trimmed.to_string()],
813+
date_range: None,
784814
};
785815
}
786816
// Ticket ID pattern: PREFIX-1234
@@ -793,6 +823,7 @@ pub fn heuristic_orchestrate(query: &str) -> OrchestrationResult {
793823
return OrchestrationResult {
794824
intent: QueryIntent::Exact,
795825
expansions: vec![trimmed.to_string()],
826+
date_range: None,
796827
};
797828
}
798829
}
@@ -803,6 +834,7 @@ pub fn heuristic_orchestrate(query: &str) -> OrchestrationResult {
803834
return OrchestrationResult {
804835
intent: QueryIntent::Relationship,
805836
expansions: vec![trimmed.to_string()],
837+
date_range: None,
806838
};
807839
}
808840

@@ -824,6 +856,7 @@ pub fn heuristic_orchestrate(query: &str) -> OrchestrationResult {
824856
OrchestrationResult {
825857
intent: QueryIntent::Exploratory,
826858
expansions,
859+
date_range: None,
827860
}
828861
}
829862

@@ -843,6 +876,7 @@ pub fn parse_orchestration_json(text: &str) -> Result<OrchestrationResult> {
843876
"exact" => QueryIntent::Exact,
844877
"conceptual" => QueryIntent::Conceptual,
845878
"relationship" => QueryIntent::Relationship,
879+
"temporal" => QueryIntent::Temporal,
846880
_ => QueryIntent::Exploratory,
847881
};
848882

@@ -859,7 +893,14 @@ pub fn parse_orchestration_json(text: &str) -> Result<OrchestrationResult> {
859893
anyhow::bail!("no expansions in orchestration response");
860894
}
861895

862-
Ok(OrchestrationResult { intent, expansions })
896+
let date_range = crate::temporal::parse_date_range_from_json(&parsed);
897+
let intent = if date_range.is_some() && intent != QueryIntent::Temporal {
898+
QueryIntent::Temporal
899+
} else {
900+
intent
901+
};
902+
903+
Ok(OrchestrationResult { intent, expansions, date_range })
863904
}
864905

865906
/// Extract the first JSON object ({...}) from text, handling nested braces.
@@ -886,8 +927,11 @@ fn extract_json_object(text: &str) -> Option<&str> {
886927
const ORCHESTRATOR_SYSTEM_PROMPT: &str = r#"You are a search query analyzer. Given a user's search query, classify it and expand it.
887928
888929
Return JSON with:
889-
- "intent": one of "exact", "conceptual", "relationship", "exploratory"
930+
- "intent": one of "exact", "conceptual", "relationship", "exploratory", "temporal"
890931
- "expansions": 2-4 alternative phrasings (always include the original query first)
932+
- "date_range": (only for temporal queries) {"start":"YYYY-MM-DD","end":"YYYY-MM-DD"}
933+
934+
Use "temporal" intent when the query references a time period (e.g. "yesterday", "last week", "March 2026").
891935
892936
Be concise. Only return the JSON object."#;
893937

@@ -1512,4 +1556,50 @@ mod tests {
15121556
let mock = MockLlm::new(256);
15131557
assert_rerank(&mock);
15141558
}
1559+
1560+
// ── Temporal intent tests ────────────────────────────────────────────────
1561+
1562+
#[test]
1563+
fn test_temporal_intent_weights() {
1564+
let weights = LaneWeights::from_intent(&QueryIntent::Temporal);
1565+
assert!(weights.temporal > weights.semantic);
1566+
assert!(weights.temporal > 1.0);
1567+
}
1568+
1569+
#[test]
1570+
fn test_non_temporal_intent_has_zero_temporal() {
1571+
let exact = LaneWeights::from_intent(&QueryIntent::Exact);
1572+
assert!((exact.temporal - 0.0).abs() < f64::EPSILON);
1573+
let conceptual = LaneWeights::from_intent(&QueryIntent::Conceptual);
1574+
assert!((conceptual.temporal - 0.0).abs() < f64::EPSILON);
1575+
}
1576+
1577+
#[test]
1578+
fn test_heuristic_orchestrate_temporal() {
1579+
let result = heuristic_orchestrate("what happened yesterday");
1580+
assert_eq!(result.intent, QueryIntent::Temporal);
1581+
assert!(result.date_range.is_some());
1582+
}
1583+
1584+
#[test]
1585+
fn test_heuristic_orchestrate_non_temporal() {
1586+
let result = heuristic_orchestrate("how does auth work");
1587+
assert!(result.date_range.is_none());
1588+
assert_ne!(result.intent, QueryIntent::Temporal);
1589+
}
1590+
1591+
#[test]
1592+
fn test_parse_json_with_date_range() {
1593+
let json = r#"{"intent":"temporal","expansions":["last week updates"],"date_range":{"start":"2026-03-19","end":"2026-03-25"}}"#;
1594+
let result = parse_orchestration_json(json).unwrap();
1595+
assert_eq!(result.intent, QueryIntent::Temporal);
1596+
assert!(result.date_range.is_some());
1597+
}
1598+
1599+
#[test]
1600+
fn test_parse_json_without_date_range_backward_compat() {
1601+
let json = r#"{"intent":"exact","expansions":["BRE-1234"]}"#;
1602+
let result = parse_orchestration_json(json).unwrap();
1603+
assert!(result.date_range.is_none());
1604+
}
15151605
}

0 commit comments

Comments
 (0)