@@ -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
117123impl 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 {
776795pub 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> {
886927const ORCHESTRATOR_SYSTEM_PROMPT : & str = r#"You are a search query analyzer. Given a user's search query, classify it and expand it.
887928
888929Return 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
892936Be 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