hindsight-api 0.0.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hindsight_api/__init__.py +38 -0
- hindsight_api/api/__init__.py +105 -0
- hindsight_api/api/http.py +1872 -0
- hindsight_api/api/mcp.py +157 -0
- hindsight_api/engine/__init__.py +47 -0
- hindsight_api/engine/cross_encoder.py +97 -0
- hindsight_api/engine/db_utils.py +93 -0
- hindsight_api/engine/embeddings.py +113 -0
- hindsight_api/engine/entity_resolver.py +575 -0
- hindsight_api/engine/llm_wrapper.py +269 -0
- hindsight_api/engine/memory_engine.py +3095 -0
- hindsight_api/engine/query_analyzer.py +519 -0
- hindsight_api/engine/response_models.py +222 -0
- hindsight_api/engine/retain/__init__.py +50 -0
- hindsight_api/engine/retain/bank_utils.py +423 -0
- hindsight_api/engine/retain/chunk_storage.py +82 -0
- hindsight_api/engine/retain/deduplication.py +104 -0
- hindsight_api/engine/retain/embedding_processing.py +62 -0
- hindsight_api/engine/retain/embedding_utils.py +54 -0
- hindsight_api/engine/retain/entity_processing.py +90 -0
- hindsight_api/engine/retain/fact_extraction.py +1027 -0
- hindsight_api/engine/retain/fact_storage.py +176 -0
- hindsight_api/engine/retain/link_creation.py +121 -0
- hindsight_api/engine/retain/link_utils.py +651 -0
- hindsight_api/engine/retain/orchestrator.py +405 -0
- hindsight_api/engine/retain/types.py +206 -0
- hindsight_api/engine/search/__init__.py +15 -0
- hindsight_api/engine/search/fusion.py +122 -0
- hindsight_api/engine/search/observation_utils.py +132 -0
- hindsight_api/engine/search/reranking.py +103 -0
- hindsight_api/engine/search/retrieval.py +503 -0
- hindsight_api/engine/search/scoring.py +161 -0
- hindsight_api/engine/search/temporal_extraction.py +64 -0
- hindsight_api/engine/search/think_utils.py +255 -0
- hindsight_api/engine/search/trace.py +215 -0
- hindsight_api/engine/search/tracer.py +447 -0
- hindsight_api/engine/search/types.py +160 -0
- hindsight_api/engine/task_backend.py +223 -0
- hindsight_api/engine/utils.py +203 -0
- hindsight_api/metrics.py +227 -0
- hindsight_api/migrations.py +163 -0
- hindsight_api/models.py +309 -0
- hindsight_api/pg0.py +425 -0
- hindsight_api/web/__init__.py +12 -0
- hindsight_api/web/server.py +143 -0
- hindsight_api-0.0.13.dist-info/METADATA +41 -0
- hindsight_api-0.0.13.dist-info/RECORD +48 -0
- hindsight_api-0.0.13.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core response models for Hindsight memory system.
|
|
3
|
+
|
|
4
|
+
These models define the structure of data returned by the core MemoryEngine class.
|
|
5
|
+
API response models should be kept separate and convert from these core models to maintain
|
|
6
|
+
API stability even if internal models change.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Optional, List, Dict, Any
|
|
10
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PersonalityTraits(BaseModel):
|
|
14
|
+
"""
|
|
15
|
+
Personality traits for a bank using the Big Five model.
|
|
16
|
+
|
|
17
|
+
All traits are scored 0.0-1.0 where higher values indicate stronger presence of the trait.
|
|
18
|
+
"""
|
|
19
|
+
openness: float = Field(description="Openness to experience (0.0-1.0)")
|
|
20
|
+
conscientiousness: float = Field(description="Conscientiousness and organization (0.0-1.0)")
|
|
21
|
+
extraversion: float = Field(description="Extraversion and sociability (0.0-1.0)")
|
|
22
|
+
agreeableness: float = Field(description="Agreeableness and cooperation (0.0-1.0)")
|
|
23
|
+
neuroticism: float = Field(description="Emotional sensitivity and neuroticism (0.0-1.0)")
|
|
24
|
+
bias_strength: float = Field(description="How strongly personality influences thinking (0.0-1.0)")
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(json_schema_extra={
|
|
27
|
+
"example": {
|
|
28
|
+
"openness": 0.8,
|
|
29
|
+
"conscientiousness": 0.6,
|
|
30
|
+
"extraversion": 0.4,
|
|
31
|
+
"agreeableness": 0.7,
|
|
32
|
+
"neuroticism": 0.3,
|
|
33
|
+
"bias_strength": 0.5
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MemoryFact(BaseModel):
|
|
39
|
+
"""
|
|
40
|
+
A single memory fact returned by search or think operations.
|
|
41
|
+
|
|
42
|
+
This represents a unit of information stored in the memory system,
|
|
43
|
+
including both the content and metadata.
|
|
44
|
+
"""
|
|
45
|
+
model_config = ConfigDict(json_schema_extra={
|
|
46
|
+
"example": {
|
|
47
|
+
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
48
|
+
"text": "Alice works at Google on the AI team",
|
|
49
|
+
"fact_type": "world",
|
|
50
|
+
"entities": ["Alice", "Google"],
|
|
51
|
+
"context": "work info",
|
|
52
|
+
"occurred_start": "2024-01-15T10:30:00Z",
|
|
53
|
+
"occurred_end": "2024-01-15T10:30:00Z",
|
|
54
|
+
"mentioned_at": "2024-01-15T10:30:00Z",
|
|
55
|
+
"document_id": "session_abc123",
|
|
56
|
+
"metadata": {"source": "slack"},
|
|
57
|
+
"chunk_id": "bank123_session_abc123_0",
|
|
58
|
+
"activation": 0.95
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
id: str = Field(description="Unique identifier for the memory fact")
|
|
63
|
+
text: str = Field(description="The actual text content of the memory")
|
|
64
|
+
fact_type: str = Field(description="Type of fact: 'world', 'bank', 'opinion', or 'observation'")
|
|
65
|
+
entities: Optional[List[str]] = Field(None, description="Entity names mentioned in this fact")
|
|
66
|
+
context: Optional[str] = Field(None, description="Additional context for the memory")
|
|
67
|
+
occurred_start: Optional[str] = Field(None, description="ISO format date when the event started occurring")
|
|
68
|
+
occurred_end: Optional[str] = Field(None, description="ISO format date when the event ended occurring")
|
|
69
|
+
mentioned_at: Optional[str] = Field(None, description="ISO format date when the fact was mentioned/learned")
|
|
70
|
+
document_id: Optional[str] = Field(None, description="ID of the document this memory belongs to")
|
|
71
|
+
metadata: Optional[Dict[str, str]] = Field(None, description="User-defined metadata")
|
|
72
|
+
chunk_id: Optional[str] = Field(None, description="ID of the chunk this fact was extracted from (format: bank_id_document_id_chunk_index)")
|
|
73
|
+
|
|
74
|
+
# Internal metrics (used by system but may not be exposed in API)
|
|
75
|
+
activation: Optional[float] = Field(None, description="Internal activation score")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ChunkInfo(BaseModel):
|
|
79
|
+
"""Information about a chunk."""
|
|
80
|
+
chunk_text: str = Field(description="The raw chunk text")
|
|
81
|
+
chunk_index: int = Field(description="Index of the chunk within the document")
|
|
82
|
+
truncated: bool = Field(default=False, description="Whether the chunk was truncated due to token limits")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RecallResult(BaseModel):
|
|
86
|
+
"""
|
|
87
|
+
Result from a recall operation.
|
|
88
|
+
|
|
89
|
+
Contains a list of matching memory facts and optional trace information
|
|
90
|
+
for debugging and transparency.
|
|
91
|
+
"""
|
|
92
|
+
model_config = ConfigDict(json_schema_extra={
|
|
93
|
+
"example": {
|
|
94
|
+
"results": [
|
|
95
|
+
{
|
|
96
|
+
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
97
|
+
"text": "Alice works at Google on the AI team",
|
|
98
|
+
"fact_type": "world",
|
|
99
|
+
"context": "work info",
|
|
100
|
+
"occurred_start": "2024-01-15T10:30:00Z",
|
|
101
|
+
"occurred_end": "2024-01-15T10:30:00Z",
|
|
102
|
+
"activation": 0.95
|
|
103
|
+
}
|
|
104
|
+
],
|
|
105
|
+
"trace": {
|
|
106
|
+
"query": "What did Alice say about machine learning?",
|
|
107
|
+
"num_results": 1
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
results: List[MemoryFact] = Field(description="List of memory facts matching the query")
|
|
113
|
+
trace: Optional[Dict[str, Any]] = Field(None, description="Trace information for debugging")
|
|
114
|
+
entities: Optional[Dict[str, "EntityState"]] = Field(
|
|
115
|
+
None,
|
|
116
|
+
description="Entity states for entities mentioned in results (keyed by canonical name)"
|
|
117
|
+
)
|
|
118
|
+
chunks: Optional[Dict[str, ChunkInfo]] = Field(
|
|
119
|
+
None,
|
|
120
|
+
description="Chunks for facts, keyed by '{document_id}_{chunk_index}'"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ReflectResult(BaseModel):
|
|
125
|
+
"""
|
|
126
|
+
Result from a reflect operation.
|
|
127
|
+
|
|
128
|
+
Contains the formulated answer, the facts it was based on (organized by type),
|
|
129
|
+
and any new opinions that were formed during the reflection process.
|
|
130
|
+
"""
|
|
131
|
+
model_config = ConfigDict(json_schema_extra={
|
|
132
|
+
"example": {
|
|
133
|
+
"text": "Based on my knowledge, machine learning is being actively used in healthcare...",
|
|
134
|
+
"based_on": {
|
|
135
|
+
"world": [
|
|
136
|
+
{
|
|
137
|
+
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
138
|
+
"text": "Machine learning is used in medical diagnosis",
|
|
139
|
+
"fact_type": "world",
|
|
140
|
+
"context": "healthcare",
|
|
141
|
+
"occurred_start": "2024-01-15T10:30:00Z",
|
|
142
|
+
"occurred_end": "2024-01-15T10:30:00Z"
|
|
143
|
+
}
|
|
144
|
+
],
|
|
145
|
+
"agent": [],
|
|
146
|
+
"opinion": []
|
|
147
|
+
},
|
|
148
|
+
"new_opinions": [
|
|
149
|
+
"Machine learning has great potential in healthcare"
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
text: str = Field(description="The formulated answer text")
|
|
155
|
+
based_on: Dict[str, List[MemoryFact]] = Field(
|
|
156
|
+
description="Facts used to formulate the answer, organized by type (world, agent, opinion)"
|
|
157
|
+
)
|
|
158
|
+
new_opinions: List[str] = Field(
|
|
159
|
+
default_factory=list,
|
|
160
|
+
description="List of newly formed opinions during reflection"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Opinion(BaseModel):
|
|
165
|
+
"""
|
|
166
|
+
An opinion with confidence score.
|
|
167
|
+
|
|
168
|
+
Opinions represent the bank's formed perspectives on topics,
|
|
169
|
+
with a confidence level indicating strength of belief.
|
|
170
|
+
"""
|
|
171
|
+
model_config = ConfigDict(json_schema_extra={
|
|
172
|
+
"example": {
|
|
173
|
+
"text": "Machine learning has great potential in healthcare",
|
|
174
|
+
"confidence": 0.85
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
text: str = Field(description="The opinion text")
|
|
179
|
+
confidence: float = Field(description="Confidence score between 0.0 and 1.0")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class EntityObservation(BaseModel):
|
|
183
|
+
"""
|
|
184
|
+
An observation about an entity.
|
|
185
|
+
|
|
186
|
+
Observations are objective facts synthesized from multiple memory facts
|
|
187
|
+
about an entity, without personality influence.
|
|
188
|
+
"""
|
|
189
|
+
model_config = ConfigDict(json_schema_extra={
|
|
190
|
+
"example": {
|
|
191
|
+
"text": "John is detail-oriented and works at Google",
|
|
192
|
+
"mentioned_at": "2024-01-15T10:30:00Z"
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
text: str = Field(description="The observation text")
|
|
197
|
+
mentioned_at: Optional[str] = Field(None, description="ISO format date when this observation was created")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class EntityState(BaseModel):
|
|
201
|
+
"""
|
|
202
|
+
Current mental model of an entity.
|
|
203
|
+
|
|
204
|
+
Contains observations synthesized from facts about the entity.
|
|
205
|
+
"""
|
|
206
|
+
model_config = ConfigDict(json_schema_extra={
|
|
207
|
+
"example": {
|
|
208
|
+
"entity_id": "123e4567-e89b-12d3-a456-426614174000",
|
|
209
|
+
"canonical_name": "John",
|
|
210
|
+
"observations": [
|
|
211
|
+
{"text": "John is detail-oriented", "mentioned_at": "2024-01-15T10:30:00Z"},
|
|
212
|
+
{"text": "John works at Google on the AI team", "mentioned_at": "2024-01-14T09:00:00Z"}
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
entity_id: str = Field(description="Unique identifier for the entity")
|
|
218
|
+
canonical_name: str = Field(description="Canonical name of the entity")
|
|
219
|
+
observations: List[EntityObservation] = Field(
|
|
220
|
+
default_factory=list,
|
|
221
|
+
description="List of observations about this entity"
|
|
222
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Retain pipeline modules for storing memories.
|
|
3
|
+
|
|
4
|
+
This package contains modular components for the retain operation:
|
|
5
|
+
- types: Type definitions for retain pipeline
|
|
6
|
+
- fact_extraction: Extract facts from content
|
|
7
|
+
- embedding_processing: Augment texts and generate embeddings
|
|
8
|
+
- deduplication: Check for duplicate facts
|
|
9
|
+
- entity_processing: Process and resolve entities
|
|
10
|
+
- link_creation: Create temporal, semantic, entity, and causal links
|
|
11
|
+
- chunk_storage: Handle chunk storage
|
|
12
|
+
- fact_storage: Handle fact insertion into database
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .types import (
|
|
16
|
+
RetainContent,
|
|
17
|
+
ExtractedFact,
|
|
18
|
+
ProcessedFact,
|
|
19
|
+
ChunkMetadata,
|
|
20
|
+
EntityRef,
|
|
21
|
+
CausalRelation,
|
|
22
|
+
RetainBatch
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from . import fact_extraction
|
|
26
|
+
from . import embedding_processing
|
|
27
|
+
from . import deduplication
|
|
28
|
+
from . import entity_processing
|
|
29
|
+
from . import link_creation
|
|
30
|
+
from . import chunk_storage
|
|
31
|
+
from . import fact_storage
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
# Types
|
|
35
|
+
"RetainContent",
|
|
36
|
+
"ExtractedFact",
|
|
37
|
+
"ProcessedFact",
|
|
38
|
+
"ChunkMetadata",
|
|
39
|
+
"EntityRef",
|
|
40
|
+
"CausalRelation",
|
|
41
|
+
"RetainBatch",
|
|
42
|
+
# Modules
|
|
43
|
+
"fact_extraction",
|
|
44
|
+
"embedding_processing",
|
|
45
|
+
"deduplication",
|
|
46
|
+
"entity_processing",
|
|
47
|
+
"link_creation",
|
|
48
|
+
"chunk_storage",
|
|
49
|
+
"fact_storage",
|
|
50
|
+
]
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""
|
|
2
|
+
bank profile utilities for personality and background management.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from typing import Dict, Optional, TypedDict
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
from ..db_utils import acquire_with_retry
|
|
11
|
+
from ..response_models import PersonalityTraits
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
DEFAULT_PERSONALITY = {
|
|
16
|
+
"openness": 0.5,
|
|
17
|
+
"conscientiousness": 0.5,
|
|
18
|
+
"extraversion": 0.5,
|
|
19
|
+
"agreeableness": 0.5,
|
|
20
|
+
"neuroticism": 0.5,
|
|
21
|
+
"bias_strength": 0.5,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BankProfile(TypedDict):
|
|
26
|
+
"""Type for bank profile data."""
|
|
27
|
+
name: str
|
|
28
|
+
personality: PersonalityTraits
|
|
29
|
+
background: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BackgroundMergeResponse(BaseModel):
|
|
33
|
+
"""LLM response for background merge with personality inference."""
|
|
34
|
+
background: str = Field(description="Merged background in first person perspective")
|
|
35
|
+
personality: PersonalityTraits = Field(description="Inferred Big Five personality traits")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def get_bank_profile(pool, bank_id: str) -> BankProfile:
|
|
39
|
+
"""
|
|
40
|
+
Get bank profile (name, personality + background).
|
|
41
|
+
Auto-creates bank with default values if not exists.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
pool: Database connection pool
|
|
45
|
+
bank_id: bank IDentifier
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
BankProfile with name, typed PersonalityTraits, and background
|
|
49
|
+
"""
|
|
50
|
+
async with acquire_with_retry(pool) as conn:
|
|
51
|
+
# Try to get existing bank
|
|
52
|
+
row = await conn.fetchrow(
|
|
53
|
+
"""
|
|
54
|
+
SELECT name, personality, background
|
|
55
|
+
FROM banks WHERE bank_id = $1
|
|
56
|
+
""",
|
|
57
|
+
bank_id
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if row:
|
|
61
|
+
# asyncpg returns JSONB as a string, so parse it
|
|
62
|
+
personality_data = row["personality"]
|
|
63
|
+
if isinstance(personality_data, str):
|
|
64
|
+
personality_data = json.loads(personality_data)
|
|
65
|
+
|
|
66
|
+
return BankProfile(
|
|
67
|
+
name=row["name"],
|
|
68
|
+
personality=PersonalityTraits(**personality_data),
|
|
69
|
+
background=row["background"]
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Bank doesn't exist, create with defaults
|
|
73
|
+
await conn.execute(
|
|
74
|
+
"""
|
|
75
|
+
INSERT INTO banks (bank_id, name, personality, background)
|
|
76
|
+
VALUES ($1, $2, $3::jsonb, $4)
|
|
77
|
+
ON CONFLICT (bank_id) DO NOTHING
|
|
78
|
+
""",
|
|
79
|
+
bank_id,
|
|
80
|
+
bank_id, # Default name is the bank_id
|
|
81
|
+
json.dumps(DEFAULT_PERSONALITY),
|
|
82
|
+
""
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return BankProfile(
|
|
86
|
+
name=bank_id,
|
|
87
|
+
personality=PersonalityTraits(**DEFAULT_PERSONALITY),
|
|
88
|
+
background=""
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def update_bank_personality(
|
|
93
|
+
pool,
|
|
94
|
+
bank_id: str,
|
|
95
|
+
personality: Dict[str, float]
|
|
96
|
+
) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Update bank personality traits.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
pool: Database connection pool
|
|
102
|
+
bank_id: bank IDentifier
|
|
103
|
+
personality: Dict with Big Five traits + bias_strength (all 0-1)
|
|
104
|
+
"""
|
|
105
|
+
# Ensure bank exists first
|
|
106
|
+
await get_bank_profile(pool, bank_id)
|
|
107
|
+
|
|
108
|
+
async with acquire_with_retry(pool) as conn:
|
|
109
|
+
await conn.execute(
|
|
110
|
+
"""
|
|
111
|
+
UPDATE banks
|
|
112
|
+
SET personality = $2::jsonb,
|
|
113
|
+
updated_at = NOW()
|
|
114
|
+
WHERE bank_id = $1
|
|
115
|
+
""",
|
|
116
|
+
bank_id,
|
|
117
|
+
json.dumps(personality)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def merge_bank_background(
|
|
122
|
+
pool,
|
|
123
|
+
llm_config,
|
|
124
|
+
bank_id: str,
|
|
125
|
+
new_info: str,
|
|
126
|
+
update_personality: bool = True
|
|
127
|
+
) -> dict:
|
|
128
|
+
"""
|
|
129
|
+
Merge new background information with existing background using LLM.
|
|
130
|
+
Normalizes to first person ("I") and resolves conflicts.
|
|
131
|
+
Optionally infers personality traits from the merged background.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
pool: Database connection pool
|
|
135
|
+
llm_config: LLM configuration for background merging
|
|
136
|
+
bank_id: bank IDentifier
|
|
137
|
+
new_info: New background information to add/merge
|
|
138
|
+
update_personality: If True, infer Big Five traits from background (default: True)
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Dict with 'background' (str) and optionally 'personality' (dict) keys
|
|
142
|
+
"""
|
|
143
|
+
# Get current profile
|
|
144
|
+
profile = await get_bank_profile(pool, bank_id)
|
|
145
|
+
current_background = profile["background"]
|
|
146
|
+
|
|
147
|
+
# Use LLM to merge backgrounds and optionally infer personality
|
|
148
|
+
result = await _llm_merge_background(
|
|
149
|
+
llm_config,
|
|
150
|
+
current_background,
|
|
151
|
+
new_info,
|
|
152
|
+
infer_personality=update_personality
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
merged_background = result["background"]
|
|
156
|
+
inferred_personality = result.get("personality")
|
|
157
|
+
|
|
158
|
+
# Update in database
|
|
159
|
+
async with acquire_with_retry(pool) as conn:
|
|
160
|
+
if inferred_personality:
|
|
161
|
+
# Update both background and personality
|
|
162
|
+
await conn.execute(
|
|
163
|
+
"""
|
|
164
|
+
UPDATE banks
|
|
165
|
+
SET background = $2,
|
|
166
|
+
personality = $3::jsonb,
|
|
167
|
+
updated_at = NOW()
|
|
168
|
+
WHERE bank_id = $1
|
|
169
|
+
""",
|
|
170
|
+
bank_id,
|
|
171
|
+
merged_background,
|
|
172
|
+
json.dumps(inferred_personality)
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
# Update only background
|
|
176
|
+
await conn.execute(
|
|
177
|
+
"""
|
|
178
|
+
UPDATE banks
|
|
179
|
+
SET background = $2,
|
|
180
|
+
updated_at = NOW()
|
|
181
|
+
WHERE bank_id = $1
|
|
182
|
+
""",
|
|
183
|
+
bank_id,
|
|
184
|
+
merged_background
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
response = {"background": merged_background}
|
|
188
|
+
if inferred_personality:
|
|
189
|
+
response["personality"] = inferred_personality
|
|
190
|
+
|
|
191
|
+
return response
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def _llm_merge_background(
|
|
195
|
+
llm_config,
|
|
196
|
+
current: str,
|
|
197
|
+
new_info: str,
|
|
198
|
+
infer_personality: bool = False
|
|
199
|
+
) -> dict:
|
|
200
|
+
"""
|
|
201
|
+
Use LLM to intelligently merge background information.
|
|
202
|
+
Optionally infer Big Five personality traits from the merged background.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
llm_config: LLM configuration to use
|
|
206
|
+
current: Current background text
|
|
207
|
+
new_info: New information to merge
|
|
208
|
+
infer_personality: If True, also infer personality traits
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Dict with 'background' (str) and optionally 'personality' (dict) keys
|
|
212
|
+
"""
|
|
213
|
+
if infer_personality:
|
|
214
|
+
prompt = f"""You are helping maintain a memory bank's background/profile and infer their personality. You MUST respond with ONLY valid JSON.
|
|
215
|
+
|
|
216
|
+
Current background: {current if current else "(empty)"}
|
|
217
|
+
|
|
218
|
+
New information to add: {new_info}
|
|
219
|
+
|
|
220
|
+
Instructions:
|
|
221
|
+
1. Merge the new information with the current background
|
|
222
|
+
2. If there are conflicts (e.g., different birthplaces), the NEW information overwrites the old
|
|
223
|
+
3. Keep additions that don't conflict
|
|
224
|
+
4. Output in FIRST PERSON ("I") perspective
|
|
225
|
+
5. Be concise - keep merged background under 500 characters
|
|
226
|
+
6. Infer Big Five personality traits from the merged background:
|
|
227
|
+
- Openness: 0.0-1.0 (creativity, curiosity, openness to new ideas)
|
|
228
|
+
- Conscientiousness: 0.0-1.0 (organization, discipline, goal-directed)
|
|
229
|
+
- Extraversion: 0.0-1.0 (sociability, assertiveness, energy from others)
|
|
230
|
+
- Agreeableness: 0.0-1.0 (cooperation, empathy, consideration)
|
|
231
|
+
- Neuroticism: 0.0-1.0 (emotional sensitivity, anxiety, stress response)
|
|
232
|
+
- Bias Strength: 0.0-1.0 (how much personality influences opinions)
|
|
233
|
+
|
|
234
|
+
CRITICAL: You MUST respond with ONLY a valid JSON object. No markdown, no code blocks, no explanations. Just the JSON.
|
|
235
|
+
|
|
236
|
+
Format:
|
|
237
|
+
{{
|
|
238
|
+
"background": "the merged background text in first person",
|
|
239
|
+
"personality": {{
|
|
240
|
+
"openness": 0.7,
|
|
241
|
+
"conscientiousness": 0.6,
|
|
242
|
+
"extraversion": 0.5,
|
|
243
|
+
"agreeableness": 0.8,
|
|
244
|
+
"neuroticism": 0.4,
|
|
245
|
+
"bias_strength": 0.6
|
|
246
|
+
}}
|
|
247
|
+
}}
|
|
248
|
+
|
|
249
|
+
Trait inference examples:
|
|
250
|
+
- "creative artist" → openness: 0.8+, bias_strength: 0.6
|
|
251
|
+
- "organized engineer" → conscientiousness: 0.8+, openness: 0.5-0.6
|
|
252
|
+
- "startup founder" → openness: 0.8+, extraversion: 0.7+, neuroticism: 0.3-0.4
|
|
253
|
+
- "risk-averse analyst" → openness: 0.3-0.4, conscientiousness: 0.8+, neuroticism: 0.6+
|
|
254
|
+
- "rational and diligent" → conscientiousness: 0.7+, openness: 0.6+
|
|
255
|
+
- "passionate and dramatic" → extraversion: 0.7+, neuroticism: 0.6+, openness: 0.7+"""
|
|
256
|
+
else:
|
|
257
|
+
prompt = f"""You are helping maintain a memory bank's background/profile.
|
|
258
|
+
|
|
259
|
+
Current background: {current if current else "(empty)"}
|
|
260
|
+
|
|
261
|
+
New information to add: {new_info}
|
|
262
|
+
|
|
263
|
+
Instructions:
|
|
264
|
+
1. Merge the new information with the current background
|
|
265
|
+
2. If there are conflicts (e.g., different birthplaces), the NEW information overwrites the old
|
|
266
|
+
3. Keep additions that don't conflict
|
|
267
|
+
4. Output in FIRST PERSON ("I") perspective
|
|
268
|
+
5. Be concise - keep it under 500 characters
|
|
269
|
+
6. Return ONLY the merged background text, no explanations
|
|
270
|
+
|
|
271
|
+
Merged background:"""
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
# Prepare messages
|
|
275
|
+
messages = [{"role": "user", "content": prompt}]
|
|
276
|
+
|
|
277
|
+
if infer_personality:
|
|
278
|
+
# Use structured output with Pydantic model for personality inference
|
|
279
|
+
try:
|
|
280
|
+
parsed = await llm_config.call(
|
|
281
|
+
messages=messages,
|
|
282
|
+
response_format=BackgroundMergeResponse,
|
|
283
|
+
scope="bank_background",
|
|
284
|
+
temperature=0.3,
|
|
285
|
+
max_tokens=8192
|
|
286
|
+
)
|
|
287
|
+
logger.info(f"Successfully got structured response: background={parsed.background[:100]}")
|
|
288
|
+
|
|
289
|
+
# Convert Pydantic model to dict format
|
|
290
|
+
return {
|
|
291
|
+
"background": parsed.background,
|
|
292
|
+
"personality": parsed.personality.model_dump()
|
|
293
|
+
}
|
|
294
|
+
except Exception as e:
|
|
295
|
+
logger.warning(f"Structured output failed, falling back to manual parsing: {e}")
|
|
296
|
+
# Fall through to manual parsing below
|
|
297
|
+
|
|
298
|
+
# Manual parsing fallback or non-personality merge
|
|
299
|
+
content = await llm_config.call(
|
|
300
|
+
messages=messages,
|
|
301
|
+
scope="bank_background",
|
|
302
|
+
temperature=0.3,
|
|
303
|
+
max_tokens=8192
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
logger.info(f"LLM response for background merge (first 500 chars): {content[:500]}")
|
|
307
|
+
|
|
308
|
+
if infer_personality:
|
|
309
|
+
# Parse JSON response - try multiple extraction methods
|
|
310
|
+
result = None
|
|
311
|
+
|
|
312
|
+
# Method 1: Direct parse
|
|
313
|
+
try:
|
|
314
|
+
result = json.loads(content)
|
|
315
|
+
logger.info("Successfully parsed JSON directly")
|
|
316
|
+
except json.JSONDecodeError:
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
# Method 2: Extract from markdown code blocks
|
|
320
|
+
if result is None:
|
|
321
|
+
# Remove markdown code blocks
|
|
322
|
+
code_block_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', content, re.DOTALL)
|
|
323
|
+
if code_block_match:
|
|
324
|
+
try:
|
|
325
|
+
result = json.loads(code_block_match.group(1))
|
|
326
|
+
logger.info("Successfully extracted JSON from markdown code block")
|
|
327
|
+
except json.JSONDecodeError:
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
# Method 3: Find nested JSON structure
|
|
331
|
+
if result is None:
|
|
332
|
+
# Look for JSON object with nested structure
|
|
333
|
+
json_match = re.search(r'\{[^{}]*"background"[^{}]*"personality"[^{}]*\{[^{}]*\}[^{}]*\}', content, re.DOTALL)
|
|
334
|
+
if json_match:
|
|
335
|
+
try:
|
|
336
|
+
result = json.loads(json_match.group())
|
|
337
|
+
logger.info("Successfully extracted JSON using nested pattern")
|
|
338
|
+
except json.JSONDecodeError:
|
|
339
|
+
pass
|
|
340
|
+
|
|
341
|
+
# All parsing methods failed - use fallback
|
|
342
|
+
if result is None:
|
|
343
|
+
logger.warning(f"Failed to extract JSON from LLM response. Raw content: {content[:200]}")
|
|
344
|
+
# Fallback: use new_info as background with default personality
|
|
345
|
+
return {
|
|
346
|
+
"background": new_info if new_info else current if current else "",
|
|
347
|
+
"personality": DEFAULT_PERSONALITY.copy()
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
# Validate personality values
|
|
351
|
+
personality = result.get("personality", {})
|
|
352
|
+
for key in ["openness", "conscientiousness", "extraversion",
|
|
353
|
+
"agreeableness", "neuroticism", "bias_strength"]:
|
|
354
|
+
if key not in personality:
|
|
355
|
+
personality[key] = 0.5 # Default to neutral
|
|
356
|
+
else:
|
|
357
|
+
# Clamp to [0, 1]
|
|
358
|
+
personality[key] = max(0.0, min(1.0, float(personality[key])))
|
|
359
|
+
|
|
360
|
+
result["personality"] = personality
|
|
361
|
+
|
|
362
|
+
# Ensure background exists
|
|
363
|
+
if "background" not in result or not result["background"]:
|
|
364
|
+
result["background"] = new_info if new_info else ""
|
|
365
|
+
|
|
366
|
+
return result
|
|
367
|
+
else:
|
|
368
|
+
# Just background merge
|
|
369
|
+
merged = content
|
|
370
|
+
if not merged or merged.lower() in ["(empty)", "none", "n/a"]:
|
|
371
|
+
merged = new_info if new_info else ""
|
|
372
|
+
return {"background": merged}
|
|
373
|
+
|
|
374
|
+
except Exception as e:
|
|
375
|
+
logger.error(f"Error merging background with LLM: {e}")
|
|
376
|
+
# Fallback: just append new info
|
|
377
|
+
if current:
|
|
378
|
+
merged = f"{current} {new_info}".strip()
|
|
379
|
+
else:
|
|
380
|
+
merged = new_info
|
|
381
|
+
|
|
382
|
+
result = {"background": merged}
|
|
383
|
+
if infer_personality:
|
|
384
|
+
result["personality"] = DEFAULT_PERSONALITY.copy()
|
|
385
|
+
return result
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def list_banks(pool) -> list:
|
|
389
|
+
"""
|
|
390
|
+
List all banks in the system.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
pool: Database connection pool
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
List of dicts with bank_id, name, personality, background, created_at, updated_at
|
|
397
|
+
"""
|
|
398
|
+
async with acquire_with_retry(pool) as conn:
|
|
399
|
+
rows = await conn.fetch(
|
|
400
|
+
"""
|
|
401
|
+
SELECT bank_id, name, personality, background, created_at, updated_at
|
|
402
|
+
FROM banks
|
|
403
|
+
ORDER BY updated_at DESC
|
|
404
|
+
"""
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
result = []
|
|
408
|
+
for row in rows:
|
|
409
|
+
# asyncpg returns JSONB as a string, so parse it
|
|
410
|
+
personality_data = row["personality"]
|
|
411
|
+
if isinstance(personality_data, str):
|
|
412
|
+
personality_data = json.loads(personality_data)
|
|
413
|
+
|
|
414
|
+
result.append({
|
|
415
|
+
"bank_id": row["bank_id"],
|
|
416
|
+
"name": row["name"],
|
|
417
|
+
"personality": personality_data,
|
|
418
|
+
"background": row["background"],
|
|
419
|
+
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
|
420
|
+
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
return result
|