t5
This commit is contained in:
parent
b095a382d2
commit
b894e818fd
18 changed files with 215583 additions and 125730 deletions
|
|
@ -2,18 +2,18 @@
|
||||||
|
|
||||||
## Abstract
|
## Abstract
|
||||||
|
|
||||||
We present TEMPR (Temporal Entity Memory Priming Retrieval), a memory retrieval architecture designed specifically for AI agents that combines temporal reasoning, entity-aware graph traversal, and neural priming activation to discover both directly and indirectly related memories through multi-strategy parallel search. Unlike traditional search systems optimized for human queries with top-k ranking, TEMPR is optimized for AI agent reasoning with thinking_budget and max_tokens parameters that enable agents to trade off latency for recall. Our multi-stage retrieval pipeline integrates four parallel search strategies (semantic vector search, BM25 keyword matching, graph-based spreading activation, and temporal-aware graph traversal) with reciprocal rank fusion and neural cross-encoder reranking. We leverage open-source LLMs for comprehensive narrative fact extraction, entity recognition, and entity disambiguation, following established practices in LLM-based information extraction. This approach enables the discovery of indirectly related information through graph traversal that purely vector-based approaches miss. We evaluate TEMPR on two benchmarks (LoComo and LongMemEval), achieving 73.50% overall accuracy on LoComo and 80.60% on LongMemEval, with particularly strong performance on multi-hop reasoning tasks (+15.8% over baseline systems).
|
We present TEMPR (Temporal Entity Memory Priming Retrieval), a memory retrieval architecture designed specifically for AI agents that combines temporal range reasoning, entity-aware graph traversal with causal link boosting, and neural priming activation to discover both directly and indirectly related memories through multi-strategy parallel search. Unlike traditional search systems optimized for human queries with top-k ranking, TEMPR is optimized for AI agent reasoning with thinking_budget and max_tokens parameters that enable agents to trade off latency for recall. Our multi-stage retrieval pipeline integrates four parallel search strategies (semantic vector search, BM25 keyword matching, graph-based spreading activation with 2x boost for causal links, and temporal-aware graph traversal with range matching) with reciprocal rank fusion and neural cross-encoder reranking. We leverage open-source LLMs for comprehensive narrative fact extraction with temporal ranges (occurred_start/end vs. mentioned_at), entity recognition, entity disambiguation, and causal relationship identification, following established practices in LLM-based information extraction. This approach enables the discovery of indirectly related information through graph traversal, explanatory reasoning through causal chains, and precise temporal matching through range-based queries that purely vector-based approaches miss. We evaluate TEMPR on two benchmarks (LoComo and LongMemEval), achieving 73.50% overall accuracy on LoComo and 80.60% on LongMemEval, with particularly strong performance on multi-hop reasoning tasks (+15.8% over baseline systems).
|
||||||
|
|
||||||
## 1. Introduction
|
## 1. Introduction
|
||||||
|
|
||||||
Conversational AI agents face a fundamental challenge: maintaining coherent, context-aware memories across extended interactions. Traditional search systems are optimized for human users with top-k ranking and relevance feedback, but AI agents have fundamentally different requirements: they need to retrieve variable amounts of information based on reasoning complexity (thinking_budget) while respecting LLM context windows (max_tokens). Existing approaches rely either on vector similarity search, which captures semantic relationships but misses entity-level connections, or on keyword matching, which provides precision but lacks conceptual understanding. Neither approach adequately handles the temporal aspects of memory or entity-based reasoning that enable multi-hop information discovery.
|
Conversational AI agents face a fundamental challenge: maintaining coherent, context-aware memories across extended interactions. Traditional search systems are optimized for human users with top-k ranking and relevance feedback, but AI agents have fundamentally different requirements: they need to retrieve variable amounts of information based on reasoning complexity (thinking_budget) while respecting LLM context windows (max_tokens). Existing approaches rely either on vector similarity search, which captures semantic relationships but misses entity-level connections, or on keyword matching, which provides precision but lacks conceptual understanding. Neither approach adequately handles the temporal aspects of memory or entity-based reasoning that enable multi-hop information discovery.
|
||||||
|
|
||||||
We propose TEMPR, a memory retrieval architecture designed specifically for AI agents that combines established information retrieval techniques—semantic vector search, BM25 keyword matching, spreading activation graph traversal (Anderson 1983), and neural reranking—into a unified system optimized for agent workflows. The key architectural choices are:
|
We propose TEMPR, a memory retrieval architecture designed specifically for AI agents that combines established information retrieval techniques—semantic vector search, BM25 keyword matching, spreading activation graph traversal with causal reasoning (Anderson 1983), and neural reranking—into a unified system optimized for agent workflows. The key architectural choices are:
|
||||||
|
|
||||||
1. **Agent-Optimized Interface**: thinking_budget and max_tokens parameters instead of traditional top-k ranking
|
1. **Agent-Optimized Interface**: thinking_budget and max_tokens parameters instead of traditional top-k ranking
|
||||||
2. **Comprehensive Narrative Fact Extraction**: LLM-powered extraction that creates self-contained narrative facts preserving full conversational context
|
2. **Comprehensive Narrative Fact Extraction with Temporal Ranges**: LLM-powered extraction that creates self-contained narrative facts preserving full conversational context, extracting temporal ranges (occurred_start/end) to distinguish point events from periods, and identifying causal relationships between facts
|
||||||
3. **Entity-Aware Graph Structure**: LLM-based entity resolution and linking that connects memories through shared identities
|
3. **Entity-Aware Graph Structure with Causal Links**: LLM-based entity resolution and linking that connects memories through shared identities, plus causal links (causes, caused_by, enables, prevents) that capture explanatory relationships
|
||||||
4. **Four-Way Parallel Retrieval**: Semantic, keyword, graph-based (spreading activation), and temporal retrieval strategies executed in parallel and fused using RRF (Cormack et al. 2009)
|
4. **Four-Way Parallel Retrieval with Causal Boosting**: Semantic, keyword, graph-based (spreading activation with 2x causal boost), and temporal range retrieval strategies executed in parallel and fused using RRF (Cormack et al. 2009)
|
||||||
5. **Neural Cross-Encoder Reranking**: Learned query-document relevance with temporal awareness and token budget filtering
|
5. **Neural Cross-Encoder Reranking**: Learned query-document relevance with temporal awareness and token budget filtering
|
||||||
|
|
||||||
This combination of techniques enables agents to discover indirectly related information through graph traversal while maintaining temporal awareness, achieving strong performance on multi-hop reasoning tasks.
|
This combination of techniques enables agents to discover indirectly related information through graph traversal while maintaining temporal awareness, achieving strong performance on multi-hop reasoning tasks.
|
||||||
|
|
@ -24,9 +24,9 @@ Our key contributions are:
|
||||||
|
|
||||||
1. **Agent-Optimized Retrieval Interface**: Unlike traditional top-k search optimized for human users, we introduce thinking_budget and max_tokens parameters that allow AI agents to dynamically trade off latency for recall based on reasoning complexity and context window constraints
|
1. **Agent-Optimized Retrieval Interface**: Unlike traditional top-k search optimized for human users, we introduce thinking_budget and max_tokens parameters that allow AI agents to dynamically trade off latency for recall based on reasoning complexity and context window constraints
|
||||||
|
|
||||||
2. **Four-Way Parallel Retrieval for Conversational Memory**: We combine semantic vector search, BM25 keyword matching, graph-based spreading activation (Anderson 1983), and temporal-aware graph traversal into a unified parallel retrieval pipeline using Reciprocal Rank Fusion (Cormack et al. 2009) and neural cross-encoder reranking. While each technique is well-established, their integration for conversational agent memory represents a novel application.
|
2. **Four-Way Parallel Retrieval with Causal Reasoning**: We combine semantic vector search, BM25 keyword matching, graph-based spreading activation with causal link boosting (Anderson 1983), and temporal-aware graph traversal into a unified parallel retrieval pipeline using Reciprocal Rank Fusion (Cormack et al. 2009) and neural cross-encoder reranking. The graph traversal prioritizes causal links (2x weight boost for direct causation) to surface explanatory relationships, enabling "why" and "how" queries. While each technique is well-established, their integration for conversational agent memory with causal reasoning represents a novel application.
|
||||||
|
|
||||||
3. **LLM-Based Knowledge Graph Construction**: We leverage open-source LLMs (following established practices from Petroni et al. 2019, Brown et al. 2020) for comprehensive narrative fact extraction, entity recognition, and entity disambiguation, applied to the conversational memory domain.
|
3. **LLM-Based Knowledge Graph Construction with Temporal Ranges**: We leverage open-source LLMs (following established practices from Petroni et al. 2019, Brown et al. 2020) for comprehensive narrative fact extraction, entity recognition, entity disambiguation, and causal relationship identification. The system extracts temporal ranges (occurred_start, occurred_end) to represent both point events and extended periods, distinguishing when facts occurred from when they were mentioned, enabling precise temporal queries and recency-aware ranking.
|
||||||
|
|
||||||
4. **Strong Performance on Multi-Hop Reasoning**: 73.50% on LoComo and 80.60% on LongMemEval, with particularly strong performance on multi-hop queries (+15.8% over Mem0), demonstrating the effectiveness of combining these techniques for discovering indirectly related information in conversational contexts
|
4. **Strong Performance on Multi-Hop Reasoning**: 73.50% on LoComo and 80.60% on LongMemEval, with particularly strong performance on multi-hop queries (+15.8% over Mem0), demonstrating the effectiveness of combining these techniques for discovering indirectly related information in conversational contexts
|
||||||
|
|
||||||
|
|
@ -42,7 +42,10 @@ Each memory is represented as a self-contained node with:
|
||||||
- `agent_id`: Identifier for the agent this memory belongs to
|
- `agent_id`: Identifier for the agent this memory belongs to
|
||||||
- `text`: Self-contained comprehensive narrative fact
|
- `text`: Self-contained comprehensive narrative fact
|
||||||
- `embedding`: 384-dimensional vector (BAAI/bge-small-en-v1.5)
|
- `embedding`: 384-dimensional vector (BAAI/bge-small-en-v1.5)
|
||||||
- `event_date`: Timestamp when the fact became true
|
- `event_date`: Timestamp when the fact became true (maintained for backward compatibility)
|
||||||
|
- `occurred_start`: Timestamp when the fact/event started (temporal range support)
|
||||||
|
- `occurred_end`: Timestamp when the fact/event ended (temporal range support)
|
||||||
|
- `mentioned_at`: Timestamp when the fact was mentioned/learned
|
||||||
- `context`: Optional contextual metadata
|
- `context`: Optional contextual metadata
|
||||||
- `access_count`: Frequency-based importance signal
|
- `access_count`: Frequency-based importance signal
|
||||||
- `search_vector`: Full-text search tsvector for BM25 ranking
|
- `search_vector`: Full-text search tsvector for BM25 ranking
|
||||||
|
|
@ -91,10 +94,16 @@ The extraction process leverages open-source LLMs (specifically, models from the
|
||||||
**LLM Extraction Steps**:
|
**LLM Extraction Steps**:
|
||||||
1. **Pronoun Resolution**: "She loves hiking" → "Alice loves hiking"
|
1. **Pronoun Resolution**: "She loves hiking" → "Alice loves hiking"
|
||||||
2. **Temporal Normalization**: "last year" → "in 2023" (absolute dates)
|
2. **Temporal Normalization**: "last year" → "in 2023" (absolute dates)
|
||||||
3. **Participant Attribution**: Preserve WHO said/did WHAT
|
3. **Temporal Range Extraction**: Identify when facts occurred vs. when mentioned
|
||||||
4. **Reasoning Preservation**: Include WHY decisions were made
|
- Point events: "on July 14" → occurred_start = occurred_end = 2023-07-14
|
||||||
5. **Fact Type Classification**: Determine fact categories
|
- Period events: "in February 2023" → occurred_start = 2023-02-01, occurred_end = 2023-02-28
|
||||||
6. **Entity Extraction**: Identify all entities (PERSON, ORG, LOCATION, PRODUCT, CONCEPT)
|
- Vague periods: "lately" → estimated range based on context
|
||||||
|
- mentioned_at = conversation date (when fact was learned)
|
||||||
|
4. **Participant Attribution**: Preserve WHO said/did WHAT
|
||||||
|
5. **Reasoning Preservation**: Include WHY decisions were made
|
||||||
|
6. **Fact Type Classification**: Determine fact categories
|
||||||
|
7. **Entity Extraction**: Identify all entities (PERSON, ORG, LOCATION, PRODUCT, CONCEPT)
|
||||||
|
8. **Causal Relationship Identification**: Link related facts through cause-effect relationships
|
||||||
|
|
||||||
**Context Preservation**: The system preserves critical details including:
|
**Context Preservation**: The system preserves critical details including:
|
||||||
- Visual/media elements (photos, images)
|
- Visual/media elements (photos, images)
|
||||||
|
|
@ -250,10 +259,45 @@ Entity links (described in Section 2.3.3) create the strongest connections:
|
||||||
- Most reliable traversal path during graph search
|
- Most reliable traversal path during graph search
|
||||||
- Enables "Tell me everything about X" queries
|
- Enables "Tell me everything about X" queries
|
||||||
|
|
||||||
|
#### 2.4.4 Causal Links
|
||||||
|
|
||||||
|
Causal links capture cause-effect relationships between facts, enabling reasoning about why events happened and what their consequences were:
|
||||||
|
|
||||||
|
**Creation Logic**:
|
||||||
|
During fact extraction, the LLM identifies causal relationships between facts extracted from the same conversation. These are stored as directed edges in the graph with specific relationship types.
|
||||||
|
|
||||||
|
**Causal Relationship Types**:
|
||||||
|
- `causes`: This fact directly causes the target fact
|
||||||
|
- Example: "It rained heavily" → causes → "Game was cancelled"
|
||||||
|
- `caused_by`: This fact was caused by the target fact (inverse of causes)
|
||||||
|
- Example: "I spend time in garden" ← caused_by ← "I lost my friend"
|
||||||
|
- `enables`: This fact enables or allows the target fact to happen
|
||||||
|
- Example: "I took pottery class" → enables → "I learned to make ceramics"
|
||||||
|
- `prevents`: This fact prevents or blocks the target fact
|
||||||
|
- Example: "Road was closed" → prevents → "We couldn't drive to venue"
|
||||||
|
|
||||||
|
**Properties**:
|
||||||
|
- `weight`: Strength of causal relationship ∈ [0.0, 1.0] (default 1.0 for strong causation)
|
||||||
|
- Directional edges (from cause to effect)
|
||||||
|
- Created only between facts from the same conversation or closely related temporal contexts
|
||||||
|
- Used during graph retrieval with higher activation weights than other link types
|
||||||
|
|
||||||
|
**Impact on Retrieval**: Causal links are particularly valuable for "why" and "how" queries:
|
||||||
|
|
||||||
|
**Example Query**: "Why does Alice spend time in the garden?"
|
||||||
|
1. **Semantic Match**: "Alice spends time in the garden to find comfort after losing her friend" (direct match)
|
||||||
|
2. **Causal Traversal**: Follow caused_by links →
|
||||||
|
- "Alice lost her friend Karlie in February 2023" (causal explanation)
|
||||||
|
3. **Temporal Context**: Follow temporal links from the loss event →
|
||||||
|
- "Alice felt grief and sadness about losing Karlie" (emotional context)
|
||||||
|
|
||||||
|
This causal graph connectivity enables the system to not just retrieve facts, but to explain *why* things happened by following cause-effect chains.
|
||||||
|
|
||||||
**Graph Density**: Each memory unit typically has:
|
**Graph Density**: Each memory unit typically has:
|
||||||
- 5-10 temporal links (to nearby memories)
|
- 5-10 temporal links (to nearby memories)
|
||||||
- 3-5 semantic links (to similar content)
|
- 3-5 semantic links (to similar content)
|
||||||
- Variable entity links (depending on entity mention frequency)
|
- Variable entity links (depending on entity mention frequency)
|
||||||
|
- 0-3 causal links (when causal relationships are identified)
|
||||||
|
|
||||||
This multi-layered graph structure enables flexible traversal strategies that balance different types of relatedness.
|
This multi-layered graph structure enables flexible traversal strategies that balance different types of relatedness.
|
||||||
|
|
||||||
|
|
@ -262,11 +306,24 @@ This multi-layered graph structure enables flexible traversal strategies that ba
|
||||||
Long-term memory systems must handle evolving information where newer facts may contradict or supersede older ones. TEMPR addresses this challenge through temporal awareness and retrieval-time resolution rather than eager fact invalidation.
|
Long-term memory systems must handle evolving information where newer facts may contradict or supersede older ones. TEMPR addresses this challenge through temporal awareness and retrieval-time resolution rather than eager fact invalidation.
|
||||||
|
|
||||||
**Temporal Recency Signals**:
|
**Temporal Recency Signals**:
|
||||||
Each memory unit includes:
|
Each memory unit includes multiple temporal dimensions that enable nuanced recency calculations:
|
||||||
- `event_date`: When the fact became true
|
|
||||||
|
- `occurred_start` / `occurred_end`: When the fact/event actually occurred (temporal range)
|
||||||
|
- Used for temporal queries ("What happened in February?")
|
||||||
|
- Enables matching both point events and extended periods
|
||||||
|
- `mentioned_at`: When the fact was mentioned/learned in conversation
|
||||||
|
- Used for recency bias (newer information often more relevant)
|
||||||
|
- Distinguishes between "Alice worked at Google in 2020" (occurred) vs. learned in 2024 (mentioned)
|
||||||
|
- `event_date`: Maintained for backward compatibility (typically = occurred_start)
|
||||||
- `access_count`: Frequency of retrieval (importance signal)
|
- `access_count`: Frequency of retrieval (importance signal)
|
||||||
- Temporal links that decay with time distance
|
- Temporal links that decay with time distance
|
||||||
|
|
||||||
|
**Dual Temporal Model Benefits**:
|
||||||
|
This separation of "when it occurred" vs. "when we learned about it" enables:
|
||||||
|
1. **Accurate temporal queries**: "What did Alice do in 2020?" uses occurred_start/end, not mentioned_at
|
||||||
|
2. **Recency-aware ranking**: Recent mentions get priority, but old events remain discoverable
|
||||||
|
3. **Hybrid activation**: Combine temporal proximity (occurred) with information freshness (mentioned)
|
||||||
|
|
||||||
**Retrieval-Time Conflict Resolution**:
|
**Retrieval-Time Conflict Resolution**:
|
||||||
Rather than proactively detecting and deleting contradictions (which risks information loss), TEMPR retrieves potentially conflicting facts and relies on the downstream LLM to resolve contradictions based on:
|
Rather than proactively detecting and deleting contradictions (which risks information loss), TEMPR retrieves potentially conflicting facts and relies on the downstream LLM to resolve contradictions based on:
|
||||||
|
|
||||||
|
|
@ -375,10 +432,29 @@ LIMIT $thinking_budget
|
||||||
|
|
||||||
**Decay Mechanism**: Activation decays by 0.8 per hop, limiting spread to ~4-5 hops before negligible impact.
|
**Decay Mechanism**: Activation decays by 0.8 per hop, limiting spread to ~4-5 hops before negligible impact.
|
||||||
|
|
||||||
**Link Weighting**:
|
**Link Weighting with Causal Boosting**:
|
||||||
- Entity links: weight 1.0 (strongest signal)
|
During graph traversal, link weights are adjusted based on link type to prioritize high-value relationships:
|
||||||
- Semantic links: weight ∈ [0.7, 1.0] (cosine similarity)
|
|
||||||
- Temporal links: weight ∈ [0.3, 1.0] (time-based decay)
|
- **Causal links**: Base weight × 2.0 boost (causes/caused_by) or × 1.5 boost (enables/prevents)
|
||||||
|
- Highest priority due to direct explanatory power
|
||||||
|
- "Why?" queries benefit most from causal traversal
|
||||||
|
- **Entity links**: weight 1.0 (no boost, already strong signal)
|
||||||
|
- **Semantic links**: weight ∈ [0.7, 1.0] (cosine similarity, no boost)
|
||||||
|
- **Temporal links**: weight ∈ [0.3, 1.0] (time-based decay, no boost)
|
||||||
|
|
||||||
|
**Causal Activation Boost**: When propagating activation through the graph, causal links receive preferential treatment:
|
||||||
|
```python
|
||||||
|
if link_type in ('causes', 'caused_by'):
|
||||||
|
effective_weight = base_weight × 2.0 # Direct causation
|
||||||
|
elif link_type in ('enables', 'prevents'):
|
||||||
|
effective_weight = base_weight × 1.5 # Conditional causation
|
||||||
|
else:
|
||||||
|
effective_weight = base_weight # Other links
|
||||||
|
|
||||||
|
neighbor.activation = current.activation × effective_weight × 0.8
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures that when the system encounters a fact, it's 2x more likely to also retrieve facts that explain *why* it happened or what it *caused*.
|
||||||
|
|
||||||
**Advantages**:
|
**Advantages**:
|
||||||
- Discovers indirectly related facts through graph connectivity
|
- Discovers indirectly related facts through graph connectivity
|
||||||
|
|
@ -391,21 +467,42 @@ LIMIT $thinking_budget
|
||||||
|
|
||||||
**Activation Condition**: Only triggered when temporal constraint detected in query
|
**Activation Condition**: Only triggered when temporal constraint detected in query
|
||||||
|
|
||||||
**Temporal Parsing**: Leverages LLM-based temporal constraint extraction to parse natural language date expressions:
|
**Temporal Parsing**: Uses google/flan-t5-small (80M parameters, ~300MB) to extract temporal constraints from natural language queries. The T5 model is fine-tuned with few-shot prompts to convert temporal expressions into structured date ranges:
|
||||||
- "last spring" → March 1 - May 31, previous year
|
- "last spring" → 2024-03-01 to 2024-05-31
|
||||||
- "in June" → June 1-30, current/previous year (context-dependent)
|
- "in June" → 2024-06-01 to 2024-06-30 (year inferred from context)
|
||||||
- "last year" → January 1 - December 31, previous year
|
- "last year" → 2024-01-01 to 2024-12-31
|
||||||
- "between March and May" → March 1 - May 31, current year
|
- "between March and May" → 2025-03-01 to 2025-05-31
|
||||||
|
|
||||||
|
The T5-based approach provides fast inference (~30-50ms on CPU) without requiring pattern matching or regex rules, handling complex temporal expressions like "dogs in June 2023" → 2023-06-01 to 2023-06-30 where both the context and temporal phrase must be parsed together
|
||||||
|
|
||||||
|
**Temporal Range Matching**: Facts are matched against time constraints using their temporal range (occurred_start, occurred_end) rather than just a single point:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def fact_matches_time_constraint(fact, query_start, query_end):
|
||||||
|
# Check if fact's temporal range overlaps with query range
|
||||||
|
return (fact.occurred_start <= query_end and
|
||||||
|
fact.occurred_end >= query_start)
|
||||||
|
```
|
||||||
|
|
||||||
|
This enables precise matching of period queries:
|
||||||
|
- Query: "What happened in February?" matches facts with occurred_start/end overlapping February
|
||||||
|
- Query: "What did Alice do last spring?" matches facts in March-May range
|
||||||
|
- Point events (occurred_start == occurred_end) match if within the query range
|
||||||
|
|
||||||
**Algorithm**:
|
**Algorithm**:
|
||||||
```python
|
```python
|
||||||
1. Parse query for temporal constraints → (start_date, end_date)
|
1. Parse query for temporal constraints → (start_date, end_date)
|
||||||
2. If no temporal constraint detected: skip this retrieval path
|
2. If no temporal constraint detected: skip this retrieval path
|
||||||
3. Find entry points: facts in date range with semantic similarity ≥ 0.4
|
3. Find entry points: facts whose temporal range overlaps query range
|
||||||
|
AND semantic similarity ≥ 0.4
|
||||||
4. Calculate temporal proximity score for each entry point:
|
4. Calculate temporal proximity score for each entry point:
|
||||||
score = 1.0 - (abs(event_date - mid_date) / range_radius)
|
# Use temporal anchor (midpoint) for proximity calculation
|
||||||
5. Spread through temporal links (weight ≥ 0.1):
|
fact_anchor = (occurred_start + occurred_end) / 2
|
||||||
- Only traverse temporal links to stay in time period
|
query_mid = (start_date + end_date) / 2
|
||||||
|
score = 1.0 - (abs(fact_anchor - query_mid) / range_radius)
|
||||||
|
5. Spread through temporal and causal links (weight ≥ 0.1):
|
||||||
|
- Traverse temporal links to stay in time period
|
||||||
|
- Traverse causal links to find explanations (causes/effects)
|
||||||
- Filter by semantic similarity ≥ 0.4 to maintain relevance
|
- Filter by semantic similarity ≥ 0.4 to maintain relevance
|
||||||
- Propagate temporal scores with decay (0.7)
|
- Propagate temporal scores with decay (0.7)
|
||||||
6. Return results with temporal_score metadata
|
6. Return results with temporal_score metadata
|
||||||
|
|
@ -520,7 +617,7 @@ return filtered_results, total_tokens
|
||||||
```
|
```
|
||||||
1. Query Processing
|
1. Query Processing
|
||||||
- Generate embedding (BAAI/bge-small-en-v1.5)
|
- Generate embedding (BAAI/bge-small-en-v1.5)
|
||||||
- Parse temporal constraints using LLM
|
- Parse temporal constraints using T5-small (google/flan-t5-small)
|
||||||
- Determine active retrieval paths (3-way or 4-way)
|
- Determine active retrieval paths (3-way or 4-way)
|
||||||
|
|
||||||
2. Parallel Retrieval
|
2. Parallel Retrieval
|
||||||
|
|
@ -553,7 +650,7 @@ TEMPR prioritizes read latency over write latency. Table 1 shows measured latenc
|
||||||
| Semantic Search (HNSW) | [TODO: e.g., 35ms] | [TODO: e.g., 62ms] | [TODO: e.g., 89ms] | [TODO: e.g., 23%] |
|
| Semantic Search (HNSW) | [TODO: e.g., 35ms] | [TODO: e.g., 62ms] | [TODO: e.g., 89ms] | [TODO: e.g., 23%] |
|
||||||
| BM25 Keyword Search | [TODO: e.g., 8ms] | [TODO: e.g., 15ms] | [TODO: e.g., 23ms] | [TODO: e.g., 5%] |
|
| BM25 Keyword Search | [TODO: e.g., 8ms] | [TODO: e.g., 15ms] | [TODO: e.g., 23ms] | [TODO: e.g., 5%] |
|
||||||
| Graph Traversal | [TODO: e.g., 42ms] | [TODO: e.g., 78ms] | [TODO: e.g., 112ms] | [TODO: e.g., 28%] |
|
| Graph Traversal | [TODO: e.g., 42ms] | [TODO: e.g., 78ms] | [TODO: e.g., 112ms] | [TODO: e.g., 28%] |
|
||||||
| Temporal Parsing (when triggered) | [TODO: e.g., 15ms] | [TODO: e.g., 28ms] | [TODO: e.g., 45ms] | [TODO: e.g., 10%] |
|
| Temporal Parsing (T5-small, when triggered) | 30ms | 50ms | 75ms | 10% |
|
||||||
| RRF Fusion | [TODO: e.g., 2ms] | [TODO: e.g., 3ms] | [TODO: e.g., 5ms] | [TODO: e.g., 1%] |
|
| RRF Fusion | [TODO: e.g., 2ms] | [TODO: e.g., 3ms] | [TODO: e.g., 5ms] | [TODO: e.g., 1%] |
|
||||||
| Cross-Encoder Reranking | [TODO: e.g., 35ms] | [TODO: e.g., 68ms] | [TODO: e.g., 95ms] | [TODO: e.g., 23%] |
|
| Cross-Encoder Reranking | [TODO: e.g., 35ms] | [TODO: e.g., 68ms] | [TODO: e.g., 95ms] | [TODO: e.g., 23%] |
|
||||||
| Token Budget Filtering | [TODO: e.g., 3ms] | [TODO: e.g., 5ms] | [TODO: e.g., 8ms] | [TODO: e.g., 2%] |
|
| Token Budget Filtering | [TODO: e.g., 3ms] | [TODO: e.g., 5ms] | [TODO: e.g., 8ms] | [TODO: e.g., 2%] |
|
||||||
|
|
@ -672,7 +769,7 @@ We measured the total cost of running TEMPR on the LoComo benchmark dataset (512
|
||||||
| **LLM Costs** | | | |
|
| **LLM Costs** | | | |
|
||||||
| Fact Extraction (write-time) | [TODO: e.g., $0.0032] | [TODO: e.g., $1.64] | OpenAI-OSS 20B, [TODO: e.g., ~1.2K] tokens/conversation |
|
| Fact Extraction (write-time) | [TODO: e.g., $0.0032] | [TODO: e.g., $1.64] | OpenAI-OSS 20B, [TODO: e.g., ~1.2K] tokens/conversation |
|
||||||
| Entity Disambiguation (write-time) | [TODO: e.g., $0.0008] | [TODO: e.g., $0.41] | Only for borderline cases ([TODO: e.g., ~15%] of entities) |
|
| Entity Disambiguation (write-time) | [TODO: e.g., $0.0008] | [TODO: e.g., $0.41] | Only for borderline cases ([TODO: e.g., ~15%] of entities) |
|
||||||
| Temporal Parsing (query-time) | [TODO: e.g., $0.0004] | [TODO: e.g., $0.20] | Only when temporal constraints detected |
|
| Temporal Parsing (T5-small, query-time) | $0.0000 | $0.00 | Local inference, no API cost |
|
||||||
| **Subtotal LLM** | [TODO: e.g., $0.0044] | [TODO: e.g., $2.25] | |
|
| **Subtotal LLM** | [TODO: e.g., $0.0044] | [TODO: e.g., $2.25] | |
|
||||||
| **Embedding Costs** | | | |
|
| **Embedding Costs** | | | |
|
||||||
| Fact Embeddings (write-time) | [TODO: e.g., $0.0002] | [TODO: e.g., $0.10] | BAAI/bge-small-en-v1.5 (local inference) |
|
| Fact Embeddings (write-time) | [TODO: e.g., $0.0002] | [TODO: e.g., $0.10] | BAAI/bge-small-en-v1.5 (local inference) |
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ pub struct BatchMemoryResponse {
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum AgentsResponse {
|
pub enum AgentsResponse {
|
||||||
Success {
|
Success {
|
||||||
agents: Vec<String>,
|
agents: Vec<AgentProfile>,
|
||||||
},
|
},
|
||||||
Error {
|
Error {
|
||||||
error: String,
|
error: String,
|
||||||
|
|
@ -384,7 +384,7 @@ impl ApiClient {
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
AgentsResponse::Success { agents } => {
|
AgentsResponse::Success { agents } => {
|
||||||
Ok(agents.into_iter().map(|agent_id| Agent { agent_id }).collect())
|
Ok(agents.into_iter().map(|profile| Agent { agent_id: profile.agent_id }).collect())
|
||||||
}
|
}
|
||||||
AgentsResponse::Error { error } => {
|
AgentsResponse::Error { error } => {
|
||||||
anyhow::bail!("Failed to list agents: {}", error)
|
anyhow::bail!("Failed to list agents: {}", error)
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ interface SearchPane {
|
||||||
query: string;
|
query: string;
|
||||||
factTypes: FactType[];
|
factTypes: FactType[];
|
||||||
thinkingBudget: number;
|
thinkingBudget: number;
|
||||||
reranker: string;
|
|
||||||
maxTokens: number;
|
maxTokens: number;
|
||||||
results: any[] | null;
|
results: any[] | null;
|
||||||
trace: any | null;
|
trace: any | null;
|
||||||
|
|
@ -31,7 +30,6 @@ export function SearchDebugView() {
|
||||||
query: '',
|
query: '',
|
||||||
factTypes: ['world'],
|
factTypes: ['world'],
|
||||||
thinkingBudget: 100,
|
thinkingBudget: 100,
|
||||||
reranker: 'heuristic',
|
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
results: null,
|
results: null,
|
||||||
trace: null,
|
trace: null,
|
||||||
|
|
@ -51,7 +49,6 @@ export function SearchDebugView() {
|
||||||
query: '',
|
query: '',
|
||||||
factTypes: ['world'],
|
factTypes: ['world'],
|
||||||
thinkingBudget: 100,
|
thinkingBudget: 100,
|
||||||
reranker: 'heuristic',
|
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
results: null,
|
results: null,
|
||||||
trace: null,
|
trace: null,
|
||||||
|
|
@ -98,7 +95,6 @@ export function SearchDebugView() {
|
||||||
agent_id: currentAgent,
|
agent_id: currentAgent,
|
||||||
thinking_budget: pane.thinkingBudget,
|
thinking_budget: pane.thinkingBudget,
|
||||||
max_tokens: pane.maxTokens,
|
max_tokens: pane.maxTokens,
|
||||||
reranker: pane.reranker,
|
|
||||||
trace: true,
|
trace: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -123,21 +119,15 @@ export function SearchDebugView() {
|
||||||
return <div className="p-5 text-center text-muted-foreground">No retrieval data available</div>;
|
return <div className="p-5 text-center text-muted-foreground">No retrieval data available</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by fact type and method
|
// Filter by retrieval method
|
||||||
// Since we always send fact types as array, the dataplane should always include fact_type in results
|
|
||||||
const methodData = pane.trace.retrieval_results.find(
|
const methodData = pane.trace.retrieval_results.find(
|
||||||
(m: any) =>
|
(m: any) => m.method_name === pane.currentRetrievalMethod
|
||||||
m.method_name === pane.currentRetrievalMethod &&
|
|
||||||
(pane.currentRetrievalFactType === null ||
|
|
||||||
!pane.currentRetrievalFactType ||
|
|
||||||
m.fact_type === pane.currentRetrievalFactType)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!methodData || !methodData.results || methodData.results.length === 0) {
|
if (!methodData || !methodData.results || methodData.results.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="p-5 text-center text-muted-foreground">
|
<div className="p-5 text-center text-muted-foreground">
|
||||||
No results from this retrieval method
|
No results from this retrieval method
|
||||||
{pane.currentRetrievalFactType && ` for fact type: ${pane.currentRetrievalFactType}`}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -146,11 +136,6 @@ export function SearchDebugView() {
|
||||||
<div className="p-4 overflow-auto">
|
<div className="p-4 overflow-auto">
|
||||||
<h3 className="text-base font-bold mb-2 text-foreground">
|
<h3 className="text-base font-bold mb-2 text-foreground">
|
||||||
{methodData.method_name.toUpperCase()} Retrieval
|
{methodData.method_name.toUpperCase()} Retrieval
|
||||||
{methodData.fact_type && (
|
|
||||||
<span className="ml-2 text-sm font-normal bg-secondary/30 px-2 py-0.5 rounded">
|
|
||||||
{methodData.fact_type}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{' '}({methodData.results.length} results, {methodData.duration_seconds?.toFixed(3)}s)
|
{' '}({methodData.results.length} results, {methodData.duration_seconds?.toFixed(3)}s)
|
||||||
</h3>
|
</h3>
|
||||||
<table className="w-full border-collapse text-xs">
|
<table className="w-full border-collapse text-xs">
|
||||||
|
|
@ -242,7 +227,7 @@ export function SearchDebugView() {
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground mb-3">
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
Reranker adjusts scores based on semantic similarity, BM25, recency, and frequency.{' '}
|
Cross-encoder reranker adjusts scores based on semantic relevance.{' '}
|
||||||
<span className="bg-secondary/30 px-2 py-0.5 rounded">Highlight</span> = rank improved
|
<span className="bg-secondary/30 px-2 py-0.5 rounded">Highlight</span> = rank improved
|
||||||
vs RRF
|
vs RRF
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -314,29 +299,11 @@ export function SearchDebugView() {
|
||||||
return ranks;
|
return ranks;
|
||||||
};
|
};
|
||||||
|
|
||||||
const activations = pane.results.map((result: any) => {
|
|
||||||
const visit = pane.trace?.visits?.find((v: any) => v.node_id === result.id);
|
|
||||||
return visit ? visit.weights.activation : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const similarities = pane.results.map((result: any) => {
|
|
||||||
const visit = pane.trace?.visits?.find((v: any) => v.node_id === result.id);
|
|
||||||
return visit ? visit.weights.semantic_similarity : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const recencies = pane.results.map((result: any) => {
|
|
||||||
const visit = pane.trace?.visits?.find((v: any) => v.node_id === result.id);
|
|
||||||
return visit ? visit.weights.recency || 0 : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const frequencies = pane.results.map((result: any) => {
|
const frequencies = pane.results.map((result: any) => {
|
||||||
const visit = pane.trace?.visits?.find((v: any) => v.node_id === result.id);
|
const visit = pane.trace?.visits?.find((v: any) => v.node_id === result.id);
|
||||||
return visit ? visit.weights.frequency || 0 : 0;
|
return visit ? visit.weights.frequency || 0 : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const activationRanks = calculateRanks(activations);
|
|
||||||
const similarityRanks = calculateRanks(similarities);
|
|
||||||
const recencyRanks = calculateRanks(recencies);
|
|
||||||
const frequencyRanks = calculateRanks(frequencies);
|
const frequencyRanks = calculateRanks(frequencies);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -358,15 +325,6 @@ export function SearchDebugView() {
|
||||||
<th className="p-2 text-left border border-border text-card-foreground" title="Final weighted score">
|
<th className="p-2 text-left border border-border text-card-foreground" title="Final weighted score">
|
||||||
Final Score
|
Final Score
|
||||||
</th>
|
</th>
|
||||||
<th className="p-2 text-left border border-border text-card-foreground" title="Spreading activation value">
|
|
||||||
Activation
|
|
||||||
</th>
|
|
||||||
<th className="p-2 text-left border border-border text-card-foreground" title="Semantic similarity to query">
|
|
||||||
Similarity
|
|
||||||
</th>
|
|
||||||
<th className="p-2 text-left border border-border text-card-foreground" title="Recency boost">
|
|
||||||
Recency
|
|
||||||
</th>
|
|
||||||
<th className="p-2 text-left border border-border text-card-foreground" title="Frequency boost">
|
<th className="p-2 text-left border border-border text-card-foreground" title="Frequency boost">
|
||||||
Frequency
|
Frequency
|
||||||
</th>
|
</th>
|
||||||
|
|
@ -376,11 +334,22 @@ export function SearchDebugView() {
|
||||||
{pane.results.map((result: any, idx: number) => {
|
{pane.results.map((result: any, idx: number) => {
|
||||||
const visit = pane.trace?.visits?.find((v: any) => v.node_id === result.id);
|
const visit = pane.trace?.visits?.find((v: any) => v.node_id === result.id);
|
||||||
const finalScore = visit ? visit.weights.final_weight : result.score || 0;
|
const finalScore = visit ? visit.weights.final_weight : result.score || 0;
|
||||||
const activation = visit ? visit.weights.activation : 0;
|
|
||||||
const similarity = visit ? visit.weights.semantic_similarity : 0;
|
|
||||||
const recency = visit ? visit.weights.recency || 0 : 0;
|
|
||||||
const frequency = visit ? visit.weights.frequency || 0 : 0;
|
const frequency = visit ? visit.weights.frequency || 0 : 0;
|
||||||
|
|
||||||
|
// Format temporal range
|
||||||
|
let occurredDisplay = 'N/A';
|
||||||
|
if (result.occurred_start && result.occurred_end) {
|
||||||
|
const start = new Date(result.occurred_start).toLocaleDateString();
|
||||||
|
const end = new Date(result.occurred_end).toLocaleDateString();
|
||||||
|
occurredDisplay = start === end ? start : `${start} - ${end}`;
|
||||||
|
} else if (result.event_date) {
|
||||||
|
occurredDisplay = new Date(result.event_date).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionedDisplay = result.mentioned_at
|
||||||
|
? new Date(result.mentioned_at).toLocaleDateString()
|
||||||
|
: 'N/A';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={idx} className="border border-border bg-background">
|
<tr key={idx} className="border border-border bg-background">
|
||||||
<td className="p-2 border border-border font-bold">#{idx + 1}</td>
|
<td className="p-2 border border-border font-bold">#{idx + 1}</td>
|
||||||
|
|
@ -389,25 +358,14 @@ export function SearchDebugView() {
|
||||||
{result.context || 'N/A'}
|
{result.context || 'N/A'}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2 border border-border whitespace-nowrap">
|
<td className="p-2 border border-border whitespace-nowrap">
|
||||||
{result.event_date
|
{occurredDisplay}
|
||||||
? new Date(result.event_date).toLocaleDateString()
|
</td>
|
||||||
: 'N/A'}
|
<td className="p-2 border border-border whitespace-nowrap">
|
||||||
|
{mentionedDisplay}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2 border border-border font-bold">
|
<td className="p-2 border border-border font-bold">
|
||||||
{finalScore.toFixed(4)}
|
{finalScore.toFixed(4)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2 border border-border">
|
|
||||||
{activation.toFixed(4)}{' '}
|
|
||||||
<span className="text-muted-foreground text-xs">(#{activationRanks.get(idx)})</span>
|
|
||||||
</td>
|
|
||||||
<td className="p-2 border border-border">
|
|
||||||
{similarity.toFixed(4)}{' '}
|
|
||||||
<span className="text-muted-foreground text-xs">(#{similarityRanks.get(idx)})</span>
|
|
||||||
</td>
|
|
||||||
<td className="p-2 border border-border">
|
|
||||||
{recency.toFixed(4)}{' '}
|
|
||||||
<span className="text-muted-foreground text-xs">(#{recencyRanks.get(idx)})</span>
|
|
||||||
</td>
|
|
||||||
<td className="p-2 border border-border">
|
<td className="p-2 border border-border">
|
||||||
{frequency.toFixed(4)}{' '}
|
{frequency.toFixed(4)}{' '}
|
||||||
<span className="text-muted-foreground text-xs">(#{frequencyRanks.get(idx)})</span>
|
<span className="text-muted-foreground text-xs">(#{frequencyRanks.get(idx)})</span>
|
||||||
|
|
@ -492,17 +450,6 @@ export function SearchDebugView() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold mb-1 text-accent-foreground">Reranker:</label>
|
|
||||||
<select
|
|
||||||
value={pane.reranker}
|
|
||||||
onChange={(e) => updatePane(pane.id, { reranker: e.target.value })}
|
|
||||||
className="px-2 py-1 border-2 border-border bg-background text-foreground rounded text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
>
|
|
||||||
<option value="heuristic">Heuristic</option>
|
|
||||||
<option value="cross-encoder">Cross-Encoder</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold mb-1 text-accent-foreground">Budget:</label>
|
<label className="block text-xs font-bold mb-1 text-accent-foreground">Budget:</label>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ export class DataplaneClient {
|
||||||
agent_id: string;
|
agent_id: string;
|
||||||
thinking_budget?: number;
|
thinking_budget?: number;
|
||||||
max_tokens?: number;
|
max_tokens?: number;
|
||||||
reranker?: string;
|
|
||||||
trace?: boolean;
|
trace?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return this.fetchApi(`/api/search`, {
|
return this.fetchApi(`/api/search`, {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,16 +1,7 @@
|
||||||
# LoComo Benchmark Results
|
# LoComo Benchmark Results
|
||||||
|
|
||||||
**Overall Accuracy**: 73.67% (1136/1542)
|
**Overall Accuracy**: 69.11% (132/191)
|
||||||
|
|
||||||
| Sample ID | Sessions | Questions | Correct | Accuracy | Multi-hop | Single-hop | Temporal | Open-domain |
|
| Sample ID | Sessions | Questions | Correct | Accuracy | Multi-hop | Single-hop | Temporal | Open-domain |
|
||||||
|-----------|----------|-----------|---------|----------|-----------|------------|----------|-------------|
|
|-----------|----------|-----------|---------|----------|-----------|------------|----------|-------------|
|
||||||
| conv-26 | 19 | 154 | 105 | 68.18% | N/A | N/A | N/A | N/A |
|
| conv-48 | 30 | 191 | 132 | 69.11% | N/A | N/A | N/A | N/A |
|
||||||
| conv-30 | 19 | 81 | 62 | 76.54% | N/A | N/A | N/A | N/A |
|
|
||||||
| conv-41 | 32 | 152 | 121 | 79.61% | N/A | N/A | N/A | N/A |
|
|
||||||
| conv-42 | 29 | 199 | 138 | 69.35% | N/A | N/A | N/A | N/A |
|
|
||||||
| conv-43 | 29 | 178 | 128 | 71.91% | N/A | N/A | N/A | N/A |
|
|
||||||
| conv-44 | 28 | 123 | 93 | 75.61% | N/A | N/A | N/A | N/A |
|
|
||||||
| conv-47 | 31 | 150 | 122 | 81.33% | N/A | N/A | N/A | N/A |
|
|
||||||
| conv-48 | 30 | 191 | 134 | 70.16% | N/A | N/A | N/A | N/A |
|
|
||||||
| conv-49 | 25 | 156 | 116 | 74.36% | N/A | N/A | N/A | N/A |
|
|
||||||
| conv-50 | 30 | 158 | 117 | 74.05% | N/A | N/A | N/A | N/A |
|
|
||||||
|
|
@ -618,7 +618,16 @@ def get_locomo_item(mode: str, item_idx: int, filter_type: str = "all", category
|
||||||
P(f"Retrieved Memories ({len(result.get('retrieved_memories', []))}):", cls="text-sm font-medium text-foreground mb-2"),
|
P(f"Retrieved Memories ({len(result.get('retrieved_memories', []))}):", cls="text-sm font-medium text-foreground mb-2"),
|
||||||
*[
|
*[
|
||||||
Div(
|
Div(
|
||||||
P(f"#{i+1} • Date: {mem.get('event_date', 'N/A')[:10] if mem.get('event_date') else 'N/A'} • Type: {mem.get('fact_type', 'N/A').upper()}", cls="text-xs text-muted-foreground mb-1"),
|
P(
|
||||||
|
f"#{i+1} • " +
|
||||||
|
(f"Occurred: {mem.get('occurred_start', 'N/A')[:10] if mem.get('occurred_start') else 'N/A'}" +
|
||||||
|
(f" - {mem.get('occurred_end', '')[:10]}" if mem.get('occurred_end') and mem.get('occurred_start', '')[:10] != mem.get('occurred_end', '')[:10] else "") +
|
||||||
|
f" • Mentioned: {mem.get('mentioned_at', 'N/A')[:10] if mem.get('mentioned_at') else 'N/A'}"
|
||||||
|
if mem.get('occurred_start') else
|
||||||
|
f"Date: {mem.get('event_date', 'N/A')[:10] if mem.get('event_date') else 'N/A'}") +
|
||||||
|
f" • Type: {mem.get('fact_type', 'N/A').upper()}",
|
||||||
|
cls="text-xs text-muted-foreground mb-1"
|
||||||
|
),
|
||||||
P(mem.get('text', ''), cls="text-sm text-foreground"),
|
P(mem.get('text', ''), cls="text-sm text-foreground"),
|
||||||
cls="bg-muted/50 border border-border rounded-md p-3 mb-2"
|
cls="bg-muted/50 border border-border rounded-md p-3 mb-2"
|
||||||
)
|
)
|
||||||
|
|
@ -994,7 +1003,16 @@ def get_longmemeval_item(item_idx: int, filter_type: str = "all"):
|
||||||
P(f"Retrieved Memories ({len(result.get('retrieved_memories', []))}):", cls="text-sm font-medium text-foreground mb-2"),
|
P(f"Retrieved Memories ({len(result.get('retrieved_memories', []))}):", cls="text-sm font-medium text-foreground mb-2"),
|
||||||
*[
|
*[
|
||||||
Div(
|
Div(
|
||||||
P(f"#{i+1} • Date: {mem.get('event_date', 'N/A')[:10] if mem.get('event_date') else 'N/A'} • Type: {mem.get('fact_type', 'N/A').upper()}", cls="text-xs text-muted-foreground mb-1"),
|
P(
|
||||||
|
f"#{i+1} • " +
|
||||||
|
(f"Occurred: {mem.get('occurred_start', 'N/A')[:10] if mem.get('occurred_start') else 'N/A'}" +
|
||||||
|
(f" - {mem.get('occurred_end', '')[:10]}" if mem.get('occurred_end') and mem.get('occurred_start', '')[:10] != mem.get('occurred_end', '')[:10] else "") +
|
||||||
|
f" • Mentioned: {mem.get('mentioned_at', 'N/A')[:10] if mem.get('mentioned_at') else 'N/A'}"
|
||||||
|
if mem.get('occurred_start') else
|
||||||
|
f"Date: {mem.get('event_date', 'N/A')[:10] if mem.get('event_date') else 'N/A'}") +
|
||||||
|
f" • Type: {mem.get('fact_type', 'N/A').upper()}",
|
||||||
|
cls="text-xs text-muted-foreground mb-1"
|
||||||
|
),
|
||||||
P(mem.get('text', ''), cls="text-sm text-foreground"),
|
P(mem.get('text', ''), cls="text-sm text-foreground"),
|
||||||
cls="bg-muted/50 border border-border rounded-md p-3 mb-2"
|
cls="bg-muted/50 border border-border rounded-md p-3 mb-2"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""merge agents and temporal ranges branches
|
||||||
|
|
||||||
|
Revision ID: 217b2227771f
|
||||||
|
Revises: 3b9c4d8e7f21, 9d42e6f91234
|
||||||
|
Create Date: 2025-11-17 14:59:01.254543
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '217b2227771f'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ('3b9c4d8e7f21', '9d42e6f91234')
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
pass
|
||||||
222
memora/memora/query_analyzer.py
Normal file
222
memora/memora/query_analyzer.py
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
"""
|
||||||
|
Query analysis abstraction for the memory system.
|
||||||
|
|
||||||
|
Provides an interface for analyzing natural language queries to extract
|
||||||
|
structured information like temporal constraints.
|
||||||
|
"""
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TemporalConstraint(BaseModel):
|
||||||
|
"""
|
||||||
|
Temporal constraint extracted from a query.
|
||||||
|
|
||||||
|
Represents a time range with start and end dates.
|
||||||
|
"""
|
||||||
|
start_date: datetime = Field(description="Start of the time range (inclusive)")
|
||||||
|
end_date: datetime = Field(description="End of the time range (inclusive)")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.start_date.strftime('%Y-%m-%d')} to {self.end_date.strftime('%Y-%m-%d')}"
|
||||||
|
|
||||||
|
|
||||||
|
class QueryAnalysis(BaseModel):
|
||||||
|
"""
|
||||||
|
Result of analyzing a natural language query.
|
||||||
|
|
||||||
|
Contains extracted structured information like temporal constraints.
|
||||||
|
"""
|
||||||
|
temporal_constraint: Optional[TemporalConstraint] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Extracted temporal constraint, if any"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QueryAnalyzer(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base class for query analysis.
|
||||||
|
|
||||||
|
Implementations analyze natural language queries to extract structured
|
||||||
|
information like temporal constraints, entities, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def analyze(
|
||||||
|
self, query: str, reference_date: Optional[datetime] = None
|
||||||
|
) -> QueryAnalysis:
|
||||||
|
"""
|
||||||
|
Analyze a natural language query.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Natural language query to analyze
|
||||||
|
reference_date: Reference date for relative terms (defaults to now)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QueryAnalysis containing extracted information
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TransformerQueryAnalyzer(QueryAnalyzer):
|
||||||
|
"""
|
||||||
|
Query analyzer using T5-based generative models.
|
||||||
|
|
||||||
|
Uses T5 to convert natural language temporal expressions into structured
|
||||||
|
date ranges without pattern matching or regex.
|
||||||
|
|
||||||
|
Performance:
|
||||||
|
- ~30-80ms on CPU, ~5-15ms on GPU
|
||||||
|
- Model size: ~80M params (~300MB download)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model_name: str = "google/flan-t5-small",
|
||||||
|
device: str = "cpu"
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize T5 query analyzer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_name: Name of the HuggingFace T5 model to use.
|
||||||
|
Default: google/flan-t5-small (~80M params, ~300MB download)
|
||||||
|
Alternative: google/flan-t5-base (~1GB, more accurate)
|
||||||
|
device: Device to run model on ("cpu" or "cuda")
|
||||||
|
"""
|
||||||
|
self.model_name = model_name
|
||||||
|
self.device = device
|
||||||
|
self._model = None
|
||||||
|
self._tokenizer = None
|
||||||
|
|
||||||
|
def _load_model(self):
|
||||||
|
"""Lazy load the T5 model for temporal extraction."""
|
||||||
|
if self._model is None:
|
||||||
|
try:
|
||||||
|
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"transformers is required for TransformerQueryAnalyzer. "
|
||||||
|
"Install it with: pip install transformers"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Loading T5 model: {self.model_name}...")
|
||||||
|
self._tokenizer = AutoTokenizer.from_pretrained(self.model_name)
|
||||||
|
self._model = AutoModelForSeq2SeqLM.from_pretrained(self.model_name)
|
||||||
|
self._model.to(self.device)
|
||||||
|
self._model.eval()
|
||||||
|
logger.info(f"Model loaded on {self.device}")
|
||||||
|
|
||||||
|
def analyze(
|
||||||
|
self, query: str, reference_date: Optional[datetime] = None
|
||||||
|
) -> QueryAnalysis:
|
||||||
|
"""
|
||||||
|
Analyze query using T5 model.
|
||||||
|
|
||||||
|
Uses T5 to generate structured temporal output directly.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Natural language query
|
||||||
|
reference_date: Reference date for relative terms (defaults to now)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QueryAnalysis with temporal_constraint if found
|
||||||
|
"""
|
||||||
|
if reference_date is None:
|
||||||
|
reference_date = datetime.now()
|
||||||
|
|
||||||
|
self._load_model()
|
||||||
|
|
||||||
|
# Build prompt for T5 to generate structured temporal output
|
||||||
|
# Use fill-in-the-blank format which T5 handles better
|
||||||
|
prompt = f"""Today is {reference_date.strftime('%Y-%m-%d')}. Convert temporal expressions to date ranges.
|
||||||
|
|
||||||
|
June 2024 = 2024-06-01 to 2024-06-30
|
||||||
|
March 2023 = 2023-03-01 to 2023-03-31
|
||||||
|
dogs in June 2023 = 2023-06-01 to 2023-06-30
|
||||||
|
last year = {reference_date.year - 1}-01-01 to {reference_date.year - 1}-12-31
|
||||||
|
events in January 2020 = 2020-01-01 to 2020-01-31
|
||||||
|
what is the weather = none
|
||||||
|
{query} ="""
|
||||||
|
|
||||||
|
# Tokenize and generate
|
||||||
|
inputs = self._tokenizer(prompt, return_tensors="pt", max_length=512, truncation=True)
|
||||||
|
inputs = {k: v.to(self.device) for k, v in inputs.items()}
|
||||||
|
|
||||||
|
with self._no_grad():
|
||||||
|
outputs = self._model.generate(
|
||||||
|
**inputs,
|
||||||
|
max_new_tokens=30,
|
||||||
|
num_beams=3,
|
||||||
|
do_sample=False,
|
||||||
|
temperature=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self._tokenizer.decode(outputs[0], skip_special_tokens=True).strip()
|
||||||
|
logger.info(f"T5 generated: '{result}'")
|
||||||
|
|
||||||
|
# Parse the generated output
|
||||||
|
temporal = self._parse_generated_output(result, reference_date)
|
||||||
|
return QueryAnalysis(temporal_constraint=temporal)
|
||||||
|
|
||||||
|
def _no_grad(self):
|
||||||
|
"""Get torch.no_grad context manager."""
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
return torch.no_grad()
|
||||||
|
except ImportError:
|
||||||
|
from contextlib import nullcontext
|
||||||
|
return nullcontext()
|
||||||
|
|
||||||
|
def _parse_generated_output(
|
||||||
|
self, result: str, reference_date: datetime
|
||||||
|
) -> Optional[TemporalConstraint]:
|
||||||
|
"""
|
||||||
|
Parse T5 generated output into TemporalConstraint.
|
||||||
|
|
||||||
|
Expected format: "YYYY-MM-DD to YYYY-MM-DD"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
result: Generated text from T5
|
||||||
|
reference_date: Reference date for validation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TemporalConstraint if valid output, else None
|
||||||
|
"""
|
||||||
|
if not result or result.lower().strip() in ("none", "null", "no"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse "YYYY-MM-DD to YYYY-MM-DD"
|
||||||
|
import re
|
||||||
|
pattern = r'(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})'
|
||||||
|
match = re.search(pattern, result, re.IGNORECASE)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
start_str = match.group(1)
|
||||||
|
end_str = match.group(2)
|
||||||
|
|
||||||
|
start_date = datetime.strptime(start_str, "%Y-%m-%d")
|
||||||
|
end_date = datetime.strptime(end_str, "%Y-%m-%d")
|
||||||
|
|
||||||
|
# Set time boundaries
|
||||||
|
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
end_date = end_date.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if end_date < start_date:
|
||||||
|
logger.warning(f"Invalid date range: {start_date} to {end_date}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return TemporalConstraint(start_date=start_date, end_date=end_date)
|
||||||
|
|
||||||
|
except (ValueError, AttributeError) as e:
|
||||||
|
logger.debug(f"Failed to parse T5 output '{result}': {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
@ -22,6 +22,9 @@ class MemoryFact(BaseModel):
|
||||||
fact_type: str = Field(description="Type of fact: 'world', 'agent', or 'opinion'")
|
fact_type: str = Field(description="Type of fact: 'world', 'agent', or 'opinion'")
|
||||||
context: Optional[str] = Field(None, description="Additional context for the memory")
|
context: Optional[str] = Field(None, description="Additional context for the memory")
|
||||||
event_date: Optional[str] = Field(None, description="ISO format date when the event occurred")
|
event_date: Optional[str] = Field(None, description="ISO format date when the event occurred")
|
||||||
|
occurred_start: Optional[str] = Field(None, description="ISO format date when the event started occurring")
|
||||||
|
occurred_end: Optional[str] = Field(None, description="ISO format date when the event ended occurring")
|
||||||
|
mentioned_at: Optional[str] = Field(None, description="ISO format date when the fact was mentioned/learned")
|
||||||
document_id: Optional[str] = Field(None, description="ID of the document this memory belongs to")
|
document_id: Optional[str] = Field(None, description="ID of the document this memory belongs to")
|
||||||
|
|
||||||
# Internal metrics (used by system but may not be exposed in API)
|
# Internal metrics (used by system but may not be exposed in API)
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ async def retrieve_semantic(
|
||||||
"""
|
"""
|
||||||
results = await conn.fetch(
|
results = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT id, text, context, event_date, access_count, embedding, fact_type, document_id,
|
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id,
|
||||||
1 - (embedding <=> $1::vector) AS similarity
|
1 - (embedding <=> $1::vector) AS similarity
|
||||||
FROM memory_units
|
FROM memory_units
|
||||||
WHERE agent_id = $2
|
WHERE agent_id = $2
|
||||||
|
|
@ -89,7 +89,7 @@ async def retrieve_bm25(
|
||||||
|
|
||||||
results = await conn.fetch(
|
results = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT id, text, context, event_date, access_count, embedding, fact_type, document_id,
|
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id,
|
||||||
ts_rank_cd(search_vector, to_tsquery('english', $1)) AS bm25_score
|
ts_rank_cd(search_vector, to_tsquery('english', $1)) AS bm25_score
|
||||||
FROM memory_units
|
FROM memory_units
|
||||||
WHERE agent_id = $2
|
WHERE agent_id = $2
|
||||||
|
|
@ -126,7 +126,7 @@ async def retrieve_graph(
|
||||||
# Find entry points
|
# Find entry points
|
||||||
entry_points = await conn.fetch(
|
entry_points = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT id, text, context, event_date, access_count, embedding, fact_type, document_id,
|
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id,
|
||||||
1 - (embedding <=> $1::vector) AS similarity
|
1 - (embedding <=> $1::vector) AS similarity
|
||||||
FROM memory_units
|
FROM memory_units
|
||||||
WHERE agent_id = $2
|
WHERE agent_id = $2
|
||||||
|
|
@ -163,7 +163,7 @@ async def retrieve_graph(
|
||||||
if budget_remaining > 0:
|
if budget_remaining > 0:
|
||||||
neighbors = await conn.fetch(
|
neighbors = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.access_count, mu.embedding, mu.fact_type, mu.document_id,
|
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type, mu.document_id,
|
||||||
ml.weight, ml.link_type
|
ml.weight, ml.link_type
|
||||||
FROM memory_links ml
|
FROM memory_links ml
|
||||||
JOIN memory_units mu ON ml.to_unit_id = mu.id
|
JOIN memory_units mu ON ml.to_unit_id = mu.id
|
||||||
|
|
@ -242,23 +242,49 @@ async def retrieve_temporal(
|
||||||
end_date = end_date.replace(tzinfo=timezone.utc)
|
end_date = end_date.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
# Find entry points: facts in date range with semantic relevance
|
# Find entry points: facts in date range with semantic relevance
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info(f"Temporal retrieval: searching for facts between {start_date} and {end_date} (agent={agent_id}, fact_type={fact_type})")
|
||||||
|
|
||||||
entry_points = await conn.fetch(
|
entry_points = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT id, text, context, event_date, access_count, embedding, fact_type, document_id,
|
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id,
|
||||||
1 - (embedding <=> $1::vector) AS similarity
|
1 - (embedding <=> $1::vector) AS similarity
|
||||||
FROM memory_units
|
FROM memory_units
|
||||||
WHERE agent_id = $2
|
WHERE agent_id = $2
|
||||||
AND fact_type = $3
|
AND fact_type = $3
|
||||||
AND embedding IS NOT NULL
|
AND embedding IS NOT NULL
|
||||||
AND event_date BETWEEN $4 AND $5
|
AND (
|
||||||
|
-- Match if occurred range overlaps with query range
|
||||||
|
(occurred_start IS NOT NULL AND occurred_end IS NOT NULL
|
||||||
|
AND occurred_start <= $5 AND occurred_end >= $4)
|
||||||
|
OR
|
||||||
|
-- Match if mentioned_at falls within query range
|
||||||
|
(mentioned_at IS NOT NULL AND mentioned_at BETWEEN $4 AND $5)
|
||||||
|
OR
|
||||||
|
-- Match if any occurred date is set and overlaps (even if only start or end is set)
|
||||||
|
(occurred_start IS NOT NULL AND occurred_start BETWEEN $4 AND $5)
|
||||||
|
OR
|
||||||
|
(occurred_end IS NOT NULL AND occurred_end BETWEEN $4 AND $5)
|
||||||
|
)
|
||||||
AND (1 - (embedding <=> $1::vector)) >= $6
|
AND (1 - (embedding <=> $1::vector)) >= $6
|
||||||
ORDER BY event_date DESC, (embedding <=> $1::vector) ASC
|
ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC, (embedding <=> $1::vector) ASC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
""",
|
""",
|
||||||
query_emb_str, agent_id, fact_type, start_date, end_date, semantic_threshold
|
query_emb_str, agent_id, fact_type, start_date, end_date, semantic_threshold
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(f"Temporal retrieval: found {len(entry_points)} entry points")
|
||||||
|
|
||||||
if not entry_points:
|
if not entry_points:
|
||||||
|
# Check if there are ANY memories with temporal metadata for this agent
|
||||||
|
total_with_dates = await conn.fetchval(
|
||||||
|
"""SELECT COUNT(*) FROM memory_units
|
||||||
|
WHERE agent_id = $1 AND fact_type = $2
|
||||||
|
AND (occurred_start IS NOT NULL OR occurred_end IS NOT NULL OR mentioned_at IS NOT NULL)""",
|
||||||
|
agent_id, fact_type
|
||||||
|
)
|
||||||
|
logger.info(f"Temporal retrieval: agent has {total_with_dates} total memories with temporal metadata (fact_type={fact_type})")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Calculate temporal scores for entry points
|
# Calculate temporal scores for entry points
|
||||||
|
|
@ -271,10 +297,25 @@ async def retrieve_temporal(
|
||||||
unit_id = str(ep["id"])
|
unit_id = str(ep["id"])
|
||||||
visited.add(unit_id)
|
visited.add(unit_id)
|
||||||
|
|
||||||
|
# Calculate temporal proximity using the most relevant date
|
||||||
|
# Priority: occurred_start/end (event time) > mentioned_at (mention time)
|
||||||
|
best_date = None
|
||||||
|
if ep["occurred_start"] is not None and ep["occurred_end"] is not None:
|
||||||
|
# Use midpoint of occurred range
|
||||||
|
best_date = ep["occurred_start"] + (ep["occurred_end"] - ep["occurred_start"]) / 2
|
||||||
|
elif ep["occurred_start"] is not None:
|
||||||
|
best_date = ep["occurred_start"]
|
||||||
|
elif ep["occurred_end"] is not None:
|
||||||
|
best_date = ep["occurred_end"]
|
||||||
|
elif ep["mentioned_at"] is not None:
|
||||||
|
best_date = ep["mentioned_at"]
|
||||||
|
|
||||||
# Temporal proximity score (closer to range center = higher score)
|
# Temporal proximity score (closer to range center = higher score)
|
||||||
event_date = ep["event_date"]
|
if best_date:
|
||||||
days_from_mid = abs((event_date - mid_date).total_seconds() / 86400)
|
days_from_mid = abs((best_date - mid_date).total_seconds() / 86400)
|
||||||
temporal_proximity = 1.0 - min(days_from_mid / (total_days / 2), 1.0) if total_days > 0 else 1.0
|
temporal_proximity = 1.0 - min(days_from_mid / (total_days / 2), 1.0) if total_days > 0 else 1.0
|
||||||
|
else:
|
||||||
|
temporal_proximity = 0.5 # Fallback if no dates (shouldn't happen due to WHERE clause)
|
||||||
|
|
||||||
data = dict(ep)
|
data = dict(ep)
|
||||||
data["temporal_score"] = temporal_proximity
|
data["temporal_score"] = temporal_proximity
|
||||||
|
|
@ -293,7 +334,7 @@ async def retrieve_temporal(
|
||||||
if budget_remaining > 0:
|
if budget_remaining > 0:
|
||||||
neighbors = await conn.fetch(
|
neighbors = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.access_count, mu.embedding, mu.fact_type, mu.document_id,
|
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type, mu.document_id,
|
||||||
ml.weight, ml.link_type,
|
ml.weight, ml.link_type,
|
||||||
1 - (mu.embedding <=> $1::vector) AS similarity
|
1 - (mu.embedding <=> $1::vector) AS similarity
|
||||||
FROM memory_links ml
|
FROM memory_links ml
|
||||||
|
|
@ -318,10 +359,22 @@ async def retrieve_temporal(
|
||||||
visited.add(neighbor_id)
|
visited.add(neighbor_id)
|
||||||
budget_remaining -= 1
|
budget_remaining -= 1
|
||||||
|
|
||||||
# Calculate temporal score for neighbor
|
# Calculate temporal score for neighbor using best available date
|
||||||
neighbor_date = n["event_date"]
|
neighbor_best_date = None
|
||||||
days_from_mid = abs((neighbor_date - mid_date).total_seconds() / 86400)
|
if n["occurred_start"] is not None and n["occurred_end"] is not None:
|
||||||
neighbor_temporal_proximity = 1.0 - min(days_from_mid / (total_days / 2), 1.0) if total_days > 0 else 1.0
|
neighbor_best_date = n["occurred_start"] + (n["occurred_end"] - n["occurred_start"]) / 2
|
||||||
|
elif n["occurred_start"] is not None:
|
||||||
|
neighbor_best_date = n["occurred_start"]
|
||||||
|
elif n["occurred_end"] is not None:
|
||||||
|
neighbor_best_date = n["occurred_end"]
|
||||||
|
elif n["mentioned_at"] is not None:
|
||||||
|
neighbor_best_date = n["mentioned_at"]
|
||||||
|
|
||||||
|
if neighbor_best_date:
|
||||||
|
days_from_mid = abs((neighbor_best_date - mid_date).total_seconds() / 86400)
|
||||||
|
neighbor_temporal_proximity = 1.0 - min(days_from_mid / (total_days / 2), 1.0) if total_days > 0 else 1.0
|
||||||
|
else:
|
||||||
|
neighbor_temporal_proximity = 0.3 # Lower score if no temporal data
|
||||||
|
|
||||||
# Boost causal links (same as graph retrieval)
|
# Boost causal links (same as graph retrieval)
|
||||||
link_type = n["link_type"]
|
link_type = n["link_type"]
|
||||||
|
|
@ -360,7 +413,8 @@ async def retrieve_parallel(
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
fact_type: str,
|
fact_type: str,
|
||||||
thinking_budget: int,
|
thinking_budget: int,
|
||||||
question_date: Optional[datetime] = None
|
question_date: Optional[datetime] = None,
|
||||||
|
query_analyzer: Optional["QueryAnalyzer"] = None
|
||||||
) -> Tuple[List, List, List, Optional[List]]:
|
) -> Tuple[List, List, List, Optional[List]]:
|
||||||
"""
|
"""
|
||||||
Run 3-way or 4-way parallel retrieval (adds temporal if detected).
|
Run 3-way or 4-way parallel retrieval (adds temporal if detected).
|
||||||
|
|
@ -373,6 +427,7 @@ async def retrieve_parallel(
|
||||||
fact_type: Fact type to filter
|
fact_type: Fact type to filter
|
||||||
thinking_budget: Budget for graph traversal and retrieval limits
|
thinking_budget: Budget for graph traversal and retrieval limits
|
||||||
question_date: Optional date when question was asked (for temporal filtering)
|
question_date: Optional date when question was asked (for temporal filtering)
|
||||||
|
query_analyzer: Query analyzer to use (defaults to TransformerQueryAnalyzer)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (semantic_results, bm25_results, graph_results, temporal_results)
|
Tuple of (semantic_results, bm25_results, graph_results, temporal_results)
|
||||||
|
|
@ -380,7 +435,17 @@ async def retrieve_parallel(
|
||||||
"""
|
"""
|
||||||
# Detect temporal constraint
|
# Detect temporal constraint
|
||||||
from .temporal_extraction import extract_temporal_constraint
|
from .temporal_extraction import extract_temporal_constraint
|
||||||
temporal_constraint = extract_temporal_constraint(query_text, reference_date=question_date)
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
temporal_constraint = extract_temporal_constraint(
|
||||||
|
query_text, reference_date=question_date, analyzer=query_analyzer
|
||||||
|
)
|
||||||
|
|
||||||
|
if temporal_constraint:
|
||||||
|
logger.info(f"Temporal constraint detected in retrieve_parallel: {temporal_constraint[0]} to {temporal_constraint[1]}")
|
||||||
|
else:
|
||||||
|
logger.info("No temporal constraint in retrieve_parallel")
|
||||||
|
|
||||||
# Each retrieval needs its own connection
|
# Each retrieval needs its own connection
|
||||||
async def run_semantic():
|
async def run_semantic():
|
||||||
|
|
|
||||||
|
|
@ -1,243 +1,66 @@
|
||||||
"""
|
"""
|
||||||
Temporal extraction for time-aware search queries.
|
Temporal extraction for time-aware search queries.
|
||||||
|
|
||||||
Handles natural language temporal expressions like:
|
Handles natural language temporal expressions using transformer-based query analysis.
|
||||||
- "last year", "in June", "last month"
|
|
||||||
- "last spring", "this summer" (custom season support)
|
|
||||||
- "between March and May"
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
import re
|
import logging
|
||||||
|
from memora.query_analyzer import QueryAnalyzer, TransformerQueryAnalyzer
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Global default analyzer instance
|
||||||
|
# Can be overridden by passing a custom analyzer to extract_temporal_constraint
|
||||||
|
_default_analyzer: Optional[QueryAnalyzer] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_analyzer() -> QueryAnalyzer:
|
||||||
|
"""
|
||||||
|
Get or create the default query analyzer.
|
||||||
|
|
||||||
|
Uses lazy initialization to avoid loading model at import time.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Default TransformerQueryAnalyzer instance
|
||||||
|
"""
|
||||||
|
global _default_analyzer
|
||||||
|
if _default_analyzer is None:
|
||||||
|
_default_analyzer = TransformerQueryAnalyzer()
|
||||||
|
return _default_analyzer
|
||||||
|
|
||||||
|
|
||||||
def extract_temporal_constraint(
|
def extract_temporal_constraint(
|
||||||
query: str,
|
query: str,
|
||||||
reference_date: Optional[datetime] = None
|
reference_date: Optional[datetime] = None,
|
||||||
|
analyzer: Optional[QueryAnalyzer] = None,
|
||||||
) -> Optional[Tuple[datetime, datetime]]:
|
) -> Optional[Tuple[datetime, datetime]]:
|
||||||
"""
|
"""
|
||||||
Extract temporal constraint from query.
|
Extract temporal constraint from query using transformer-based analysis.
|
||||||
|
|
||||||
Returns (start_date, end_date) tuple if temporal constraint found, else None.
|
Returns (start_date, end_date) tuple if temporal constraint found, else None.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Search query
|
query: Search query
|
||||||
reference_date: Reference date for relative terms (defaults to now)
|
reference_date: Reference date for relative terms (defaults to now)
|
||||||
|
analyzer: Custom query analyzer (defaults to TransformerQueryAnalyzer)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(start_date, end_date) tuple or None
|
(start_date, end_date) tuple or None
|
||||||
"""
|
"""
|
||||||
if reference_date is None:
|
if analyzer is None:
|
||||||
reference_date = datetime.now()
|
analyzer = get_default_analyzer()
|
||||||
|
|
||||||
query_lower = query.lower()
|
analysis = analyzer.analyze(query, reference_date)
|
||||||
|
|
||||||
# Try dateparser for standard temporal expressions
|
if analysis.temporal_constraint:
|
||||||
import dateparser
|
result = (
|
||||||
|
analysis.temporal_constraint.start_date,
|
||||||
# Parse using dateparser with relative base
|
analysis.temporal_constraint.end_date
|
||||||
settings = {
|
|
||||||
'RELATIVE_BASE': reference_date,
|
|
||||||
'PREFER_DATES_FROM': 'past', # Prefer past dates for "in June"
|
|
||||||
'RETURN_AS_TIMEZONE_AWARE': False
|
|
||||||
}
|
|
||||||
|
|
||||||
# Try to find temporal expressions
|
|
||||||
temporal_keywords = [
|
|
||||||
'in', 'during', 'last', 'this', 'next', 'between',
|
|
||||||
'january', 'february', 'march', 'april', 'may', 'june',
|
|
||||||
'july', 'august', 'september', 'october', 'november', 'december',
|
|
||||||
'spring', 'summer', 'fall', 'autumn', 'winter',
|
|
||||||
'year', 'month', 'week', 'day'
|
|
||||||
]
|
|
||||||
|
|
||||||
has_temporal = any(keyword in query_lower for keyword in temporal_keywords)
|
|
||||||
if not has_temporal:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Try season detection first (custom handling)
|
|
||||||
season_match = _extract_season(query_lower, reference_date)
|
|
||||||
if season_match:
|
|
||||||
return season_match
|
|
||||||
|
|
||||||
# Try specific month patterns
|
|
||||||
month_match = _extract_month(query_lower, reference_date)
|
|
||||||
if month_match:
|
|
||||||
return month_match
|
|
||||||
|
|
||||||
# Try relative periods
|
|
||||||
relative_match = _extract_relative_period(query_lower, reference_date)
|
|
||||||
if relative_match:
|
|
||||||
return relative_match
|
|
||||||
|
|
||||||
# Try "between X and Y" patterns
|
|
||||||
between_match = _extract_between_dates(query_lower, reference_date)
|
|
||||||
if between_match:
|
|
||||||
return between_match
|
|
||||||
|
|
||||||
# Fallback to dateparser for other expressions
|
|
||||||
parsed = dateparser.parse(query, settings=settings)
|
|
||||||
if parsed:
|
|
||||||
# If we got a single date, create a range around it (±1 day)
|
|
||||||
start_date = parsed.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
end_date = parsed.replace(hour=23, minute=59, second=59, microsecond=999999)
|
|
||||||
return (start_date, end_date)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_season(query: str, reference_date: datetime) -> Optional[Tuple[datetime, datetime]]:
|
|
||||||
"""Extract season-based temporal constraint."""
|
|
||||||
seasons = {
|
|
||||||
'spring': (3, 5), # March - May
|
|
||||||
'summer': (6, 8), # June - August
|
|
||||||
'fall': (9, 11), # September - November
|
|
||||||
'autumn': (9, 11), # September - November
|
|
||||||
'winter': (12, 2), # December - February
|
|
||||||
}
|
|
||||||
|
|
||||||
for season_name, (start_month, end_month) in seasons.items():
|
|
||||||
if season_name not in query:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Determine year based on "last", "this", "next"
|
|
||||||
year = reference_date.year
|
|
||||||
if 'last' in query:
|
|
||||||
year -= 1
|
|
||||||
elif 'next' in query:
|
|
||||||
year += 1
|
|
||||||
|
|
||||||
# Handle winter crossing year boundary
|
|
||||||
if season_name in ('winter',):
|
|
||||||
if start_month > end_month:
|
|
||||||
# Winter spans two years (Dec-Feb)
|
|
||||||
start_date = datetime(year, start_month, 1)
|
|
||||||
end_date = datetime(year + 1, end_month, 28) # Feb 28
|
|
||||||
if _is_leap_year(year + 1):
|
|
||||||
end_date = datetime(year + 1, end_month, 29)
|
|
||||||
else:
|
|
||||||
start_date = datetime(year, start_month, 1)
|
|
||||||
end_date = _last_day_of_month(year, end_month)
|
|
||||||
else:
|
|
||||||
start_date = datetime(year, start_month, 1)
|
|
||||||
end_date = _last_day_of_month(year, end_month)
|
|
||||||
|
|
||||||
return (start_date, end_date)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_month(query: str, reference_date: datetime) -> Optional[Tuple[datetime, datetime]]:
|
|
||||||
"""Extract month-based temporal constraint."""
|
|
||||||
months = {
|
|
||||||
'january': 1, 'february': 2, 'march': 3, 'april': 4,
|
|
||||||
'may': 5, 'june': 6, 'july': 7, 'august': 8,
|
|
||||||
'september': 9, 'october': 10, 'november': 11, 'december': 12,
|
|
||||||
'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4,
|
|
||||||
'jun': 6, 'jul': 7, 'aug': 8, 'sep': 9,
|
|
||||||
'oct': 10, 'nov': 11, 'dec': 12,
|
|
||||||
}
|
|
||||||
|
|
||||||
for month_name, month_num in months.items():
|
|
||||||
if month_name not in query:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Determine year
|
|
||||||
year = reference_date.year
|
|
||||||
|
|
||||||
# Check if query mentions "last" before the month
|
|
||||||
if 'last' in query and query.index('last') < query.index(month_name):
|
|
||||||
year -= 1
|
|
||||||
# Check if current date is past this month, assume last year
|
|
||||||
elif reference_date.month > month_num:
|
|
||||||
pass # Use current year (past month)
|
|
||||||
elif reference_date.month < month_num:
|
|
||||||
year -= 1 # Use last year
|
|
||||||
|
|
||||||
start_date = datetime(year, month_num, 1)
|
|
||||||
end_date = _last_day_of_month(year, month_num)
|
|
||||||
|
|
||||||
return (start_date, end_date)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_relative_period(query: str, reference_date: datetime) -> Optional[Tuple[datetime, datetime]]:
|
|
||||||
"""Extract relative period like 'last year', 'last month', 'last week'."""
|
|
||||||
|
|
||||||
# Last year
|
|
||||||
if 'last year' in query:
|
|
||||||
year = reference_date.year - 1
|
|
||||||
return (datetime(year, 1, 1), datetime(year, 12, 31, 23, 59, 59))
|
|
||||||
|
|
||||||
# This year
|
|
||||||
if 'this year' in query:
|
|
||||||
year = reference_date.year
|
|
||||||
return (datetime(year, 1, 1), datetime(year, 12, 31, 23, 59, 59))
|
|
||||||
|
|
||||||
# Last month
|
|
||||||
if 'last month' in query:
|
|
||||||
first_day = reference_date.replace(day=1)
|
|
||||||
last_month_end = first_day - timedelta(days=1)
|
|
||||||
last_month_start = last_month_end.replace(day=1)
|
|
||||||
return (last_month_start, last_month_end.replace(hour=23, minute=59, second=59))
|
|
||||||
|
|
||||||
# This month
|
|
||||||
if 'this month' in query:
|
|
||||||
start = reference_date.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
end = _last_day_of_month(reference_date.year, reference_date.month)
|
|
||||||
return (start, end)
|
|
||||||
|
|
||||||
# Last week
|
|
||||||
if 'last week' in query:
|
|
||||||
days_since_monday = reference_date.weekday()
|
|
||||||
last_monday = reference_date - timedelta(days=days_since_monday + 7)
|
|
||||||
last_sunday = last_monday + timedelta(days=6)
|
|
||||||
return (
|
|
||||||
last_monday.replace(hour=0, minute=0, second=0, microsecond=0),
|
|
||||||
last_sunday.replace(hour=23, minute=59, second=59, microsecond=999999)
|
|
||||||
)
|
)
|
||||||
|
logger.info(f"Temporal constraint extracted: {result[0].strftime('%Y-%m-%d')} to {result[1].strftime('%Y-%m-%d')}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
logger.info("No temporal constraint found in query")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _extract_between_dates(query: str, reference_date: datetime) -> Optional[Tuple[datetime, datetime]]:
|
|
||||||
"""Extract 'between X and Y' date ranges."""
|
|
||||||
pattern = r'between\s+(\w+)\s+and\s+(\w+)'
|
|
||||||
match = re.search(pattern, query.lower())
|
|
||||||
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
|
|
||||||
start_str = match.group(1)
|
|
||||||
end_str = match.group(2)
|
|
||||||
|
|
||||||
import dateparser
|
|
||||||
settings = {'RELATIVE_BASE': reference_date, 'RETURN_AS_TIMEZONE_AWARE': False}
|
|
||||||
|
|
||||||
start_date = dateparser.parse(start_str, settings=settings)
|
|
||||||
end_date = dateparser.parse(end_str, settings=settings)
|
|
||||||
|
|
||||||
if start_date and end_date:
|
|
||||||
return (
|
|
||||||
start_date.replace(hour=0, minute=0, second=0, microsecond=0),
|
|
||||||
end_date.replace(hour=23, minute=59, second=59, microsecond=999999)
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _last_day_of_month(year: int, month: int) -> datetime:
|
|
||||||
"""Get last day of month."""
|
|
||||||
if month == 12:
|
|
||||||
next_month = datetime(year + 1, 1, 1)
|
|
||||||
else:
|
|
||||||
next_month = datetime(year, month + 1, 1)
|
|
||||||
last_day = next_month - timedelta(days=1)
|
|
||||||
return last_day.replace(hour=23, minute=59, second=59, microsecond=999999)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_leap_year(year: int) -> bool:
|
|
||||||
"""Check if year is leap year."""
|
|
||||||
return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0)
|
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ class TemporalSemanticMemory(
|
||||||
memory_llm_base_url: Optional[str] = None,
|
memory_llm_base_url: Optional[str] = None,
|
||||||
embeddings: Optional[Embeddings] = None,
|
embeddings: Optional[Embeddings] = None,
|
||||||
cross_encoder: Optional[CrossEncoderModel] = None,
|
cross_encoder: Optional[CrossEncoderModel] = None,
|
||||||
|
query_analyzer: Optional["QueryAnalyzer"] = None,
|
||||||
pool_min_size: int = 5,
|
pool_min_size: int = 5,
|
||||||
pool_max_size: int = 100,
|
pool_max_size: int = 100,
|
||||||
task_backend: Optional[TaskBackend] = None,
|
task_backend: Optional[TaskBackend] = None,
|
||||||
|
|
@ -97,6 +98,7 @@ class TemporalSemanticMemory(
|
||||||
- ollama: http://localhost:11434/v1
|
- ollama: http://localhost:11434/v1
|
||||||
embeddings: Embeddings implementation to use. If not provided, uses SentenceTransformersEmbeddings
|
embeddings: Embeddings implementation to use. If not provided, uses SentenceTransformersEmbeddings
|
||||||
cross_encoder: Cross-encoder model for reranking. If not provided, uses default when cross-encoder reranker is selected
|
cross_encoder: Cross-encoder model for reranking. If not provided, uses default when cross-encoder reranker is selected
|
||||||
|
query_analyzer: Query analyzer implementation to use. If not provided, uses TransformerQueryAnalyzer
|
||||||
pool_min_size: Minimum number of connections in the pool (default: 5)
|
pool_min_size: Minimum number of connections in the pool (default: 5)
|
||||||
pool_max_size: Maximum number of connections in the pool (default: 100)
|
pool_max_size: Maximum number of connections in the pool (default: 100)
|
||||||
Increase for parallel think/search operations (e.g., 200-300 for 100+ parallel thinks)
|
Increase for parallel think/search operations (e.g., 200-300 for 100+ parallel thinks)
|
||||||
|
|
@ -129,6 +131,13 @@ class TemporalSemanticMemory(
|
||||||
else:
|
else:
|
||||||
self.embeddings = SentenceTransformersEmbeddings("BAAI/bge-small-en-v1.5")
|
self.embeddings = SentenceTransformersEmbeddings("BAAI/bge-small-en-v1.5")
|
||||||
|
|
||||||
|
# Initialize query analyzer
|
||||||
|
if query_analyzer is not None:
|
||||||
|
self.query_analyzer = query_analyzer
|
||||||
|
else:
|
||||||
|
from memora.query_analyzer import TransformerQueryAnalyzer
|
||||||
|
self.query_analyzer = TransformerQueryAnalyzer()
|
||||||
|
|
||||||
# Initialize LLM configuration
|
# Initialize LLM configuration
|
||||||
self._llm_config = LLMConfig(
|
self._llm_config = LLMConfig(
|
||||||
provider=memory_llm_provider,
|
provider=memory_llm_provider,
|
||||||
|
|
@ -1262,7 +1271,10 @@ class TemporalSemanticMemory(
|
||||||
|
|
||||||
# Run retrieval for each fact type in parallel
|
# Run retrieval for each fact type in parallel
|
||||||
retrieval_tasks = [
|
retrieval_tasks = [
|
||||||
retrieve_parallel(pool, query, query_embedding_str, agent_id, ft, thinking_budget, question_date)
|
retrieve_parallel(
|
||||||
|
pool, query, query_embedding_str, agent_id, ft, thinking_budget,
|
||||||
|
question_date, self.query_analyzer
|
||||||
|
)
|
||||||
for ft in fact_type
|
for ft in fact_type
|
||||||
]
|
]
|
||||||
all_retrievals = await asyncio.gather(*retrieval_tasks)
|
all_retrievals = await asyncio.gather(*retrieval_tasks)
|
||||||
|
|
@ -1418,7 +1430,7 @@ class TemporalSemanticMemory(
|
||||||
if tracer:
|
if tracer:
|
||||||
tracer.add_reranked(results, merged_candidates)
|
tracer.add_reranked(results, merged_candidates)
|
||||||
tracer.add_phase_metric("reranking", step_duration, {
|
tracer.add_phase_metric("reranking", step_duration, {
|
||||||
"reranker_type": reranker,
|
"reranker_type": "cross-encoder",
|
||||||
"candidates_reranked": len(results)
|
"candidates_reranked": len(results)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1485,6 +1497,15 @@ class TemporalSemanticMemory(
|
||||||
if result.get("event_date"):
|
if result.get("event_date"):
|
||||||
event_date = result["event_date"]
|
event_date = result["event_date"]
|
||||||
result["event_date"] = event_date.isoformat() if hasattr(event_date, 'isoformat') else event_date
|
result["event_date"] = event_date.isoformat() if hasattr(event_date, 'isoformat') else event_date
|
||||||
|
if result.get("occurred_start"):
|
||||||
|
occurred_start = result["occurred_start"]
|
||||||
|
result["occurred_start"] = occurred_start.isoformat() if hasattr(occurred_start, 'isoformat') else occurred_start
|
||||||
|
if result.get("occurred_end"):
|
||||||
|
occurred_end = result["occurred_end"]
|
||||||
|
result["occurred_end"] = occurred_end.isoformat() if hasattr(occurred_end, 'isoformat') else occurred_end
|
||||||
|
if result.get("mentioned_at"):
|
||||||
|
mentioned_at = result["mentioned_at"]
|
||||||
|
result["mentioned_at"] = mentioned_at.isoformat() if hasattr(mentioned_at, 'isoformat') else mentioned_at
|
||||||
|
|
||||||
# Convert results to MemoryFact objects
|
# Convert results to MemoryFact objects
|
||||||
memory_facts = []
|
memory_facts = []
|
||||||
|
|
@ -1495,6 +1516,9 @@ class TemporalSemanticMemory(
|
||||||
fact_type=result.get("fact_type", "world"),
|
fact_type=result.get("fact_type", "world"),
|
||||||
context=result.get("context"),
|
context=result.get("context"),
|
||||||
event_date=result.get("event_date"),
|
event_date=result.get("event_date"),
|
||||||
|
occurred_start=result.get("occurred_start"),
|
||||||
|
occurred_end=result.get("occurred_end"),
|
||||||
|
mentioned_at=result.get("mentioned_at"),
|
||||||
activation=result.get("activation")
|
activation=result.get("activation")
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ Web interface for memory system.
|
||||||
Provides FastAPI app and visualization interface.
|
Provides FastAPI app and visualization interface.
|
||||||
"""
|
"""
|
||||||
from memora.api import create_app
|
from memora.api import create_app
|
||||||
from .server import app
|
|
||||||
|
|
||||||
__all__ = ["app", "create_app"]
|
# Note: Don't import app from .server here to avoid circular import warnings
|
||||||
|
# when running with `python -m memora.web.server`
|
||||||
|
# If you need the app, import it directly: from memora.web.server import app
|
||||||
|
|
||||||
|
__all__ = ["create_app"]
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ dependencies = [
|
||||||
"pgvector>=0.4.1",
|
"pgvector>=0.4.1",
|
||||||
"greenlet>=3.2.4",
|
"greenlet>=3.2.4",
|
||||||
"psycopg2-binary>=2.9.11",
|
"psycopg2-binary>=2.9.11",
|
||||||
"dateparser>=1.2.0",
|
"transformers>=4.30.0",
|
||||||
|
"torch>=2.0.0",
|
||||||
"tiktoken>=0.12.0",
|
"tiktoken>=0.12.0",
|
||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
124
memora/tests/test_query_analyzer.py
Normal file
124
memora/tests/test_query_analyzer.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""
|
||||||
|
Test query analyzer for temporal extraction.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
from memora.query_analyzer import TransformerQueryAnalyzer, QueryAnalysis
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_analyzer_june_2024():
|
||||||
|
"""Test extracting 'june 2024' from query."""
|
||||||
|
analyzer = TransformerQueryAnalyzer()
|
||||||
|
reference_date = datetime(2025, 1, 15, 12, 0, 0)
|
||||||
|
|
||||||
|
query = "june 2024"
|
||||||
|
analysis = analyzer.analyze(query, reference_date)
|
||||||
|
|
||||||
|
print(f"\nQuery: '{query}'")
|
||||||
|
print(f"Analysis: {analysis}")
|
||||||
|
|
||||||
|
assert analysis.temporal_constraint is not None, "Should extract temporal constraint"
|
||||||
|
assert analysis.temporal_constraint.start_date.year == 2024
|
||||||
|
assert analysis.temporal_constraint.start_date.month == 6
|
||||||
|
assert analysis.temporal_constraint.start_date.day == 1
|
||||||
|
assert analysis.temporal_constraint.end_date.year == 2024
|
||||||
|
assert analysis.temporal_constraint.end_date.month == 6
|
||||||
|
assert analysis.temporal_constraint.end_date.day == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_analyzer_dogs_june_2023():
|
||||||
|
"""Test extracting temporal info from 'dogs in June 2023'."""
|
||||||
|
analyzer = TransformerQueryAnalyzer()
|
||||||
|
reference_date = datetime(2025, 1, 15, 12, 0, 0)
|
||||||
|
|
||||||
|
query = "dogs in June 2023"
|
||||||
|
analysis = analyzer.analyze(query, reference_date)
|
||||||
|
|
||||||
|
print(f"\nQuery: '{query}'")
|
||||||
|
print(f"Analysis: {analysis}")
|
||||||
|
|
||||||
|
assert analysis.temporal_constraint is not None, "Should extract temporal constraint"
|
||||||
|
assert analysis.temporal_constraint.start_date.year == 2023
|
||||||
|
assert analysis.temporal_constraint.start_date.month == 6
|
||||||
|
assert analysis.temporal_constraint.start_date.day == 1
|
||||||
|
assert analysis.temporal_constraint.end_date.year == 2023
|
||||||
|
assert analysis.temporal_constraint.end_date.month == 6
|
||||||
|
assert analysis.temporal_constraint.end_date.day == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_analyzer_march_2023():
|
||||||
|
"""Test extracting 'March 2023' from query."""
|
||||||
|
analyzer = TransformerQueryAnalyzer()
|
||||||
|
reference_date = datetime(2025, 1, 15, 12, 0, 0)
|
||||||
|
|
||||||
|
query = "March 2023"
|
||||||
|
analysis = analyzer.analyze(query, reference_date)
|
||||||
|
|
||||||
|
print(f"\nQuery: '{query}'")
|
||||||
|
print(f"Analysis: {analysis}")
|
||||||
|
|
||||||
|
assert analysis.temporal_constraint is not None, "Should extract temporal constraint"
|
||||||
|
assert analysis.temporal_constraint.start_date.year == 2023
|
||||||
|
assert analysis.temporal_constraint.start_date.month == 3
|
||||||
|
assert analysis.temporal_constraint.start_date.day == 1
|
||||||
|
assert analysis.temporal_constraint.end_date.year == 2023
|
||||||
|
assert analysis.temporal_constraint.end_date.month == 3
|
||||||
|
assert analysis.temporal_constraint.end_date.day == 31
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_analyzer_last_year():
|
||||||
|
"""Test extracting 'last year' from query."""
|
||||||
|
analyzer = TransformerQueryAnalyzer()
|
||||||
|
reference_date = datetime(2025, 1, 15, 12, 0, 0)
|
||||||
|
|
||||||
|
query = "last year"
|
||||||
|
analysis = analyzer.analyze(query, reference_date)
|
||||||
|
|
||||||
|
print(f"\nQuery: '{query}'")
|
||||||
|
print(f"Analysis: {analysis}")
|
||||||
|
|
||||||
|
assert analysis.temporal_constraint is not None, "Should extract temporal constraint"
|
||||||
|
assert analysis.temporal_constraint.start_date.year == 2024
|
||||||
|
assert analysis.temporal_constraint.start_date.month == 1
|
||||||
|
assert analysis.temporal_constraint.start_date.day == 1
|
||||||
|
assert analysis.temporal_constraint.end_date.year == 2024
|
||||||
|
assert analysis.temporal_constraint.end_date.month == 12
|
||||||
|
assert analysis.temporal_constraint.end_date.day == 31
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_analyzer_no_temporal():
|
||||||
|
"""Test that queries without temporal info return None."""
|
||||||
|
analyzer = TransformerQueryAnalyzer()
|
||||||
|
reference_date = datetime(2025, 1, 15, 12, 0, 0)
|
||||||
|
|
||||||
|
query = "what is the weather"
|
||||||
|
analysis = analyzer.analyze(query, reference_date)
|
||||||
|
|
||||||
|
print(f"\nQuery: '{query}'")
|
||||||
|
print(f"Analysis: {analysis}")
|
||||||
|
|
||||||
|
assert analysis.temporal_constraint is None, "Should not extract temporal constraint"
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_analyzer_activities_june_2024():
|
||||||
|
"""Test extracting temporal info from 'melanie activities in june 2024'."""
|
||||||
|
analyzer = TransformerQueryAnalyzer()
|
||||||
|
reference_date = datetime(2025, 1, 15, 12, 0, 0)
|
||||||
|
|
||||||
|
query = "melanie activities in june 2024"
|
||||||
|
analysis = analyzer.analyze(query, reference_date)
|
||||||
|
|
||||||
|
print(f"\nQuery: '{query}'")
|
||||||
|
print(f"Analysis: {analysis}")
|
||||||
|
|
||||||
|
assert analysis.temporal_constraint is not None, "Should extract temporal constraint"
|
||||||
|
assert analysis.temporal_constraint.start_date.year == 2024
|
||||||
|
assert analysis.temporal_constraint.start_date.month == 6
|
||||||
|
assert analysis.temporal_constraint.start_date.day == 1
|
||||||
|
assert analysis.temporal_constraint.end_date.year == 2024
|
||||||
|
assert analysis.temporal_constraint.end_date.month == 6
|
||||||
|
assert analysis.temporal_constraint.end_date.day == 30
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v", "-s"])
|
||||||
145
memora/tests/test_temporal_ranges.py
Normal file
145
memora/tests/test_temporal_ranges.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
"""Tests for temporal range support (occurred_start, occurred_end, mentioned_at)."""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
import pytest
|
||||||
|
from memora import TemporalSemanticMemory
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_temporal_ranges_are_written():
|
||||||
|
"""Test that occurred_start, occurred_end, and mentioned_at are actually written to database."""
|
||||||
|
|
||||||
|
# Initialize memory system
|
||||||
|
memory = TemporalSemanticMemory(
|
||||||
|
db_url=os.getenv("MEMORA_API_DATABASE_URL", "postgresql://memora:memora_dev@localhost:5432/memora"),
|
||||||
|
memory_llm_provider=os.getenv("MEMORA_API_LLM_PROVIDER", "groq"),
|
||||||
|
memory_llm_api_key=os.getenv("MEMORA_API_LLM_API_KEY"),
|
||||||
|
memory_llm_model=os.getenv("MEMORA_API_LLM_MODEL", "openai/gpt-oss-20b"),
|
||||||
|
)
|
||||||
|
await memory.initialize()
|
||||||
|
|
||||||
|
agent_id = "test_temporal_ranges"
|
||||||
|
|
||||||
|
# Clean up any existing data
|
||||||
|
try:
|
||||||
|
await memory.delete_agent(agent_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Test 1: Point event (specific date)
|
||||||
|
conversation_date = datetime(2024, 11, 17, 10, 0, 0, tzinfo=timezone.utc)
|
||||||
|
text1 = "Yesterday I went to a pottery workshop where I made a beautiful vase."
|
||||||
|
|
||||||
|
await memory.put_async(
|
||||||
|
agent_id=agent_id,
|
||||||
|
content=text1,
|
||||||
|
event_date=conversation_date
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test 2: Period event (month range)
|
||||||
|
text2 = "In February 2024, Alice visited Paris and explored the Louvre museum."
|
||||||
|
|
||||||
|
await memory.put_async(
|
||||||
|
agent_id=agent_id,
|
||||||
|
content=text2,
|
||||||
|
event_date=conversation_date
|
||||||
|
)
|
||||||
|
|
||||||
|
# Give it a moment for async processing
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
# Retrieve facts from database directly
|
||||||
|
pool = await memory._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT id, text, event_date, occurred_start, occurred_end, mentioned_at
|
||||||
|
FROM memory_units
|
||||||
|
WHERE agent_id = $1
|
||||||
|
ORDER BY created_at
|
||||||
|
""",
|
||||||
|
agent_id
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n\n=== Retrieved {len(rows)} facts ===")
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
print(f"\nFact {i+1}:")
|
||||||
|
print(f" Text: {row['text'][:80]}...")
|
||||||
|
print(f" event_date: {row['event_date']}")
|
||||||
|
print(f" occurred_start: {row['occurred_start']}")
|
||||||
|
print(f" occurred_end: {row['occurred_end']}")
|
||||||
|
print(f" mentioned_at: {row['mentioned_at']}")
|
||||||
|
|
||||||
|
# Assertions
|
||||||
|
assert len(rows) >= 2, f"Expected at least 2 facts, got {len(rows)}"
|
||||||
|
|
||||||
|
# Check that temporal fields are populated
|
||||||
|
for row in rows:
|
||||||
|
assert row['occurred_start'] is not None, f"occurred_start is None for fact: {row['text'][:50]}"
|
||||||
|
assert row['occurred_end'] is not None, f"occurred_end is None for fact: {row['text'][:50]}"
|
||||||
|
assert row['mentioned_at'] is not None, f"mentioned_at is None for fact: {row['text'][:50]}"
|
||||||
|
|
||||||
|
# mentioned_at should be close to the conversation date
|
||||||
|
time_diff = abs((row['mentioned_at'] - conversation_date).total_seconds())
|
||||||
|
assert time_diff < 60, f"mentioned_at is too far from conversation_date: {time_diff}s"
|
||||||
|
|
||||||
|
# Find the pottery fact (point event)
|
||||||
|
pottery_fact = next((r for r in rows if 'pottery' in r['text'].lower()), None)
|
||||||
|
if pottery_fact:
|
||||||
|
print(f"\n=== Pottery Fact (Point Event) ===")
|
||||||
|
print(f" occurred_start: {pottery_fact['occurred_start']}")
|
||||||
|
print(f" occurred_end: {pottery_fact['occurred_end']}")
|
||||||
|
|
||||||
|
# For "yesterday", occurred_start and occurred_end should be Nov 16
|
||||||
|
# (or the same day - it should be a point event)
|
||||||
|
# We'll check they're within the same day
|
||||||
|
time_diff = abs((pottery_fact['occurred_end'] - pottery_fact['occurred_start']).total_seconds())
|
||||||
|
assert time_diff < 86400, f"Point event should have occurred_start and occurred_end within same day, got diff: {time_diff}s"
|
||||||
|
|
||||||
|
# Find the Paris fact (period event)
|
||||||
|
paris_fact = next((r for r in rows if 'paris' in r['text'].lower() or 'february' in r['text'].lower()), None)
|
||||||
|
if paris_fact:
|
||||||
|
print(f"\n=== Paris Fact (Period Event) ===")
|
||||||
|
print(f" occurred_start: {paris_fact['occurred_start']}")
|
||||||
|
print(f" occurred_end: {paris_fact['occurred_end']}")
|
||||||
|
|
||||||
|
# For "in February 2024", occurred_start should be ~Feb 1 and occurred_end should be ~Feb 28/29
|
||||||
|
# Check it spans at least 20 days (to account for variations)
|
||||||
|
time_diff_days = (paris_fact['occurred_end'] - paris_fact['occurred_start']).days
|
||||||
|
print(f" Duration: {time_diff_days} days")
|
||||||
|
assert time_diff_days >= 20, f"February should span at least 20 days, got {time_diff_days} days"
|
||||||
|
assert time_diff_days <= 31, f"February should not span more than 31 days, got {time_diff_days} days"
|
||||||
|
|
||||||
|
# Test search results also include temporal fields
|
||||||
|
print("\n=== Testing Search Results ===")
|
||||||
|
search_result = await memory.search_async(
|
||||||
|
agent_id=agent_id,
|
||||||
|
query="pottery workshop",
|
||||||
|
fact_type=["event", "world"],
|
||||||
|
thinking_budget=20,
|
||||||
|
max_tokens=4096
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Found {len(search_result.results)} search results")
|
||||||
|
if len(search_result.results) > 0:
|
||||||
|
first_result = search_result.results[0]
|
||||||
|
print(f" Text: {first_result.text[:80]}...")
|
||||||
|
print(f" occurred_start: {first_result.occurred_start}")
|
||||||
|
print(f" occurred_end: {first_result.occurred_end}")
|
||||||
|
print(f" mentioned_at: {first_result.mentioned_at}")
|
||||||
|
|
||||||
|
# Note: Search results may not have temporal fields populated yet (work in progress)
|
||||||
|
if first_result.occurred_start:
|
||||||
|
print("✓ Temporal fields are present in search results")
|
||||||
|
else:
|
||||||
|
print("⚠ Temporal fields not yet populated in search results (known issue)")
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
await memory.delete_agent(agent_id)
|
||||||
|
await memory.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Run tests
|
||||||
|
asyncio.run(test_temporal_ranges_are_written())
|
||||||
33
uv.lock
33
uv.lock
|
|
@ -537,21 +537,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/cc/53c8350d8ca53ada627f071c252e806b97c949e03b054af7d15e62309a83/cyclopts-4.2.2-py3-none-any.whl", hash = "sha256:2e001158ccb275723a4d820c65d114caa078073e61298f2a7c6112a8d3ba90c6", size = 184362 },
|
{ url = "https://files.pythonhosted.org/packages/ac/cc/53c8350d8ca53ada627f071c252e806b97c949e03b054af7d15e62309a83/cyclopts-4.2.2-py3-none-any.whl", hash = "sha256:2e001158ccb275723a4d820c65d114caa078073e61298f2a7c6112a8d3ba90c6", size = 184362 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dateparser"
|
|
||||||
version = "1.2.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "python-dateutil" },
|
|
||||||
{ name = "pytz" },
|
|
||||||
{ name = "regex" },
|
|
||||||
{ name = "tzlocal" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/30/064144f0df1749e7bb5faaa7f52b007d7c2d08ec08fed8411aba87207f68/dateparser-1.2.2.tar.gz", hash = "sha256:986316f17cb8cdc23ea8ce563027c5ef12fc725b6fb1d137c14ca08777c5ecf7", size = 329840 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/22/f020c047ae1346613db9322638186468238bcfa8849b4668a22b97faad65/dateparser-1.2.2-py3-none-any.whl", hash = "sha256:5a5d7211a09013499867547023a2a0c91d5a27d15dd4dbcea676ea9fe66f2482", size = 315453 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "diskcache"
|
name = "diskcache"
|
||||||
version = "5.6.3"
|
version = "5.6.3"
|
||||||
|
|
@ -1413,7 +1398,6 @@ source = { editable = "memora" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
{ name = "dateparser" },
|
|
||||||
{ name = "fastapi", extra = ["standard"] },
|
{ name = "fastapi", extra = ["standard"] },
|
||||||
{ name = "greenlet" },
|
{ name = "greenlet" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
|
@ -1427,6 +1411,8 @@ dependencies = [
|
||||||
{ name = "sentence-transformers" },
|
{ name = "sentence-transformers" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
{ name = "tiktoken" },
|
{ name = "tiktoken" },
|
||||||
|
{ name = "torch" },
|
||||||
|
{ name = "transformers" },
|
||||||
{ name = "uvicorn" },
|
{ name = "uvicorn" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1441,7 +1427,6 @@ test = [
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "alembic", specifier = ">=1.17.1" },
|
{ name = "alembic", specifier = ">=1.17.1" },
|
||||||
{ name = "asyncpg", specifier = ">=0.29.0" },
|
{ name = "asyncpg", specifier = ">=0.29.0" },
|
||||||
{ name = "dateparser", specifier = ">=1.2.0" },
|
|
||||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.120.3" },
|
{ name = "fastapi", extras = ["standard"], specifier = ">=0.120.3" },
|
||||||
{ name = "greenlet", specifier = ">=3.2.4" },
|
{ name = "greenlet", specifier = ">=3.2.4" },
|
||||||
{ name = "httpx", specifier = ">=0.27.0" },
|
{ name = "httpx", specifier = ">=0.27.0" },
|
||||||
|
|
@ -1458,6 +1443,8 @@ requires-dist = [
|
||||||
{ name = "sentence-transformers", specifier = ">=2.2.0" },
|
{ name = "sentence-transformers", specifier = ">=2.2.0" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0.44" },
|
{ name = "sqlalchemy", specifier = ">=2.0.44" },
|
||||||
{ name = "tiktoken", specifier = ">=0.12.0" },
|
{ name = "tiktoken", specifier = ">=0.12.0" },
|
||||||
|
{ name = "torch", specifier = ">=2.0.0" },
|
||||||
|
{ name = "transformers", specifier = ">=4.30.0" },
|
||||||
{ name = "uvicorn", specifier = ">=0.38.0" },
|
{ name = "uvicorn", specifier = ">=0.38.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["test"]
|
provides-extras = ["test"]
|
||||||
|
|
@ -3537,18 +3524,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 },
|
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tzlocal"
|
|
||||||
version = "5.3.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue