Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
393add896e | ||
|
|
f1dccda218 | ||
|
|
7935b0accd | ||
|
|
26bf5714cd | ||
|
|
6232e690fc | ||
|
|
4135a6cee5 | ||
|
|
eb2702bcba | ||
|
|
0d0abaaa9f | ||
|
|
a6798f7e2a | ||
|
|
fb31a35a86 |
+15
-15
@@ -222,7 +222,7 @@ jobs:
|
||||
|
||||
- name: Install API dependencies
|
||||
working-directory: ./hindsight-api
|
||||
run: uv sync --no-install-project --index-strategy unsafe-best-match
|
||||
run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match
|
||||
|
||||
- name: Create .env file
|
||||
run: |
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-api
|
||||
run: uv sync --extra test --no-install-project --index-strategy unsafe-best-match
|
||||
run: uv sync --frozen --extra test --no-install-project --index-strategy unsafe-best-match
|
||||
|
||||
- name: Cache HuggingFace models
|
||||
uses: actions/cache@v4
|
||||
@@ -413,11 +413,11 @@ jobs:
|
||||
|
||||
- name: Install client test dependencies
|
||||
working-directory: ./hindsight-clients/python
|
||||
run: uv sync --extra test --index-strategy unsafe-best-match
|
||||
run: uv sync --frozen --extra test --index-strategy unsafe-best-match
|
||||
|
||||
- name: Install API dependencies
|
||||
working-directory: ./hindsight-api
|
||||
run: uv sync --no-install-project --index-strategy unsafe-best-match
|
||||
run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match
|
||||
|
||||
- name: Create .env file
|
||||
run: |
|
||||
@@ -490,7 +490,7 @@ jobs:
|
||||
|
||||
- name: Install API dependencies
|
||||
working-directory: ./hindsight-api
|
||||
run: uv sync --no-install-project --index-strategy unsafe-best-match
|
||||
run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match
|
||||
|
||||
- name: Install TypeScript client dependencies
|
||||
working-directory: ./hindsight-clients/typescript
|
||||
@@ -578,7 +578,7 @@ jobs:
|
||||
|
||||
- name: Install API dependencies
|
||||
working-directory: ./hindsight-api
|
||||
run: uv sync --no-install-project --index-strategy unsafe-best-match
|
||||
run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match
|
||||
|
||||
- name: Create .env file
|
||||
run: |
|
||||
@@ -645,11 +645,11 @@ jobs:
|
||||
|
||||
- name: Install API dependencies
|
||||
working-directory: ./hindsight-api
|
||||
run: uv sync --no-install-project --index-strategy unsafe-best-match
|
||||
run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match
|
||||
|
||||
- name: Install integration test dependencies
|
||||
working-directory: ./hindsight-integration-tests
|
||||
run: uv sync
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Cache HuggingFace models
|
||||
uses: actions/cache@v4
|
||||
@@ -729,7 +729,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/litellm
|
||||
run: uv sync --extra dev
|
||||
run: uv sync --frozen --extra dev
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/litellm
|
||||
@@ -760,7 +760,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-embed
|
||||
run: uv sync --index-strategy unsafe-best-match
|
||||
run: uv sync --frozen --index-strategy unsafe-best-match
|
||||
|
||||
- name: Cache HuggingFace models
|
||||
uses: actions/cache@v4
|
||||
@@ -820,11 +820,11 @@ jobs:
|
||||
working-directory: ./hindsight-api
|
||||
run: |
|
||||
uv build
|
||||
uv sync --no-install-project --index-strategy unsafe-best-match
|
||||
uv sync --frozen --no-install-project --index-strategy unsafe-best-match
|
||||
|
||||
- name: Install Python client dependencies
|
||||
working-directory: ./hindsight-clients/python
|
||||
run: uv sync --extra test --index-strategy unsafe-best-match
|
||||
run: uv sync --frozen --extra test --index-strategy unsafe-best-match
|
||||
|
||||
- name: Install TypeScript client
|
||||
run: |
|
||||
@@ -928,9 +928,9 @@ jobs:
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
cd hindsight-dev && uv sync --index-strategy unsafe-best-match
|
||||
cd ../hindsight-api && uv sync --index-strategy unsafe-best-match
|
||||
cd ../hindsight-embed && uv sync --index-strategy unsafe-best-match
|
||||
cd hindsight-dev && uv sync --frozen --index-strategy unsafe-best-match
|
||||
cd ../hindsight-api && uv sync --frozen --index-strategy unsafe-best-match
|
||||
cd ../hindsight-embed && uv sync --frozen --index-strategy unsafe-best-match
|
||||
|
||||
- name: Run generate-openapi
|
||||
run: ./scripts/generate-openapi.sh
|
||||
|
||||
@@ -27,6 +27,10 @@ docker-compose.override.yml
|
||||
# NLTK data (will be downloaded automatically)
|
||||
nltk_data/
|
||||
|
||||
# Monitoring stack (Prometheus/Grafana binaries and data)
|
||||
.monitoring/
|
||||
.pgbouncer
|
||||
|
||||
# Large benchmark datasets (will be downloaded automatically)
|
||||
**/longmemeval_s_cleaned.json
|
||||
|
||||
|
||||
@@ -108,6 +108,52 @@ PostgreSQL with pgvector. Schema managed via Alembic migrations in `hindsight-ap
|
||||
|
||||
Key tables: `banks`, `memory_units`, `documents`, `entities`, `entity_links`
|
||||
|
||||
### Adding Database Migrations
|
||||
|
||||
1. **Create a new migration file** in `hindsight-api/hindsight_api/alembic/versions/`:
|
||||
- File name format: `<revision_id>_<description>.py` (e.g., `f1a2b3c4d5e6_add_new_index.py`)
|
||||
- Use a unique hex revision ID (12 chars)
|
||||
- Set `down_revision` to the previous migration's revision ID
|
||||
|
||||
2. **Migration template**:
|
||||
```python
|
||||
"""Description of the migration
|
||||
|
||||
Revision ID: f1a2b3c4d5e6
|
||||
Revises: <previous_revision_id>
|
||||
Create Date: YYYY-MM-DD
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
from alembic import context, op
|
||||
|
||||
revision: str = "f1a2b3c4d5e6"
|
||||
down_revision: str | Sequence[str] | None = "<previous_revision_id>"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Get schema prefix for table names (required for multi-tenant support)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
def upgrade() -> None:
|
||||
schema = _get_schema_prefix()
|
||||
op.execute(f"CREATE INDEX ... ON {schema}table_name(...)")
|
||||
|
||||
def downgrade() -> None:
|
||||
schema = _get_schema_prefix()
|
||||
op.execute(f"DROP INDEX IF EXISTS {schema}index_name")
|
||||
```
|
||||
|
||||
3. **Run migrations locally**:
|
||||
```bash
|
||||
# Set database URL and run migrations
|
||||
uv run hindsight-admin run-db-migration
|
||||
|
||||
# Run on a specific tenant schema
|
||||
uv run hindsight-admin run-db-migration --schema tenant_xyz
|
||||
```
|
||||
|
||||
## Key Conventions
|
||||
|
||||
### Code Quality
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
"""add_memory_links_from_type_weight_index
|
||||
|
||||
Revision ID: f1a2b3c4d5e6
|
||||
Revises: e0a1b2c3d4e5
|
||||
Create Date: 2025-01-12
|
||||
|
||||
Add composite index on memory_links (from_unit_id, link_type, weight DESC)
|
||||
to optimize MPFP graph traversal queries that need top-k edges per type.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "f1a2b3c4d5e6"
|
||||
down_revision: str | Sequence[str] | None = "e0a1b2c3d4e5"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Get schema prefix for table names (e.g., 'tenant_x.' or '' for public)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add composite index for efficient MPFP edge loading."""
|
||||
schema = _get_schema_prefix()
|
||||
# Create composite index for efficient top-k per (from_node, link_type) queries
|
||||
# This enables LATERAL joins to use index-only scans with early termination
|
||||
# Note: Not using CONCURRENTLY here as it requires running outside a transaction
|
||||
# For production with large tables, consider running this manually with CONCURRENTLY
|
||||
op.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS idx_memory_links_from_type_weight "
|
||||
f"ON {schema}memory_links(from_unit_id, link_type, weight DESC)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove the composite index."""
|
||||
schema = _get_schema_prefix()
|
||||
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_links_from_type_weight")
|
||||
@@ -188,12 +188,18 @@ class EntityListResponse(BaseModel):
|
||||
"first_seen": "2024-01-15T10:30:00Z",
|
||||
"last_seen": "2024-02-01T14:00:00Z",
|
||||
}
|
||||
]
|
||||
],
|
||||
"total": 150,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
items: list[EntityListItem]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class EntityDetailResponse(BaseModel):
|
||||
@@ -957,6 +963,12 @@ def create_app(
|
||||
await memory.initialize()
|
||||
logging.info("Memory system initialized")
|
||||
|
||||
# Set up DB pool metrics after memory initialization
|
||||
metrics_collector = get_metrics_collector()
|
||||
if memory._pool is not None and hasattr(metrics_collector, "set_db_pool"):
|
||||
metrics_collector.set_db_pool(memory._pool)
|
||||
logging.info("DB pool metrics configured")
|
||||
|
||||
# Call HTTP extension startup hook
|
||||
if http_extension:
|
||||
await http_extension.on_startup()
|
||||
@@ -993,6 +1005,30 @@ def create_app(
|
||||
# This is required for mounted sub-applications where lifespan may not fire
|
||||
app.state.memory = memory
|
||||
|
||||
# Add HTTP metrics middleware
|
||||
@app.middleware("http")
|
||||
async def http_metrics_middleware(request, call_next):
|
||||
"""Record HTTP request metrics."""
|
||||
# Normalize endpoint path to reduce cardinality
|
||||
# Replace UUIDs and numeric IDs with placeholders
|
||||
import re
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
path = request.url.path
|
||||
# Replace UUIDs
|
||||
path = re.sub(r"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "/{id}", path)
|
||||
# Replace numeric IDs
|
||||
path = re.sub(r"/\d+(?=/|$)", "/{id}", path)
|
||||
|
||||
status_code = [500] # Default to 500, will be updated
|
||||
metrics_collector = get_metrics_collector()
|
||||
|
||||
with metrics_collector.record_http_request(request.method, path, lambda: status_code[0]):
|
||||
response = await call_next(request)
|
||||
status_code[0] = response.status_code
|
||||
return response
|
||||
|
||||
# Register all routes
|
||||
_register_routes(app)
|
||||
|
||||
@@ -1160,6 +1196,9 @@ def _register_routes(app: FastAPI):
|
||||
bank_id: str, request: RecallRequest, request_context: RequestContext = Depends(get_request_context)
|
||||
):
|
||||
"""Run a recall and return results with trace."""
|
||||
import time
|
||||
|
||||
handler_start = time.time()
|
||||
metrics = get_metrics_collector()
|
||||
|
||||
try:
|
||||
@@ -1185,10 +1224,12 @@ def _register_routes(app: FastAPI):
|
||||
include_chunks = request.include.chunks is not None
|
||||
max_chunk_tokens = request.include.chunks.max_tokens if include_chunks else 8192
|
||||
|
||||
pre_recall = time.time() - handler_start
|
||||
# Run recall with tracing (record metrics)
|
||||
with metrics.record_operation(
|
||||
"recall", bank_id=bank_id, source="api", budget=request.budget.value, max_tokens=request.max_tokens
|
||||
):
|
||||
recall_start = time.time()
|
||||
core_result = await app.state.memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=request.query,
|
||||
@@ -1247,9 +1288,21 @@ def _register_routes(app: FastAPI):
|
||||
],
|
||||
)
|
||||
|
||||
return RecallResponse(
|
||||
response = RecallResponse(
|
||||
results=recall_results, trace=core_result.trace, entities=entities_response, chunks=chunks_response
|
||||
)
|
||||
|
||||
handler_duration = time.time() - handler_start
|
||||
recall_duration = time.time() - recall_start
|
||||
post_recall = handler_duration - pre_recall - recall_duration
|
||||
if handler_duration > 1.0:
|
||||
logging.info(
|
||||
f"[RECALL HTTP] bank={bank_id} handler_total={handler_duration:.3f}s "
|
||||
f"pre={pre_recall:.3f}s recall={recall_duration:.3f}s post={post_recall:.3f}s "
|
||||
f"results={len(recall_results)} entities={len(entities_response) if entities_response else 0}"
|
||||
)
|
||||
|
||||
return response
|
||||
except HTTPException:
|
||||
raise
|
||||
except OperationValidationError as e:
|
||||
@@ -1259,8 +1312,11 @@ def _register_routes(app: FastAPI):
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
handler_duration = time.time() - handler_start
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in /v1/default/banks/{bank_id}/memories/recall: {error_detail}")
|
||||
logger.error(
|
||||
f"[RECALL ERROR] bank={bank_id} handler_duration={handler_duration:.3f}s error={str(e)}\n{error_detail}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post(
|
||||
@@ -1486,19 +1542,27 @@ def _register_routes(app: FastAPI):
|
||||
"/v1/default/banks/{bank_id}/entities",
|
||||
response_model=EntityListResponse,
|
||||
summary="List entities",
|
||||
description="List all entities (people, organizations, etc.) known by the bank, ordered by mention count.",
|
||||
description="List all entities (people, organizations, etc.) known by the bank, ordered by mention count. Supports pagination.",
|
||||
operation_id="list_entities",
|
||||
tags=["Entities"],
|
||||
)
|
||||
async def api_list_entities(
|
||||
bank_id: str,
|
||||
limit: int = Query(default=100, description="Maximum number of entities to return"),
|
||||
offset: int = Query(default=0, description="Offset for pagination"),
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""List entities for a memory bank."""
|
||||
"""List entities for a memory bank with pagination."""
|
||||
try:
|
||||
entities = await app.state.memory.list_entities(bank_id, limit=limit, request_context=request_context)
|
||||
return EntityListResponse(items=[EntityListItem(**e) for e in entities])
|
||||
data = await app.state.memory.list_entities(
|
||||
bank_id, limit=limit, offset=offset, request_context=request_context
|
||||
)
|
||||
return EntityListResponse(
|
||||
items=[EntityListItem(**e) for e in data["items"]],
|
||||
total=data["total"],
|
||||
limit=data["limit"],
|
||||
offset=data["offset"],
|
||||
)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@@ -48,13 +48,23 @@ ENV_RERANKER_COHERE_MODEL = "HINDSIGHT_API_RERANKER_COHERE_MODEL"
|
||||
|
||||
ENV_RERANKER_PROVIDER = "HINDSIGHT_API_RERANKER_PROVIDER"
|
||||
ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL"
|
||||
ENV_RERANKER_LOCAL_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT"
|
||||
ENV_RERANKER_TEI_URL = "HINDSIGHT_API_RERANKER_TEI_URL"
|
||||
ENV_RERANKER_TEI_BATCH_SIZE = "HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE"
|
||||
ENV_RERANKER_TEI_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT"
|
||||
ENV_RERANKER_MAX_CANDIDATES = "HINDSIGHT_API_RERANKER_MAX_CANDIDATES"
|
||||
ENV_RERANKER_FLASHRANK_MODEL = "HINDSIGHT_API_RERANKER_FLASHRANK_MODEL"
|
||||
ENV_RERANKER_FLASHRANK_CACHE_DIR = "HINDSIGHT_API_RERANKER_FLASHRANK_CACHE_DIR"
|
||||
|
||||
ENV_HOST = "HINDSIGHT_API_HOST"
|
||||
ENV_PORT = "HINDSIGHT_API_PORT"
|
||||
ENV_LOG_LEVEL = "HINDSIGHT_API_LOG_LEVEL"
|
||||
ENV_WORKERS = "HINDSIGHT_API_WORKERS"
|
||||
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
|
||||
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
|
||||
ENV_MPFP_TOP_K_NEIGHBORS = "HINDSIGHT_API_MPFP_TOP_K_NEIGHBORS"
|
||||
ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
|
||||
ENV_RECALL_CONNECTION_BUDGET = "HINDSIGHT_API_RECALL_CONNECTION_BUDGET"
|
||||
ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
|
||||
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
|
||||
|
||||
@@ -66,6 +76,8 @@ ENV_OBSERVATION_TOP_ENTITIES = "HINDSIGHT_API_OBSERVATION_TOP_ENTITIES"
|
||||
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
|
||||
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
|
||||
ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
|
||||
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
|
||||
ENV_RETAIN_OBSERVATIONS_ASYNC = "HINDSIGHT_API_RETAIN_OBSERVATIONS_ASYNC"
|
||||
|
||||
# Optimization flags
|
||||
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
|
||||
@@ -81,8 +93,9 @@ ENV_DB_COMMAND_TIMEOUT = "HINDSIGHT_API_DB_COMMAND_TIMEOUT"
|
||||
ENV_DB_ACQUIRE_TIMEOUT = "HINDSIGHT_API_DB_ACQUIRE_TIMEOUT"
|
||||
|
||||
# Background task processing
|
||||
ENV_TASK_BATCH_SIZE = "HINDSIGHT_API_TASK_BATCH_SIZE"
|
||||
ENV_TASK_BATCH_INTERVAL = "HINDSIGHT_API_TASK_BATCH_INTERVAL"
|
||||
ENV_TASK_BACKEND = "HINDSIGHT_API_TASK_BACKEND"
|
||||
ENV_TASK_BACKEND_MEMORY_BATCH_SIZE = "HINDSIGHT_API_TASK_BACKEND_MEMORY_BATCH_SIZE"
|
||||
ENV_TASK_BACKEND_MEMORY_BATCH_INTERVAL = "HINDSIGHT_API_TASK_BACKEND_MEMORY_BATCH_INTERVAL"
|
||||
|
||||
# Default values
|
||||
DEFAULT_DATABASE_URL = "pg0"
|
||||
@@ -98,6 +111,12 @@ DEFAULT_EMBEDDING_DIMENSION = 384
|
||||
|
||||
DEFAULT_RERANKER_PROVIDER = "local"
|
||||
DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
||||
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4 # Limit concurrent CPU-bound reranking to prevent thrashing
|
||||
DEFAULT_RERANKER_TEI_BATCH_SIZE = 128
|
||||
DEFAULT_RERANKER_TEI_MAX_CONCURRENT = 8
|
||||
DEFAULT_RERANKER_MAX_CANDIDATES = 300
|
||||
DEFAULT_RERANKER_FLASHRANK_MODEL = "ms-marco-MiniLM-L-12-v2" # Best balance of speed and quality
|
||||
DEFAULT_RERANKER_FLASHRANK_CACHE_DIR = None # Use default cache directory
|
||||
|
||||
DEFAULT_EMBEDDINGS_COHERE_MODEL = "embed-english-v3.0"
|
||||
DEFAULT_RERANKER_COHERE_MODEL = "rerank-english-v3.0"
|
||||
@@ -105,8 +124,12 @@ DEFAULT_RERANKER_COHERE_MODEL = "rerank-english-v3.0"
|
||||
DEFAULT_HOST = "0.0.0.0"
|
||||
DEFAULT_PORT = 8888
|
||||
DEFAULT_LOG_LEVEL = "info"
|
||||
DEFAULT_WORKERS = 1
|
||||
DEFAULT_MCP_ENABLED = True
|
||||
DEFAULT_GRAPH_RETRIEVER = "bfs" # Options: "bfs", "mpfp"
|
||||
DEFAULT_GRAPH_RETRIEVER = "link_expansion" # Options: "link_expansion", "mpfp", "bfs"
|
||||
DEFAULT_MPFP_TOP_K_NEIGHBORS = 20 # Fan-out limit per node in MPFP graph traversal
|
||||
DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker
|
||||
DEFAULT_RECALL_CONNECTION_BUDGET = 4 # Max concurrent DB connections per recall operation
|
||||
DEFAULT_MCP_LOCAL_BANK_ID = "mcp"
|
||||
|
||||
# Observation thresholds
|
||||
@@ -117,6 +140,9 @@ DEFAULT_OBSERVATION_TOP_ENTITIES = 5 # Max entities to process per retain batch
|
||||
DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction LLM call
|
||||
DEFAULT_RETAIN_CHUNK_SIZE = 3000 # Max chars per chunk for fact extraction
|
||||
DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS = True # Extract causal links between facts
|
||||
DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise" or "verbose"
|
||||
RETAIN_EXTRACTION_MODES = ("concise", "verbose") # Allowed extraction modes
|
||||
DEFAULT_RETAIN_OBSERVATIONS_ASYNC = False # Run observation generation async (after retain completes)
|
||||
|
||||
# Database migrations
|
||||
DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True
|
||||
@@ -128,8 +154,9 @@ DEFAULT_DB_COMMAND_TIMEOUT = 60 # seconds
|
||||
DEFAULT_DB_ACQUIRE_TIMEOUT = 30 # seconds
|
||||
|
||||
# Background task processing
|
||||
DEFAULT_TASK_BATCH_SIZE = 10
|
||||
DEFAULT_TASK_BATCH_INTERVAL = 1.0 # seconds
|
||||
DEFAULT_TASK_BACKEND = "memory" # Options: "memory", "noop"
|
||||
DEFAULT_TASK_BACKEND_MEMORY_BATCH_SIZE = 10
|
||||
DEFAULT_TASK_BACKEND_MEMORY_BATCH_INTERVAL = 1.0 # seconds
|
||||
|
||||
# Default MCP tool descriptions (can be customized via env vars)
|
||||
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
|
||||
@@ -155,6 +182,18 @@ Use this tool PROACTIVELY to:
|
||||
EMBEDDING_DIMENSION = DEFAULT_EMBEDDING_DIMENSION
|
||||
|
||||
|
||||
def _validate_extraction_mode(mode: str) -> str:
|
||||
"""Validate and normalize extraction mode."""
|
||||
mode_lower = mode.lower()
|
||||
if mode_lower not in RETAIN_EXTRACTION_MODES:
|
||||
logger.warning(
|
||||
f"Invalid extraction mode '{mode}', must be one of {RETAIN_EXTRACTION_MODES}. "
|
||||
f"Defaulting to '{DEFAULT_RETAIN_EXTRACTION_MODE}'."
|
||||
)
|
||||
return DEFAULT_RETAIN_EXTRACTION_MODE
|
||||
return mode_lower
|
||||
|
||||
|
||||
@dataclass
|
||||
class HindsightConfig:
|
||||
"""Configuration container for Hindsight API."""
|
||||
@@ -190,6 +229,9 @@ class HindsightConfig:
|
||||
reranker_provider: str
|
||||
reranker_local_model: str
|
||||
reranker_tei_url: str | None
|
||||
reranker_tei_batch_size: int
|
||||
reranker_tei_max_concurrent: int
|
||||
reranker_max_candidates: int
|
||||
|
||||
# Server
|
||||
host: str
|
||||
@@ -199,6 +241,9 @@ class HindsightConfig:
|
||||
|
||||
# Recall
|
||||
graph_retriever: str
|
||||
mpfp_top_k_neighbors: int
|
||||
recall_max_concurrent: int
|
||||
recall_connection_budget: int
|
||||
|
||||
# Observation thresholds
|
||||
observation_min_facts: int
|
||||
@@ -208,6 +253,8 @@ class HindsightConfig:
|
||||
retain_max_completion_tokens: int
|
||||
retain_chunk_size: int
|
||||
retain_extract_causal_links: bool
|
||||
retain_extraction_mode: str
|
||||
retain_observations_async: bool
|
||||
|
||||
# Optimization flags
|
||||
skip_llm_verification: bool
|
||||
@@ -223,8 +270,9 @@ class HindsightConfig:
|
||||
db_acquire_timeout: int
|
||||
|
||||
# Background task processing
|
||||
task_batch_size: int
|
||||
task_batch_interval: float
|
||||
task_backend: str
|
||||
task_backend_memory_batch_size: int
|
||||
task_backend_memory_batch_interval: float
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "HindsightConfig":
|
||||
@@ -256,6 +304,11 @@ class HindsightConfig:
|
||||
reranker_provider=os.getenv(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER),
|
||||
reranker_local_model=os.getenv(ENV_RERANKER_LOCAL_MODEL, DEFAULT_RERANKER_LOCAL_MODEL),
|
||||
reranker_tei_url=os.getenv(ENV_RERANKER_TEI_URL),
|
||||
reranker_tei_batch_size=int(os.getenv(ENV_RERANKER_TEI_BATCH_SIZE, str(DEFAULT_RERANKER_TEI_BATCH_SIZE))),
|
||||
reranker_tei_max_concurrent=int(
|
||||
os.getenv(ENV_RERANKER_TEI_MAX_CONCURRENT, str(DEFAULT_RERANKER_TEI_MAX_CONCURRENT))
|
||||
),
|
||||
reranker_max_candidates=int(os.getenv(ENV_RERANKER_MAX_CANDIDATES, str(DEFAULT_RERANKER_MAX_CANDIDATES))),
|
||||
# Server
|
||||
host=os.getenv(ENV_HOST, DEFAULT_HOST),
|
||||
port=int(os.getenv(ENV_PORT, DEFAULT_PORT)),
|
||||
@@ -263,6 +316,11 @@ class HindsightConfig:
|
||||
mcp_enabled=os.getenv(ENV_MCP_ENABLED, str(DEFAULT_MCP_ENABLED)).lower() == "true",
|
||||
# Recall
|
||||
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
|
||||
mpfp_top_k_neighbors=int(os.getenv(ENV_MPFP_TOP_K_NEIGHBORS, str(DEFAULT_MPFP_TOP_K_NEIGHBORS))),
|
||||
recall_max_concurrent=int(os.getenv(ENV_RECALL_MAX_CONCURRENT, str(DEFAULT_RECALL_MAX_CONCURRENT))),
|
||||
recall_connection_budget=int(
|
||||
os.getenv(ENV_RECALL_CONNECTION_BUDGET, str(DEFAULT_RECALL_CONNECTION_BUDGET))
|
||||
),
|
||||
# Optimization flags
|
||||
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
|
||||
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
|
||||
@@ -280,6 +338,13 @@ class HindsightConfig:
|
||||
ENV_RETAIN_EXTRACT_CAUSAL_LINKS, str(DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS)
|
||||
).lower()
|
||||
== "true",
|
||||
retain_extraction_mode=_validate_extraction_mode(
|
||||
os.getenv(ENV_RETAIN_EXTRACTION_MODE, DEFAULT_RETAIN_EXTRACTION_MODE)
|
||||
),
|
||||
retain_observations_async=os.getenv(
|
||||
ENV_RETAIN_OBSERVATIONS_ASYNC, str(DEFAULT_RETAIN_OBSERVATIONS_ASYNC)
|
||||
).lower()
|
||||
== "true",
|
||||
# Database migrations
|
||||
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
|
||||
# Database connection pool
|
||||
@@ -288,8 +353,13 @@ class HindsightConfig:
|
||||
db_command_timeout=int(os.getenv(ENV_DB_COMMAND_TIMEOUT, str(DEFAULT_DB_COMMAND_TIMEOUT))),
|
||||
db_acquire_timeout=int(os.getenv(ENV_DB_ACQUIRE_TIMEOUT, str(DEFAULT_DB_ACQUIRE_TIMEOUT))),
|
||||
# Background task processing
|
||||
task_batch_size=int(os.getenv(ENV_TASK_BATCH_SIZE, str(DEFAULT_TASK_BATCH_SIZE))),
|
||||
task_batch_interval=float(os.getenv(ENV_TASK_BATCH_INTERVAL, str(DEFAULT_TASK_BATCH_INTERVAL))),
|
||||
task_backend=os.getenv(ENV_TASK_BACKEND, DEFAULT_TASK_BACKEND),
|
||||
task_backend_memory_batch_size=int(
|
||||
os.getenv(ENV_TASK_BACKEND_MEMORY_BATCH_SIZE, str(DEFAULT_TASK_BACKEND_MEMORY_BATCH_SIZE))
|
||||
),
|
||||
task_backend_memory_batch_interval=float(
|
||||
os.getenv(ENV_TASK_BACKEND_MEMORY_BATCH_INTERVAL, str(DEFAULT_TASK_BACKEND_MEMORY_BATCH_INTERVAL))
|
||||
),
|
||||
)
|
||||
|
||||
def get_llm_base_url(self) -> str:
|
||||
|
||||
@@ -6,20 +6,32 @@ Provides an interface for reranking with different backends.
|
||||
Configuration via environment variables - see hindsight_api.config for all env var names.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import (
|
||||
DEFAULT_RERANKER_COHERE_MODEL,
|
||||
DEFAULT_RERANKER_FLASHRANK_CACHE_DIR,
|
||||
DEFAULT_RERANKER_FLASHRANK_MODEL,
|
||||
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
|
||||
DEFAULT_RERANKER_LOCAL_MODEL,
|
||||
DEFAULT_RERANKER_PROVIDER,
|
||||
DEFAULT_RERANKER_TEI_BATCH_SIZE,
|
||||
DEFAULT_RERANKER_TEI_MAX_CONCURRENT,
|
||||
ENV_COHERE_API_KEY,
|
||||
ENV_RERANKER_COHERE_MODEL,
|
||||
ENV_RERANKER_FLASHRANK_CACHE_DIR,
|
||||
ENV_RERANKER_FLASHRANK_MODEL,
|
||||
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
|
||||
ENV_RERANKER_LOCAL_MODEL,
|
||||
ENV_RERANKER_PROVIDER,
|
||||
ENV_RERANKER_TEI_BATCH_SIZE,
|
||||
ENV_RERANKER_TEI_MAX_CONCURRENT,
|
||||
ENV_RERANKER_TEI_URL,
|
||||
)
|
||||
|
||||
@@ -50,7 +62,7 @@ class CrossEncoderModel(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""
|
||||
Score query-document pairs for relevance.
|
||||
|
||||
@@ -73,25 +85,34 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
- Fast inference (~80ms for 100 pairs on CPU)
|
||||
- Small model (80MB)
|
||||
- Trained for passage re-ranking
|
||||
|
||||
Uses a dedicated thread pool to limit concurrent CPU-bound work.
|
||||
"""
|
||||
|
||||
def __init__(self, model_name: str | None = None):
|
||||
# Shared executor across all instances (one model loaded anyway)
|
||||
_executor: ThreadPoolExecutor | None = None
|
||||
_max_concurrent: int = 4 # Limit concurrent CPU-bound reranking calls
|
||||
|
||||
def __init__(self, model_name: str | None = None, max_concurrent: int = 4):
|
||||
"""
|
||||
Initialize local SentenceTransformers cross-encoder.
|
||||
|
||||
Args:
|
||||
model_name: Name of the CrossEncoder model to use.
|
||||
Default: cross-encoder/ms-marco-MiniLM-L-6-v2
|
||||
max_concurrent: Maximum concurrent reranking calls (default: 2).
|
||||
Higher values may cause CPU thrashing under load.
|
||||
"""
|
||||
self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL
|
||||
self._model = None
|
||||
LocalSTCrossEncoder._max_concurrent = max_concurrent
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "local"
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Load the cross-encoder model."""
|
||||
"""Load the cross-encoder model and initialize the executor."""
|
||||
if self._model is not None:
|
||||
return
|
||||
|
||||
@@ -103,14 +124,30 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
"Install it with: pip install sentence-transformers"
|
||||
)
|
||||
|
||||
# Note: We use CPU even when GPU/MPS is available because:
|
||||
# 1. The reranker model (MiniLM) is tiny (~22M params)
|
||||
# 2. Batch sizes are small (~100-200 pairs)
|
||||
# 3. Data transfer overhead to GPU outweighs compute benefit
|
||||
# 4. CPU inference is actually faster for this workload
|
||||
logger.info(f"Reranker: initializing local provider with model {self.model_name}")
|
||||
self._model = CrossEncoder(self.model_name)
|
||||
logger.info("Reranker: local provider initialized")
|
||||
|
||||
def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
# Initialize shared executor (limited workers naturally limits concurrency)
|
||||
if LocalSTCrossEncoder._executor is None:
|
||||
LocalSTCrossEncoder._executor = ThreadPoolExecutor(
|
||||
max_workers=LocalSTCrossEncoder._max_concurrent,
|
||||
thread_name_prefix="reranker",
|
||||
)
|
||||
logger.info(f"Reranker: local provider initialized (max_concurrent={LocalSTCrossEncoder._max_concurrent})")
|
||||
else:
|
||||
logger.info("Reranker: local provider initialized (using existing executor)")
|
||||
|
||||
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""
|
||||
Score query-document pairs for relevance.
|
||||
|
||||
Uses a dedicated thread pool with limited workers to prevent CPU thrashing.
|
||||
|
||||
Args:
|
||||
pairs: List of (query, document) tuples to score
|
||||
|
||||
@@ -119,7 +156,13 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
"""
|
||||
if self._model is None:
|
||||
raise RuntimeError("Reranker not initialized. Call initialize() first.")
|
||||
scores = self._model.predict(pairs, show_progress_bar=False)
|
||||
|
||||
# Use dedicated executor - limited workers naturally limits concurrency
|
||||
loop = asyncio.get_event_loop()
|
||||
scores = await loop.run_in_executor(
|
||||
LocalSTCrossEncoder._executor,
|
||||
lambda: self._model.predict(pairs, show_progress_bar=False),
|
||||
)
|
||||
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
|
||||
|
||||
|
||||
@@ -131,13 +174,21 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
|
||||
See: https://github.com/huggingface/text-embeddings-inference
|
||||
|
||||
Note: The TEI server must be running a cross-encoder/reranker model.
|
||||
|
||||
Requests are made in parallel with configurable batch size and max concurrency (backpressure).
|
||||
Uses a GLOBAL semaphore to limit concurrent requests across ALL recall operations.
|
||||
"""
|
||||
|
||||
# Global semaphore shared across all instances and calls to prevent thundering herd
|
||||
_global_semaphore: asyncio.Semaphore | None = None
|
||||
_global_max_concurrent: int = DEFAULT_RERANKER_TEI_MAX_CONCURRENT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
timeout: float = 30.0,
|
||||
batch_size: int = 32,
|
||||
batch_size: int = DEFAULT_RERANKER_TEI_BATCH_SIZE,
|
||||
max_concurrent: int = DEFAULT_RERANKER_TEI_MAX_CONCURRENT,
|
||||
max_retries: int = 3,
|
||||
retry_delay: float = 0.5,
|
||||
):
|
||||
@@ -147,138 +198,187 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
|
||||
Args:
|
||||
base_url: Base URL of the TEI server (e.g., "http://localhost:8080")
|
||||
timeout: Request timeout in seconds (default: 30.0)
|
||||
batch_size: Maximum batch size for rerank requests (default: 32)
|
||||
batch_size: Maximum batch size for rerank requests (default: 128)
|
||||
max_concurrent: Maximum concurrent requests for backpressure (default: 8).
|
||||
This is a GLOBAL limit across all parallel recall operations.
|
||||
max_retries: Maximum number of retries for failed requests (default: 3)
|
||||
retry_delay: Initial delay between retries in seconds, doubles each retry (default: 0.5)
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.batch_size = batch_size
|
||||
self.max_concurrent = max_concurrent
|
||||
self.max_retries = max_retries
|
||||
self.retry_delay = retry_delay
|
||||
self._client: httpx.Client | None = None
|
||||
self._async_client: httpx.AsyncClient | None = None
|
||||
self._model_id: str | None = None
|
||||
|
||||
# Update global semaphore if max_concurrent changed
|
||||
if (
|
||||
RemoteTEICrossEncoder._global_semaphore is None
|
||||
or RemoteTEICrossEncoder._global_max_concurrent != max_concurrent
|
||||
):
|
||||
RemoteTEICrossEncoder._global_max_concurrent = max_concurrent
|
||||
RemoteTEICrossEncoder._global_semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "tei"
|
||||
|
||||
def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response:
|
||||
"""Make an HTTP request with automatic retries on transient errors."""
|
||||
import time
|
||||
|
||||
async def _async_request_with_retry(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
semaphore: asyncio.Semaphore,
|
||||
method: str,
|
||||
url: str,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Make an async HTTP request with automatic retries on transient errors and semaphore for backpressure."""
|
||||
last_error = None
|
||||
delay = self.retry_delay
|
||||
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
if method == "GET":
|
||||
response = self._client.get(url, **kwargs)
|
||||
else:
|
||||
response = self._client.post(url, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as e:
|
||||
last_error = e
|
||||
if attempt < self.max_retries:
|
||||
logger.warning(
|
||||
f"TEI request failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s..."
|
||||
)
|
||||
time.sleep(delay)
|
||||
delay *= 2 # Exponential backoff
|
||||
except httpx.HTTPStatusError as e:
|
||||
# Retry on 5xx server errors
|
||||
if e.response.status_code >= 500 and attempt < self.max_retries:
|
||||
async with semaphore:
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
if method == "GET":
|
||||
response = await client.get(url, **kwargs)
|
||||
else:
|
||||
response = await client.post(url, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as e:
|
||||
last_error = e
|
||||
logger.warning(
|
||||
f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s..."
|
||||
)
|
||||
time.sleep(delay)
|
||||
delay *= 2
|
||||
else:
|
||||
raise
|
||||
if attempt < self.max_retries:
|
||||
logger.warning(
|
||||
f"TEI request failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. "
|
||||
f"Retrying in {delay}s..."
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2 # Exponential backoff
|
||||
except httpx.HTTPStatusError as e:
|
||||
# Retry on 5xx server errors
|
||||
if e.response.status_code >= 500 and attempt < self.max_retries:
|
||||
last_error = e
|
||||
logger.warning(
|
||||
f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. "
|
||||
f"Retrying in {delay}s..."
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
else:
|
||||
raise
|
||||
|
||||
raise last_error
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the HTTP client and verify server connectivity."""
|
||||
if self._client is not None:
|
||||
if self._async_client is not None:
|
||||
return
|
||||
|
||||
logger.info(f"Reranker: initializing TEI provider at {self.base_url}")
|
||||
self._client = httpx.Client(timeout=self.timeout)
|
||||
logger.info(
|
||||
f"Reranker: initializing TEI provider at {self.base_url} "
|
||||
f"(batch_size={self.batch_size}, max_concurrent={self.max_concurrent})"
|
||||
)
|
||||
self._async_client = httpx.AsyncClient(timeout=self.timeout)
|
||||
|
||||
# Verify server is reachable and get model info
|
||||
# Use a temporary semaphore for initialization
|
||||
init_semaphore = asyncio.Semaphore(1)
|
||||
try:
|
||||
response = self._request_with_retry("GET", f"{self.base_url}/info")
|
||||
response = await self._async_request_with_retry(
|
||||
self._async_client, init_semaphore, "GET", f"{self.base_url}/info"
|
||||
)
|
||||
info = response.json()
|
||||
self._model_id = info.get("model_id", "unknown")
|
||||
logger.info(f"Reranker: TEI provider initialized (model: {self._model_id})")
|
||||
except httpx.HTTPError as e:
|
||||
self._async_client = None
|
||||
raise RuntimeError(f"Failed to connect to TEI server at {self.base_url}: {e}")
|
||||
|
||||
def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
async def _rerank_query_group(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
semaphore: asyncio.Semaphore,
|
||||
query: str,
|
||||
texts: list[str],
|
||||
) -> list[tuple[int, float]]:
|
||||
"""Rerank a single query group and return list of (original_index, score) tuples."""
|
||||
try:
|
||||
response = await self._async_request_with_retry(
|
||||
client,
|
||||
semaphore,
|
||||
"POST",
|
||||
f"{self.base_url}/rerank",
|
||||
json={
|
||||
"query": query,
|
||||
"texts": texts,
|
||||
"return_text": False,
|
||||
},
|
||||
)
|
||||
results = response.json()
|
||||
# TEI returns results sorted by score descending, with original index
|
||||
return [(result["index"], result["score"]) for result in results]
|
||||
except httpx.HTTPError as e:
|
||||
raise RuntimeError(f"TEI rerank request failed: {e}")
|
||||
|
||||
async def _predict_async(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""Async implementation of predict that runs requests in parallel with backpressure."""
|
||||
if not pairs:
|
||||
return []
|
||||
|
||||
# Group all pairs by query
|
||||
query_groups: dict[str, list[tuple[int, str]]] = {}
|
||||
for idx, (query, text) in enumerate(pairs):
|
||||
if query not in query_groups:
|
||||
query_groups[query] = []
|
||||
query_groups[query].append((idx, text))
|
||||
|
||||
# Split each query group into batches
|
||||
tasks_info: list[tuple[str, list[int], list[str]]] = [] # (query, indices, texts)
|
||||
for query, indexed_texts in query_groups.items():
|
||||
indices = [idx for idx, _ in indexed_texts]
|
||||
texts = [text for _, text in indexed_texts]
|
||||
|
||||
# Split into batches
|
||||
for i in range(0, len(texts), self.batch_size):
|
||||
batch_indices = indices[i : i + self.batch_size]
|
||||
batch_texts = texts[i : i + self.batch_size]
|
||||
tasks_info.append((query, batch_indices, batch_texts))
|
||||
|
||||
# Run all requests in parallel with GLOBAL semaphore for backpressure
|
||||
# This ensures max_concurrent is respected across ALL parallel recall operations
|
||||
all_scores = [0.0] * len(pairs)
|
||||
semaphore = RemoteTEICrossEncoder._global_semaphore
|
||||
|
||||
tasks = [
|
||||
self._rerank_query_group(self._async_client, semaphore, query, texts) for query, _, texts in tasks_info
|
||||
]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# Map scores back to original positions
|
||||
for (_, indices, _), result_scores in zip(tasks_info, results):
|
||||
for original_idx_in_batch, score in result_scores:
|
||||
global_idx = indices[original_idx_in_batch]
|
||||
all_scores[global_idx] = score
|
||||
|
||||
return all_scores
|
||||
|
||||
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""
|
||||
Score query-document pairs using the remote TEI reranker.
|
||||
|
||||
Requests are made in parallel with configurable backpressure.
|
||||
|
||||
Args:
|
||||
pairs: List of (query, document) tuples to score
|
||||
|
||||
Returns:
|
||||
List of relevance scores
|
||||
"""
|
||||
if self._client is None:
|
||||
if self._async_client is None:
|
||||
raise RuntimeError("Reranker not initialized. Call initialize() first.")
|
||||
|
||||
if not pairs:
|
||||
return []
|
||||
|
||||
all_scores = []
|
||||
|
||||
# Process in batches
|
||||
for i in range(0, len(pairs), self.batch_size):
|
||||
batch = pairs[i : i + self.batch_size]
|
||||
|
||||
# TEI rerank endpoint expects query and texts separately
|
||||
# All pairs in a batch should have the same query for optimal performance
|
||||
# but we handle mixed queries by making separate requests per unique query
|
||||
query_groups: dict[str, list[tuple[int, str]]] = {}
|
||||
for idx, (query, text) in enumerate(batch):
|
||||
if query not in query_groups:
|
||||
query_groups[query] = []
|
||||
query_groups[query].append((idx, text))
|
||||
|
||||
batch_scores = [0.0] * len(batch)
|
||||
|
||||
for query, indexed_texts in query_groups.items():
|
||||
texts = [text for _, text in indexed_texts]
|
||||
indices = [idx for idx, _ in indexed_texts]
|
||||
|
||||
try:
|
||||
response = self._request_with_retry(
|
||||
"POST",
|
||||
f"{self.base_url}/rerank",
|
||||
json={
|
||||
"query": query,
|
||||
"texts": texts,
|
||||
"return_text": False,
|
||||
},
|
||||
)
|
||||
results = response.json()
|
||||
|
||||
# TEI returns results sorted by score descending, with original index
|
||||
for result in results:
|
||||
original_idx = result["index"]
|
||||
score = result["score"]
|
||||
# Map back to batch position
|
||||
batch_scores[indices[original_idx]] = score
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
raise RuntimeError(f"TEI rerank request failed: {e}")
|
||||
|
||||
all_scores.extend(batch_scores)
|
||||
|
||||
return all_scores
|
||||
return await self._predict_async(pairs)
|
||||
|
||||
|
||||
class CohereCrossEncoder(CrossEncoderModel):
|
||||
@@ -325,7 +425,7 @@ class CohereCrossEncoder(CrossEncoderModel):
|
||||
self._client = cohere.Client(api_key=self.api_key, timeout=self.timeout)
|
||||
logger.info("Reranker: Cohere provider initialized")
|
||||
|
||||
def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""
|
||||
Score query-document pairs using the Cohere Rerank API.
|
||||
|
||||
@@ -341,6 +441,12 @@ class CohereCrossEncoder(CrossEncoderModel):
|
||||
if not pairs:
|
||||
return []
|
||||
|
||||
# Run sync Cohere API calls in thread pool
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._predict_sync, pairs)
|
||||
|
||||
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""Synchronous predict implementation for Cohere API."""
|
||||
# Group pairs by query for efficient batching
|
||||
# Cohere rerank expects one query with multiple documents
|
||||
query_groups: dict[str, list[tuple[int, str]]] = {}
|
||||
@@ -371,6 +477,170 @@ class CohereCrossEncoder(CrossEncoderModel):
|
||||
return all_scores
|
||||
|
||||
|
||||
class RRFPassthroughCrossEncoder(CrossEncoderModel):
|
||||
"""
|
||||
Passthrough cross-encoder that preserves RRF scores without neural reranking.
|
||||
|
||||
This is useful for:
|
||||
- Testing retrieval quality without reranking overhead
|
||||
- Deployments where reranking latency is unacceptable
|
||||
- Debugging to isolate retrieval vs reranking issues
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize RRF passthrough cross-encoder."""
|
||||
pass
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "rrf"
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""No initialization needed."""
|
||||
logger.info("Reranker: RRF passthrough provider initialized (neural reranking disabled)")
|
||||
|
||||
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""
|
||||
Return neutral scores - actual ranking uses RRF scores from retrieval.
|
||||
|
||||
Args:
|
||||
pairs: List of (query, document) tuples (ignored)
|
||||
|
||||
Returns:
|
||||
List of 0.5 scores (neutral, lets RRF scores dominate)
|
||||
"""
|
||||
# Return neutral scores so RRF ranking is preserved
|
||||
return [0.5] * len(pairs)
|
||||
|
||||
|
||||
class FlashRankCrossEncoder(CrossEncoderModel):
|
||||
"""
|
||||
FlashRank cross-encoder implementation.
|
||||
|
||||
FlashRank is an ultra-lite reranking library that runs on CPU without
|
||||
requiring PyTorch or Transformers. It's ideal for serverless deployments
|
||||
with minimal cold-start overhead.
|
||||
|
||||
Available models:
|
||||
- ms-marco-TinyBERT-L-2-v2: Fastest, ~4MB
|
||||
- ms-marco-MiniLM-L-12-v2: Best quality, ~34MB (default)
|
||||
- rank-T5-flan: Best zero-shot, ~110MB
|
||||
- ms-marco-MultiBERT-L-12: Multi-lingual, ~150MB
|
||||
"""
|
||||
|
||||
# Shared executor for CPU-bound reranking
|
||||
_executor: ThreadPoolExecutor | None = None
|
||||
_max_concurrent: int = 4
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str | None = None,
|
||||
cache_dir: str | None = None,
|
||||
max_length: int = 512,
|
||||
max_concurrent: int = 4,
|
||||
):
|
||||
"""
|
||||
Initialize FlashRank cross-encoder.
|
||||
|
||||
Args:
|
||||
model_name: FlashRank model name. Default: ms-marco-MiniLM-L-12-v2
|
||||
cache_dir: Directory to cache downloaded models. Default: system cache
|
||||
max_length: Maximum sequence length for reranking. Default: 512
|
||||
max_concurrent: Maximum concurrent reranking calls. Default: 4
|
||||
"""
|
||||
self.model_name = model_name or DEFAULT_RERANKER_FLASHRANK_MODEL
|
||||
self.cache_dir = cache_dir or DEFAULT_RERANKER_FLASHRANK_CACHE_DIR
|
||||
self.max_length = max_length
|
||||
self._ranker = None
|
||||
FlashRankCrossEncoder._max_concurrent = max_concurrent
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "flashrank"
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Load the FlashRank model."""
|
||||
if self._ranker is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
from flashrank import Ranker # type: ignore[import-untyped]
|
||||
except ImportError:
|
||||
raise ImportError("flashrank is required for FlashRankCrossEncoder. Install it with: pip install flashrank")
|
||||
|
||||
logger.info(f"Reranker: initializing FlashRank provider with model {self.model_name}")
|
||||
|
||||
# Initialize ranker with optional cache directory
|
||||
ranker_kwargs = {"model_name": self.model_name, "max_length": self.max_length}
|
||||
if self.cache_dir:
|
||||
ranker_kwargs["cache_dir"] = self.cache_dir
|
||||
|
||||
self._ranker = Ranker(**ranker_kwargs)
|
||||
|
||||
# Initialize shared executor
|
||||
if FlashRankCrossEncoder._executor is None:
|
||||
FlashRankCrossEncoder._executor = ThreadPoolExecutor(
|
||||
max_workers=FlashRankCrossEncoder._max_concurrent,
|
||||
thread_name_prefix="flashrank",
|
||||
)
|
||||
logger.info(
|
||||
f"Reranker: FlashRank provider initialized (max_concurrent={FlashRankCrossEncoder._max_concurrent})"
|
||||
)
|
||||
else:
|
||||
logger.info("Reranker: FlashRank provider initialized (using existing executor)")
|
||||
|
||||
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""Synchronous predict - processes each query group."""
|
||||
from flashrank import RerankRequest # type: ignore[import-untyped]
|
||||
|
||||
if not pairs:
|
||||
return []
|
||||
|
||||
# Group pairs by query
|
||||
query_groups: dict[str, list[tuple[int, str]]] = {}
|
||||
for idx, (query, text) in enumerate(pairs):
|
||||
if query not in query_groups:
|
||||
query_groups[query] = []
|
||||
query_groups[query].append((idx, text))
|
||||
|
||||
all_scores = [0.0] * len(pairs)
|
||||
|
||||
for query, indexed_texts in query_groups.items():
|
||||
# Build passages list for FlashRank
|
||||
passages = [{"id": i, "text": text} for i, (_, text) in enumerate(indexed_texts)]
|
||||
global_indices = [idx for idx, _ in indexed_texts]
|
||||
|
||||
# Create rerank request
|
||||
request = RerankRequest(query=query, passages=passages)
|
||||
results = self._ranker.rerank(request)
|
||||
|
||||
# Map scores back to original positions
|
||||
for result in results:
|
||||
local_idx = result["id"]
|
||||
score = result["score"]
|
||||
global_idx = global_indices[local_idx]
|
||||
all_scores[global_idx] = score
|
||||
|
||||
return all_scores
|
||||
|
||||
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""
|
||||
Score query-document pairs using FlashRank.
|
||||
|
||||
Args:
|
||||
pairs: List of (query, document) tuples to score
|
||||
|
||||
Returns:
|
||||
List of relevance scores (higher = more relevant)
|
||||
"""
|
||||
if self._ranker is None:
|
||||
raise RuntimeError("Reranker not initialized. Call initialize() first.")
|
||||
|
||||
# Run in thread pool to avoid blocking event loop
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(FlashRankCrossEncoder._executor, self._predict_sync, pairs)
|
||||
|
||||
|
||||
def create_cross_encoder_from_env() -> CrossEncoderModel:
|
||||
"""
|
||||
Create a CrossEncoderModel instance based on environment variables.
|
||||
@@ -386,16 +656,29 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
|
||||
url = os.environ.get(ENV_RERANKER_TEI_URL)
|
||||
if not url:
|
||||
raise ValueError(f"{ENV_RERANKER_TEI_URL} is required when {ENV_RERANKER_PROVIDER} is 'tei'")
|
||||
return RemoteTEICrossEncoder(base_url=url)
|
||||
batch_size = int(os.environ.get(ENV_RERANKER_TEI_BATCH_SIZE, str(DEFAULT_RERANKER_TEI_BATCH_SIZE)))
|
||||
max_concurrent = int(os.environ.get(ENV_RERANKER_TEI_MAX_CONCURRENT, str(DEFAULT_RERANKER_TEI_MAX_CONCURRENT)))
|
||||
return RemoteTEICrossEncoder(base_url=url, batch_size=batch_size, max_concurrent=max_concurrent)
|
||||
elif provider == "local":
|
||||
model = os.environ.get(ENV_RERANKER_LOCAL_MODEL)
|
||||
model_name = model or DEFAULT_RERANKER_LOCAL_MODEL
|
||||
return LocalSTCrossEncoder(model_name=model_name)
|
||||
max_concurrent = int(
|
||||
os.environ.get(ENV_RERANKER_LOCAL_MAX_CONCURRENT, str(DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT))
|
||||
)
|
||||
return LocalSTCrossEncoder(model_name=model_name, max_concurrent=max_concurrent)
|
||||
elif provider == "cohere":
|
||||
api_key = os.environ.get(ENV_COHERE_API_KEY)
|
||||
if not api_key:
|
||||
raise ValueError(f"{ENV_COHERE_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'cohere'")
|
||||
model = os.environ.get(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL)
|
||||
return CohereCrossEncoder(api_key=api_key, model=model)
|
||||
elif provider == "flashrank":
|
||||
model = os.environ.get(ENV_RERANKER_FLASHRANK_MODEL, DEFAULT_RERANKER_FLASHRANK_MODEL)
|
||||
cache_dir = os.environ.get(ENV_RERANKER_FLASHRANK_CACHE_DIR, DEFAULT_RERANKER_FLASHRANK_CACHE_DIR)
|
||||
return FlashRankCrossEncoder(model_name=model, cache_dir=cache_dir)
|
||||
elif provider == "rrf":
|
||||
return RRFPassthroughCrossEncoder()
|
||||
else:
|
||||
raise ValueError(f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere'")
|
||||
raise ValueError(
|
||||
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'flashrank', 'rrf'"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Database connection budget management.
|
||||
|
||||
Limits concurrent database connections per operation to prevent
|
||||
a single operation (e.g., recall with parallel queries) from
|
||||
exhausting the connection pool.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, AsyncIterator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import asyncpg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OperationBudget:
|
||||
"""
|
||||
Tracks connection budget for a single operation.
|
||||
|
||||
Each operation gets a semaphore limiting its concurrent connections.
|
||||
"""
|
||||
|
||||
operation_id: str
|
||||
max_connections: int
|
||||
semaphore: asyncio.Semaphore = field(init=False)
|
||||
active_count: int = field(default=0, init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
self.semaphore = asyncio.Semaphore(self.max_connections)
|
||||
|
||||
|
||||
class ConnectionBudgetManager:
|
||||
"""
|
||||
Manages per-operation connection budgets.
|
||||
|
||||
Usage:
|
||||
manager = ConnectionBudgetManager(default_budget=4)
|
||||
|
||||
# Start an operation
|
||||
async with manager.operation(max_connections=2) as op:
|
||||
# Acquire connections within the budget
|
||||
async with op.acquire(pool) as conn:
|
||||
await conn.fetch(...)
|
||||
|
||||
# Multiple connections respect the budget
|
||||
async with op.acquire(pool) as conn1, op.acquire(pool) as conn2:
|
||||
# At most 2 concurrent connections for this operation
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(self, default_budget: int = 4):
|
||||
"""
|
||||
Initialize the budget manager.
|
||||
|
||||
Args:
|
||||
default_budget: Default max connections per operation
|
||||
"""
|
||||
self.default_budget = default_budget
|
||||
self._operations: dict[str, OperationBudget] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@asynccontextmanager
|
||||
async def operation(
|
||||
self,
|
||||
max_connections: int | None = None,
|
||||
operation_id: str | None = None,
|
||||
) -> AsyncIterator["BudgetedOperation"]:
|
||||
"""
|
||||
Create a budgeted operation context.
|
||||
|
||||
Args:
|
||||
max_connections: Max concurrent connections for this operation.
|
||||
Defaults to manager's default_budget.
|
||||
operation_id: Optional custom operation ID. Auto-generated if not provided.
|
||||
|
||||
Yields:
|
||||
BudgetedOperation context for acquiring connections
|
||||
"""
|
||||
op_id = operation_id or f"op-{uuid.uuid4().hex[:12]}"
|
||||
budget = max_connections or self.default_budget
|
||||
|
||||
async with self._lock:
|
||||
if op_id in self._operations:
|
||||
raise ValueError(f"Operation {op_id} already exists")
|
||||
self._operations[op_id] = OperationBudget(op_id, budget)
|
||||
|
||||
try:
|
||||
yield BudgetedOperation(self, op_id)
|
||||
finally:
|
||||
async with self._lock:
|
||||
self._operations.pop(op_id, None)
|
||||
|
||||
def _get_budget(self, operation_id: str) -> OperationBudget:
|
||||
"""Get budget for an operation (internal use)."""
|
||||
budget = self._operations.get(operation_id)
|
||||
if not budget:
|
||||
raise ValueError(f"Operation {operation_id} not found")
|
||||
return budget
|
||||
|
||||
|
||||
class BudgetedOperation:
|
||||
"""
|
||||
A single operation with connection budget.
|
||||
|
||||
Provides methods to acquire connections within the budget.
|
||||
"""
|
||||
|
||||
def __init__(self, manager: ConnectionBudgetManager, operation_id: str):
|
||||
self._manager = manager
|
||||
self.operation_id = operation_id
|
||||
|
||||
@property
|
||||
def budget(self) -> OperationBudget:
|
||||
"""Get the budget for this operation."""
|
||||
return self._manager._get_budget(self.operation_id)
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self, pool: "asyncpg.Pool") -> AsyncIterator["asyncpg.Connection"]:
|
||||
"""
|
||||
Acquire a connection within the operation's budget.
|
||||
|
||||
Blocks if the operation has reached its connection limit.
|
||||
|
||||
Args:
|
||||
pool: asyncpg connection pool
|
||||
|
||||
Yields:
|
||||
Database connection
|
||||
"""
|
||||
budget = self.budget
|
||||
async with budget.semaphore:
|
||||
budget.active_count += 1
|
||||
conn = await pool.acquire()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
budget.active_count -= 1
|
||||
await pool.release(conn)
|
||||
|
||||
def wrap_pool(self, pool: "asyncpg.Pool") -> "BudgetedPool":
|
||||
"""
|
||||
Wrap a pool with this operation's budget.
|
||||
|
||||
The returned BudgetedPool can be passed to functions expecting a pool,
|
||||
and all acquire() calls will be limited by this operation's budget.
|
||||
|
||||
Args:
|
||||
pool: asyncpg connection pool to wrap
|
||||
|
||||
Returns:
|
||||
BudgetedPool that limits connections to this operation's budget
|
||||
"""
|
||||
return BudgetedPool(pool, self)
|
||||
|
||||
async def acquire_many(
|
||||
self,
|
||||
pool: "asyncpg.Pool",
|
||||
count: int,
|
||||
) -> AsyncIterator[list["asyncpg.Connection"]]:
|
||||
"""
|
||||
Acquire multiple connections within the budget.
|
||||
|
||||
Note: This acquires connections sequentially to respect the budget.
|
||||
For parallel acquisition, use multiple acquire() calls with asyncio.gather().
|
||||
|
||||
Args:
|
||||
pool: asyncpg connection pool
|
||||
count: Number of connections to acquire
|
||||
|
||||
Yields:
|
||||
List of database connections
|
||||
"""
|
||||
connections = []
|
||||
try:
|
||||
for _ in range(count):
|
||||
conn = await pool.acquire()
|
||||
connections.append(conn)
|
||||
yield connections
|
||||
finally:
|
||||
for conn in connections:
|
||||
await pool.release(conn)
|
||||
|
||||
|
||||
# Global default manager instance
|
||||
_default_manager: ConnectionBudgetManager | None = None
|
||||
|
||||
|
||||
def get_budget_manager(default_budget: int = 4) -> ConnectionBudgetManager:
|
||||
"""
|
||||
Get or create the global budget manager.
|
||||
|
||||
Args:
|
||||
default_budget: Default max connections per operation
|
||||
|
||||
Returns:
|
||||
Global ConnectionBudgetManager instance
|
||||
"""
|
||||
global _default_manager
|
||||
if _default_manager is None:
|
||||
_default_manager = ConnectionBudgetManager(default_budget=default_budget)
|
||||
return _default_manager
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def budgeted_operation(
|
||||
max_connections: int | None = None,
|
||||
operation_id: str | None = None,
|
||||
default_budget: int = 4,
|
||||
) -> AsyncIterator[BudgetedOperation]:
|
||||
"""
|
||||
Convenience function to create a budgeted operation.
|
||||
|
||||
Args:
|
||||
max_connections: Max concurrent connections for this operation
|
||||
operation_id: Optional custom operation ID
|
||||
default_budget: Default budget if manager not yet created
|
||||
|
||||
Yields:
|
||||
BudgetedOperation context
|
||||
|
||||
Example:
|
||||
async with budgeted_operation(max_connections=2) as op:
|
||||
async with op.acquire(pool) as conn:
|
||||
await conn.fetch(...)
|
||||
"""
|
||||
manager = get_budget_manager(default_budget)
|
||||
async with manager.operation(max_connections, operation_id) as op:
|
||||
yield op
|
||||
|
||||
|
||||
class BudgetedPool:
|
||||
"""
|
||||
A pool wrapper that limits concurrent connection acquisitions.
|
||||
|
||||
This can be passed to functions expecting a pool, and acquire()
|
||||
calls will be limited by the budget semaphore.
|
||||
|
||||
Usage:
|
||||
async with budgeted_operation(max_connections=4) as op:
|
||||
budgeted_pool = op.wrap_pool(pool)
|
||||
# Pass budgeted_pool to functions that expect a pool
|
||||
await some_function(budgeted_pool, ...)
|
||||
"""
|
||||
|
||||
def __init__(self, pool: "asyncpg.Pool", operation: BudgetedOperation):
|
||||
self._pool = pool
|
||||
self._operation = operation
|
||||
|
||||
async def acquire(self) -> "asyncpg.Connection":
|
||||
"""
|
||||
Acquire a connection within the budget.
|
||||
|
||||
Note: Caller must release the connection when done.
|
||||
Prefer using as context manager via acquire_with_retry or op.acquire().
|
||||
"""
|
||||
budget = self._operation.budget
|
||||
await budget.semaphore.acquire()
|
||||
budget.active_count += 1
|
||||
try:
|
||||
return await self._pool.acquire()
|
||||
except Exception:
|
||||
budget.active_count -= 1
|
||||
budget.semaphore.release()
|
||||
raise
|
||||
|
||||
async def release(self, conn: "asyncpg.Connection") -> None:
|
||||
"""Release a connection back to the pool."""
|
||||
budget = self._operation.budget
|
||||
try:
|
||||
await self._pool.release(conn)
|
||||
finally:
|
||||
budget.active_count -= 1
|
||||
budget.semaphore.release()
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Proxy other attributes to the underlying pool."""
|
||||
return getattr(self._pool, name)
|
||||
@@ -83,11 +83,22 @@ async def acquire_with_retry(pool: asyncpg.Pool, max_retries: int = DEFAULT_MAX_
|
||||
Yields:
|
||||
An asyncpg connection
|
||||
"""
|
||||
import time
|
||||
|
||||
start = time.time()
|
||||
|
||||
async def acquire():
|
||||
return await pool.acquire()
|
||||
|
||||
conn = await retry_with_backoff(acquire, max_retries=max_retries)
|
||||
acquire_time = time.time() - start
|
||||
|
||||
# Log slow connection acquisitions (indicates pool contention)
|
||||
if acquire_time > 0.05: # 50ms threshold
|
||||
pool_size = pool.get_size()
|
||||
pool_free = pool.get_idle_size()
|
||||
logger.warning(f"[DB POOL] Slow acquire: {acquire_time:.3f}s | size={pool_size}, idle={pool_free}")
|
||||
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
|
||||
@@ -406,18 +406,20 @@ class MemoryEngineInterface(ABC):
|
||||
bank_id: str,
|
||||
*,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
request_context: "RequestContext",
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
List entities for a bank.
|
||||
List entities for a bank with pagination.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID.
|
||||
limit: Maximum results.
|
||||
offset: Offset for pagination.
|
||||
request_context: Request context for authentication.
|
||||
|
||||
Returns:
|
||||
List of entity dicts.
|
||||
Dict with items, total, limit, offset.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..config import get_config
|
||||
from ..metrics import get_metrics_collector
|
||||
from .db_budget import budgeted_operation
|
||||
|
||||
# Context variable for current schema (async-safe, per-task isolation)
|
||||
_current_schema: contextvars.ContextVar[str] = contextvars.ContextVar("current_schema", default="public")
|
||||
@@ -150,7 +151,7 @@ from .retain import bank_utils, embedding_utils
|
||||
from .retain.types import RetainContentDict
|
||||
from .search import observation_utils, think_utils
|
||||
from .search.reranking import CrossEncoderReranker
|
||||
from .task_backend import AsyncIOQueueBackend, TaskBackend
|
||||
from .task_backend import AsyncIOQueueBackend, NoopTaskBackend, TaskBackend
|
||||
|
||||
|
||||
class Budget(str, Enum):
|
||||
@@ -257,8 +258,8 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
db_command_timeout: PostgreSQL command timeout in seconds. Defaults to HINDSIGHT_API_DB_COMMAND_TIMEOUT.
|
||||
db_acquire_timeout: Connection acquisition timeout in seconds. Defaults to HINDSIGHT_API_DB_ACQUIRE_TIMEOUT.
|
||||
task_backend: Custom task backend. If not provided, uses AsyncIOQueueBackend.
|
||||
task_batch_size: Background task batch size. Defaults to HINDSIGHT_API_TASK_BATCH_SIZE.
|
||||
task_batch_interval: Background task batch interval in seconds. Defaults to HINDSIGHT_API_TASK_BATCH_INTERVAL.
|
||||
task_batch_size: Background task batch size. Defaults to HINDSIGHT_API_TASK_BACKEND_MEMORY_BATCH_SIZE.
|
||||
task_batch_interval: Background task batch interval in seconds. Defaults to HINDSIGHT_API_TASK_BACKEND_MEMORY_BATCH_INTERVAL.
|
||||
run_migrations: Whether to run database migrations during initialize(). Default: True
|
||||
operation_validator: Optional extension to validate operations before execution.
|
||||
If provided, retain/recall/reflect operations will be validated.
|
||||
@@ -396,17 +397,21 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
self._cross_encoder_reranker = CrossEncoderReranker(cross_encoder=cross_encoder)
|
||||
|
||||
# Initialize task backend
|
||||
_task_batch_size = task_batch_size if task_batch_size is not None else config.task_batch_size
|
||||
_task_batch_interval = task_batch_interval if task_batch_interval is not None else config.task_batch_interval
|
||||
self._task_backend = task_backend or AsyncIOQueueBackend(
|
||||
batch_size=_task_batch_size, batch_interval=_task_batch_interval
|
||||
)
|
||||
if task_backend:
|
||||
self._task_backend = task_backend
|
||||
elif config.task_backend == "noop":
|
||||
self._task_backend = NoopTaskBackend()
|
||||
else:
|
||||
# Default to memory (AsyncIOQueueBackend)
|
||||
_task_batch_size = task_batch_size if task_batch_size is not None else config.task_backend_memory_batch_size
|
||||
_task_batch_interval = (
|
||||
task_batch_interval if task_batch_interval is not None else config.task_backend_memory_batch_interval
|
||||
)
|
||||
self._task_backend = AsyncIOQueueBackend(batch_size=_task_batch_size, batch_interval=_task_batch_interval)
|
||||
|
||||
# Backpressure mechanism: limit concurrent searches to prevent overwhelming the database
|
||||
# Limit concurrent searches to prevent connection pool exhaustion
|
||||
# Each search can use 2-4 connections, so with 10 concurrent searches
|
||||
# we use ~20-40 connections max, staying well within pool limits
|
||||
self._search_semaphore = asyncio.Semaphore(10)
|
||||
# Configurable via HINDSIGHT_API_RECALL_MAX_CONCURRENT (default: 50)
|
||||
self._search_semaphore = asyncio.Semaphore(get_config().recall_max_concurrent)
|
||||
|
||||
# Backpressure for put operations: limit concurrent puts to prevent database contention
|
||||
# Each put_batch holds a connection for the entire transaction, so we limit to 5
|
||||
@@ -1412,7 +1417,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Backpressure: limit concurrent recalls to prevent overwhelming the database
|
||||
result = None
|
||||
error_msg = None
|
||||
semaphore_wait_start = time.time()
|
||||
async with self._search_semaphore:
|
||||
semaphore_wait = time.time() - semaphore_wait_start
|
||||
# Retry loop for connection errors
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries + 1):
|
||||
@@ -1430,6 +1437,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
include_chunks,
|
||||
max_chunk_tokens,
|
||||
request_context,
|
||||
semaphore_wait=semaphore_wait,
|
||||
)
|
||||
break # Success - exit retry loop
|
||||
except Exception as e:
|
||||
@@ -1547,6 +1555,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
include_chunks: bool = False,
|
||||
max_chunk_tokens: int = 8192,
|
||||
request_context: "RequestContext" = None,
|
||||
semaphore_wait: float = 0.0,
|
||||
) -> RecallResultModel:
|
||||
"""
|
||||
Search implementation with modular retrieval and reranking.
|
||||
@@ -1601,37 +1610,65 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
tracer.record_query_embedding(query_embedding)
|
||||
tracer.add_phase_metric("generate_query_embedding", step_duration)
|
||||
|
||||
# Step 2: N*4-Way Parallel Retrieval (N fact types × 4 retrieval methods)
|
||||
# Step 2: Optimized parallel retrieval using batched queries
|
||||
# - Semantic + BM25 combined in 1 CTE query for ALL fact types
|
||||
# - Graph runs per fact type (complex traversal)
|
||||
# - Temporal runs per fact type (if constraint detected)
|
||||
step_start = time.time()
|
||||
query_embedding_str = str(query_embedding)
|
||||
|
||||
from .search.retrieval import retrieve_parallel
|
||||
from .search.retrieval import (
|
||||
get_default_graph_retriever,
|
||||
retrieve_all_fact_types_parallel,
|
||||
)
|
||||
|
||||
# Track each retrieval start time
|
||||
retrieval_start = time.time()
|
||||
|
||||
# Run retrieval for each fact type in parallel
|
||||
retrieval_tasks = [
|
||||
retrieve_parallel(
|
||||
pool, query, query_embedding_str, bank_id, ft, thinking_budget, question_date, self.query_analyzer
|
||||
# Run optimized retrieval with connection budget
|
||||
config = get_config()
|
||||
async with budgeted_operation(
|
||||
max_connections=config.recall_connection_budget,
|
||||
operation_id=f"recall-{recall_id}",
|
||||
) as op:
|
||||
budgeted_pool = op.wrap_pool(pool)
|
||||
parallel_start = time.time()
|
||||
multi_result = await retrieve_all_fact_types_parallel(
|
||||
budgeted_pool,
|
||||
query,
|
||||
query_embedding_str,
|
||||
bank_id,
|
||||
fact_type, # Pass all fact types at once
|
||||
thinking_budget,
|
||||
question_date,
|
||||
self.query_analyzer,
|
||||
)
|
||||
for ft in fact_type
|
||||
]
|
||||
all_retrievals = await asyncio.gather(*retrieval_tasks)
|
||||
parallel_duration = time.time() - parallel_start
|
||||
|
||||
# Combine all results from all fact types and aggregate timings
|
||||
semantic_results = []
|
||||
bm25_results = []
|
||||
graph_results = []
|
||||
temporal_results = []
|
||||
aggregated_timings = {"semantic": 0.0, "bm25": 0.0, "graph": 0.0, "temporal": 0.0}
|
||||
aggregated_timings = {
|
||||
"semantic": 0.0,
|
||||
"bm25": 0.0,
|
||||
"graph": 0.0,
|
||||
"temporal": 0.0,
|
||||
"temporal_extraction": 0.0,
|
||||
}
|
||||
all_mpfp_timings = []
|
||||
|
||||
detected_temporal_constraint = None
|
||||
for idx, retrieval_result in enumerate(all_retrievals):
|
||||
max_conn_wait = multi_result.max_conn_wait
|
||||
for ft in fact_type:
|
||||
retrieval_result = multi_result.results_by_fact_type.get(ft)
|
||||
if not retrieval_result:
|
||||
continue
|
||||
|
||||
# Log fact types in this retrieval batch
|
||||
ft_name = fact_type[idx] if idx < len(fact_type) else "unknown"
|
||||
logger.debug(
|
||||
f"[RECALL {recall_id}] Fact type '{ft_name}': semantic={len(retrieval_result.semantic)}, bm25={len(retrieval_result.bm25)}, graph={len(retrieval_result.graph)}, temporal={len(retrieval_result.temporal) if retrieval_result.temporal else 0}"
|
||||
f"[RECALL {recall_id}] Fact type '{ft}': semantic={len(retrieval_result.semantic)}, bm25={len(retrieval_result.bm25)}, graph={len(retrieval_result.graph)}, temporal={len(retrieval_result.temporal) if retrieval_result.temporal else 0}"
|
||||
)
|
||||
|
||||
semantic_results.extend(retrieval_result.semantic)
|
||||
@@ -1645,6 +1682,8 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Capture temporal constraint (same across all fact types)
|
||||
if retrieval_result.temporal_constraint:
|
||||
detected_temporal_constraint = retrieval_result.temporal_constraint
|
||||
# Collect MPFP timings
|
||||
all_mpfp_timings.extend(retrieval_result.mpfp_timings)
|
||||
|
||||
# If no temporal results from any fact type, set to None
|
||||
if not temporal_results:
|
||||
@@ -1663,12 +1702,12 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
retrieval_duration = time.time() - retrieval_start
|
||||
|
||||
step_duration = time.time() - step_start
|
||||
total_retrievals = len(fact_type) * (4 if temporal_results else 3)
|
||||
# Format per-method timings
|
||||
# Format per-method timings (these are the actual parallel retrieval times)
|
||||
timing_parts = [
|
||||
f"semantic={len(semantic_results)}({aggregated_timings['semantic']:.3f}s)",
|
||||
f"bm25={len(bm25_results)}({aggregated_timings['bm25']:.3f}s)",
|
||||
f"graph={len(graph_results)}({aggregated_timings['graph']:.3f}s)",
|
||||
f"temporal_extraction={aggregated_timings['temporal_extraction']:.3f}s",
|
||||
]
|
||||
temporal_info = ""
|
||||
if detected_temporal_constraint:
|
||||
@@ -1677,9 +1716,36 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
timing_parts.append(f"temporal={temporal_count}({aggregated_timings['temporal']:.3f}s)")
|
||||
temporal_info = f" | temporal_range={start_dt.strftime('%Y-%m-%d')} to {end_dt.strftime('%Y-%m-%d')}"
|
||||
log_buffer.append(
|
||||
f" [2] {total_retrievals}-way retrieval ({len(fact_type)} fact_types): {', '.join(timing_parts)} in {step_duration:.3f}s{temporal_info}"
|
||||
f" [2] Parallel retrieval ({len(fact_type)} fact_types): {', '.join(timing_parts)} in {parallel_duration:.3f}s{temporal_info}"
|
||||
)
|
||||
|
||||
# Log graph retriever timing breakdown if available
|
||||
if all_mpfp_timings:
|
||||
retriever_name = get_default_graph_retriever().name.upper()
|
||||
mpfp_total = all_mpfp_timings[0] # Take first fact type's timing as representative
|
||||
mpfp_parts = [
|
||||
f"db_queries={mpfp_total.db_queries}",
|
||||
f"edge_load={mpfp_total.edge_load_time:.3f}s",
|
||||
f"edges={mpfp_total.edge_count}",
|
||||
f"patterns={mpfp_total.pattern_count}",
|
||||
]
|
||||
if mpfp_total.seeds_time > 0.01:
|
||||
mpfp_parts.append(f"seeds={mpfp_total.seeds_time:.3f}s")
|
||||
if mpfp_total.fusion > 0.001:
|
||||
mpfp_parts.append(f"fusion={mpfp_total.fusion:.3f}s")
|
||||
if mpfp_total.fetch > 0.001:
|
||||
mpfp_parts.append(f"fetch={mpfp_total.fetch:.3f}s")
|
||||
log_buffer.append(f" [{retriever_name}] {', '.join(mpfp_parts)}")
|
||||
# Log detailed hop timing for debugging slow queries
|
||||
if mpfp_total.hop_details:
|
||||
for hd in mpfp_total.hop_details:
|
||||
log_buffer.append(
|
||||
f" hop{hd['hop']}: exec={hd.get('exec_time', 0) * 1000:.0f}ms, "
|
||||
f"uncached={hd.get('uncached_after_filter', 0)}, "
|
||||
f"load={hd.get('load_time', 0) * 1000:.0f}ms, "
|
||||
f"edges={hd.get('edges_loaded', 0)}"
|
||||
)
|
||||
|
||||
# Record retrieval results for tracer - per fact type
|
||||
if tracer:
|
||||
# Convert RetrievalResult to old tuple format for tracer
|
||||
@@ -1687,8 +1753,10 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
return [(r.id, r.__dict__) for r in results]
|
||||
|
||||
# Add retrieval results per fact type (to show parallel execution in UI)
|
||||
for idx, rr in enumerate(all_retrievals):
|
||||
ft_name = fact_type[idx] if idx < len(fact_type) else "unknown"
|
||||
for ft_name in fact_type:
|
||||
rr = multi_result.results_by_fact_type.get(ft_name)
|
||||
if not rr:
|
||||
continue
|
||||
|
||||
# Add semantic retrieval results for this fact type
|
||||
tracer.add_retrieval_results(
|
||||
@@ -1777,11 +1845,24 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Ensure reranker is initialized (for lazy initialization mode)
|
||||
await reranker_instance.ensure_initialized()
|
||||
|
||||
# Pre-filter candidates to reduce reranking cost (RRF already provides good ranking)
|
||||
# This is especially important for remote rerankers with network latency
|
||||
reranker_max_candidates = get_config().reranker_max_candidates
|
||||
pre_filtered_count = 0
|
||||
if len(merged_candidates) > reranker_max_candidates:
|
||||
# Sort by RRF score and take top candidates
|
||||
merged_candidates.sort(key=lambda mc: mc.rrf_score, reverse=True)
|
||||
pre_filtered_count = len(merged_candidates) - reranker_max_candidates
|
||||
merged_candidates = merged_candidates[:reranker_max_candidates]
|
||||
|
||||
# Rerank using cross-encoder
|
||||
scored_results = reranker_instance.rerank(query, merged_candidates)
|
||||
scored_results = await reranker_instance.rerank(query, merged_candidates)
|
||||
|
||||
step_duration = time.time() - step_start
|
||||
log_buffer.append(f" [4] Reranking: {len(scored_results)} candidates scored in {step_duration:.3f}s")
|
||||
pre_filter_note = f" (pre-filtered {pre_filtered_count})" if pre_filtered_count > 0 else ""
|
||||
log_buffer.append(
|
||||
f" [4] Reranking: {len(scored_results)} candidates scored in {step_duration:.3f}s{pre_filter_note}"
|
||||
)
|
||||
|
||||
# Step 4.5: Combine cross-encoder score with retrieval signals
|
||||
# This preserves retrieval work (RRF, temporal, recency) instead of pure cross-encoder ranking
|
||||
@@ -1831,9 +1912,6 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
# Re-sort by combined score
|
||||
scored_results.sort(key=lambda x: x.weight, reverse=True)
|
||||
log_buffer.append(
|
||||
" [4.6] Combined scoring: cross_encoder(0.6) + rrf(0.2) + temporal(0.1) + recency(0.1)"
|
||||
)
|
||||
|
||||
# Add reranked results to tracer AFTER combined scoring (so normalized values are included)
|
||||
if tracer:
|
||||
@@ -1852,7 +1930,6 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Step 5: Truncate to thinking_budget * 2 for token filtering
|
||||
rerank_limit = thinking_budget * 2
|
||||
top_scored = scored_results[:rerank_limit]
|
||||
log_buffer.append(f" [5] Truncated to top {len(top_scored)} results")
|
||||
|
||||
# Step 6: Token budget filtering
|
||||
step_start = time.time()
|
||||
@@ -1867,7 +1944,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
step_duration = time.time() - step_start
|
||||
log_buffer.append(
|
||||
f" [6] Token filtering: {len(top_scored)} results, {total_tokens}/{max_tokens} tokens in {step_duration:.3f}s"
|
||||
f" [5] Token filtering: {len(top_scored)} results, {total_tokens}/{max_tokens} tokens in {step_duration:.3f}s"
|
||||
)
|
||||
|
||||
if tracer:
|
||||
@@ -1901,7 +1978,6 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
visited_ids = list(set([sr.id for sr in scored_results[:50]])) # Top 50
|
||||
if visited_ids:
|
||||
await self._task_backend.submit_task({"type": "access_count_update", "node_ids": visited_ids})
|
||||
log_buffer.append(f" [7] Queued access count updates for {len(visited_ids)} nodes")
|
||||
|
||||
# Log fact_type distribution in results
|
||||
fact_type_counts = {}
|
||||
@@ -1934,6 +2010,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
top_results_dicts.append(result_dict)
|
||||
|
||||
# Get entities for each fact if include_entities is requested
|
||||
step_start = time.time()
|
||||
fact_entity_map = {} # unit_id -> list of (entity_id, entity_name)
|
||||
if include_entities and top_scored:
|
||||
unit_ids = [uuid.UUID(sr.id) for sr in top_scored]
|
||||
@@ -1955,6 +2032,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
fact_entity_map[unit_id].append(
|
||||
{"entity_id": str(row["entity_id"]), "canonical_name": row["canonical_name"]}
|
||||
)
|
||||
entity_map_duration = time.time() - step_start
|
||||
|
||||
# Convert results to MemoryFact objects
|
||||
memory_facts = []
|
||||
@@ -1981,6 +2059,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
)
|
||||
|
||||
# Fetch entity observations if requested
|
||||
step_start = time.time()
|
||||
entities_dict = None
|
||||
total_entity_tokens = 0
|
||||
total_chunk_tokens = 0
|
||||
@@ -2001,7 +2080,13 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
entities_ordered.append((entity_id, entity_name))
|
||||
seen_entity_ids.add(entity_id)
|
||||
|
||||
# Fetch observations for each entity (respect token budget, in order)
|
||||
# Fetch all observations in a single batched query
|
||||
entity_ids = [eid for eid, _ in entities_ordered]
|
||||
all_observations = await self.get_entity_observations_batch(
|
||||
bank_id, entity_ids, limit_per_entity=5, request_context=request_context
|
||||
)
|
||||
|
||||
# Build entities_dict respecting token budget, in relevance order
|
||||
entities_dict = {}
|
||||
encoding = _get_tiktoken_encoding()
|
||||
|
||||
@@ -2009,9 +2094,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
if total_entity_tokens >= max_entity_tokens:
|
||||
break
|
||||
|
||||
observations = await self.get_entity_observations(
|
||||
bank_id, entity_id, limit=5, request_context=request_context
|
||||
)
|
||||
observations = all_observations.get(entity_id, [])
|
||||
|
||||
# Calculate tokens for this entity's observations
|
||||
entity_tokens = 0
|
||||
@@ -2029,8 +2112,10 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
entity_id=entity_id, canonical_name=entity_name, observations=included_observations
|
||||
)
|
||||
total_entity_tokens += entity_tokens
|
||||
entity_obs_duration = time.time() - step_start
|
||||
|
||||
# Fetch chunks if requested
|
||||
step_start = time.time()
|
||||
chunks_dict = None
|
||||
if include_chunks and top_scored:
|
||||
from .response_models import ChunkInfo
|
||||
@@ -2090,6 +2175,12 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
chunk_text=chunk_text, chunk_index=row["chunk_index"], truncated=False
|
||||
)
|
||||
total_chunk_tokens += chunk_tokens
|
||||
chunks_duration = time.time() - step_start
|
||||
|
||||
# Log entity/chunk fetch timing (only if any enrichment was requested)
|
||||
log_buffer.append(
|
||||
f" [6] Response enrichment: entity_map={entity_map_duration:.3f}s, entity_obs={entity_obs_duration:.3f}s, chunks={chunks_duration:.3f}s"
|
||||
)
|
||||
|
||||
# Finalize trace if enabled
|
||||
trace_dict = None
|
||||
@@ -2101,8 +2192,15 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
total_time = time.time() - recall_start
|
||||
num_chunks = len(chunks_dict) if chunks_dict else 0
|
||||
num_entities = len(entities_dict) if entities_dict else 0
|
||||
# Include wait times in log if significant
|
||||
wait_parts = []
|
||||
if semaphore_wait > 0.01:
|
||||
wait_parts.append(f"sem={semaphore_wait:.3f}s")
|
||||
if max_conn_wait > 0.01:
|
||||
wait_parts.append(f"conn={max_conn_wait:.3f}s")
|
||||
wait_info = f" | waits: {', '.join(wait_parts)}" if wait_parts else ""
|
||||
log_buffer.append(
|
||||
f"[RECALL {recall_id}] Complete: {len(top_scored)} facts ({total_tokens} tok), {num_chunks} chunks ({total_chunk_tokens} tok), {num_entities} entities ({total_entity_tokens} tok) | {fact_type_summary} | {total_time:.3f}s"
|
||||
f"[RECALL {recall_id}] Complete: {len(top_scored)} facts ({total_tokens} tok), {num_chunks} chunks ({total_chunk_tokens} tok), {num_entities} entities ({total_entity_tokens} tok) | {fact_type_summary} | {total_time:.3f}s{wait_info}"
|
||||
)
|
||||
logger.info("\n" + "\n".join(log_buffer))
|
||||
|
||||
@@ -2298,9 +2396,10 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
await self._authenticate_tenant(request_context)
|
||||
pool = await self._get_pool()
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
# Ensure connection is not in read-only mode (can happen with connection poolers)
|
||||
await conn.execute("SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE")
|
||||
async with conn.transaction():
|
||||
# Ensure transaction is not in read-only mode (can happen with connection poolers)
|
||||
# Using SET LOCAL so it only affects this transaction, not the session
|
||||
await conn.execute("SET LOCAL transaction_read_only TO off")
|
||||
try:
|
||||
if fact_type:
|
||||
# Delete only memories of a specific fact type
|
||||
@@ -3485,37 +3584,110 @@ Guidelines:
|
||||
observations.append(EntityObservation(text=row["text"], mentioned_at=mentioned_at))
|
||||
return observations
|
||||
|
||||
async def get_entity_observations_batch(
|
||||
self,
|
||||
bank_id: str,
|
||||
entity_ids: list[str],
|
||||
*,
|
||||
limit_per_entity: int = 5,
|
||||
request_context: "RequestContext",
|
||||
) -> dict[str, list[Any]]:
|
||||
"""
|
||||
Get observations for multiple entities in a single query.
|
||||
|
||||
Args:
|
||||
bank_id: bank IDentifier
|
||||
entity_ids: List of entity UUIDs to get observations for
|
||||
limit_per_entity: Maximum observations per entity
|
||||
request_context: Request context for authentication.
|
||||
|
||||
Returns:
|
||||
Dict mapping entity_id -> list of EntityObservation objects
|
||||
"""
|
||||
if not entity_ids:
|
||||
return {}
|
||||
|
||||
await self._authenticate_tenant(request_context)
|
||||
pool = await self._get_pool()
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
# Use window function to limit observations per entity
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
ue.entity_id,
|
||||
mu.text,
|
||||
mu.mentioned_at,
|
||||
ROW_NUMBER() OVER (PARTITION BY ue.entity_id ORDER BY mu.mentioned_at DESC) as rn
|
||||
FROM {fq_table("memory_units")} mu
|
||||
JOIN {fq_table("unit_entities")} ue ON mu.id = ue.unit_id
|
||||
WHERE mu.bank_id = $1
|
||||
AND mu.fact_type = 'observation'
|
||||
AND ue.entity_id = ANY($2::uuid[])
|
||||
)
|
||||
SELECT entity_id, text, mentioned_at
|
||||
FROM ranked
|
||||
WHERE rn <= $3
|
||||
ORDER BY entity_id, rn
|
||||
""",
|
||||
bank_id,
|
||||
[uuid.UUID(eid) for eid in entity_ids],
|
||||
limit_per_entity,
|
||||
)
|
||||
|
||||
result: dict[str, list[Any]] = {eid: [] for eid in entity_ids}
|
||||
for row in rows:
|
||||
entity_id = str(row["entity_id"])
|
||||
mentioned_at = row["mentioned_at"].isoformat() if row["mentioned_at"] else None
|
||||
result[entity_id].append(EntityObservation(text=row["text"], mentioned_at=mentioned_at))
|
||||
return result
|
||||
|
||||
async def list_entities(
|
||||
self,
|
||||
bank_id: str,
|
||||
*,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
request_context: "RequestContext",
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
List all entities for a bank.
|
||||
List all entities for a bank with pagination.
|
||||
|
||||
Args:
|
||||
bank_id: bank IDentifier
|
||||
limit: Maximum number of entities to return
|
||||
offset: Offset for pagination
|
||||
request_context: Request context for authentication.
|
||||
|
||||
Returns:
|
||||
List of entity dicts with id, canonical_name, mention_count, first_seen, last_seen
|
||||
Dict with items, total, limit, offset
|
||||
"""
|
||||
await self._authenticate_tenant(request_context)
|
||||
pool = await self._get_pool()
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
# Get total count
|
||||
total_row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT COUNT(*) as total
|
||||
FROM {fq_table("entities")}
|
||||
WHERE bank_id = $1
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
total = total_row["total"] if total_row else 0
|
||||
|
||||
# Get paginated entities
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, canonical_name, mention_count, first_seen, last_seen, metadata
|
||||
FROM {fq_table("entities")}
|
||||
WHERE bank_id = $1
|
||||
ORDER BY mention_count DESC, last_seen DESC
|
||||
LIMIT $2
|
||||
LIMIT $2 OFFSET $3
|
||||
""",
|
||||
bank_id,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
|
||||
entities = []
|
||||
@@ -3542,7 +3714,12 @@ Guidelines:
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
return entities
|
||||
return {
|
||||
"items": entities,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
async def get_entity_state(
|
||||
self,
|
||||
|
||||
@@ -84,7 +84,7 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
|
||||
|
||||
Performance:
|
||||
- ~10-50ms per query
|
||||
- No model loading required
|
||||
- No model loading required (lazy import on first use)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -112,8 +112,6 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
|
||||
Returns:
|
||||
QueryAnalysis with temporal_constraint if found
|
||||
"""
|
||||
self.load()
|
||||
|
||||
if reference_date is None:
|
||||
reference_date = datetime.now()
|
||||
|
||||
@@ -123,6 +121,9 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
|
||||
if period_result is not None:
|
||||
return QueryAnalysis(temporal_constraint=period_result)
|
||||
|
||||
# Lazy load dateparser (only imports on first call, then cached)
|
||||
self.load()
|
||||
|
||||
# Use dateparser's search_dates to find temporal expressions
|
||||
settings = {
|
||||
"RELATIVE_BASE": reference_date,
|
||||
|
||||
@@ -210,6 +210,98 @@ class FactExtractionResponse(BaseModel):
|
||||
facts: list[ExtractedFact] = Field(description="List of extracted factual statements")
|
||||
|
||||
|
||||
class ExtractedFactVerbose(BaseModel):
|
||||
"""A single extracted fact with verbose field descriptions for detailed extraction."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_mode="validation",
|
||||
json_schema_extra={"required": ["what", "when", "where", "who", "why", "fact_type"]},
|
||||
)
|
||||
|
||||
what: str = Field(
|
||||
description="WHAT happened - COMPLETE, DETAILED description with ALL specifics. "
|
||||
"NEVER summarize or omit details. Include: exact actions, objects, quantities, specifics. "
|
||||
"BE VERBOSE - capture every detail that was mentioned. "
|
||||
"Example: 'Emily got married to Sarah at a rooftop garden ceremony with 50 guests attending and a live jazz band playing' "
|
||||
"NOT: 'A wedding happened' or 'Emily got married'"
|
||||
)
|
||||
|
||||
when: str = Field(
|
||||
description="WHEN it happened - ALWAYS include temporal information if mentioned. "
|
||||
"Include: specific dates, times, durations, relative time references. "
|
||||
"Examples: 'on June 15th, 2024 at 3pm', 'last weekend', 'for the past 3 years', 'every morning at 6am'. "
|
||||
"Write 'N/A' ONLY if absolutely no temporal context exists. Prefer converting to absolute dates when possible."
|
||||
)
|
||||
|
||||
where: str = Field(
|
||||
description="WHERE it happened or is about - SPECIFIC locations, places, areas, regions if applicable. "
|
||||
"Include: cities, neighborhoods, venues, buildings, countries, specific addresses when mentioned. "
|
||||
"Examples: 'downtown San Francisco at a rooftop garden venue', 'at the user's home in Brooklyn', 'online via Zoom', 'Paris, France'. "
|
||||
"Write 'N/A' ONLY if absolutely no location context exists or if the fact is completely location-agnostic."
|
||||
)
|
||||
|
||||
who: str = Field(
|
||||
description="WHO is involved - ALL people/entities with FULL context and relationships. "
|
||||
"Include: names, roles, relationships to user, background details. "
|
||||
"Resolve coreferences (if 'my roommate' is later named 'Emily', write 'Emily, the user's college roommate'). "
|
||||
"BE DETAILED about relationships and roles. "
|
||||
"Example: 'Emily (user's college roommate from Stanford, now works at Google), Sarah (Emily's partner of 5 years, software engineer)' "
|
||||
"NOT: 'my friend' or 'Emily and Sarah'"
|
||||
)
|
||||
|
||||
why: str = Field(
|
||||
description="WHY it matters - ALL emotional, contextual, and motivational details. "
|
||||
"Include EVERYTHING: feelings, preferences, motivations, observations, context, background, significance. "
|
||||
"BE VERBOSE - capture all the nuance and meaning. "
|
||||
"FOR ASSISTANT FACTS: MUST include what the user asked/requested that led to this interaction! "
|
||||
"Example (world): 'The user felt thrilled and inspired, has always dreamed of an outdoor ceremony, mentioned wanting a similar garden venue, was particularly moved by the intimate atmosphere and personal vows' "
|
||||
"Example (assistant): 'User asked how to fix slow API performance with 1000+ concurrent users, expected 70-80% reduction in database load' "
|
||||
"NOT: 'User liked it' or 'To help user'"
|
||||
)
|
||||
|
||||
fact_kind: str = Field(
|
||||
default="conversation",
|
||||
description="'event' = specific datable occurrence (set occurred dates), 'conversation' = general info (no occurred dates)",
|
||||
)
|
||||
|
||||
occurred_start: str | None = Field(
|
||||
default=None,
|
||||
description="WHEN the event happened (ISO timestamp). Only for fact_kind='event'. Leave null for conversations.",
|
||||
)
|
||||
occurred_end: str | None = Field(
|
||||
default=None,
|
||||
description="WHEN the event ended (ISO timestamp). Only for events with duration. Leave null for conversations.",
|
||||
)
|
||||
|
||||
fact_type: Literal["world", "assistant"] = Field(
|
||||
description="'world' = about the user/others (background, experiences). 'assistant' = experience with the assistant."
|
||||
)
|
||||
|
||||
entities: list[Entity] | None = Field(
|
||||
default=None,
|
||||
description="Named entities, objects, AND abstract concepts from the fact. Include: people names, organizations, places, significant objects (e.g., 'coffee maker', 'car'), AND abstract concepts/themes (e.g., 'friendship', 'career growth', 'loss', 'celebration'). Extract anything that could help link related facts together.",
|
||||
)
|
||||
|
||||
causal_relations: list[FactCausalRelation] | None = Field(
|
||||
default=None,
|
||||
description="Causal links to PREVIOUS facts only. target_index MUST be less than this fact's position. "
|
||||
"Example: fact #3 can only reference facts 0, 1, or 2. Max 2 relations per fact.",
|
||||
)
|
||||
|
||||
@field_validator("entities", mode="before")
|
||||
@classmethod
|
||||
def ensure_entities_list(cls, v):
|
||||
if v is None:
|
||||
return []
|
||||
return v
|
||||
|
||||
|
||||
class FactExtractionResponseVerbose(BaseModel):
|
||||
"""Response for verbose fact extraction."""
|
||||
|
||||
facts: list[ExtractedFactVerbose] = Field(description="List of extracted factual statements")
|
||||
|
||||
|
||||
class ExtractedFactNoCausal(BaseModel):
|
||||
"""A single extracted fact WITHOUT causal relations (for when causal extraction is disabled)."""
|
||||
|
||||
@@ -342,35 +434,12 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
|
||||
return chunks if chunks else [json.dumps(turns, ensure_ascii=False)]
|
||||
|
||||
|
||||
async def _extract_facts_from_chunk(
|
||||
chunk: str,
|
||||
chunk_index: int,
|
||||
total_chunks: int,
|
||||
event_date: datetime,
|
||||
context: str,
|
||||
llm_config: "LLMConfig",
|
||||
agent_name: str = None,
|
||||
extract_opinions: bool = False,
|
||||
) -> tuple[list[dict[str, str]], TokenUsage]:
|
||||
"""
|
||||
Extract facts from a single chunk (internal helper for parallel processing).
|
||||
# =============================================================================
|
||||
# FACT EXTRACTION PROMPTS
|
||||
# =============================================================================
|
||||
|
||||
Note: event_date parameter is kept for backward compatibility but not used in prompt.
|
||||
The LLM extracts temporal information from the context string instead.
|
||||
"""
|
||||
memory_bank_context = f"\n- Your name: {agent_name}" if agent_name and extract_opinions else ""
|
||||
|
||||
# Determine which fact types to extract based on the flag
|
||||
# Note: We use "assistant" in the prompt but convert to "bank" for storage
|
||||
if extract_opinions:
|
||||
# Opinion extraction uses a separate prompt (not this one)
|
||||
fact_types_instruction = "Extract ONLY 'opinion' type facts (formed opinions, beliefs, and perspectives). DO NOT extract 'world' or 'assistant' facts."
|
||||
else:
|
||||
fact_types_instruction = (
|
||||
"Extract ONLY 'world' and 'assistant' type facts. DO NOT extract opinions - those are extracted separately."
|
||||
)
|
||||
|
||||
prompt = f"""Extract SIGNIFICANT facts from text. Be SELECTIVE - only extract facts worth remembering long-term.
|
||||
# Concise extraction prompt (default) - selective, high-quality facts
|
||||
CONCISE_FACT_EXTRACTION_PROMPT = """Extract SIGNIFICANT facts from text. Be SELECTIVE - only extract facts worth remembering long-term.
|
||||
|
||||
LANGUAGE RULE (CRITICAL): Output facts in the EXACT SAME language as the input text. If input is Japanese, output Japanese. If input is Chinese, output Chinese. NEVER translate to English. Preserve original language completely.
|
||||
|
||||
@@ -470,8 +539,123 @@ QUALITY OVER QUANTITY
|
||||
|
||||
Ask: "Would this be useful to recall in 6 months?" If no, skip it."""
|
||||
|
||||
# Causal relationships section - only included if enabled in config
|
||||
causal_relationships_section = """
|
||||
|
||||
# Verbose extraction prompt - detailed, comprehensive facts (legacy mode)
|
||||
VERBOSE_FACT_EXTRACTION_PROMPT = """Extract facts from text into structured format with FIVE required dimensions - BE EXTREMELY DETAILED.
|
||||
|
||||
LANGUAGE REQUIREMENT: Detect the language of the input text. All extracted facts, entity names, descriptions,
|
||||
and other output MUST be in the SAME language as the input. Do not translate to English if the input is in another language.
|
||||
|
||||
{fact_types_instruction}
|
||||
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
FACT FORMAT - ALL FIVE DIMENSIONS REQUIRED - MAXIMUM VERBOSITY
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
For EACH fact, CAPTURE ALL DETAILS - NEVER SUMMARIZE OR OMIT:
|
||||
|
||||
1. **what**: WHAT happened - COMPLETE description with ALL specifics (objects, actions, quantities, details)
|
||||
2. **when**: WHEN it happened - ALWAYS include temporal info with DAY OF WEEK (e.g., "Monday, June 10, 2024")
|
||||
- Always include the day name: Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday
|
||||
- Format: "day_name, month day, year" (e.g., "Saturday, June 9, 2024")
|
||||
3. **where**: WHERE it happened or is about - SPECIFIC locations, places, areas, regions (if applicable)
|
||||
4. **who**: WHO is involved - ALL people/entities with FULL relationships and background
|
||||
5. **why**: WHY it matters - ALL emotions, preferences, motivations, significance, nuance
|
||||
- For assistant facts: MUST include what the user asked/requested that triggered this!
|
||||
|
||||
Plus: fact_type, fact_kind, entities, occurred_start/end (for structured dates), where (structured location)
|
||||
|
||||
VERBOSITY REQUIREMENT: Include EVERY detail mentioned. More detail is ALWAYS better than less.
|
||||
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
COREFERENCE RESOLUTION (CRITICAL)
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
When text uses BOTH a generic relation AND a name for the same person → LINK THEM!
|
||||
|
||||
Example input: "I went to my college roommate's wedding last June. Emily finally married Sarah after 5 years together."
|
||||
|
||||
CORRECT output:
|
||||
- what: "Emily got married to Sarah at a rooftop garden ceremony"
|
||||
- when: "Saturday, June 8, 2024, after dating for 5 years"
|
||||
- where: "downtown San Francisco, at a rooftop garden venue"
|
||||
- who: "Emily (user's college roommate), Sarah (Emily's partner of 5 years)"
|
||||
- why: "User found it romantic and beautiful, dreams of similar outdoor ceremony"
|
||||
- where (structured): "San Francisco"
|
||||
|
||||
WRONG output:
|
||||
- what: "User's roommate got married" ← LOSES THE NAME!
|
||||
- who: "the roommate" ← WRONG - use the actual name!
|
||||
- where: (missing) ← WRONG - include the location!
|
||||
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
FACT_KIND CLASSIFICATION (CRITICAL FOR TEMPORAL HANDLING)
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
⚠️ MUST set fact_kind correctly - this determines whether occurred_start/end are set!
|
||||
|
||||
fact_kind="event" - USE FOR:
|
||||
- Actions that happened at a specific time: "went to", "attended", "visited", "bought", "made"
|
||||
- Past events: "yesterday I...", "last week...", "in March 2020..."
|
||||
- Future plans with dates: "will go to", "scheduled for"
|
||||
- Examples: "I went to a pottery workshop" → event
|
||||
"Alice visited Paris in February" → event
|
||||
"I bought a new car yesterday" → event
|
||||
"The user graduated from MIT in March 2020" → event
|
||||
|
||||
fact_kind="conversation" - USE FOR:
|
||||
- Ongoing states: "works as", "lives in", "is married to"
|
||||
- Preferences: "loves", "prefers", "enjoys"
|
||||
- Traits/abilities: "speaks fluent French", "knows Python"
|
||||
- Examples: "I love Italian food" → conversation
|
||||
"Alice works at Google" → conversation
|
||||
"I prefer outdoor dining" → conversation
|
||||
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
TEMPORAL HANDLING (CRITICAL - USE EVENT DATE AS REFERENCE)
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
⚠️ IMPORTANT: Use the "Event Date" provided in the input as your reference point!
|
||||
All relative dates ("yesterday", "last week", "recently") must be resolved relative to the Event Date, NOT today's date.
|
||||
|
||||
For EVENTS (fact_kind="event") - MUST SET BOTH occurred_start AND occurred_end:
|
||||
- Convert relative dates → absolute using Event Date as reference
|
||||
- If Event Date is "Saturday, March 15, 2020", then "yesterday" = Friday, March 14, 2020
|
||||
- Dates mentioned in text (e.g., "in March 2020") should use THAT year, not current year
|
||||
- Always include the day name (Monday, Tuesday, etc.) in the 'when' field
|
||||
- Set occurred_start AND occurred_end to WHEN IT HAPPENED (not when mentioned)
|
||||
- For single-day/point events: set occurred_end = occurred_start (same timestamp)
|
||||
|
||||
For CONVERSATIONS (fact_kind="conversation"):
|
||||
- General info, preferences, ongoing states → NO occurred dates
|
||||
- Examples: "loves coffee", "works as engineer"
|
||||
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
FACT TYPE
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
- **world**: User's life, other people, events (would exist without this conversation)
|
||||
- **assistant**: Interactions with assistant (requests, recommendations, help)
|
||||
⚠️ CRITICAL for assistant facts: ALWAYS capture the user's request/question in the fact!
|
||||
Include: what the user asked, what problem they wanted solved, what context they provided
|
||||
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
ENTITIES - EXTRACT EVERYTHING
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Extract ALL of the following from the fact:
|
||||
- People names (Emily, Alice, Dr. Smith)
|
||||
- Organizations (Google, MIT, local coffee shop)
|
||||
- Places (San Francisco, Brooklyn, Paris)
|
||||
- Significant objects mentioned (coffee maker, new car, wedding dress)
|
||||
- Abstract concepts/themes (friendship, career growth, loss, celebration)
|
||||
|
||||
ALWAYS include "user" when fact is about the user.
|
||||
Extract anything that could help link related facts together."""
|
||||
|
||||
|
||||
# Causal relationships section - appended when causal extraction is enabled
|
||||
CAUSAL_RELATIONSHIPS_SECTION = """
|
||||
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
CAUSAL RELATIONSHIPS
|
||||
@@ -485,14 +669,57 @@ Example: "Lost job → couldn't pay rent → moved apartment"
|
||||
- Fact 1: Couldn't pay rent, causal_relations: [{target_index: 0, relation_type: "caused_by"}]
|
||||
- Fact 2: Moved apartment, causal_relations: [{target_index: 1, relation_type: "caused_by"}]"""
|
||||
|
||||
# Check config for causal link extraction
|
||||
|
||||
async def _extract_facts_from_chunk(
|
||||
chunk: str,
|
||||
chunk_index: int,
|
||||
total_chunks: int,
|
||||
event_date: datetime,
|
||||
context: str,
|
||||
llm_config: "LLMConfig",
|
||||
agent_name: str = None,
|
||||
extract_opinions: bool = False,
|
||||
) -> tuple[list[dict[str, str]], TokenUsage]:
|
||||
"""
|
||||
Extract facts from a single chunk (internal helper for parallel processing).
|
||||
|
||||
Note: event_date parameter is kept for backward compatibility but not used in prompt.
|
||||
The LLM extracts temporal information from the context string instead.
|
||||
"""
|
||||
memory_bank_context = f"\n- Your name: {agent_name}" if agent_name and extract_opinions else ""
|
||||
|
||||
# Determine which fact types to extract based on the flag
|
||||
# Note: We use "assistant" in the prompt but convert to "bank" for storage
|
||||
if extract_opinions:
|
||||
# Opinion extraction uses a separate prompt (not this one)
|
||||
fact_types_instruction = "Extract ONLY 'opinion' type facts (formed opinions, beliefs, and perspectives). DO NOT extract 'world' or 'assistant' facts."
|
||||
else:
|
||||
fact_types_instruction = (
|
||||
"Extract ONLY 'world' and 'assistant' type facts. DO NOT extract opinions - those are extracted separately."
|
||||
)
|
||||
|
||||
# Check config for extraction mode and causal link extraction
|
||||
config = get_config()
|
||||
extraction_mode = config.retain_extraction_mode
|
||||
extract_causal_links = config.retain_extract_causal_links
|
||||
|
||||
# Select base prompt based on extraction mode
|
||||
if extraction_mode == "verbose":
|
||||
base_prompt = VERBOSE_FACT_EXTRACTION_PROMPT
|
||||
else:
|
||||
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
|
||||
|
||||
# Format the prompt with fact types instruction
|
||||
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
|
||||
|
||||
# Build the full prompt with or without causal relationships section
|
||||
# Select appropriate response schema based on extraction mode and causal links
|
||||
if extract_causal_links:
|
||||
prompt = prompt + causal_relationships_section
|
||||
response_schema = FactExtractionResponse
|
||||
prompt = prompt + CAUSAL_RELATIONSHIPS_SECTION
|
||||
if extraction_mode == "verbose":
|
||||
response_schema = FactExtractionResponseVerbose
|
||||
else:
|
||||
response_schema = FactExtractionResponse
|
||||
else:
|
||||
response_schema = FactExtractionResponseNoCausal
|
||||
|
||||
@@ -898,7 +1125,7 @@ async def extract_facts_from_text(
|
||||
# Log chunk count before starting LLM requests
|
||||
total_chars = sum(len(c) for c in chunks)
|
||||
if len(chunks) > 1:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
f"[FACT_EXTRACTION] Text chunked into {len(chunks)} chunks ({total_chars:,} chars total, "
|
||||
f"chunk_size={config.retain_chunk_size:,}) - starting parallel LLM extraction"
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from ...config import get_config
|
||||
from ..db_utils import acquire_with_retry
|
||||
from . import bank_utils
|
||||
|
||||
@@ -395,16 +396,26 @@ async def retain_batch(
|
||||
causal_link_count = await link_creation.create_causal_links_batch(conn, unit_ids, non_duplicate_facts)
|
||||
log_buffer.append(f"[10] Causal links: {causal_link_count} links in {time.time() - step_start:.3f}s")
|
||||
|
||||
# Regenerate observations INSIDE transaction for atomicity
|
||||
await observation_regeneration.regenerate_observations_batch(
|
||||
conn, embeddings_model, llm_config, bank_id, entity_links, log_buffer
|
||||
)
|
||||
# Regenerate observations - sync (in transaction) or async (background task)
|
||||
config = get_config()
|
||||
if config.retain_observations_async:
|
||||
# Queue for async processing after transaction commits
|
||||
entity_ids_for_async = list(set(link.entity_id for link in entity_links)) if entity_links else []
|
||||
log_buffer.append(
|
||||
f"[11] Observations: queued {len(entity_ids_for_async)} entities for async processing"
|
||||
)
|
||||
else:
|
||||
# Run synchronously inside transaction for atomicity
|
||||
await observation_regeneration.regenerate_observations_batch(
|
||||
conn, embeddings_model, llm_config, bank_id, entity_links, log_buffer
|
||||
)
|
||||
entity_ids_for_async = []
|
||||
|
||||
# Map results back to original content items
|
||||
result_unit_ids = _map_results_to_contents(contents, extracted_facts, is_duplicate_flags, unit_ids)
|
||||
|
||||
# Trigger background tasks AFTER transaction commits (opinion reinforcement only)
|
||||
await _trigger_background_tasks(task_backend, bank_id, unit_ids, non_duplicate_facts)
|
||||
# Trigger background tasks AFTER transaction commits
|
||||
await _trigger_background_tasks(task_backend, bank_id, unit_ids, non_duplicate_facts, entity_ids_for_async)
|
||||
|
||||
# Log final summary
|
||||
total_time = time.time() - start_time
|
||||
@@ -454,8 +465,9 @@ async def _trigger_background_tasks(
|
||||
bank_id: str,
|
||||
unit_ids: list[str],
|
||||
facts: list[ProcessedFact],
|
||||
entity_ids_for_observations: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Trigger opinion reinforcement as background task (after transaction commits)."""
|
||||
"""Trigger background tasks after transaction commits."""
|
||||
# Trigger opinion reinforcement if there are entities
|
||||
fact_entities = [[e.name for e in fact.entities] for fact in facts]
|
||||
if any(fact_entities):
|
||||
@@ -468,3 +480,13 @@ async def _trigger_background_tasks(
|
||||
"unit_entities": fact_entities,
|
||||
}
|
||||
)
|
||||
|
||||
# Trigger observation regeneration if async mode is enabled
|
||||
if entity_ids_for_observations:
|
||||
await task_backend.submit_task(
|
||||
{
|
||||
"type": "regenerate_observations",
|
||||
"bank_id": bank_id,
|
||||
"entity_ids": entity_ids_for_observations,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ from abc import ABC, abstractmethod
|
||||
|
||||
from ..db_utils import acquire_with_retry
|
||||
from ..memory_engine import fq_table
|
||||
from .types import RetrievalResult
|
||||
from .types import MPFPTimings, RetrievalResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,7 +42,8 @@ class GraphRetriever(ABC):
|
||||
query_text: str | None = None,
|
||||
semantic_seeds: list[RetrievalResult] | None = None,
|
||||
temporal_seeds: list[RetrievalResult] | None = None,
|
||||
) -> list[RetrievalResult]:
|
||||
adjacency=None, # TypedAdjacency, optional pre-loaded graph
|
||||
) -> tuple[list[RetrievalResult], MPFPTimings | None]:
|
||||
"""
|
||||
Retrieve relevant facts via graph traversal.
|
||||
|
||||
@@ -55,9 +56,10 @@ class GraphRetriever(ABC):
|
||||
query_text: Original query text (optional, for some strategies)
|
||||
semantic_seeds: Pre-computed semantic entry points (from semantic retrieval)
|
||||
temporal_seeds: Pre-computed temporal entry points (from temporal retrieval)
|
||||
adjacency: Pre-loaded typed adjacency graph (optional, for MPFP)
|
||||
|
||||
Returns:
|
||||
List of RetrievalResult objects with activation scores set
|
||||
Tuple of (List of RetrievalResult with activation scores, optional timing info)
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -111,7 +113,8 @@ class BFSGraphRetriever(GraphRetriever):
|
||||
query_text: str | None = None,
|
||||
semantic_seeds: list[RetrievalResult] | None = None,
|
||||
temporal_seeds: list[RetrievalResult] | None = None,
|
||||
) -> list[RetrievalResult]:
|
||||
adjacency=None, # Not used by BFS
|
||||
) -> tuple[list[RetrievalResult], MPFPTimings | None]:
|
||||
"""
|
||||
Retrieve facts using BFS spreading activation.
|
||||
|
||||
@@ -122,11 +125,12 @@ class BFSGraphRetriever(GraphRetriever):
|
||||
4. Return visited nodes up to budget
|
||||
|
||||
Note: BFS finds its own entry points via embedding search.
|
||||
The semantic_seeds and temporal_seeds parameters are accepted
|
||||
The semantic_seeds, temporal_seeds, and adjacency parameters are accepted
|
||||
for interface compatibility but not used.
|
||||
"""
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
return await self._retrieve_with_conn(conn, query_embedding_str, bank_id, fact_type, budget)
|
||||
results = await self._retrieve_with_conn(conn, query_embedding_str, bank_id, fact_type, budget)
|
||||
return results, None
|
||||
|
||||
async def _retrieve_with_conn(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
Link Expansion graph retrieval.
|
||||
|
||||
A simple, fast graph retrieval that expands from seeds via:
|
||||
1. Entity links: Find facts sharing entities with seeds (filtered by entity frequency)
|
||||
2. Causal links: Find facts causally linked to seeds (top-k by weight)
|
||||
|
||||
Characteristics:
|
||||
- 2-3 DB queries (seed finding + parallel entity/causal expansion)
|
||||
- Sublinear: only touches connected facts via indexes
|
||||
- No iteration, no propagation, no normalization
|
||||
- Target: <100ms
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from ..db_utils import acquire_with_retry
|
||||
from ..memory_engine import fq_table
|
||||
from .graph_retrieval import GraphRetriever
|
||||
from .types import MPFPTimings, RetrievalResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _find_semantic_seeds(
|
||||
conn,
|
||||
query_embedding_str: str,
|
||||
bank_id: str,
|
||||
fact_type: str,
|
||||
limit: int = 20,
|
||||
threshold: float = 0.3,
|
||||
) -> list[RetrievalResult]:
|
||||
"""Find semantic seeds via embedding search."""
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end,
|
||||
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
AND embedding IS NOT NULL
|
||||
AND fact_type = $3
|
||||
AND (1 - (embedding <=> $1::vector)) >= $4
|
||||
ORDER BY embedding <=> $1::vector
|
||||
LIMIT $5
|
||||
""",
|
||||
query_embedding_str,
|
||||
bank_id,
|
||||
fact_type,
|
||||
threshold,
|
||||
limit,
|
||||
)
|
||||
return [RetrievalResult.from_db_row(dict(r)) for r in rows]
|
||||
|
||||
|
||||
class LinkExpansionRetriever(GraphRetriever):
|
||||
"""
|
||||
Graph retrieval via direct link expansion from seeds.
|
||||
|
||||
Expands through entity co-occurrence and causal links in a single query.
|
||||
Fast and simple alternative to MPFP.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_entity_frequency: int = 500,
|
||||
causal_weight_threshold: float = 0.3,
|
||||
causal_limit_per_seed: int = 10,
|
||||
):
|
||||
"""
|
||||
Initialize link expansion retriever.
|
||||
|
||||
Args:
|
||||
max_entity_frequency: Skip entities appearing in more than this many facts
|
||||
causal_weight_threshold: Minimum weight for causal links
|
||||
causal_limit_per_seed: Max causal links to follow per seed
|
||||
"""
|
||||
self.max_entity_frequency = max_entity_frequency
|
||||
self.causal_weight_threshold = causal_weight_threshold
|
||||
self.causal_limit_per_seed = causal_limit_per_seed
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "link_expansion"
|
||||
|
||||
async def retrieve(
|
||||
self,
|
||||
pool,
|
||||
query_embedding_str: str,
|
||||
bank_id: str,
|
||||
fact_type: str,
|
||||
budget: int,
|
||||
query_text: str | None = None,
|
||||
semantic_seeds: list[RetrievalResult] | None = None,
|
||||
temporal_seeds: list[RetrievalResult] | None = None,
|
||||
adjacency=None,
|
||||
) -> tuple[list[RetrievalResult], MPFPTimings | None]:
|
||||
"""
|
||||
Retrieve facts by expanding links from seeds.
|
||||
|
||||
Args:
|
||||
pool: Database connection pool
|
||||
query_embedding_str: Query embedding (unused, kept for interface)
|
||||
bank_id: Memory bank ID
|
||||
fact_type: Fact type to filter
|
||||
budget: Maximum results to return
|
||||
query_text: Original query text (unused)
|
||||
semantic_seeds: Pre-computed semantic entry points
|
||||
temporal_seeds: Pre-computed temporal entry points
|
||||
adjacency: Unused, kept for interface compatibility
|
||||
|
||||
Returns:
|
||||
Tuple of (results, timings)
|
||||
"""
|
||||
start_time = time.time()
|
||||
timings = MPFPTimings(fact_type=fact_type)
|
||||
|
||||
# Use single connection for all queries to reduce pool pressure
|
||||
# (queries are fast ~50ms each, connection acquisition is the bottleneck)
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
# Find seeds if not provided
|
||||
if semantic_seeds:
|
||||
all_seeds = list(semantic_seeds)
|
||||
else:
|
||||
seeds_start = time.time()
|
||||
all_seeds = await _find_semantic_seeds(
|
||||
conn, query_embedding_str, bank_id, fact_type, limit=20, threshold=0.3
|
||||
)
|
||||
timings.seeds_time = time.time() - seeds_start
|
||||
|
||||
# Add temporal seeds if provided
|
||||
if temporal_seeds:
|
||||
all_seeds.extend(temporal_seeds)
|
||||
|
||||
if not all_seeds:
|
||||
return [], timings
|
||||
|
||||
seed_ids = list({s.id for s in all_seeds})
|
||||
timings.pattern_count = len(seed_ids)
|
||||
|
||||
# Run entity and causal expansion sequentially on same connection
|
||||
query_start = time.time()
|
||||
|
||||
entity_rows = await conn.fetch(
|
||||
f"""
|
||||
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, mu.chunk_id,
|
||||
COUNT(*)::float AS score
|
||||
FROM {fq_table("unit_entities")} seed_ue
|
||||
JOIN {fq_table("entities")} e ON seed_ue.entity_id = e.id
|
||||
JOIN {fq_table("unit_entities")} other_ue ON seed_ue.entity_id = other_ue.entity_id
|
||||
JOIN {fq_table("memory_units")} mu ON other_ue.unit_id = mu.id
|
||||
WHERE seed_ue.unit_id = ANY($1::uuid[])
|
||||
AND e.mention_count < $2
|
||||
AND mu.id != ALL($1::uuid[])
|
||||
AND mu.fact_type = $3
|
||||
GROUP BY mu.id
|
||||
ORDER BY score DESC
|
||||
LIMIT $4
|
||||
""",
|
||||
seed_ids,
|
||||
self.max_entity_frequency,
|
||||
fact_type,
|
||||
budget,
|
||||
)
|
||||
|
||||
causal_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT DISTINCT ON (mu.id)
|
||||
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, mu.chunk_id,
|
||||
ml.weight + 1.0 AS score
|
||||
FROM {fq_table("memory_links")} ml
|
||||
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
|
||||
WHERE ml.from_unit_id = ANY($1::uuid[])
|
||||
AND ml.link_type IN ('causes', 'caused_by', 'enables', 'prevents')
|
||||
AND ml.weight >= $2
|
||||
AND mu.fact_type = $3
|
||||
ORDER BY mu.id, ml.weight DESC
|
||||
LIMIT $4
|
||||
""",
|
||||
seed_ids,
|
||||
self.causal_weight_threshold,
|
||||
fact_type,
|
||||
budget,
|
||||
)
|
||||
|
||||
timings.edge_load_time = time.time() - query_start
|
||||
timings.db_queries = 2
|
||||
timings.edge_count = len(entity_rows) + len(causal_rows)
|
||||
|
||||
# Merge results, taking max score per fact
|
||||
score_map: dict[str, float] = {}
|
||||
row_map: dict[str, dict] = {}
|
||||
|
||||
for row in entity_rows:
|
||||
fact_id = str(row["id"])
|
||||
score_map[fact_id] = max(score_map.get(fact_id, 0), row["score"])
|
||||
row_map[fact_id] = dict(row)
|
||||
|
||||
for row in causal_rows:
|
||||
fact_id = str(row["id"])
|
||||
score_map[fact_id] = max(score_map.get(fact_id, 0), row["score"])
|
||||
if fact_id not in row_map:
|
||||
row_map[fact_id] = dict(row)
|
||||
|
||||
# Sort by score and limit
|
||||
sorted_ids = sorted(score_map.keys(), key=lambda x: score_map[x], reverse=True)[:budget]
|
||||
rows = [row_map[fact_id] for fact_id in sorted_ids]
|
||||
|
||||
# Convert to results
|
||||
results = []
|
||||
for row in rows:
|
||||
result = RetrievalResult.from_db_row(dict(row))
|
||||
result.activation = row["score"]
|
||||
results.append(result)
|
||||
|
||||
timings.result_count = len(results)
|
||||
timings.traverse = time.time() - start_time
|
||||
|
||||
logger.debug(
|
||||
f"LinkExpansion: {len(results)} results from {len(seed_ids)} seeds "
|
||||
f"in {timings.traverse * 1000:.1f}ms (query: {timings.edge_load_time * 1000:.1f}ms)"
|
||||
)
|
||||
|
||||
return results, timings
|
||||
@@ -9,6 +9,7 @@ propagation from Approximate PPR.
|
||||
|
||||
Key properties:
|
||||
- Sublinear in graph size (threshold pruning bounds active nodes)
|
||||
- Lazy edge loading: only loads edges for frontier nodes, not entire graph
|
||||
- Predefined patterns capture different retrieval intents
|
||||
- All patterns run in parallel, results fused via RRF
|
||||
- No LLM in the loop during traversal
|
||||
@@ -22,7 +23,7 @@ from dataclasses import dataclass, field
|
||||
from ..db_utils import acquire_with_retry
|
||||
from ..memory_engine import fq_table
|
||||
from .graph_retrieval import GraphRetriever
|
||||
from .types import RetrievalResult
|
||||
from .types import MPFPTimings, RetrievalResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,11 +42,27 @@ class EdgeTarget:
|
||||
|
||||
|
||||
@dataclass
|
||||
class TypedAdjacency:
|
||||
"""Adjacency lists split by edge type."""
|
||||
class EdgeCache:
|
||||
"""
|
||||
Cache for lazily-loaded edges.
|
||||
|
||||
# edge_type -> from_node_id -> list of (to_node_id, weight)
|
||||
Grows per-hop as edges are loaded for frontier nodes.
|
||||
Shared across patterns to avoid redundant loads.
|
||||
Loads ALL edge types at once to minimize DB queries.
|
||||
Thread-safe via asyncio lock to prevent redundant concurrent loads.
|
||||
"""
|
||||
|
||||
# edge_type -> from_node_id -> list of EdgeTarget
|
||||
graphs: dict[str, dict[str, list[EdgeTarget]]] = field(default_factory=dict)
|
||||
# Track which nodes have been fully loaded (all edge types)
|
||||
_fully_loaded: set[str] = field(default_factory=set)
|
||||
# Timing stats
|
||||
db_queries: int = 0
|
||||
edge_load_time: float = 0.0
|
||||
# Detailed hop timing for debugging
|
||||
hop_details: list[dict] = field(default_factory=list)
|
||||
# Lock to prevent redundant concurrent loads
|
||||
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
def get_neighbors(self, edge_type: str, node_id: str) -> list[EdgeTarget]:
|
||||
"""Get neighbors for a node via a specific edge type."""
|
||||
@@ -63,6 +80,31 @@ class TypedAdjacency:
|
||||
|
||||
return [EdgeTarget(node_id=n.node_id, weight=n.weight / total) for n in neighbors]
|
||||
|
||||
def is_fully_loaded(self, node_id: str) -> bool:
|
||||
"""Check if all edges for this node have been loaded."""
|
||||
return node_id in self._fully_loaded
|
||||
|
||||
def get_uncached(self, node_ids: list[str]) -> list[str]:
|
||||
"""Get node IDs that haven't been fully loaded yet."""
|
||||
return [n for n in node_ids if not self.is_fully_loaded(n)]
|
||||
|
||||
def add_all_edges(self, edges_by_type: dict[str, dict[str, list[EdgeTarget]]], all_queried: list[str]):
|
||||
"""
|
||||
Add loaded edges to the cache (all edge types at once).
|
||||
|
||||
Args:
|
||||
edges_by_type: Dict mapping edge_type -> from_node_id -> list of EdgeTarget
|
||||
all_queried: All node IDs that were queried (marks them as fully loaded)
|
||||
"""
|
||||
for edge_type, edges in edges_by_type.items():
|
||||
if edge_type not in self.graphs:
|
||||
self.graphs[edge_type] = {}
|
||||
for node_id, neighbors in edges.items():
|
||||
self.graphs[edge_type][node_id] = neighbors
|
||||
|
||||
# Mark all queried nodes as fully loaded (even if they have no edges)
|
||||
self._fully_loaded.update(all_queried)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PatternResult:
|
||||
@@ -109,66 +151,249 @@ class SeedNode:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Core Algorithm
|
||||
# Lazy Edge Loading
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def mpfp_traverse(
|
||||
seeds: list[SeedNode],
|
||||
pattern: list[str],
|
||||
adjacency: TypedAdjacency,
|
||||
config: MPFPConfig,
|
||||
) -> PatternResult:
|
||||
async def load_all_edges_for_frontier(
|
||||
pool,
|
||||
node_ids: list[str],
|
||||
top_k_per_type: int = 20,
|
||||
) -> dict[str, dict[str, list[EdgeTarget]]]:
|
||||
"""
|
||||
Forward Push traversal following a meta-path pattern.
|
||||
Load top-k edges per (node, edge_type) for frontier nodes.
|
||||
|
||||
Uses a LATERAL join to efficiently fetch only the top-k edges per type,
|
||||
avoiding loading hundreds of entity edges when only 20 are needed.
|
||||
|
||||
Requires composite index: (from_unit_id, link_type, weight DESC)
|
||||
|
||||
Args:
|
||||
seeds: Entry point nodes with initial scores
|
||||
pattern: Sequence of edge types to follow
|
||||
adjacency: Typed adjacency structure
|
||||
config: Algorithm parameters
|
||||
pool: Database connection pool
|
||||
node_ids: Frontier node IDs to load edges for
|
||||
top_k_per_type: Max edges to load per (node, link_type) pair
|
||||
|
||||
Returns:
|
||||
PatternResult with accumulated scores per node
|
||||
Dict mapping edge_type -> from_node_id -> list of EdgeTarget
|
||||
"""
|
||||
if not node_ids:
|
||||
return {}
|
||||
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
# Use LATERAL join to get top-k per (from_node, link_type)
|
||||
# This leverages the composite index for efficient early termination
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
WITH frontier(node_id) AS (SELECT unnest($1::uuid[]))
|
||||
SELECT f.node_id as from_unit_id, lt.link_type, edges.to_unit_id, edges.weight
|
||||
FROM frontier f
|
||||
CROSS JOIN (VALUES ('semantic'), ('temporal'), ('entity'), ('causes'), ('caused_by')) AS lt(link_type)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT ml.to_unit_id, ml.weight
|
||||
FROM {fq_table("memory_links")} ml
|
||||
WHERE ml.from_unit_id = f.node_id
|
||||
AND ml.link_type = lt.link_type
|
||||
AND ml.weight >= 0.1
|
||||
ORDER BY ml.weight DESC
|
||||
LIMIT $2
|
||||
) edges
|
||||
""",
|
||||
node_ids,
|
||||
top_k_per_type,
|
||||
)
|
||||
|
||||
# Group by edge_type -> from_node -> neighbors
|
||||
result: dict[str, dict[str, list[EdgeTarget]]] = defaultdict(lambda: defaultdict(list))
|
||||
for row in rows:
|
||||
edge_type = row["link_type"]
|
||||
from_id = str(row["from_unit_id"])
|
||||
to_id = str(row["to_unit_id"])
|
||||
weight = row["weight"]
|
||||
result[edge_type][from_id].append(EdgeTarget(node_id=to_id, weight=weight))
|
||||
|
||||
# Convert nested defaultdicts to regular dicts
|
||||
return {edge_type: dict(edges) for edge_type, edges in result.items()}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Core Algorithm (Async with Lazy Loading)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class PatternState:
|
||||
"""State for a pattern traversal between hops."""
|
||||
|
||||
pattern: list[str]
|
||||
hop_index: int
|
||||
scores: dict[str, float]
|
||||
frontier: dict[str, float]
|
||||
|
||||
|
||||
def _init_pattern_state(seeds: list[SeedNode], pattern: list[str]) -> PatternState:
|
||||
"""Initialize pattern state from seeds."""
|
||||
if not seeds:
|
||||
return PatternState(pattern=pattern, hop_index=0, scores={}, frontier={})
|
||||
|
||||
total_seed_score = sum(s.score for s in seeds)
|
||||
if total_seed_score == 0:
|
||||
total_seed_score = len(seeds)
|
||||
|
||||
frontier = {s.node_id: s.score / total_seed_score for s in seeds}
|
||||
return PatternState(pattern=pattern, hop_index=0, scores={}, frontier=frontier)
|
||||
|
||||
|
||||
def _execute_hop(state: PatternState, cache: EdgeCache, config: MPFPConfig) -> set[str]:
|
||||
"""
|
||||
Execute ONE hop of traversal, return frontier nodes for next hop.
|
||||
|
||||
This is a pure function that uses cached edges (no DB access).
|
||||
Returns set of uncached nodes needed for next hop.
|
||||
"""
|
||||
if state.hop_index >= len(state.pattern):
|
||||
return set()
|
||||
|
||||
edge_type = state.pattern[state.hop_index]
|
||||
|
||||
# Collect active nodes above threshold
|
||||
active_nodes = [node_id for node_id, mass in state.frontier.items() if mass >= config.threshold]
|
||||
if not active_nodes:
|
||||
state.frontier = {}
|
||||
return set()
|
||||
|
||||
# Propagate mass using cached edges
|
||||
next_frontier: dict[str, float] = {}
|
||||
uncached_for_next: set[str] = set()
|
||||
|
||||
for node_id, mass in state.frontier.items():
|
||||
if mass < config.threshold:
|
||||
continue
|
||||
|
||||
# Keep α portion for this node
|
||||
state.scores[node_id] = state.scores.get(node_id, 0) + config.alpha * mass
|
||||
|
||||
# Push (1-α) to neighbors
|
||||
push_mass = (1 - config.alpha) * mass
|
||||
neighbors = cache.get_normalized_neighbors(edge_type, node_id, config.top_k_neighbors)
|
||||
|
||||
for neighbor in neighbors:
|
||||
next_frontier[neighbor.node_id] = next_frontier.get(neighbor.node_id, 0) + push_mass * neighbor.weight
|
||||
# Track if we'll need edges for this node in the next hop
|
||||
if not cache.is_fully_loaded(neighbor.node_id):
|
||||
uncached_for_next.add(neighbor.node_id)
|
||||
|
||||
state.frontier = next_frontier
|
||||
state.hop_index += 1
|
||||
|
||||
return uncached_for_next
|
||||
|
||||
|
||||
def _finalize_pattern(state: PatternState, config: MPFPConfig) -> PatternResult:
|
||||
"""Finalize pattern by adding remaining frontier mass to scores."""
|
||||
for node_id, mass in state.frontier.items():
|
||||
if mass >= config.threshold:
|
||||
state.scores[node_id] = state.scores.get(node_id, 0) + mass
|
||||
|
||||
return PatternResult(pattern=state.pattern, scores=state.scores)
|
||||
|
||||
|
||||
async def mpfp_traverse_hop_synchronized(
|
||||
pool,
|
||||
pattern_jobs: list[tuple[list[SeedNode], list[str]]],
|
||||
config: MPFPConfig,
|
||||
cache: EdgeCache,
|
||||
) -> list[PatternResult]:
|
||||
"""
|
||||
Execute ALL patterns with hop-synchronized edge loading.
|
||||
|
||||
Instead of running each pattern independently (causing multiple DB queries),
|
||||
this function:
|
||||
1. Runs hop 1 for ALL patterns (using pre-warmed seed edges)
|
||||
2. Collects ALL unique hop-2 frontier nodes across patterns
|
||||
3. Pre-warms hop-2 edges in ONE query
|
||||
4. Runs hop 2 for ALL patterns
|
||||
|
||||
This reduces DB queries from O(patterns * hops) to O(hops).
|
||||
|
||||
Args:
|
||||
pool: Database connection pool
|
||||
pattern_jobs: List of (seeds, pattern) tuples
|
||||
config: Algorithm parameters
|
||||
cache: Shared edge cache (should be pre-warmed with seed edges)
|
||||
|
||||
Returns:
|
||||
List of PatternResult for each pattern
|
||||
"""
|
||||
import time
|
||||
|
||||
# Initialize all pattern states
|
||||
states = [_init_pattern_state(seeds, pattern) for seeds, pattern in pattern_jobs]
|
||||
|
||||
# Determine max hops (all patterns should be same length, but be safe)
|
||||
max_hops = max((len(p) for _, p in pattern_jobs), default=0)
|
||||
|
||||
# Detailed timing for debugging
|
||||
hop_times: list[dict] = []
|
||||
|
||||
# Execute hop-by-hop across ALL patterns
|
||||
for hop in range(max_hops):
|
||||
hop_start = time.time()
|
||||
hop_timing = {"hop": hop, "patterns_executed": 0, "uncached_count": 0, "load_time": 0.0}
|
||||
|
||||
# Execute this hop for all patterns, collect uncached nodes for next hop
|
||||
all_uncached: set[str] = set()
|
||||
exec_start = time.time()
|
||||
for state in states:
|
||||
if state.hop_index < len(state.pattern):
|
||||
uncached = _execute_hop(state, cache, config)
|
||||
all_uncached.update(uncached)
|
||||
hop_timing["patterns_executed"] += 1
|
||||
hop_timing["exec_time"] = time.time() - exec_start
|
||||
|
||||
# Pre-warm edges for ALL uncached nodes before next hop
|
||||
hop_timing["uncached_count"] = len(all_uncached)
|
||||
if all_uncached:
|
||||
uncached_list = list(all_uncached - cache._fully_loaded)
|
||||
hop_timing["uncached_after_filter"] = len(uncached_list)
|
||||
if uncached_list:
|
||||
load_start = time.time()
|
||||
edges_by_type = await load_all_edges_for_frontier(pool, uncached_list, config.top_k_neighbors)
|
||||
hop_timing["load_time"] = time.time() - load_start
|
||||
cache.edge_load_time += hop_timing["load_time"]
|
||||
cache.db_queries += 1
|
||||
cache.add_all_edges(edges_by_type, uncached_list)
|
||||
hop_timing["edges_loaded"] = sum(
|
||||
len(neighbors) for edges in edges_by_type.values() for neighbors in edges.values()
|
||||
)
|
||||
|
||||
hop_timing["total_time"] = time.time() - hop_start
|
||||
hop_times.append(hop_timing)
|
||||
|
||||
# Store hop timing details in cache for logging
|
||||
cache.hop_details = hop_times
|
||||
|
||||
# Finalize all patterns
|
||||
return [_finalize_pattern(state, config) for state in states]
|
||||
|
||||
|
||||
async def mpfp_traverse_async(
|
||||
pool,
|
||||
seeds: list[SeedNode],
|
||||
pattern: list[str],
|
||||
config: MPFPConfig,
|
||||
cache: EdgeCache,
|
||||
) -> PatternResult:
|
||||
"""
|
||||
Async Forward Push traversal with lazy edge loading.
|
||||
|
||||
NOTE: For better performance with multiple patterns, use mpfp_traverse_hop_synchronized().
|
||||
This function is kept for single-pattern use cases.
|
||||
"""
|
||||
if not seeds:
|
||||
return PatternResult(pattern=pattern, scores={})
|
||||
|
||||
scores: dict[str, float] = {}
|
||||
|
||||
# Initialize frontier with seed masses (normalized)
|
||||
total_seed_score = sum(s.score for s in seeds)
|
||||
if total_seed_score == 0:
|
||||
total_seed_score = len(seeds) # fallback to uniform
|
||||
|
||||
frontier: dict[str, float] = {s.node_id: s.score / total_seed_score for s in seeds}
|
||||
|
||||
# Follow pattern hop by hop
|
||||
for edge_type in pattern:
|
||||
next_frontier: dict[str, float] = {}
|
||||
|
||||
for node_id, mass in frontier.items():
|
||||
if mass < config.threshold:
|
||||
continue
|
||||
|
||||
# Keep α portion for this node
|
||||
scores[node_id] = scores.get(node_id, 0) + config.alpha * mass
|
||||
|
||||
# Push (1-α) to neighbors
|
||||
push_mass = (1 - config.alpha) * mass
|
||||
neighbors = adjacency.get_normalized_neighbors(edge_type, node_id, config.top_k_neighbors)
|
||||
|
||||
for neighbor in neighbors:
|
||||
next_frontier[neighbor.node_id] = next_frontier.get(neighbor.node_id, 0) + push_mass * neighbor.weight
|
||||
|
||||
frontier = next_frontier
|
||||
|
||||
# Final frontier nodes get their remaining mass
|
||||
for node_id, mass in frontier.items():
|
||||
if mass >= config.threshold:
|
||||
scores[node_id] = scores.get(node_id, 0) + mass
|
||||
|
||||
return PatternResult(pattern=pattern, scores=scores)
|
||||
results = await mpfp_traverse_hop_synchronized(pool, [(seeds, pattern)], config, cache)
|
||||
return results[0] if results else PatternResult(pattern=pattern, scores={})
|
||||
|
||||
|
||||
def rrf_fusion(
|
||||
@@ -210,38 +435,6 @@ def rrf_fusion(
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def load_typed_adjacency(pool, bank_id: str) -> TypedAdjacency:
|
||||
"""
|
||||
Load all edges for a bank, split by edge type.
|
||||
|
||||
Single query, then organize in-memory for fast traversal.
|
||||
"""
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT ml.from_unit_id, ml.to_unit_id, ml.link_type, ml.weight
|
||||
FROM {fq_table("memory_links")} ml
|
||||
JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
|
||||
WHERE mu.bank_id = $1
|
||||
AND ml.weight >= 0.1
|
||||
ORDER BY ml.from_unit_id, ml.weight DESC
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
graphs: dict[str, dict[str, list[EdgeTarget]]] = defaultdict(lambda: defaultdict(list))
|
||||
|
||||
for row in rows:
|
||||
from_id = str(row["from_unit_id"])
|
||||
to_id = str(row["to_unit_id"])
|
||||
link_type = row["link_type"]
|
||||
weight = row["weight"]
|
||||
|
||||
graphs[link_type][from_id].append(EdgeTarget(node_id=to_id, weight=weight))
|
||||
|
||||
return TypedAdjacency(graphs=dict(graphs))
|
||||
|
||||
|
||||
async def fetch_memory_units_by_ids(
|
||||
pool,
|
||||
node_ids: list[str],
|
||||
@@ -274,10 +467,10 @@ async def fetch_memory_units_by_ids(
|
||||
|
||||
class MPFPGraphRetriever(GraphRetriever):
|
||||
"""
|
||||
Graph retrieval using Meta-Path Forward Push.
|
||||
Graph retrieval using Meta-Path Forward Push with lazy edge loading.
|
||||
|
||||
Runs predefined patterns in parallel from semantic and temporal seeds,
|
||||
then fuses results via RRF.
|
||||
loading edges on-demand per hop instead of loading entire graph upfront.
|
||||
"""
|
||||
|
||||
def __init__(self, config: MPFPConfig | None = None):
|
||||
@@ -287,8 +480,13 @@ class MPFPGraphRetriever(GraphRetriever):
|
||||
Args:
|
||||
config: Algorithm configuration (uses defaults if None)
|
||||
"""
|
||||
self.config = config or MPFPConfig()
|
||||
self._adjacency_cache: dict[str, TypedAdjacency] = {}
|
||||
if config is None:
|
||||
# Read top_k_neighbors from global config
|
||||
from ...config import get_config
|
||||
|
||||
global_config = get_config()
|
||||
config = MPFPConfig(top_k_neighbors=global_config.mpfp_top_k_neighbors)
|
||||
self.config = config
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -304,9 +502,10 @@ class MPFPGraphRetriever(GraphRetriever):
|
||||
query_text: str | None = None,
|
||||
semantic_seeds: list[RetrievalResult] | None = None,
|
||||
temporal_seeds: list[RetrievalResult] | None = None,
|
||||
) -> list[RetrievalResult]:
|
||||
adjacency=None, # Ignored - kept for interface compatibility
|
||||
) -> tuple[list[RetrievalResult], MPFPTimings | None]:
|
||||
"""
|
||||
Retrieve facts using MPFP algorithm.
|
||||
Retrieve facts using MPFP algorithm with lazy edge loading.
|
||||
|
||||
Args:
|
||||
pool: Database connection pool
|
||||
@@ -317,12 +516,14 @@ class MPFPGraphRetriever(GraphRetriever):
|
||||
query_text: Original query text (optional)
|
||||
semantic_seeds: Pre-computed semantic entry points
|
||||
temporal_seeds: Pre-computed temporal entry points
|
||||
adjacency: Ignored (kept for interface compatibility)
|
||||
|
||||
Returns:
|
||||
List of RetrievalResult with activation scores
|
||||
Tuple of (List of RetrievalResult with activation scores, MPFPTimings)
|
||||
"""
|
||||
# Load typed adjacency (could cache per bank_id with TTL)
|
||||
adjacency = await load_typed_adjacency(pool, bank_id)
|
||||
import time
|
||||
|
||||
timings = MPFPTimings(fact_type=fact_type)
|
||||
|
||||
# Convert seeds to SeedNode format
|
||||
semantic_seed_nodes = self._convert_seeds(semantic_seeds, "similarity")
|
||||
@@ -330,54 +531,72 @@ class MPFPGraphRetriever(GraphRetriever):
|
||||
|
||||
# If no semantic seeds provided, fall back to finding our own
|
||||
if not semantic_seed_nodes:
|
||||
seeds_start = time.time()
|
||||
semantic_seed_nodes = await self._find_semantic_seeds(pool, query_embedding_str, bank_id, fact_type)
|
||||
timings.seeds_time = time.time() - seeds_start
|
||||
|
||||
# Run all patterns in parallel
|
||||
tasks = []
|
||||
# Collect all pattern jobs
|
||||
pattern_jobs = []
|
||||
|
||||
# Patterns from semantic seeds
|
||||
for pattern in self.config.patterns_semantic:
|
||||
if semantic_seed_nodes:
|
||||
tasks.append(
|
||||
asyncio.to_thread(
|
||||
mpfp_traverse,
|
||||
semantic_seed_nodes,
|
||||
pattern,
|
||||
adjacency,
|
||||
self.config,
|
||||
)
|
||||
)
|
||||
pattern_jobs.append((semantic_seed_nodes, pattern))
|
||||
|
||||
# Patterns from temporal seeds
|
||||
for pattern in self.config.patterns_temporal:
|
||||
if temporal_seed_nodes:
|
||||
tasks.append(
|
||||
asyncio.to_thread(
|
||||
mpfp_traverse,
|
||||
temporal_seed_nodes,
|
||||
pattern,
|
||||
adjacency,
|
||||
self.config,
|
||||
)
|
||||
)
|
||||
pattern_jobs.append((temporal_seed_nodes, pattern))
|
||||
|
||||
if not tasks:
|
||||
return []
|
||||
if not pattern_jobs:
|
||||
return [], timings
|
||||
|
||||
# Gather pattern results
|
||||
pattern_results = await asyncio.gather(*tasks)
|
||||
timings.pattern_count = len(pattern_jobs)
|
||||
|
||||
# Shared edge cache across all patterns
|
||||
cache = EdgeCache()
|
||||
|
||||
# Pre-warm cache with ALL seed node edges BEFORE running patterns
|
||||
# This prevents redundant DB queries at hop 1
|
||||
all_seed_ids = list({s.node_id for seeds, _ in pattern_jobs for s in seeds})
|
||||
if all_seed_ids:
|
||||
import time as time_module
|
||||
|
||||
prewarm_start = time_module.time()
|
||||
edges_by_type = await load_all_edges_for_frontier(pool, all_seed_ids, self.config.top_k_neighbors)
|
||||
cache.edge_load_time += time_module.time() - prewarm_start
|
||||
cache.db_queries += 1
|
||||
cache.add_all_edges(edges_by_type, all_seed_ids)
|
||||
|
||||
# Run all patterns with HOP-SYNCHRONIZED edge loading
|
||||
# This batches hop-2 edge loads across ALL patterns into ONE query
|
||||
# Reduces DB queries from O(patterns * hops) to O(hops)
|
||||
step_start = time.time()
|
||||
pattern_results = await mpfp_traverse_hop_synchronized(pool, pattern_jobs, self.config, cache)
|
||||
timings.traverse = time.time() - step_start
|
||||
|
||||
# Record edge loading stats from cache
|
||||
timings.edge_count = sum(len(neighbors) for g in cache.graphs.values() for neighbors in g.values())
|
||||
timings.db_queries = cache.db_queries
|
||||
timings.edge_load_time = cache.edge_load_time
|
||||
timings.hop_details = cache.hop_details
|
||||
|
||||
# Fuse results
|
||||
step_start = time.time()
|
||||
fused = rrf_fusion(pattern_results, top_k=budget)
|
||||
timings.fusion = time.time() - step_start
|
||||
|
||||
if not fused:
|
||||
return []
|
||||
return [], timings
|
||||
|
||||
# Get top result IDs (don't exclude seeds - they may be highly relevant)
|
||||
# Get top result IDs
|
||||
result_ids = [node_id for node_id, score in fused][:budget]
|
||||
|
||||
# Fetch full details
|
||||
step_start = time.time()
|
||||
results = await fetch_memory_units_by_ids(pool, result_ids, fact_type)
|
||||
timings.fetch = time.time() - step_start
|
||||
timings.result_count = len(results)
|
||||
|
||||
# Add activation scores from fusion
|
||||
score_map = {node_id: score for node_id, score in fused}
|
||||
@@ -387,7 +606,7 @@ class MPFPGraphRetriever(GraphRetriever):
|
||||
# Sort by activation
|
||||
results.sort(key=lambda r: r.activation or 0, reverse=True)
|
||||
|
||||
return results
|
||||
return results, timings
|
||||
|
||||
def _convert_seeds(
|
||||
self,
|
||||
|
||||
@@ -44,7 +44,7 @@ class CrossEncoderReranker:
|
||||
await cross_encoder.initialize()
|
||||
self._initialized = True
|
||||
|
||||
def rerank(self, query: str, candidates: list[MergedCandidate]) -> list[ScoredResult]:
|
||||
async def rerank(self, query: str, candidates: list[MergedCandidate]) -> list[ScoredResult]:
|
||||
"""
|
||||
Rerank candidates using cross-encoder scores.
|
||||
|
||||
@@ -85,7 +85,7 @@ class CrossEncoderReranker:
|
||||
pairs.append([query, doc_text])
|
||||
|
||||
# Get cross-encoder scores
|
||||
scores = self.cross_encoder.predict(pairs)
|
||||
scores = await self.cross_encoder.predict(pairs)
|
||||
|
||||
# Normalize scores using sigmoid to [0, 1] range
|
||||
# Cross-encoder returns logits which can be negative
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,24 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class MPFPTimings:
|
||||
"""Timing breakdown for a single MPFP retrieval call."""
|
||||
|
||||
fact_type: str
|
||||
edge_count: int = 0 # Total edges loaded
|
||||
db_queries: int = 0 # Number of DB queries for edge loading
|
||||
edge_load_time: float = 0.0 # Time spent loading edges from DB
|
||||
traverse: float = 0.0 # Total traversal time (includes edge loading)
|
||||
pattern_count: int = 0 # Number of patterns executed
|
||||
fusion: float = 0.0 # Time for RRF fusion
|
||||
fetch: float = 0.0 # Time to fetch memory unit details
|
||||
seeds_time: float = 0.0 # Time to find semantic seeds (if fallback used)
|
||||
result_count: int = 0 # Number of results returned
|
||||
# Detailed per-hop timing: list of {hop, exec_time, uncached, load_time, edges_loaded, total_time}
|
||||
hop_details: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetrievalResult:
|
||||
"""
|
||||
|
||||
@@ -121,6 +121,29 @@ class SyncTaskBackend(TaskBackend):
|
||||
logger.debug("SyncTaskBackend shutdown")
|
||||
|
||||
|
||||
class NoopTaskBackend(TaskBackend):
|
||||
"""
|
||||
No-op task backend that discards all tasks.
|
||||
|
||||
This is useful for tests where background task execution is not needed
|
||||
and would only slow down the test suite.
|
||||
"""
|
||||
|
||||
async def initialize(self):
|
||||
"""No-op."""
|
||||
self._initialized = True
|
||||
logger.debug("NoopTaskBackend initialized")
|
||||
|
||||
async def submit_task(self, task_dict: dict[str, Any]):
|
||||
"""Discard the task (do nothing)."""
|
||||
pass
|
||||
|
||||
async def shutdown(self):
|
||||
"""No-op."""
|
||||
self._initialized = False
|
||||
logger.debug("NoopTaskBackend shutdown")
|
||||
|
||||
|
||||
class AsyncIOQueueBackend(TaskBackend):
|
||||
"""
|
||||
Task backend implementation using asyncio queues.
|
||||
|
||||
@@ -23,7 +23,7 @@ import uvicorn
|
||||
from . import MemoryEngine
|
||||
from .api import create_app
|
||||
from .banner import print_banner
|
||||
from .config import HindsightConfig, get_config
|
||||
from .config import DEFAULT_WORKERS, ENV_WORKERS, HindsightConfig, get_config
|
||||
from .daemon import (
|
||||
DEFAULT_DAEMON_PORT,
|
||||
DEFAULT_IDLE_TIMEOUT,
|
||||
@@ -95,7 +95,12 @@ def main():
|
||||
|
||||
# Development options
|
||||
parser.add_argument("--reload", action="store_true", help="Enable auto-reload on code changes (development only)")
|
||||
parser.add_argument("--workers", type=int, default=1, help="Number of worker processes (default: 1)")
|
||||
parser.add_argument(
|
||||
"--workers",
|
||||
type=int,
|
||||
default=int(os.getenv(ENV_WORKERS, str(DEFAULT_WORKERS))),
|
||||
help=f"Number of worker processes (env: {ENV_WORKERS}, default: {DEFAULT_WORKERS})",
|
||||
)
|
||||
|
||||
# Access log options
|
||||
parser.add_argument("--access-log", action="store_true", help="Enable access log")
|
||||
@@ -185,16 +190,24 @@ def main():
|
||||
reranker_provider=config.reranker_provider,
|
||||
reranker_local_model=config.reranker_local_model,
|
||||
reranker_tei_url=config.reranker_tei_url,
|
||||
reranker_tei_batch_size=config.reranker_tei_batch_size,
|
||||
reranker_tei_max_concurrent=config.reranker_tei_max_concurrent,
|
||||
reranker_max_candidates=config.reranker_max_candidates,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
log_level=args.log_level,
|
||||
mcp_enabled=config.mcp_enabled,
|
||||
graph_retriever=config.graph_retriever,
|
||||
mpfp_top_k_neighbors=config.mpfp_top_k_neighbors,
|
||||
recall_max_concurrent=config.recall_max_concurrent,
|
||||
recall_connection_budget=config.recall_connection_budget,
|
||||
observation_min_facts=config.observation_min_facts,
|
||||
observation_top_entities=config.observation_top_entities,
|
||||
retain_max_completion_tokens=config.retain_max_completion_tokens,
|
||||
retain_chunk_size=config.retain_chunk_size,
|
||||
retain_extract_causal_links=config.retain_extract_causal_links,
|
||||
retain_extraction_mode=config.retain_extraction_mode,
|
||||
retain_observations_async=config.retain_observations_async,
|
||||
skip_llm_verification=config.skip_llm_verification,
|
||||
lazy_reranker=config.lazy_reranker,
|
||||
run_migrations_on_startup=config.run_migrations_on_startup,
|
||||
@@ -202,8 +215,9 @@ def main():
|
||||
db_pool_max_size=config.db_pool_max_size,
|
||||
db_command_timeout=config.db_command_timeout,
|
||||
db_acquire_timeout=config.db_acquire_timeout,
|
||||
task_batch_size=config.task_batch_size,
|
||||
task_batch_interval=config.task_batch_interval,
|
||||
task_backend=config.task_backend,
|
||||
task_backend_memory_batch_size=config.task_backend_memory_batch_size,
|
||||
task_backend_memory_batch_interval=config.task_backend_memory_batch_interval,
|
||||
)
|
||||
config.configure_logging()
|
||||
if not args.daemon:
|
||||
@@ -260,14 +274,27 @@ def main():
|
||||
app = idle_middleware
|
||||
|
||||
# Prepare uvicorn config
|
||||
# When using workers or reload, we must use import string so each worker can import the app
|
||||
use_import_string = args.workers > 1 or args.reload
|
||||
# Check for uvloop availability
|
||||
try:
|
||||
import uvloop # noqa: F401
|
||||
|
||||
loop_impl = "uvloop"
|
||||
print("uvloop available, will use for event loop")
|
||||
except ImportError:
|
||||
loop_impl = "asyncio"
|
||||
print("uvloop not installed, using default asyncio event loop")
|
||||
|
||||
uvicorn_config = {
|
||||
"app": app,
|
||||
"app": "hindsight_api.server:app" if use_import_string else app,
|
||||
"host": args.host,
|
||||
"port": args.port,
|
||||
"log_level": args.log_level,
|
||||
"access_log": args.access_log,
|
||||
"proxy_headers": args.proxy_headers,
|
||||
"ws": "wsproto", # Use wsproto instead of websockets to avoid deprecation warnings
|
||||
"loop": loop_impl, # Explicitly set event loop implementation
|
||||
}
|
||||
|
||||
# Add optional parameters if provided
|
||||
|
||||
@@ -6,11 +6,18 @@ This module provides metrics for:
|
||||
- Token usage (input/output) per operation
|
||||
- Per-bank granularity via labels
|
||||
- LLM call latency and token usage with scope dimension
|
||||
- HTTP request metrics (latency, count by endpoint/method/status)
|
||||
- Process metrics (CPU, memory, file descriptors, threads)
|
||||
- Database connection pool metrics
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import resource
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from opentelemetry import metrics
|
||||
from opentelemetry.exporter.prometheus import PrometheusMetricReader
|
||||
@@ -18,6 +25,9 @@ from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.view import ExplicitBucketHistogramAggregation, View
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import asyncpg
|
||||
|
||||
# Custom bucket boundaries for operation duration (in seconds)
|
||||
# Fine granularity in 0-30s range where most operations complete
|
||||
DURATION_BUCKETS = (0.1, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 5.0, 7.5, 10.0, 15.0, 20.0, 30.0, 60.0, 120.0)
|
||||
@@ -25,6 +35,9 @@ DURATION_BUCKETS = (0.1, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 5.0, 7.5, 10.0, 15.0, 2
|
||||
# LLM duration buckets (finer granularity for faster LLM calls)
|
||||
LLM_DURATION_BUCKETS = (0.1, 0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 30.0, 60.0, 120.0)
|
||||
|
||||
# HTTP request duration buckets (millisecond-level for fast endpoints)
|
||||
HTTP_DURATION_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0)
|
||||
|
||||
|
||||
def get_token_bucket(token_count: int) -> str:
|
||||
"""
|
||||
@@ -107,9 +120,17 @@ def initialize_metrics(service_name: str = "hindsight-api", service_version: str
|
||||
aggregation=ExplicitBucketHistogramAggregation(boundaries=LLM_DURATION_BUCKETS),
|
||||
)
|
||||
|
||||
# Create view with custom bucket boundaries for HTTP request duration histogram
|
||||
http_duration_view = View(
|
||||
instrument_name="hindsight.http.duration",
|
||||
aggregation=ExplicitBucketHistogramAggregation(boundaries=HTTP_DURATION_BUCKETS),
|
||||
)
|
||||
|
||||
# Create meter provider with Prometheus exporter and custom views
|
||||
provider = MeterProvider(
|
||||
resource=resource, metric_readers=[prometheus_reader], views=[duration_view, llm_duration_view]
|
||||
resource=resource,
|
||||
metric_readers=[prometheus_reader],
|
||||
views=[duration_view, llm_duration_view, http_duration_view],
|
||||
)
|
||||
|
||||
# Set the global meter provider
|
||||
@@ -167,6 +188,15 @@ class MetricsCollectorBase:
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@contextmanager
|
||||
def record_http_request(self, method: str, endpoint: str, status_code_getter: Callable[[], int]):
|
||||
"""Context manager to record HTTP request metrics."""
|
||||
raise NotImplementedError
|
||||
|
||||
def set_db_pool(self, pool: "asyncpg.Pool"):
|
||||
"""Set the database pool for metrics collection."""
|
||||
pass
|
||||
|
||||
|
||||
class NoOpMetricsCollector(MetricsCollectorBase):
|
||||
"""No-op metrics collector that does nothing. Used when metrics are disabled."""
|
||||
@@ -196,6 +226,11 @@ class NoOpMetricsCollector(MetricsCollectorBase):
|
||||
"""No-op LLM call recording."""
|
||||
pass
|
||||
|
||||
@contextmanager
|
||||
def record_http_request(self, method: str, endpoint: str, status_code_getter: Callable[[], int]):
|
||||
"""No-op HTTP request recording."""
|
||||
yield
|
||||
|
||||
|
||||
class MetricsCollector(MetricsCollectorBase):
|
||||
"""
|
||||
@@ -238,6 +273,27 @@ class MetricsCollector(MetricsCollectorBase):
|
||||
name="hindsight.llm.calls.total", description="Total number of LLM API calls", unit="calls"
|
||||
)
|
||||
|
||||
# HTTP request metrics
|
||||
self.http_request_duration = self.meter.create_histogram(
|
||||
name="hindsight.http.duration", description="Duration of HTTP requests in seconds", unit="s"
|
||||
)
|
||||
|
||||
self.http_requests_total = self.meter.create_counter(
|
||||
name="hindsight.http.requests.total", description="Total number of HTTP requests", unit="requests"
|
||||
)
|
||||
|
||||
self.http_requests_in_progress = self.meter.create_up_down_counter(
|
||||
name="hindsight.http.requests.in_progress",
|
||||
description="Number of HTTP requests in progress",
|
||||
unit="requests",
|
||||
)
|
||||
|
||||
# Process metrics (observable gauges - collected on scrape)
|
||||
self._setup_process_metrics()
|
||||
|
||||
# DB pool metrics holder (set via set_db_pool)
|
||||
self._db_pool: "asyncpg.Pool | None" = None
|
||||
|
||||
@contextmanager
|
||||
def record_operation(
|
||||
self,
|
||||
@@ -340,6 +396,196 @@ class MetricsCollector(MetricsCollectorBase):
|
||||
}
|
||||
self.llm_tokens_output.add(output_tokens, output_attributes)
|
||||
|
||||
@contextmanager
|
||||
def record_http_request(self, method: str, endpoint: str, status_code_getter: Callable[[], int]):
|
||||
"""
|
||||
Context manager to record HTTP request metrics.
|
||||
|
||||
Usage:
|
||||
status_code = [200] # Use list for mutability
|
||||
with metrics.record_http_request("GET", "/api/banks", lambda: status_code[0]):
|
||||
# ... handle request
|
||||
status_code[0] = response.status_code
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
endpoint: Request endpoint path
|
||||
status_code_getter: Callable that returns the status code after request completes
|
||||
"""
|
||||
start_time = time.time()
|
||||
base_attributes = {"method": method, "endpoint": endpoint}
|
||||
|
||||
# Track in-progress
|
||||
self.http_requests_in_progress.add(1, base_attributes)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
duration = time.time() - start_time
|
||||
status_code = status_code_getter()
|
||||
status_class = f"{status_code // 100}xx"
|
||||
|
||||
attributes = {
|
||||
**base_attributes,
|
||||
"status_code": str(status_code),
|
||||
"status_class": status_class,
|
||||
}
|
||||
|
||||
# Record duration and count
|
||||
self.http_request_duration.record(duration, attributes)
|
||||
self.http_requests_total.add(1, attributes)
|
||||
|
||||
# Decrement in-progress
|
||||
self.http_requests_in_progress.add(-1, base_attributes)
|
||||
|
||||
def _setup_process_metrics(self):
|
||||
"""Set up observable gauges for process metrics."""
|
||||
|
||||
def get_cpu_times(_options):
|
||||
"""Get process CPU times."""
|
||||
try:
|
||||
rusage = resource.getrusage(resource.RUSAGE_SELF)
|
||||
yield metrics.Observation(rusage.ru_utime, {"type": "user"})
|
||||
yield metrics.Observation(rusage.ru_stime, {"type": "system"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_memory_usage(_options):
|
||||
"""Get process memory usage in bytes."""
|
||||
try:
|
||||
rusage = resource.getrusage(resource.RUSAGE_SELF)
|
||||
# ru_maxrss is in kilobytes on Linux, bytes on macOS
|
||||
max_rss = rusage.ru_maxrss
|
||||
if os.uname().sysname == "Linux":
|
||||
max_rss *= 1024 # Convert KB to bytes
|
||||
yield metrics.Observation(max_rss, {"type": "rss_max"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_open_file_descriptors(_options):
|
||||
"""Get number of open file descriptors."""
|
||||
try:
|
||||
# Try to count open FDs by checking /proc on Linux
|
||||
if os.path.exists("/proc/self/fd"):
|
||||
count = len(os.listdir("/proc/self/fd"))
|
||||
yield metrics.Observation(count)
|
||||
else:
|
||||
# Fallback: use resource limits
|
||||
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||
yield metrics.Observation(soft, {"limit": "soft"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_thread_count(_options):
|
||||
"""Get number of active threads."""
|
||||
try:
|
||||
yield metrics.Observation(threading.active_count())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Create observable gauges
|
||||
self.meter.create_observable_gauge(
|
||||
name="hindsight.process.cpu.seconds",
|
||||
callbacks=[get_cpu_times],
|
||||
description="Process CPU time in seconds",
|
||||
unit="s",
|
||||
)
|
||||
|
||||
self.meter.create_observable_gauge(
|
||||
name="hindsight.process.memory.bytes",
|
||||
callbacks=[get_memory_usage],
|
||||
description="Process memory usage in bytes",
|
||||
unit="By",
|
||||
)
|
||||
|
||||
self.meter.create_observable_gauge(
|
||||
name="hindsight.process.open_fds",
|
||||
callbacks=[get_open_file_descriptors],
|
||||
description="Number of open file descriptors",
|
||||
unit="{fds}",
|
||||
)
|
||||
|
||||
self.meter.create_observable_gauge(
|
||||
name="hindsight.process.threads",
|
||||
callbacks=[get_thread_count],
|
||||
description="Number of active threads",
|
||||
unit="{threads}",
|
||||
)
|
||||
|
||||
def set_db_pool(self, pool: "asyncpg.Pool"):
|
||||
"""
|
||||
Set the database pool for metrics collection.
|
||||
|
||||
Args:
|
||||
pool: asyncpg connection pool instance
|
||||
"""
|
||||
self._db_pool = pool
|
||||
self._setup_db_pool_metrics()
|
||||
|
||||
def _setup_db_pool_metrics(self):
|
||||
"""Set up observable gauges for database pool metrics."""
|
||||
|
||||
def get_pool_size(_options):
|
||||
"""Get current pool size."""
|
||||
if self._db_pool is not None:
|
||||
try:
|
||||
yield metrics.Observation(self._db_pool.get_size())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_pool_free_size(_options):
|
||||
"""Get number of free connections in pool."""
|
||||
if self._db_pool is not None:
|
||||
try:
|
||||
yield metrics.Observation(self._db_pool.get_idle_size())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_pool_min_size(_options):
|
||||
"""Get pool minimum size."""
|
||||
if self._db_pool is not None:
|
||||
try:
|
||||
yield metrics.Observation(self._db_pool.get_min_size())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_pool_max_size(_options):
|
||||
"""Get pool maximum size."""
|
||||
if self._db_pool is not None:
|
||||
try:
|
||||
yield metrics.Observation(self._db_pool.get_max_size())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Create observable gauges for pool metrics
|
||||
self.meter.create_observable_gauge(
|
||||
name="hindsight.db.pool.size",
|
||||
callbacks=[get_pool_size],
|
||||
description="Current number of connections in the pool",
|
||||
unit="{connections}",
|
||||
)
|
||||
|
||||
self.meter.create_observable_gauge(
|
||||
name="hindsight.db.pool.idle",
|
||||
callbacks=[get_pool_free_size],
|
||||
description="Number of idle connections in the pool",
|
||||
unit="{connections}",
|
||||
)
|
||||
|
||||
self.meter.create_observable_gauge(
|
||||
name="hindsight.db.pool.min",
|
||||
callbacks=[get_pool_min_size],
|
||||
description="Minimum pool size",
|
||||
unit="{connections}",
|
||||
)
|
||||
|
||||
self.meter.create_observable_gauge(
|
||||
name="hindsight.db.pool.max",
|
||||
callbacks=[get_pool_max_size],
|
||||
description="Maximum pool size",
|
||||
unit="{connections}",
|
||||
)
|
||||
|
||||
|
||||
# Global metrics collector instance (defaults to no-op)
|
||||
_metrics_collector: MetricsCollectorBase = NoOpMetricsCollector()
|
||||
|
||||
@@ -22,6 +22,7 @@ from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.script.revision import ResolutionError
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -78,7 +79,18 @@ def _run_migrations_internal(database_url: str, script_location: str, schema: st
|
||||
alembic_cfg.set_main_option("target_schema", schema)
|
||||
|
||||
# Run migrations
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
try:
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
except ResolutionError as e:
|
||||
# This happens during rolling deployments when a newer version of the code
|
||||
# has already run migrations, and this older replica doesn't have the new
|
||||
# migration files. The database is already at a newer revision than we know.
|
||||
# This is safe to ignore - the newer code has already applied its migrations.
|
||||
logger.warning(
|
||||
f"Database is at a newer migration revision than this code version knows about. "
|
||||
f"This is expected during rolling deployments. Skipping migrations. Error: {e}"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"Database migrations completed successfully for schema '{schema_name}'")
|
||||
|
||||
|
||||
@@ -27,10 +27,17 @@ config.configure_logging()
|
||||
|
||||
# Create app at module level (required for uvicorn import string)
|
||||
# MemoryEngine reads configuration from environment variables automatically
|
||||
_memory = MemoryEngine()
|
||||
# Note: run_migrations=True by default, but migrations are idempotent so safe with workers
|
||||
_memory = MemoryEngine(run_migrations=config.run_migrations_on_startup)
|
||||
|
||||
# Create unified app with both HTTP and optionally MCP
|
||||
app = create_app(memory=_memory, http_api_enabled=True, mcp_api_enabled=config.mcp_enabled, mcp_mount_path="/mcp")
|
||||
app = create_app(
|
||||
memory=_memory,
|
||||
http_api_enabled=True,
|
||||
mcp_api_enabled=config.mcp_enabled,
|
||||
mcp_mount_path="/mcp",
|
||||
initialize_memory=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -37,10 +37,12 @@ dependencies = [
|
||||
"anthropic>=0.40.0",
|
||||
"typer>=0.9.0",
|
||||
"cohere>=5.0.0",
|
||||
"flashrank>=0.2.0",
|
||||
# Local ML models for embeddings/reranking - can be excluded in Docker with INCLUDE_LOCAL_MODELS=false
|
||||
"sentence-transformers>=3.0.0,<3.3.0",
|
||||
"transformers>=4.30.0,<4.46.0",
|
||||
"torch>=2.0.0",
|
||||
"uvloop>=0.22.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -514,14 +514,15 @@ class TestCohereCrossEncoder:
|
||||
"""Test that Cohere cross-encoder initializes correctly."""
|
||||
assert cohere_cross_encoder.provider_name == "cohere"
|
||||
|
||||
def test_cohere_cross_encoder_predict(self, cohere_cross_encoder):
|
||||
@pytest.mark.asyncio
|
||||
async def test_cohere_cross_encoder_predict(self, cohere_cross_encoder):
|
||||
"""Test that Cohere cross-encoder can score pairs."""
|
||||
pairs = [
|
||||
("What is the capital of France?", "Paris is the capital of France."),
|
||||
("What is the capital of France?", "The Eiffel Tower is in Paris."),
|
||||
("What is the capital of France?", "Python is a programming language."),
|
||||
]
|
||||
scores = cohere_cross_encoder.predict(pairs)
|
||||
scores = await cohere_cross_encoder.predict(pairs)
|
||||
|
||||
assert len(scores) == 3
|
||||
assert all(isinstance(s, float) for s in scores)
|
||||
|
||||
@@ -250,11 +250,34 @@ async def test_full_api_workflow(api_client, test_bank_id):
|
||||
# 8. Test Entity Endpoints
|
||||
# ================================================================
|
||||
|
||||
# List entities
|
||||
# List entities with pagination
|
||||
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/entities")
|
||||
assert response.status_code == 200
|
||||
entities_data = response.json()
|
||||
assert "items" in entities_data
|
||||
assert "total" in entities_data
|
||||
assert "limit" in entities_data
|
||||
assert "offset" in entities_data
|
||||
assert entities_data["offset"] == 0
|
||||
assert entities_data["limit"] == 100 # default limit
|
||||
|
||||
# Test pagination with custom limit and offset
|
||||
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/entities?limit=5&offset=0")
|
||||
assert response.status_code == 200
|
||||
paginated_data = response.json()
|
||||
assert paginated_data["limit"] == 5
|
||||
assert paginated_data["offset"] == 0
|
||||
assert len(paginated_data["items"]) <= 5
|
||||
|
||||
# Test offset
|
||||
if entities_data["total"] > 1:
|
||||
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/entities?limit=1&offset=1")
|
||||
assert response.status_code == 200
|
||||
offset_data = response.json()
|
||||
assert offset_data["offset"] == 1
|
||||
# With offset=1, we should get different entity than first one (if there are multiple)
|
||||
if len(offset_data["items"]) > 0 and len(entities_data["items"]) > 1:
|
||||
assert offset_data["items"][0]["id"] != entities_data["items"][0]["id"]
|
||||
|
||||
# Get specific entity if any exist
|
||||
if len(entities_data['items']) > 0:
|
||||
|
||||
@@ -64,12 +64,12 @@ class TestMetricsCollector:
|
||||
def mock_meter(self):
|
||||
"""Create a mock meter for testing."""
|
||||
meter = MagicMock()
|
||||
# Create separate mocks for each histogram (operation_duration, llm_duration)
|
||||
histogram_mocks = [MagicMock(), MagicMock()]
|
||||
# Create separate mocks for each histogram (operation_duration, llm_duration, http_request_duration)
|
||||
histogram_mocks = [MagicMock(), MagicMock(), MagicMock()]
|
||||
meter.create_histogram.side_effect = histogram_mocks
|
||||
# Create separate mocks for each counter
|
||||
# (operation_total, llm_tokens_input, llm_tokens_output, llm_calls_total)
|
||||
counter_mocks = [MagicMock() for _ in range(4)]
|
||||
# (operation_total, llm_tokens_input, llm_tokens_output, llm_calls_total, http_requests_total)
|
||||
counter_mocks = [MagicMock() for _ in range(5)]
|
||||
meter.create_counter.side_effect = counter_mocks
|
||||
return meter
|
||||
|
||||
@@ -257,12 +257,12 @@ class TestLLMMetrics:
|
||||
def mock_meter(self):
|
||||
"""Create a mock meter for testing."""
|
||||
meter = MagicMock()
|
||||
# Create separate mocks for each histogram (operation_duration, llm_duration)
|
||||
histogram_mocks = [MagicMock(), MagicMock()]
|
||||
# Create separate mocks for each histogram (operation_duration, llm_duration, http_request_duration)
|
||||
histogram_mocks = [MagicMock(), MagicMock(), MagicMock()]
|
||||
meter.create_histogram.side_effect = histogram_mocks
|
||||
# Create separate mocks for each counter
|
||||
# (operation_total, llm_tokens_input, llm_tokens_output, llm_calls_total)
|
||||
counter_mocks = [MagicMock() for _ in range(4)]
|
||||
# (operation_total, llm_tokens_input, llm_tokens_output, llm_calls_total, http_requests_total)
|
||||
counter_mocks = [MagicMock() for _ in range(5)]
|
||||
meter.create_counter.side_effect = counter_mocks
|
||||
return meter
|
||||
|
||||
|
||||
@@ -0,0 +1,819 @@
|
||||
"""
|
||||
Tests for MPFP (Meta-Path Forward Push) graph retrieval.
|
||||
|
||||
Tests cover:
|
||||
1. EdgeCache - lazy caching behavior
|
||||
2. mpfp_traverse_async - core traversal algorithm
|
||||
3. load_edges_for_frontier - lazy edge loading
|
||||
4. rrf_fusion - result fusion
|
||||
5. MPFPGraphRetriever - full integration
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from hindsight_api.engine.search.mpfp_retrieval import (
|
||||
EdgeCache,
|
||||
EdgeTarget,
|
||||
MPFPConfig,
|
||||
MPFPGraphRetriever,
|
||||
PatternResult,
|
||||
SeedNode,
|
||||
load_all_edges_for_frontier,
|
||||
mpfp_traverse_async,
|
||||
rrf_fusion,
|
||||
)
|
||||
from hindsight_api.engine.search.types import RetrievalResult
|
||||
|
||||
|
||||
class TestEdgeCache:
|
||||
"""Tests for the EdgeCache lazy loading cache."""
|
||||
|
||||
def test_empty_cache_returns_empty_neighbors(self):
|
||||
"""Empty cache should return empty list for any node."""
|
||||
cache = EdgeCache()
|
||||
neighbors = cache.get_neighbors("semantic", "node-1")
|
||||
assert neighbors == []
|
||||
|
||||
def test_is_fully_loaded_false_for_uncached(self):
|
||||
"""is_fully_loaded should return False for nodes not yet loaded."""
|
||||
cache = EdgeCache()
|
||||
assert cache.is_fully_loaded("node-1") is False
|
||||
|
||||
def test_add_all_edges_marks_as_fully_loaded(self):
|
||||
"""Adding edges should mark nodes as fully loaded."""
|
||||
cache = EdgeCache()
|
||||
|
||||
edges_by_type = {
|
||||
"semantic": {"node-1": [EdgeTarget("node-2", 0.8), EdgeTarget("node-3", 0.6)]},
|
||||
}
|
||||
cache.add_all_edges(edges_by_type, ["node-1", "node-4"]) # node-4 has no edges
|
||||
|
||||
assert cache.is_fully_loaded("node-1") is True
|
||||
assert cache.is_fully_loaded("node-4") is True # Marked even with no edges
|
||||
assert cache.is_fully_loaded("node-2") is False # Target, not source
|
||||
|
||||
def test_get_neighbors_returns_added_edges(self):
|
||||
"""get_neighbors should return edges after add_all_edges."""
|
||||
cache = EdgeCache()
|
||||
|
||||
edges_by_type = {
|
||||
"semantic": {"node-1": [EdgeTarget("node-2", 0.8), EdgeTarget("node-3", 0.6)]},
|
||||
}
|
||||
cache.add_all_edges(edges_by_type, ["node-1"])
|
||||
|
||||
neighbors = cache.get_neighbors("semantic", "node-1")
|
||||
assert len(neighbors) == 2
|
||||
assert neighbors[0].node_id == "node-2"
|
||||
assert neighbors[0].weight == 0.8
|
||||
|
||||
def test_get_uncached_filters_loaded_nodes(self):
|
||||
"""get_uncached should only return nodes not yet fully loaded."""
|
||||
cache = EdgeCache()
|
||||
|
||||
# Load some nodes (all edge types)
|
||||
cache.add_all_edges({"semantic": {"node-1": []}}, ["node-1", "node-2"])
|
||||
|
||||
# Check uncached
|
||||
uncached = cache.get_uncached(["node-1", "node-2", "node-3", "node-4"])
|
||||
assert set(uncached) == {"node-3", "node-4"}
|
||||
|
||||
def test_get_normalized_neighbors_normalizes_weights(self):
|
||||
"""get_normalized_neighbors should normalize weights to sum to 1."""
|
||||
cache = EdgeCache()
|
||||
|
||||
edges_by_type = {
|
||||
"semantic": {
|
||||
"node-1": [
|
||||
EdgeTarget("node-2", 0.8),
|
||||
EdgeTarget("node-3", 0.4),
|
||||
EdgeTarget("node-4", 0.2),
|
||||
],
|
||||
},
|
||||
}
|
||||
cache.add_all_edges(edges_by_type, ["node-1"])
|
||||
|
||||
# Get top 2, normalized
|
||||
neighbors = cache.get_normalized_neighbors("semantic", "node-1", top_k=2)
|
||||
assert len(neighbors) == 2
|
||||
|
||||
# Weights should sum to 1
|
||||
total = sum(n.weight for n in neighbors)
|
||||
assert abs(total - 1.0) < 0.001
|
||||
|
||||
# node-2 should have higher normalized weight than node-3
|
||||
assert neighbors[0].node_id == "node-2"
|
||||
assert neighbors[1].node_id == "node-3"
|
||||
# Original: 0.8 and 0.4, so normalized: 0.8/1.2 and 0.4/1.2
|
||||
assert abs(neighbors[0].weight - 0.8 / 1.2) < 0.001
|
||||
assert abs(neighbors[1].weight - 0.4 / 1.2) < 0.001
|
||||
|
||||
def test_different_edge_types_are_separate(self):
|
||||
"""Different edge types should be stored separately."""
|
||||
cache = EdgeCache()
|
||||
|
||||
edges_by_type = {
|
||||
"semantic": {"node-1": [EdgeTarget("node-2", 0.8)]},
|
||||
"temporal": {"node-1": [EdgeTarget("node-3", 0.5)]},
|
||||
}
|
||||
cache.add_all_edges(edges_by_type, ["node-1"])
|
||||
|
||||
semantic_neighbors = cache.get_neighbors("semantic", "node-1")
|
||||
temporal_neighbors = cache.get_neighbors("temporal", "node-1")
|
||||
|
||||
assert len(semantic_neighbors) == 1
|
||||
assert semantic_neighbors[0].node_id == "node-2"
|
||||
|
||||
assert len(temporal_neighbors) == 1
|
||||
assert temporal_neighbors[0].node_id == "node-3"
|
||||
|
||||
|
||||
class TestRRFFusion:
|
||||
"""Tests for RRF (Reciprocal Rank Fusion)."""
|
||||
|
||||
def test_empty_results(self):
|
||||
"""Empty results should return empty fusion."""
|
||||
fused = rrf_fusion([])
|
||||
assert fused == []
|
||||
|
||||
def test_single_pattern_ranking(self):
|
||||
"""Single pattern should preserve ranking order."""
|
||||
result = PatternResult(
|
||||
pattern=["semantic"],
|
||||
scores={"node-1": 0.9, "node-2": 0.7, "node-3": 0.5},
|
||||
)
|
||||
|
||||
fused = rrf_fusion([result], top_k=3)
|
||||
assert len(fused) == 3
|
||||
# node-1 should be first (highest score)
|
||||
assert fused[0][0] == "node-1"
|
||||
assert fused[1][0] == "node-2"
|
||||
assert fused[2][0] == "node-3"
|
||||
|
||||
def test_multiple_patterns_boost_common_nodes(self):
|
||||
"""Nodes appearing in multiple patterns should get boosted."""
|
||||
result1 = PatternResult(
|
||||
pattern=["semantic", "semantic"],
|
||||
scores={"node-1": 0.9, "node-2": 0.7},
|
||||
)
|
||||
result2 = PatternResult(
|
||||
pattern=["entity", "temporal"],
|
||||
scores={"node-1": 0.8, "node-3": 0.6}, # node-1 in both
|
||||
)
|
||||
|
||||
fused = rrf_fusion([result1, result2], top_k=3)
|
||||
|
||||
# node-1 should be first (appears in both patterns)
|
||||
assert fused[0][0] == "node-1"
|
||||
# Its score should be higher than others
|
||||
assert fused[0][1] > fused[1][1]
|
||||
|
||||
def test_top_k_limits_results(self):
|
||||
"""top_k should limit the number of results."""
|
||||
result = PatternResult(
|
||||
pattern=["semantic"],
|
||||
scores={f"node-{i}": 1.0 / (i + 1) for i in range(10)},
|
||||
)
|
||||
|
||||
fused = rrf_fusion([result], top_k=3)
|
||||
assert len(fused) == 3
|
||||
|
||||
def test_empty_pattern_scores_ignored(self):
|
||||
"""Patterns with empty scores should be ignored."""
|
||||
result1 = PatternResult(pattern=["semantic"], scores={})
|
||||
result2 = PatternResult(
|
||||
pattern=["entity"],
|
||||
scores={"node-1": 0.5},
|
||||
)
|
||||
|
||||
fused = rrf_fusion([result1, result2], top_k=3)
|
||||
assert len(fused) == 1
|
||||
assert fused[0][0] == "node-1"
|
||||
|
||||
|
||||
class TestMPFPTraverseAsync:
|
||||
"""Tests for the async MPFP traversal algorithm."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_seeds_returns_empty(self):
|
||||
"""Empty seeds should return empty result."""
|
||||
cache = EdgeCache()
|
||||
config = MPFPConfig()
|
||||
|
||||
result = await mpfp_traverse_async(
|
||||
pool=None, # Not used when no seeds
|
||||
seeds=[],
|
||||
pattern=["semantic"],
|
||||
config=config,
|
||||
cache=cache,
|
||||
)
|
||||
|
||||
assert result.scores == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_hop_no_edges(self):
|
||||
"""Single hop with no edges should deposit mass at seeds."""
|
||||
cache = EdgeCache()
|
||||
config = MPFPConfig(alpha=0.15, threshold=1e-6)
|
||||
|
||||
# Pre-populate cache with empty edges for seed (marks as fully loaded)
|
||||
cache.add_all_edges({}, ["seed-1"])
|
||||
|
||||
seeds = [SeedNode("seed-1", 1.0)]
|
||||
|
||||
with patch(
|
||||
"hindsight_api.engine.search.mpfp_retrieval.load_all_edges_for_frontier",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
):
|
||||
result = await mpfp_traverse_async(
|
||||
pool=MagicMock(),
|
||||
seeds=seeds,
|
||||
pattern=["semantic"],
|
||||
config=config,
|
||||
cache=cache,
|
||||
)
|
||||
|
||||
# Seed should have alpha portion of its mass
|
||||
assert "seed-1" in result.scores
|
||||
assert result.scores["seed-1"] == pytest.approx(config.alpha, rel=0.01)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_hop_with_edges(self):
|
||||
"""Single hop should spread mass to neighbors."""
|
||||
cache = EdgeCache()
|
||||
config = MPFPConfig(alpha=0.15, threshold=1e-6, top_k_neighbors=10)
|
||||
|
||||
seeds = [SeedNode("seed-1", 1.0)]
|
||||
|
||||
# Pre-populate cache with seed edges (mimics pre-warming in retrieve())
|
||||
cache.add_all_edges(
|
||||
{
|
||||
"semantic": {
|
||||
"seed-1": [
|
||||
EdgeTarget("neighbor-1", 0.8),
|
||||
EdgeTarget("neighbor-2", 0.4),
|
||||
]
|
||||
}
|
||||
},
|
||||
["seed-1"],
|
||||
)
|
||||
|
||||
# Mock for loading neighbor edges (after hop 0)
|
||||
async def mock_load_all_edges(pool, node_ids, top_k=20):
|
||||
return {}
|
||||
|
||||
with patch(
|
||||
"hindsight_api.engine.search.mpfp_retrieval.load_all_edges_for_frontier",
|
||||
side_effect=mock_load_all_edges,
|
||||
):
|
||||
result = await mpfp_traverse_async(
|
||||
pool=MagicMock(),
|
||||
seeds=seeds,
|
||||
pattern=["semantic"],
|
||||
config=config,
|
||||
cache=cache,
|
||||
)
|
||||
|
||||
# Seed keeps alpha portion
|
||||
assert "seed-1" in result.scores
|
||||
assert result.scores["seed-1"] == pytest.approx(config.alpha, rel=0.01)
|
||||
|
||||
# Neighbors get remaining mass (normalized)
|
||||
assert "neighbor-1" in result.scores
|
||||
assert "neighbor-2" in result.scores
|
||||
|
||||
# neighbor-1 should get more (higher weight)
|
||||
assert result.scores["neighbor-1"] > result.scores["neighbor-2"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_hops(self):
|
||||
"""Two-hop pattern should traverse through neighbors."""
|
||||
cache = EdgeCache()
|
||||
config = MPFPConfig(alpha=0.15, threshold=1e-6, top_k_neighbors=10)
|
||||
|
||||
seeds = [SeedNode("seed-1", 1.0)]
|
||||
|
||||
# Pre-populate cache with seed edges (mimics pre-warming in retrieve())
|
||||
cache.add_all_edges(
|
||||
{"semantic": {"seed-1": [EdgeTarget("hop1-node", 1.0)]}},
|
||||
["seed-1"],
|
||||
)
|
||||
|
||||
# Mock edge loading for hop 1 nodes
|
||||
async def mock_load_all_edges(pool, node_ids, top_k=20):
|
||||
edges: dict[str, dict[str, list[EdgeTarget]]] = {"semantic": {}}
|
||||
if "hop1-node" in node_ids:
|
||||
edges["semantic"]["hop1-node"] = [EdgeTarget("hop2-node", 1.0)]
|
||||
return edges
|
||||
|
||||
with patch(
|
||||
"hindsight_api.engine.search.mpfp_retrieval.load_all_edges_for_frontier",
|
||||
side_effect=mock_load_all_edges,
|
||||
):
|
||||
result = await mpfp_traverse_async(
|
||||
pool=MagicMock(),
|
||||
seeds=seeds,
|
||||
pattern=["semantic", "semantic"], # Two hops
|
||||
config=config,
|
||||
cache=cache,
|
||||
)
|
||||
|
||||
# Should have scores for all three nodes
|
||||
assert "seed-1" in result.scores
|
||||
assert "hop1-node" in result.scores
|
||||
assert "hop2-node" in result.scores
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_reuse(self):
|
||||
"""Cache should prevent redundant edge loading for already-cached nodes."""
|
||||
cache = EdgeCache()
|
||||
config = MPFPConfig(alpha=0.15, threshold=1e-6)
|
||||
|
||||
# Pre-load cache (marks seed-1 AND neighbor-1 as fully loaded)
|
||||
# neighbor-1 is also cached because after hop 0, the frontier contains neighbor-1
|
||||
# and the algorithm tries to pre-warm edges for the next hop
|
||||
cache.add_all_edges(
|
||||
{"semantic": {"seed-1": [EdgeTarget("neighbor-1", 1.0)], "neighbor-1": []}},
|
||||
["seed-1", "neighbor-1"],
|
||||
)
|
||||
|
||||
seeds = [SeedNode("seed-1", 1.0)]
|
||||
|
||||
load_mock = AsyncMock(return_value={})
|
||||
|
||||
with patch(
|
||||
"hindsight_api.engine.search.mpfp_retrieval.load_all_edges_for_frontier",
|
||||
load_mock,
|
||||
):
|
||||
await mpfp_traverse_async(
|
||||
pool=MagicMock(),
|
||||
seeds=seeds,
|
||||
pattern=["semantic"],
|
||||
config=config,
|
||||
cache=cache,
|
||||
)
|
||||
|
||||
# Should not call load_all_edges_for_frontier since all nodes are already cached
|
||||
load_mock.assert_not_called()
|
||||
|
||||
|
||||
class TestMPFPGraphRetriever:
|
||||
"""Tests for the MPFPGraphRetriever class."""
|
||||
|
||||
def test_name_is_mpfp(self):
|
||||
"""Retriever name should be 'mpfp'."""
|
||||
retriever = MPFPGraphRetriever()
|
||||
assert retriever.name == "mpfp"
|
||||
|
||||
def test_default_config(self):
|
||||
"""Default config should have expected patterns."""
|
||||
# Use explicit config to avoid global config dependency
|
||||
config = MPFPConfig()
|
||||
retriever = MPFPGraphRetriever(config=config)
|
||||
|
||||
assert len(retriever.config.patterns_semantic) > 0
|
||||
assert len(retriever.config.patterns_temporal) > 0
|
||||
assert retriever.config.alpha == 0.15
|
||||
assert retriever.config.top_k_neighbors == 20
|
||||
|
||||
def test_custom_config(self):
|
||||
"""Custom config should be used."""
|
||||
config = MPFPConfig(alpha=0.3, top_k_neighbors=10)
|
||||
retriever = MPFPGraphRetriever(config=config)
|
||||
|
||||
assert retriever.config.alpha == 0.3
|
||||
assert retriever.config.top_k_neighbors == 10
|
||||
|
||||
def test_convert_seeds_from_retrieval_results(self):
|
||||
"""_convert_seeds should extract scores from RetrievalResult."""
|
||||
retriever = MPFPGraphRetriever()
|
||||
|
||||
results = [
|
||||
RetrievalResult(id="id-1", text="text1", fact_type="world", similarity=0.9),
|
||||
RetrievalResult(id="id-2", text="text2", fact_type="world", similarity=0.7),
|
||||
]
|
||||
|
||||
seeds = retriever._convert_seeds(results, "similarity")
|
||||
|
||||
assert len(seeds) == 2
|
||||
assert seeds[0].node_id == "id-1"
|
||||
assert seeds[0].score == 0.9
|
||||
assert seeds[1].node_id == "id-2"
|
||||
assert seeds[1].score == 0.7
|
||||
|
||||
def test_convert_seeds_empty(self):
|
||||
"""_convert_seeds should handle empty/None input."""
|
||||
retriever = MPFPGraphRetriever()
|
||||
|
||||
assert retriever._convert_seeds(None, "similarity") == []
|
||||
assert retriever._convert_seeds([], "similarity") == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve_no_seeds_returns_empty(self):
|
||||
"""Retrieve with no seeds should return empty results."""
|
||||
# Use explicit config to avoid global config dependency
|
||||
config = MPFPConfig()
|
||||
retriever = MPFPGraphRetriever(config=config)
|
||||
|
||||
# Mock _find_semantic_seeds to return empty
|
||||
with patch.object(retriever, "_find_semantic_seeds", new_callable=AsyncMock, return_value=[]):
|
||||
results, timings = await retriever.retrieve(
|
||||
pool=MagicMock(),
|
||||
query_embedding_str="[0.1, 0.2]",
|
||||
bank_id="test",
|
||||
fact_type="world",
|
||||
budget=10,
|
||||
)
|
||||
|
||||
assert results == []
|
||||
assert timings is not None
|
||||
assert timings.pattern_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve_with_semantic_seeds(self):
|
||||
"""Retrieve with semantic seeds should run patterns and return results."""
|
||||
# Use explicit config to avoid global config dependency
|
||||
config = MPFPConfig()
|
||||
retriever = MPFPGraphRetriever(config=config)
|
||||
|
||||
semantic_seeds = [
|
||||
RetrievalResult(id="seed-1", text="seed text", fact_type="world", similarity=0.9),
|
||||
]
|
||||
|
||||
# Mock the internal functions
|
||||
# mpfp_traverse_hop_synchronized returns a list of PatternResult (one per pattern)
|
||||
async def mock_traverse(*args, **kwargs):
|
||||
return [PatternResult(pattern=["semantic"], scores={"seed-1": 0.5, "result-1": 0.3})]
|
||||
|
||||
async def mock_fetch(pool, node_ids, fact_type):
|
||||
return [
|
||||
RetrievalResult(id="seed-1", text="seed text", fact_type="world"),
|
||||
RetrievalResult(id="result-1", text="result text", fact_type="world"),
|
||||
]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"hindsight_api.engine.search.mpfp_retrieval.mpfp_traverse_hop_synchronized",
|
||||
side_effect=mock_traverse,
|
||||
),
|
||||
patch(
|
||||
"hindsight_api.engine.search.mpfp_retrieval.fetch_memory_units_by_ids",
|
||||
side_effect=mock_fetch,
|
||||
),
|
||||
patch(
|
||||
"hindsight_api.engine.search.mpfp_retrieval.load_all_edges_for_frontier",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
),
|
||||
):
|
||||
results, timings = await retriever.retrieve(
|
||||
pool=MagicMock(),
|
||||
query_embedding_str="[0.1, 0.2]",
|
||||
bank_id="test",
|
||||
fact_type="world",
|
||||
budget=10,
|
||||
semantic_seeds=semantic_seeds,
|
||||
)
|
||||
|
||||
assert len(results) == 2
|
||||
assert timings is not None
|
||||
assert timings.pattern_count > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mpfp_integration(memory, request_context):
|
||||
"""Integration test: MPFP retrieval with real database."""
|
||||
bank_id = f"test_mpfp_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Store memories with entity relationships
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Alice works at TechCorp as a software engineer",
|
||||
context="employee info",
|
||||
request_context=request_context,
|
||||
)
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="TechCorp is located in San Francisco",
|
||||
context="company info",
|
||||
request_context=request_context,
|
||||
)
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Bob is Alice's manager at TechCorp",
|
||||
context="employee info",
|
||||
request_context=request_context,
|
||||
)
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="San Francisco has many tech companies",
|
||||
context="city info",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Query should find related facts via graph traversal
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Tell me about Alice",
|
||||
fact_type=["world"],
|
||||
budget=Budget.MID,
|
||||
max_tokens=2048,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Should return results
|
||||
assert result.results is not None
|
||||
assert len(result.results) > 0
|
||||
|
||||
# Should find Alice-related facts
|
||||
fact_texts = [f.text for f in result.results]
|
||||
alice_facts = [t for t in fact_texts if "Alice" in t or "TechCorp" in t]
|
||||
assert len(alice_facts) > 0, f"Should find Alice-related facts, got: {fact_texts}"
|
||||
|
||||
print(f"\n✓ MPFP integration test passed! Found {len(result.results)} facts")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mpfp_lazy_loading_efficiency(memory, request_context):
|
||||
"""Test that MPFP loads edges lazily, not upfront."""
|
||||
bank_id = f"test_mpfp_lazy_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Store many memories to create a larger graph
|
||||
for i in range(20):
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content=f"Fact number {i} about topic {i % 5}",
|
||||
context=f"context {i}",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
|
||||
# Query - MPFP should only load edges for relevant frontier nodes
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="topic 0",
|
||||
fact_type=["world"],
|
||||
budget=Budget.LOW,
|
||||
max_tokens=1024,
|
||||
enable_trace=True,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert result.results is not None
|
||||
|
||||
# Check trace for timing info
|
||||
if result.trace:
|
||||
print(f"\n✓ MPFP lazy loading test passed!")
|
||||
print(f" - Facts returned: {len(result.results)}")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MPFP Performance Benchmark Tests
|
||||
# ============================================================================
|
||||
# These tests require an external database with a large memory bank to be useful.
|
||||
# Set EXTERNAL_DATABASE_URL and BENCHMARK_BANK_ID environment variables to run.
|
||||
# Example:
|
||||
# EXTERNAL_DATABASE_URL=postgresql://user:pass@host:port/db \
|
||||
# BENCHMARK_BANK_ID=load-test \
|
||||
# pytest tests/test_mpfp_retrieval.py::test_mpfp_edge_loading_performance -v -s
|
||||
|
||||
|
||||
import os
|
||||
import asyncpg
|
||||
|
||||
EXTERNAL_DATABASE_URL = os.environ.get("EXTERNAL_DATABASE_URL")
|
||||
BENCHMARK_BANK_ID = os.environ.get("BENCHMARK_BANK_ID", "load-test")
|
||||
|
||||
requires_external_db = pytest.mark.skipif(
|
||||
EXTERNAL_DATABASE_URL is None,
|
||||
reason="EXTERNAL_DATABASE_URL not set - skipping external DB benchmark",
|
||||
)
|
||||
|
||||
|
||||
@requires_external_db
|
||||
@pytest.mark.asyncio
|
||||
async def test_mpfp_edge_loading_performance():
|
||||
"""
|
||||
Benchmark MPFP edge loading performance.
|
||||
|
||||
This test measures the performance of the LATERAL query optimization
|
||||
for loading edges in the MPFP graph traversal algorithm.
|
||||
|
||||
Set EXTERNAL_DATABASE_URL to point to a database with existing data.
|
||||
Set BENCHMARK_BANK_ID to specify which bank to query (default: load-test).
|
||||
|
||||
Example usage:
|
||||
EXTERNAL_DATABASE_URL=postgresql://hindsight:hindsight@localhost:5435/hindsight \
|
||||
BENCHMARK_BANK_ID=load-test \
|
||||
pytest tests/test_mpfp_retrieval.py::test_mpfp_edge_loading_performance -v -s
|
||||
"""
|
||||
import time
|
||||
|
||||
# Connect to external database
|
||||
pool = await asyncpg.create_pool(EXTERNAL_DATABASE_URL, min_size=2, max_size=10)
|
||||
|
||||
try:
|
||||
# Get some sample node IDs from the database
|
||||
async with pool.acquire() as conn:
|
||||
# First check how many links exist
|
||||
stats = await conn.fetchrow("""
|
||||
SELECT
|
||||
count(*) as total_links,
|
||||
count(DISTINCT from_unit_id) as unique_sources
|
||||
FROM memory_links
|
||||
""")
|
||||
print(f"\n📊 Database Stats:")
|
||||
print(f" Total links: {stats['total_links']:,}")
|
||||
print(f" Unique sources: {stats['unique_sources']:,}")
|
||||
|
||||
# Get edge distribution by type
|
||||
type_stats = await conn.fetch("""
|
||||
SELECT link_type, count(*) as cnt,
|
||||
round(avg(weight)::numeric, 3) as avg_weight
|
||||
FROM memory_links
|
||||
GROUP BY link_type
|
||||
ORDER BY cnt DESC
|
||||
""")
|
||||
print(f"\n Edge distribution:")
|
||||
for row in type_stats:
|
||||
print(f" - {row['link_type']}: {row['cnt']:,} (avg_weight={row['avg_weight']})")
|
||||
|
||||
# Get sample frontier nodes (from memory_units in the benchmark bank)
|
||||
# bank_id is the text primary key in banks table
|
||||
frontier_rows = await conn.fetch("""
|
||||
SELECT id FROM memory_units
|
||||
WHERE bank_id = $1
|
||||
LIMIT 100
|
||||
""", BENCHMARK_BANK_ID)
|
||||
|
||||
if not frontier_rows:
|
||||
pytest.skip(f"No memory units found for bank '{BENCHMARK_BANK_ID}'")
|
||||
|
||||
frontier_node_ids = [str(row['id']) for row in frontier_rows]
|
||||
print(f"\n🎯 Testing with {len(frontier_node_ids)} frontier nodes from bank '{BENCHMARK_BANK_ID}'")
|
||||
|
||||
# Test 1: Original query approach (all edges, no per-type limit)
|
||||
async with pool.acquire() as conn:
|
||||
start = time.time()
|
||||
original_rows = await conn.fetch("""
|
||||
SELECT ml.from_unit_id, ml.to_unit_id, ml.link_type, ml.weight
|
||||
FROM memory_links ml
|
||||
WHERE ml.from_unit_id = ANY($1::uuid[])
|
||||
AND ml.weight >= 0.1
|
||||
ORDER BY ml.from_unit_id, ml.link_type, ml.weight DESC
|
||||
""", frontier_node_ids)
|
||||
original_time = time.time() - start
|
||||
original_count = len(original_rows)
|
||||
|
||||
# Test 2: New LATERAL query approach (top-k per type)
|
||||
async with pool.acquire() as conn:
|
||||
start = time.time()
|
||||
lateral_rows = await conn.fetch("""
|
||||
WITH frontier(node_id) AS (SELECT unnest($1::uuid[]))
|
||||
SELECT f.node_id as from_unit_id, lt.link_type, edges.to_unit_id, edges.weight
|
||||
FROM frontier f
|
||||
CROSS JOIN (VALUES ('semantic'), ('temporal'), ('entity'), ('causes'), ('caused_by')) AS lt(link_type)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT ml.to_unit_id, ml.weight
|
||||
FROM memory_links ml
|
||||
WHERE ml.from_unit_id = f.node_id
|
||||
AND ml.link_type = lt.link_type
|
||||
AND ml.weight >= 0.1
|
||||
ORDER BY ml.weight DESC
|
||||
LIMIT 20
|
||||
) edges
|
||||
""", frontier_node_ids)
|
||||
lateral_time = time.time() - start
|
||||
lateral_count = len(lateral_rows)
|
||||
|
||||
# Print results
|
||||
print(f"\n⏱️ Performance Comparison ({len(frontier_node_ids)} nodes):")
|
||||
print(f"\n Original (all edges):")
|
||||
print(f" - Time: {original_time * 1000:.2f}ms")
|
||||
print(f" - Rows: {original_count:,}")
|
||||
print(f" - Rows/node: {original_count / len(frontier_node_ids):.1f}")
|
||||
|
||||
print(f"\n LATERAL (top-20 per type):")
|
||||
print(f" - Time: {lateral_time * 1000:.2f}ms")
|
||||
print(f" - Rows: {lateral_count:,}")
|
||||
print(f" - Rows/node: {lateral_count / len(frontier_node_ids):.1f}")
|
||||
|
||||
speedup = original_time / lateral_time if lateral_time > 0 else float('inf')
|
||||
reduction = (1 - lateral_count / original_count) * 100 if original_count > 0 else 0
|
||||
print(f"\n 📈 Improvement:")
|
||||
print(f" - Speedup: {speedup:.2f}x faster")
|
||||
print(f" - Data reduction: {reduction:.1f}% fewer rows")
|
||||
|
||||
# Assert improvement (should be at least some improvement for large datasets)
|
||||
if original_count > 1000:
|
||||
# For large datasets, expect significant improvement
|
||||
assert speedup >= 1.5, f"Expected at least 1.5x speedup, got {speedup:.2f}x"
|
||||
assert reduction >= 30, f"Expected at least 30% data reduction, got {reduction:.1f}%"
|
||||
print(f"\n✅ Performance test PASSED!")
|
||||
else:
|
||||
print(f"\n⚠️ Dataset too small ({original_count} rows) for meaningful performance comparison")
|
||||
|
||||
finally:
|
||||
await pool.close()
|
||||
|
||||
|
||||
@requires_external_db
|
||||
@pytest.mark.asyncio
|
||||
async def test_mpfp_full_retrieval_performance():
|
||||
"""
|
||||
Benchmark full MPFP retrieval including traversal and reranking.
|
||||
|
||||
This test measures end-to-end MPFP retrieval performance.
|
||||
"""
|
||||
import time
|
||||
|
||||
pool = await asyncpg.create_pool(EXTERNAL_DATABASE_URL, min_size=2, max_size=10)
|
||||
|
||||
try:
|
||||
# Get a sample query embedding from an existing memory unit
|
||||
async with pool.acquire() as conn:
|
||||
# Check if bank exists
|
||||
bank_exists = await conn.fetchval("""
|
||||
SELECT 1 FROM banks WHERE bank_id = $1
|
||||
""", BENCHMARK_BANK_ID)
|
||||
if not bank_exists:
|
||||
pytest.skip(f"Bank '{BENCHMARK_BANK_ID}' not found")
|
||||
|
||||
sample = await conn.fetchrow("""
|
||||
SELECT embedding::text as embedding_str
|
||||
FROM memory_units
|
||||
WHERE bank_id = $1
|
||||
AND embedding IS NOT NULL
|
||||
LIMIT 1
|
||||
""", BENCHMARK_BANK_ID)
|
||||
|
||||
if not sample:
|
||||
pytest.skip("No memory units with embeddings found")
|
||||
|
||||
query_embedding_str = sample['embedding_str']
|
||||
|
||||
# Run MPFP retrieval
|
||||
retriever = MPFPGraphRetriever()
|
||||
|
||||
print(f"\n🔍 Running MPFP retrieval benchmark on bank '{BENCHMARK_BANK_ID}'...")
|
||||
|
||||
# Warm-up run
|
||||
await retriever.retrieve(
|
||||
pool=pool,
|
||||
query_embedding_str=query_embedding_str,
|
||||
bank_id=BENCHMARK_BANK_ID,
|
||||
fact_type="world",
|
||||
budget=100,
|
||||
query_text="test query",
|
||||
)
|
||||
|
||||
# Timed runs
|
||||
timings_list = []
|
||||
for i in range(3):
|
||||
start = time.time()
|
||||
results, timings = await retriever.retrieve(
|
||||
pool=pool,
|
||||
query_embedding_str=query_embedding_str,
|
||||
bank_id=BENCHMARK_BANK_ID,
|
||||
fact_type="opinion",
|
||||
budget=100,
|
||||
query_text="What did I say about training models?",
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
timings_list.append((elapsed, timings, len(results)))
|
||||
|
||||
# Print results
|
||||
print(f"\n⏱️ MPFP Retrieval Results (3 runs):")
|
||||
for i, (elapsed, timings, count) in enumerate(timings_list):
|
||||
print(f"\n Run {i + 1}:")
|
||||
print(f" - Total: {elapsed * 1000:.2f}ms")
|
||||
print(f" - Results: {count}")
|
||||
if timings:
|
||||
print(f" - Seeds: {timings.seeds_time * 1000:.2f}ms")
|
||||
print(f" - Patterns: {timings.pattern_count}")
|
||||
print(f" - Traverse: {timings.traverse * 1000:.2f}ms")
|
||||
print(f" - Edge load: {timings.edge_load_time * 1000:.2f}ms")
|
||||
print(f" - Edges: {timings.edge_count:,}")
|
||||
print(f" - DB queries: {timings.db_queries}")
|
||||
print(f" - Fusion: {timings.fusion * 1000:.2f}ms")
|
||||
print(f" - Fetch: {timings.fetch * 1000:.2f}ms")
|
||||
|
||||
avg_time = sum(t[0] for t in timings_list) / len(timings_list)
|
||||
print(f"\n 📊 Average: {avg_time * 1000:.2f}ms")
|
||||
print(f"\n✅ MPFP retrieval benchmark complete!")
|
||||
|
||||
finally:
|
||||
await pool.close()
|
||||
@@ -0,0 +1,786 @@
|
||||
"""
|
||||
Tests for RemoteTEICrossEncoder (TEI reranker client).
|
||||
|
||||
Tests cover:
|
||||
- Initialization and server connectivity
|
||||
- Basic predict functionality
|
||||
- Batch splitting
|
||||
- Parallel request handling
|
||||
- Backpressure/semaphore behavior
|
||||
- Retry logic on transient errors
|
||||
- Multiple queries handling
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.cross_encoder import RemoteTEICrossEncoder
|
||||
|
||||
|
||||
class TestRemoteTEICrossEncoderInitialization:
|
||||
"""Tests for TEI cross-encoder initialization."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_success(self):
|
||||
"""Test successful initialization with valid TEI server."""
|
||||
|
||||
async def mock_handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/info":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"model_id": "BAAI/bge-reranker-base", "version": "1.0"},
|
||||
)
|
||||
return httpx.Response(404)
|
||||
|
||||
transport = httpx.MockTransport(mock_handler)
|
||||
|
||||
with patch.object(httpx, "AsyncClient", return_value=httpx.AsyncClient(transport=transport)):
|
||||
encoder = RemoteTEICrossEncoder(base_url="http://localhost:8080")
|
||||
await encoder.initialize()
|
||||
|
||||
assert encoder._model_id == "BAAI/bge-reranker-base"
|
||||
assert encoder._async_client is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_server_unreachable(self):
|
||||
"""Test initialization fails when server is unreachable."""
|
||||
|
||||
async def mock_handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("Connection refused")
|
||||
|
||||
transport = httpx.MockTransport(mock_handler)
|
||||
|
||||
with patch.object(httpx, "AsyncClient", return_value=httpx.AsyncClient(transport=transport)):
|
||||
encoder = RemoteTEICrossEncoder(
|
||||
base_url="http://localhost:8080",
|
||||
max_retries=1,
|
||||
retry_delay=0.01,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Failed to connect to TEI server"):
|
||||
await encoder.initialize()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_idempotent(self):
|
||||
"""Test that initialize() is idempotent."""
|
||||
call_count = 0
|
||||
|
||||
async def mock_handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal call_count
|
||||
if request.url.path == "/info":
|
||||
call_count += 1
|
||||
return httpx.Response(200, json={"model_id": "test-model"})
|
||||
return httpx.Response(404)
|
||||
|
||||
transport = httpx.MockTransport(mock_handler)
|
||||
|
||||
with patch.object(httpx, "AsyncClient", return_value=httpx.AsyncClient(transport=transport)):
|
||||
encoder = RemoteTEICrossEncoder(base_url="http://localhost:8080")
|
||||
await encoder.initialize()
|
||||
await encoder.initialize()
|
||||
await encoder.initialize()
|
||||
|
||||
assert call_count == 1
|
||||
|
||||
|
||||
def create_mock_async_client(handler):
|
||||
"""Create a mock AsyncClient that uses the given handler for requests."""
|
||||
|
||||
class MockAsyncClient:
|
||||
def __init__(self, **kwargs):
|
||||
self.timeout = kwargs.get("timeout", 30.0)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
async def post(self, url, **kwargs):
|
||||
return await handler("POST", url, **kwargs)
|
||||
|
||||
async def get(self, url, **kwargs):
|
||||
return await handler("GET", url, **kwargs)
|
||||
|
||||
return MockAsyncClient()
|
||||
|
||||
|
||||
class TestRemoteTEICrossEncoderPredict:
|
||||
"""Tests for TEI cross-encoder predict functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_predict_not_initialized(self):
|
||||
"""Test predict raises error when not initialized."""
|
||||
encoder = RemoteTEICrossEncoder(base_url="http://localhost:8080")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Reranker not initialized"):
|
||||
await encoder.predict([("query", "doc")])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_predict_empty_pairs(self):
|
||||
"""Test predict returns empty list for empty input."""
|
||||
encoder = RemoteTEICrossEncoder(base_url="http://localhost:8080")
|
||||
encoder._async_client = httpx.AsyncClient()
|
||||
encoder._model_id = "test-model"
|
||||
|
||||
result = await encoder.predict([])
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_predict_single_query(self):
|
||||
"""Test predict with single query and multiple documents."""
|
||||
rerank_calls = []
|
||||
|
||||
async def mock_handler(method, url, **kwargs):
|
||||
if "/rerank" in url:
|
||||
body = kwargs.get("json", {})
|
||||
rerank_calls.append(body)
|
||||
texts = body["texts"]
|
||||
# Return scores in descending order with original indices
|
||||
results = [{"index": i, "score": 1.0 - (i * 0.1)} for i in range(len(texts))]
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.raise_for_status = MagicMock()
|
||||
response.json = MagicMock(return_value=results)
|
||||
return response
|
||||
raise httpx.HTTPStatusError("Not found", request=MagicMock(), response=MagicMock())
|
||||
|
||||
encoder = RemoteTEICrossEncoder(base_url="http://localhost:8080")
|
||||
encoder._async_client = create_mock_async_client(mock_handler)
|
||||
encoder._model_id = "test-model"
|
||||
|
||||
pairs = [
|
||||
("What is Python?", "Python is a programming language."),
|
||||
("What is Python?", "Python is a snake."),
|
||||
("What is Python?", "Java is also a language."),
|
||||
]
|
||||
|
||||
scores = await encoder.predict(pairs)
|
||||
|
||||
assert len(scores) == 3
|
||||
assert len(rerank_calls) == 1
|
||||
assert rerank_calls[0]["query"] == "What is Python?"
|
||||
assert len(rerank_calls[0]["texts"]) == 3
|
||||
# Scores should be mapped back correctly
|
||||
assert scores[0] == 1.0
|
||||
assert scores[1] == 0.9
|
||||
assert scores[2] == pytest.approx(0.8, rel=0.01)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_predict_multiple_queries(self):
|
||||
"""Test predict with multiple different queries."""
|
||||
rerank_calls = []
|
||||
|
||||
async def mock_handler(method, url, **kwargs):
|
||||
if "/rerank" in url:
|
||||
body = kwargs.get("json", {})
|
||||
rerank_calls.append(body)
|
||||
texts = body["texts"]
|
||||
results = [{"index": i, "score": 0.5 + (i * 0.1)} for i in range(len(texts))]
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.raise_for_status = MagicMock()
|
||||
response.json = MagicMock(return_value=results)
|
||||
return response
|
||||
raise httpx.HTTPStatusError("Not found", request=MagicMock(), response=MagicMock())
|
||||
|
||||
encoder = RemoteTEICrossEncoder(base_url="http://localhost:8080")
|
||||
encoder._async_client = create_mock_async_client(mock_handler)
|
||||
encoder._model_id = "test-model"
|
||||
|
||||
pairs = [
|
||||
("Query A", "Doc A1"),
|
||||
("Query B", "Doc B1"),
|
||||
("Query A", "Doc A2"),
|
||||
("Query B", "Doc B2"),
|
||||
]
|
||||
|
||||
scores = await encoder.predict(pairs)
|
||||
|
||||
assert len(scores) == 4
|
||||
# Two queries = two rerank calls (run in parallel)
|
||||
assert len(rerank_calls) == 2
|
||||
|
||||
|
||||
class TestRemoteTEICrossEncoderBatching:
|
||||
"""Tests for batch splitting behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_splitting(self):
|
||||
"""Test that large inputs are split into batches."""
|
||||
rerank_calls = []
|
||||
|
||||
async def mock_handler(method, url, **kwargs):
|
||||
if "/rerank" in url:
|
||||
body = kwargs.get("json", {})
|
||||
rerank_calls.append(body)
|
||||
texts = body["texts"]
|
||||
results = [{"index": i, "score": 0.5} for i in range(len(texts))]
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.raise_for_status = MagicMock()
|
||||
response.json = MagicMock(return_value=results)
|
||||
return response
|
||||
raise httpx.HTTPStatusError("Not found", request=MagicMock(), response=MagicMock())
|
||||
|
||||
encoder = RemoteTEICrossEncoder(
|
||||
base_url="http://localhost:8080",
|
||||
batch_size=3, # Small batch for testing
|
||||
)
|
||||
encoder._async_client = create_mock_async_client(mock_handler)
|
||||
encoder._model_id = "test-model"
|
||||
|
||||
# 7 documents with same query, batch_size=3 -> 3 batches (3+3+1)
|
||||
pairs = [("Query", f"Doc {i}") for i in range(7)]
|
||||
|
||||
scores = await encoder.predict(pairs)
|
||||
|
||||
assert len(scores) == 7
|
||||
assert len(rerank_calls) == 3
|
||||
# Check batch sizes
|
||||
batch_sizes = sorted([len(call["texts"]) for call in rerank_calls])
|
||||
assert batch_sizes == [1, 3, 3]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_score_mapping_across_batches(self):
|
||||
"""Test that scores are correctly mapped back across batches."""
|
||||
call_counter = [0]
|
||||
|
||||
async def mock_handler(method, url, **kwargs):
|
||||
if "/rerank" in url:
|
||||
body = kwargs.get("json", {})
|
||||
batch_num = call_counter[0]
|
||||
call_counter[0] += 1
|
||||
texts = body["texts"]
|
||||
# Each batch returns different scores to verify mapping
|
||||
base_score = batch_num * 10
|
||||
results = [{"index": i, "score": float(base_score + i)} for i in range(len(texts))]
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.raise_for_status = MagicMock()
|
||||
response.json = MagicMock(return_value=results)
|
||||
return response
|
||||
raise httpx.HTTPStatusError("Not found", request=MagicMock(), response=MagicMock())
|
||||
|
||||
encoder = RemoteTEICrossEncoder(
|
||||
base_url="http://localhost:8080",
|
||||
batch_size=3,
|
||||
)
|
||||
encoder._async_client = create_mock_async_client(mock_handler)
|
||||
encoder._model_id = "test-model"
|
||||
|
||||
pairs = [("Query", f"Doc {i}") for i in range(7)]
|
||||
|
||||
scores = await encoder.predict(pairs)
|
||||
|
||||
assert len(scores) == 7
|
||||
# All scores should be present (exact values depend on batch ordering)
|
||||
assert all(isinstance(s, (int, float)) for s in scores)
|
||||
|
||||
|
||||
class TestRemoteTEICrossEncoderParallelism:
|
||||
"""Tests for parallel request handling and backpressure."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parallel_requests(self):
|
||||
"""Test that requests are made in parallel."""
|
||||
concurrent_count = [0]
|
||||
max_concurrent_observed = [0]
|
||||
|
||||
async def mock_handler(method, url, **kwargs):
|
||||
if "/rerank" in url:
|
||||
concurrent_count[0] += 1
|
||||
max_concurrent_observed[0] = max(max_concurrent_observed[0], concurrent_count[0])
|
||||
|
||||
await asyncio.sleep(0.03) # Simulate latency
|
||||
|
||||
concurrent_count[0] -= 1
|
||||
body = kwargs.get("json", {})
|
||||
texts = body["texts"]
|
||||
results = [{"index": i, "score": 0.5} for i in range(len(texts))]
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.raise_for_status = MagicMock()
|
||||
response.json = MagicMock(return_value=results)
|
||||
return response
|
||||
raise httpx.HTTPStatusError("Not found", request=MagicMock(), response=MagicMock())
|
||||
|
||||
encoder = RemoteTEICrossEncoder(
|
||||
base_url="http://localhost:8080",
|
||||
batch_size=2,
|
||||
max_concurrent=10, # High limit to allow parallelism
|
||||
)
|
||||
encoder._async_client = create_mock_async_client(mock_handler)
|
||||
encoder._model_id = "test-model"
|
||||
|
||||
# 6 docs = 3 batches, should run in parallel
|
||||
pairs = [("Query", f"Doc {i}") for i in range(6)]
|
||||
|
||||
start = time.time()
|
||||
scores = await encoder.predict(pairs)
|
||||
elapsed = time.time() - start
|
||||
|
||||
assert len(scores) == 6
|
||||
# If parallel, 3 batches with 30ms each should take ~30ms, not 90ms
|
||||
assert elapsed < 0.08, f"Requests should run in parallel, took {elapsed}s"
|
||||
assert max_concurrent_observed[0] > 1, "Multiple requests should run concurrently"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backpressure_semaphore(self):
|
||||
"""Test that semaphore limits concurrent requests."""
|
||||
concurrent_count = [0]
|
||||
max_concurrent_observed = [0]
|
||||
|
||||
async def mock_handler(method, url, **kwargs):
|
||||
if "/rerank" in url:
|
||||
concurrent_count[0] += 1
|
||||
max_concurrent_observed[0] = max(max_concurrent_observed[0], concurrent_count[0])
|
||||
|
||||
await asyncio.sleep(0.01) # Simulate latency
|
||||
|
||||
concurrent_count[0] -= 1
|
||||
body = kwargs.get("json", {})
|
||||
texts = body["texts"]
|
||||
results = [{"index": i, "score": 0.5} for i in range(len(texts))]
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.raise_for_status = MagicMock()
|
||||
response.json = MagicMock(return_value=results)
|
||||
return response
|
||||
raise httpx.HTTPStatusError("Not found", request=MagicMock(), response=MagicMock())
|
||||
|
||||
max_concurrent_limit = 2
|
||||
encoder = RemoteTEICrossEncoder(
|
||||
base_url="http://localhost:8080",
|
||||
batch_size=1, # 1 doc per batch to maximize requests
|
||||
max_concurrent=max_concurrent_limit,
|
||||
)
|
||||
encoder._async_client = create_mock_async_client(mock_handler)
|
||||
encoder._model_id = "test-model"
|
||||
|
||||
# 10 docs = 10 batches, but only 2 should run at a time
|
||||
pairs = [("Query", f"Doc {i}") for i in range(10)]
|
||||
|
||||
scores = await encoder.predict(pairs)
|
||||
|
||||
assert len(scores) == 10
|
||||
assert max_concurrent_observed[0] <= max_concurrent_limit, (
|
||||
f"Semaphore should limit to {max_concurrent_limit}, observed {max_concurrent_observed[0]}"
|
||||
)
|
||||
|
||||
|
||||
class TestRemoteTEICrossEncoderRetry:
|
||||
"""Tests for retry logic on transient errors."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_on_connect_error(self):
|
||||
"""Test that connect errors trigger retries."""
|
||||
attempt_count = [0]
|
||||
|
||||
async def mock_handler(method, url, **kwargs):
|
||||
if "/rerank" in url:
|
||||
attempt_count[0] += 1
|
||||
if attempt_count[0] < 3:
|
||||
raise httpx.ConnectError("Connection refused")
|
||||
body = kwargs.get("json", {})
|
||||
texts = body["texts"]
|
||||
results = [{"index": i, "score": 0.5} for i in range(len(texts))]
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.raise_for_status = MagicMock()
|
||||
response.json = MagicMock(return_value=results)
|
||||
return response
|
||||
raise httpx.HTTPStatusError("Not found", request=MagicMock(), response=MagicMock())
|
||||
|
||||
encoder = RemoteTEICrossEncoder(
|
||||
base_url="http://localhost:8080",
|
||||
max_retries=3,
|
||||
retry_delay=0.01,
|
||||
)
|
||||
encoder._async_client = create_mock_async_client(mock_handler)
|
||||
encoder._model_id = "test-model"
|
||||
|
||||
pairs = [("Query", "Doc 1")]
|
||||
scores = await encoder.predict(pairs)
|
||||
|
||||
assert len(scores) == 1
|
||||
assert attempt_count[0] == 3 # 2 failures + 1 success
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_on_server_error(self):
|
||||
"""Test that 5xx errors trigger retries."""
|
||||
attempt_count = [0]
|
||||
|
||||
async def mock_handler(method, url, **kwargs):
|
||||
if "/rerank" in url:
|
||||
attempt_count[0] += 1
|
||||
if attempt_count[0] < 2:
|
||||
response = MagicMock()
|
||||
response.status_code = 503
|
||||
|
||||
def raise_for_status():
|
||||
raise httpx.HTTPStatusError(
|
||||
"Service unavailable",
|
||||
request=MagicMock(),
|
||||
response=response,
|
||||
)
|
||||
|
||||
response.raise_for_status = raise_for_status
|
||||
return response
|
||||
|
||||
body = kwargs.get("json", {})
|
||||
texts = body["texts"]
|
||||
results = [{"index": i, "score": 0.5} for i in range(len(texts))]
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.raise_for_status = MagicMock()
|
||||
response.json = MagicMock(return_value=results)
|
||||
return response
|
||||
raise httpx.HTTPStatusError("Not found", request=MagicMock(), response=MagicMock())
|
||||
|
||||
encoder = RemoteTEICrossEncoder(
|
||||
base_url="http://localhost:8080",
|
||||
max_retries=3,
|
||||
retry_delay=0.01,
|
||||
)
|
||||
encoder._async_client = create_mock_async_client(mock_handler)
|
||||
encoder._model_id = "test-model"
|
||||
|
||||
pairs = [("Query", "Doc 1")]
|
||||
scores = await encoder.predict(pairs)
|
||||
|
||||
assert len(scores) == 1
|
||||
assert attempt_count[0] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_retry_on_client_error(self):
|
||||
"""Test that 4xx errors do not trigger retries."""
|
||||
attempt_count = [0]
|
||||
|
||||
async def mock_handler(method, url, **kwargs):
|
||||
if "/rerank" in url:
|
||||
attempt_count[0] += 1
|
||||
response = MagicMock()
|
||||
response.status_code = 400
|
||||
|
||||
def raise_for_status():
|
||||
raise httpx.HTTPStatusError(
|
||||
"Bad request",
|
||||
request=MagicMock(),
|
||||
response=response,
|
||||
)
|
||||
|
||||
response.raise_for_status = raise_for_status
|
||||
return response
|
||||
raise httpx.HTTPStatusError("Not found", request=MagicMock(), response=MagicMock())
|
||||
|
||||
encoder = RemoteTEICrossEncoder(
|
||||
base_url="http://localhost:8080",
|
||||
max_retries=3,
|
||||
retry_delay=0.01,
|
||||
)
|
||||
encoder._async_client = create_mock_async_client(mock_handler)
|
||||
encoder._model_id = "test-model"
|
||||
|
||||
pairs = [("Query", "Doc 1")]
|
||||
|
||||
with pytest.raises(RuntimeError, match="TEI rerank request failed"):
|
||||
await encoder.predict(pairs)
|
||||
|
||||
assert attempt_count[0] == 1 # No retries for 4xx
|
||||
|
||||
|
||||
class TestRemoteTEICrossEncoderConfig:
|
||||
"""Tests for configuration from environment variables."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test default configuration values."""
|
||||
encoder = RemoteTEICrossEncoder(base_url="http://localhost:8080")
|
||||
|
||||
assert encoder.batch_size == 128
|
||||
assert encoder.max_concurrent == 8
|
||||
assert encoder.timeout == 30.0
|
||||
assert encoder.max_retries == 3
|
||||
|
||||
def test_custom_values(self):
|
||||
"""Test custom configuration values."""
|
||||
encoder = RemoteTEICrossEncoder(
|
||||
base_url="http://localhost:8080",
|
||||
batch_size=64,
|
||||
max_concurrent=4,
|
||||
timeout=60.0,
|
||||
max_retries=5,
|
||||
retry_delay=1.0,
|
||||
)
|
||||
|
||||
assert encoder.batch_size == 64
|
||||
assert encoder.max_concurrent == 4
|
||||
assert encoder.timeout == 60.0
|
||||
assert encoder.max_retries == 5
|
||||
assert encoder.retry_delay == 1.0
|
||||
|
||||
def test_create_from_env(self):
|
||||
"""Test creating encoder from environment variables."""
|
||||
import os
|
||||
|
||||
from hindsight_api.engine.cross_encoder import create_cross_encoder_from_env
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"HINDSIGHT_API_RERANKER_PROVIDER": "tei",
|
||||
"HINDSIGHT_API_RERANKER_TEI_URL": "http://test:9000",
|
||||
"HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE": "256",
|
||||
"HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT": "16",
|
||||
},
|
||||
):
|
||||
encoder = create_cross_encoder_from_env()
|
||||
|
||||
assert isinstance(encoder, RemoteTEICrossEncoder)
|
||||
assert encoder.base_url == "http://test:9000"
|
||||
assert encoder.batch_size == 256
|
||||
assert encoder.max_concurrent == 16
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TEI Reranker Performance Benchmark Tests
|
||||
# ============================================================================
|
||||
# These tests require a running TEI server to measure actual performance.
|
||||
# Set TEI_RERANKER_URL environment variable to run.
|
||||
# Example:
|
||||
# TEI_RERANKER_URL=http://localhost:8000 \
|
||||
# pytest tests/test_tei_cross_encoder.py::test_tei_reranker_performance -v -s -n0
|
||||
|
||||
import os
|
||||
|
||||
TEI_RERANKER_URL = os.environ.get("TEI_RERANKER_URL")
|
||||
|
||||
requires_tei_server = pytest.mark.skipif(
|
||||
TEI_RERANKER_URL is None,
|
||||
reason="TEI_RERANKER_URL not set - skipping TEI performance benchmark",
|
||||
)
|
||||
|
||||
|
||||
@requires_tei_server
|
||||
@pytest.mark.asyncio
|
||||
async def test_tei_reranker_performance():
|
||||
"""
|
||||
Benchmark TEI reranker performance with different configurations.
|
||||
|
||||
This test measures latency for different batch sizes and concurrency levels
|
||||
to find the optimal configuration for your TEI server.
|
||||
|
||||
Example usage:
|
||||
TEI_RERANKER_URL=http://localhost:8000 \
|
||||
pytest tests/test_tei_cross_encoder.py::test_tei_reranker_performance -v -s -n0
|
||||
"""
|
||||
import httpx
|
||||
|
||||
# Get server info
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(f"{TEI_RERANKER_URL}/info")
|
||||
info = response.json()
|
||||
print(f"\n📊 TEI Server Info:")
|
||||
print(f" URL: {TEI_RERANKER_URL}")
|
||||
print(f" Model: {info.get('model_id', 'unknown')}")
|
||||
if "reranker_model" in info:
|
||||
print(f" Reranker Model: {info['reranker_model']}")
|
||||
|
||||
# Generate test data (800 pairs to simulate real workload)
|
||||
num_pairs = 800
|
||||
query = "What did I say about training machine learning models and artificial intelligence?"
|
||||
test_pairs = [
|
||||
(query, f"Document {i} about machine learning, neural networks, and AI training techniques.")
|
||||
for i in range(num_pairs)
|
||||
]
|
||||
|
||||
# Test configurations: (batch_size, max_concurrent)
|
||||
configs = [
|
||||
(128, 8), # Default
|
||||
(256, 4), # Larger batches, fewer concurrent
|
||||
(256, 8), # Larger batches, same concurrent
|
||||
(512, 2), # Very large batches, few concurrent
|
||||
(512, 4), # Very large batches, moderate concurrent
|
||||
(64, 16), # Smaller batches, more concurrent
|
||||
(800, 1), # Single batch (all at once)
|
||||
]
|
||||
|
||||
results = []
|
||||
print(f"\n⏱️ Benchmarking {num_pairs} pairs with different configurations:\n")
|
||||
|
||||
for batch_size, max_concurrent in configs:
|
||||
encoder = RemoteTEICrossEncoder(
|
||||
base_url=TEI_RERANKER_URL,
|
||||
batch_size=batch_size,
|
||||
max_concurrent=max_concurrent,
|
||||
timeout=60.0,
|
||||
)
|
||||
await encoder.initialize()
|
||||
|
||||
# Warm-up run
|
||||
await encoder.predict(test_pairs[:100])
|
||||
|
||||
# Timed runs (3 iterations)
|
||||
times = []
|
||||
for _ in range(3):
|
||||
start = time.time()
|
||||
scores = await encoder.predict(test_pairs)
|
||||
elapsed = time.time() - start
|
||||
times.append(elapsed)
|
||||
assert len(scores) == num_pairs
|
||||
|
||||
avg_time = sum(times) / len(times)
|
||||
min_time = min(times)
|
||||
results.append({
|
||||
"batch_size": batch_size,
|
||||
"max_concurrent": max_concurrent,
|
||||
"avg_ms": avg_time * 1000,
|
||||
"min_ms": min_time * 1000,
|
||||
"num_batches": (num_pairs + batch_size - 1) // batch_size,
|
||||
})
|
||||
|
||||
print(f" batch_size={batch_size:4d}, max_concurrent={max_concurrent:2d}: "
|
||||
f"avg={avg_time * 1000:6.1f}ms, min={min_time * 1000:6.1f}ms "
|
||||
f"({results[-1]['num_batches']} batches)")
|
||||
|
||||
# Find best configuration
|
||||
best = min(results, key=lambda x: x["avg_ms"])
|
||||
print(f"\n🏆 Best Configuration:")
|
||||
print(f" batch_size={best['batch_size']}, max_concurrent={best['max_concurrent']}")
|
||||
print(f" Average: {best['avg_ms']:.1f}ms, Min: {best['min_ms']:.1f}ms")
|
||||
|
||||
# Performance target check
|
||||
target_ms = 100
|
||||
if best["avg_ms"] <= target_ms:
|
||||
print(f"\n✅ Target met! Average {best['avg_ms']:.1f}ms <= {target_ms}ms")
|
||||
else:
|
||||
print(f"\n⚠️ Target NOT met. Average {best['avg_ms']:.1f}ms > {target_ms}ms")
|
||||
print(f" Consider: larger batch size, GPU optimization, or faster network")
|
||||
|
||||
|
||||
@requires_tei_server
|
||||
@pytest.mark.asyncio
|
||||
async def test_tei_reranker_concurrent_requests():
|
||||
"""
|
||||
Test TEI reranker performance under concurrent request load.
|
||||
|
||||
This simulates multiple parallel recall requests hitting the reranker
|
||||
at the same time.
|
||||
"""
|
||||
# Smaller batches to simulate typical recall workload
|
||||
num_pairs_per_request = 200
|
||||
num_concurrent_requests = 4
|
||||
|
||||
query = "Tell me about machine learning and AI training"
|
||||
test_pairs = [
|
||||
(query, f"Document {i} about ML and training.")
|
||||
for i in range(num_pairs_per_request)
|
||||
]
|
||||
|
||||
# Test configurations
|
||||
configs = [
|
||||
(128, 8), # Default
|
||||
(256, 4), # Larger batches
|
||||
(512, 2), # Very large batches
|
||||
(200, 1), # Single batch per request
|
||||
]
|
||||
|
||||
print(f"\n⏱️ Concurrent Load Test: {num_concurrent_requests} parallel requests, "
|
||||
f"{num_pairs_per_request} pairs each:\n")
|
||||
|
||||
for batch_size, max_concurrent in configs:
|
||||
encoder = RemoteTEICrossEncoder(
|
||||
base_url=TEI_RERANKER_URL,
|
||||
batch_size=batch_size,
|
||||
max_concurrent=max_concurrent,
|
||||
timeout=60.0,
|
||||
)
|
||||
await encoder.initialize()
|
||||
|
||||
# Warm-up
|
||||
await encoder.predict(test_pairs[:50])
|
||||
|
||||
async def run_single_request():
|
||||
start = time.time()
|
||||
scores = await encoder.predict(test_pairs)
|
||||
return time.time() - start, len(scores)
|
||||
|
||||
# Run concurrent requests
|
||||
times = []
|
||||
for _ in range(3): # 3 iterations
|
||||
start = time.time()
|
||||
results = await asyncio.gather(*[run_single_request() for _ in range(num_concurrent_requests)])
|
||||
total_time = time.time() - start
|
||||
|
||||
individual_times = [r[0] for r in results]
|
||||
times.append({
|
||||
"total": total_time,
|
||||
"max_individual": max(individual_times),
|
||||
"avg_individual": sum(individual_times) / len(individual_times),
|
||||
})
|
||||
|
||||
avg_total = sum(t["total"] for t in times) / len(times)
|
||||
avg_max_individual = sum(t["max_individual"] for t in times) / len(times)
|
||||
|
||||
print(f" batch_size={batch_size:4d}, max_concurrent={max_concurrent:2d}: "
|
||||
f"total={avg_total * 1000:6.1f}ms, slowest_req={avg_max_individual * 1000:6.1f}ms")
|
||||
|
||||
|
||||
@requires_tei_server
|
||||
@pytest.mark.asyncio
|
||||
async def test_tei_reranker_latency_breakdown():
|
||||
"""
|
||||
Measure latency breakdown for TEI reranker requests.
|
||||
|
||||
This helps identify where time is spent: network vs processing.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
print(f"\n⏱️ Latency Breakdown Test:\n")
|
||||
|
||||
# Test single document latency (network overhead)
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
times = []
|
||||
for _ in range(10):
|
||||
start = time.time()
|
||||
await client.post(
|
||||
f"{TEI_RERANKER_URL}/rerank",
|
||||
json={
|
||||
"query": "test query",
|
||||
"texts": ["test document"],
|
||||
"return_text": False,
|
||||
},
|
||||
)
|
||||
times.append((time.time() - start) * 1000)
|
||||
|
||||
avg_single = sum(times) / len(times)
|
||||
print(f" Single doc latency (raw HTTP): {avg_single:.2f}ms")
|
||||
|
||||
# Test batch latencies
|
||||
batch_sizes = [10, 50, 100, 200, 500]
|
||||
for batch_size in batch_sizes:
|
||||
texts = [f"Document {i} about machine learning" for i in range(batch_size)]
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
times = []
|
||||
for _ in range(5):
|
||||
start = time.time()
|
||||
await client.post(
|
||||
f"{TEI_RERANKER_URL}/rerank",
|
||||
json={
|
||||
"query": "What about machine learning?",
|
||||
"texts": texts,
|
||||
"return_text": False,
|
||||
},
|
||||
)
|
||||
times.append((time.time() - start) * 1000)
|
||||
|
||||
avg = sum(times) / len(times)
|
||||
per_doc = avg / batch_size
|
||||
print(f" Batch size {batch_size:4d}: {avg:6.1f}ms total, {per_doc:.2f}ms/doc")
|
||||
|
||||
print(f"\n 💡 Insight: Higher per-doc time at small batches = network overhead dominant")
|
||||
print(f" 💡 Insight: Lower per-doc time at large batches = GPU efficiently utilized")
|
||||
@@ -103,7 +103,7 @@ impl ApiClient {
|
||||
|
||||
pub fn get_stats(&self, agent_id: &str, _verbose: bool) -> Result<AgentStats> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_agent_stats(agent_id).await?;
|
||||
let response = self.client.get_agent_stats(agent_id, None).await?;
|
||||
let value = response.into_inner();
|
||||
// Convert to JSON Value first, then parse into our type
|
||||
let json_value = serde_json::to_value(&value)?;
|
||||
@@ -241,9 +241,9 @@ impl ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_entities(&self, bank_id: &str, limit: Option<i64>, _verbose: bool) -> Result<types::EntityListResponse> {
|
||||
pub fn list_entities(&self, bank_id: &str, limit: Option<i64>, offset: Option<i64>, _verbose: bool) -> Result<types::EntityListResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.list_entities(bank_id, limit, None).await?;
|
||||
let response = self.client.list_entities(bank_id, limit, offset, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn list(
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.list_entities(bank_id, Some(limit), verbose)?;
|
||||
let response = client.list_entities(bank_id, Some(limit), None, verbose)?;
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
|
||||
@@ -283,7 +283,7 @@ impl App {
|
||||
}
|
||||
|
||||
fn load_entities(&mut self, bank_id: &str) -> Result<()> {
|
||||
let response = self.client.list_entities(bank_id, Some(100), false)?;
|
||||
let response = self.client.list_entities(bank_id, Some(100), None, false)?;
|
||||
self.entities = response.items;
|
||||
|
||||
if !self.entities.is_empty() && self.entities_state.selected().is_none() {
|
||||
|
||||
@@ -939,6 +939,7 @@ class BanksApi:
|
||||
async def get_agent_stats(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
@@ -958,6 +959,8 @@ class BanksApi:
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
number provided, it will be total request
|
||||
timeout. It can also be a pair (tuple) of
|
||||
@@ -982,6 +985,7 @@ class BanksApi:
|
||||
|
||||
_param = self._get_agent_stats_serialize(
|
||||
bank_id=bank_id,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
@@ -1007,6 +1011,7 @@ class BanksApi:
|
||||
async def get_agent_stats_with_http_info(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
@@ -1026,6 +1031,8 @@ class BanksApi:
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
number provided, it will be total request
|
||||
timeout. It can also be a pair (tuple) of
|
||||
@@ -1050,6 +1057,7 @@ class BanksApi:
|
||||
|
||||
_param = self._get_agent_stats_serialize(
|
||||
bank_id=bank_id,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
@@ -1075,6 +1083,7 @@ class BanksApi:
|
||||
async def get_agent_stats_without_preload_content(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
@@ -1094,6 +1103,8 @@ class BanksApi:
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
number provided, it will be total request
|
||||
timeout. It can also be a pair (tuple) of
|
||||
@@ -1118,6 +1129,7 @@ class BanksApi:
|
||||
|
||||
_param = self._get_agent_stats_serialize(
|
||||
bank_id=bank_id,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
@@ -1138,6 +1150,7 @@ class BanksApi:
|
||||
def _get_agent_stats_serialize(
|
||||
self,
|
||||
bank_id,
|
||||
authorization,
|
||||
_request_auth,
|
||||
_content_type,
|
||||
_headers,
|
||||
@@ -1163,6 +1176,8 @@ class BanksApi:
|
||||
_path_params['bank_id'] = bank_id
|
||||
# process the query parameters
|
||||
# process the header parameters
|
||||
if authorization is not None:
|
||||
_header_params['authorization'] = authorization
|
||||
# process the form parameters
|
||||
# process the body parameter
|
||||
|
||||
|
||||
@@ -338,6 +338,7 @@ class EntitiesApi:
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
limit: Annotated[Optional[StrictInt], Field(description="Maximum number of entities to return")] = None,
|
||||
offset: Annotated[Optional[StrictInt], Field(description="Offset for pagination")] = None,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
@@ -354,12 +355,14 @@ class EntitiesApi:
|
||||
) -> EntityListResponse:
|
||||
"""List entities
|
||||
|
||||
List all entities (people, organizations, etc.) known by the bank, ordered by mention count.
|
||||
List all entities (people, organizations, etc.) known by the bank, ordered by mention count. Supports pagination.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param limit: Maximum number of entities to return
|
||||
:type limit: int
|
||||
:param offset: Offset for pagination
|
||||
:type offset: int
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
@@ -387,6 +390,7 @@ class EntitiesApi:
|
||||
_param = self._list_entities_serialize(
|
||||
bank_id=bank_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
@@ -414,6 +418,7 @@ class EntitiesApi:
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
limit: Annotated[Optional[StrictInt], Field(description="Maximum number of entities to return")] = None,
|
||||
offset: Annotated[Optional[StrictInt], Field(description="Offset for pagination")] = None,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
@@ -430,12 +435,14 @@ class EntitiesApi:
|
||||
) -> ApiResponse[EntityListResponse]:
|
||||
"""List entities
|
||||
|
||||
List all entities (people, organizations, etc.) known by the bank, ordered by mention count.
|
||||
List all entities (people, organizations, etc.) known by the bank, ordered by mention count. Supports pagination.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param limit: Maximum number of entities to return
|
||||
:type limit: int
|
||||
:param offset: Offset for pagination
|
||||
:type offset: int
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
@@ -463,6 +470,7 @@ class EntitiesApi:
|
||||
_param = self._list_entities_serialize(
|
||||
bank_id=bank_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
@@ -490,6 +498,7 @@ class EntitiesApi:
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
limit: Annotated[Optional[StrictInt], Field(description="Maximum number of entities to return")] = None,
|
||||
offset: Annotated[Optional[StrictInt], Field(description="Offset for pagination")] = None,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
@@ -506,12 +515,14 @@ class EntitiesApi:
|
||||
) -> RESTResponseType:
|
||||
"""List entities
|
||||
|
||||
List all entities (people, organizations, etc.) known by the bank, ordered by mention count.
|
||||
List all entities (people, organizations, etc.) known by the bank, ordered by mention count. Supports pagination.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param limit: Maximum number of entities to return
|
||||
:type limit: int
|
||||
:param offset: Offset for pagination
|
||||
:type offset: int
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
@@ -539,6 +550,7 @@ class EntitiesApi:
|
||||
_param = self._list_entities_serialize(
|
||||
bank_id=bank_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
@@ -561,6 +573,7 @@ class EntitiesApi:
|
||||
self,
|
||||
bank_id,
|
||||
limit,
|
||||
offset,
|
||||
authorization,
|
||||
_request_auth,
|
||||
_content_type,
|
||||
@@ -590,6 +603,10 @@ class EntitiesApi:
|
||||
|
||||
_query_params.append(('limit', limit))
|
||||
|
||||
if offset is not None:
|
||||
|
||||
_query_params.append(('offset', offset))
|
||||
|
||||
# process the header parameters
|
||||
if authorization is not None:
|
||||
_header_params['authorization'] = authorization
|
||||
|
||||
@@ -17,7 +17,7 @@ import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, StrictInt
|
||||
from typing import Any, ClassVar, Dict, List
|
||||
from hindsight_client_api.models.entity_list_item import EntityListItem
|
||||
from typing import Optional, Set
|
||||
@@ -28,7 +28,10 @@ class EntityListResponse(BaseModel):
|
||||
Response model for entity list endpoint.
|
||||
""" # noqa: E501
|
||||
items: List[EntityListItem]
|
||||
__properties: ClassVar[List[str]] = ["items"]
|
||||
total: StrictInt
|
||||
limit: StrictInt
|
||||
offset: StrictInt
|
||||
__properties: ClassVar[List[str]] = ["items", "total", "limit", "offset"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -88,7 +91,10 @@ class EntityListResponse(BaseModel):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"items": [EntityListItem.from_dict(_item) for _item in obj["items"]] if obj.get("items") is not None else None
|
||||
"items": [EntityListItem.from_dict(_item) for _item in obj["items"]] if obj.get("items") is not None else None,
|
||||
"total": obj.get("total"),
|
||||
"limit": obj.get("limit"),
|
||||
"offset": obj.get("offset")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -449,6 +449,38 @@ class TestEntities:
|
||||
assert response is not None
|
||||
assert response.items is not None
|
||||
assert isinstance(response.items, list)
|
||||
# Verify pagination fields
|
||||
assert response.total is not None
|
||||
assert response.limit is not None
|
||||
assert response.offset is not None
|
||||
assert response.offset == 0
|
||||
assert response.limit == 100 # default limit
|
||||
|
||||
def test_list_entities_with_pagination(self, client, bank_id):
|
||||
"""Test listing entities with pagination parameters."""
|
||||
import asyncio
|
||||
from hindsight_client_api import ApiClient, Configuration
|
||||
from hindsight_client_api.api import EntitiesApi
|
||||
|
||||
async def do_list_paginated():
|
||||
config = Configuration(host=HINDSIGHT_API_URL)
|
||||
api_client = ApiClient(config)
|
||||
api = EntitiesApi(api_client)
|
||||
|
||||
# Test with custom limit
|
||||
response = await api.list_entities(bank_id=bank_id, limit=5, offset=0)
|
||||
assert response.limit == 5
|
||||
assert response.offset == 0
|
||||
assert len(response.items) <= 5
|
||||
|
||||
# Test with offset
|
||||
response_offset = await api.list_entities(bank_id=bank_id, limit=1, offset=1)
|
||||
assert response_offset.offset == 1
|
||||
assert response_offset.limit == 1
|
||||
|
||||
return response
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(do_list_paginated())
|
||||
|
||||
def test_get_entity(self, client, bank_id):
|
||||
"""Test getting a specific entity."""
|
||||
|
||||
@@ -169,8 +169,6 @@ export const createSseClient = <TData = unknown>({
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += value;
|
||||
// Normalize line endings: CRLF -> LF, then CR -> LF
|
||||
buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
|
||||
const chunks = buffer.split("\n\n");
|
||||
buffer = chunks.pop() ?? "";
|
||||
|
||||
@@ -236,7 +236,7 @@ export const getAgentStats = <ThrowOnError extends boolean = false>(
|
||||
/**
|
||||
* List entities
|
||||
*
|
||||
* List all entities (people, organizations, etc.) known by the bank, ordered by mention count.
|
||||
* List all entities (people, organizations, etc.) known by the bank, ordered by mention count. Supports pagination.
|
||||
*/
|
||||
export const listEntities = <ThrowOnError extends boolean = false>(
|
||||
options: Options<ListEntitiesData, ThrowOnError>,
|
||||
|
||||
@@ -495,6 +495,18 @@ export type EntityListResponse = {
|
||||
* Items
|
||||
*/
|
||||
items: Array<EntityListItem>;
|
||||
/**
|
||||
* Total
|
||||
*/
|
||||
total: number;
|
||||
/**
|
||||
* Limit
|
||||
*/
|
||||
limit: number;
|
||||
/**
|
||||
* Offset
|
||||
*/
|
||||
offset: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1320,6 +1332,12 @@ export type ListBanksResponse = ListBanksResponses[keyof ListBanksResponses];
|
||||
|
||||
export type GetAgentStatsData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
@@ -1370,6 +1388,12 @@ export type ListEntitiesData = {
|
||||
* Maximum number of entities to return
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* Offset
|
||||
*
|
||||
* Offset for pagination
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
url: "/v1/default/banks/{bank_id}/entities";
|
||||
};
|
||||
|
||||
@@ -78,6 +78,16 @@ export class HindsightClient {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the API response and throws an error if the request failed.
|
||||
*/
|
||||
private validateResponse<T>(response: { data?: T; error?: unknown }, operation: string): T {
|
||||
if (!response.data) {
|
||||
throw new Error(`${operation} failed: ${JSON.stringify(response.error || 'Unknown error')}`);
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retain a single memory for a bank.
|
||||
*/
|
||||
@@ -126,7 +136,7 @@ export class HindsightClient {
|
||||
body: { items: [item], async: options?.async },
|
||||
});
|
||||
|
||||
return response.data!;
|
||||
return this.validateResponse(response, 'retain');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,7 +170,7 @@ export class HindsightClient {
|
||||
},
|
||||
});
|
||||
|
||||
return response.data!;
|
||||
return this.validateResponse(response, 'retainBatch');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,11 +208,7 @@ export class HindsightClient {
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error(`API returned no data: ${JSON.stringify(response.error || 'Unknown error')}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
return this.validateResponse(response, 'recall');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -223,7 +229,7 @@ export class HindsightClient {
|
||||
},
|
||||
});
|
||||
|
||||
return response.data!;
|
||||
return this.validateResponse(response, 'reflect');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,7 +250,7 @@ export class HindsightClient {
|
||||
},
|
||||
});
|
||||
|
||||
return response.data!;
|
||||
return this.validateResponse(response, 'listMemories');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,7 +270,7 @@ export class HindsightClient {
|
||||
},
|
||||
});
|
||||
|
||||
return response.data!;
|
||||
return this.validateResponse(response, 'createBank');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,7 +282,7 @@ export class HindsightClient {
|
||||
path: { bank_id: bankId },
|
||||
});
|
||||
|
||||
return response.data!;
|
||||
return this.validateResponse(response, 'getBankProfile');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,12 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined;
|
||||
const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : undefined;
|
||||
|
||||
const response = await sdk.listEntities({
|
||||
client: lowLevelClient,
|
||||
path: { bank_id: bankId },
|
||||
query: { limit },
|
||||
query: { limit, offset },
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
|
||||
import { createClient, createConfig, sdk } from "@vectorize-io/hindsight-client";
|
||||
|
||||
const HEALTH_CHECK_TIMEOUT_MS = 3000;
|
||||
|
||||
export async function GET() {
|
||||
const status: {
|
||||
@@ -15,19 +17,37 @@ export async function GET() {
|
||||
service: "hindsight-control-plane",
|
||||
};
|
||||
|
||||
// Check dataplane connectivity
|
||||
// Check dataplane connectivity with a short timeout
|
||||
const dataplaneUrl = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888";
|
||||
try {
|
||||
await sdk.listBanks({ client: lowLevelClient });
|
||||
status.dataplane = {
|
||||
status: "connected",
|
||||
url: dataplaneUrl,
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS);
|
||||
|
||||
const healthClient = createClient(
|
||||
createConfig({
|
||||
baseUrl: dataplaneUrl,
|
||||
signal: controller.signal,
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
await sdk.listBanks({ client: healthClient });
|
||||
status.dataplane = {
|
||||
status: "connected",
|
||||
url: dataplaneUrl,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (error) {
|
||||
let errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
errorMessage = `Request timed out after ${HEALTH_CHECK_TIMEOUT_MS}ms`;
|
||||
}
|
||||
status.dataplane = {
|
||||
status: "disconnected",
|
||||
url: dataplaneUrl,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import { client } from "@/lib/api";
|
||||
import { useBank } from "@/lib/bank-context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -29,6 +30,8 @@ interface EntityDetail extends Entity {
|
||||
}>;
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
|
||||
export function EntitiesView() {
|
||||
const { currentBank } = useBank();
|
||||
const [entities, setEntities] = useState<Entity[]>([]);
|
||||
@@ -37,16 +40,26 @@ export function EntitiesView() {
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
|
||||
const loadEntities = async () => {
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const totalPages = Math.ceil(total / ITEMS_PER_PAGE);
|
||||
const offset = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
|
||||
const loadEntities = async (page: number = 1) => {
|
||||
if (!currentBank) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result: any = await client.listEntities({
|
||||
const pageOffset = (page - 1) * ITEMS_PER_PAGE;
|
||||
const result = await client.listEntities({
|
||||
bank_id: currentBank,
|
||||
limit: 100,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset: pageOffset,
|
||||
});
|
||||
setEntities(result.items || []);
|
||||
setTotal(result.total || 0);
|
||||
} catch (error) {
|
||||
console.error("Error loading entities:", error);
|
||||
alert("Error loading entities: " + (error as Error).message);
|
||||
@@ -86,9 +99,16 @@ export function EntitiesView() {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
loadEntities(newPage);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentBank) {
|
||||
loadEntities();
|
||||
setCurrentPage(1);
|
||||
loadEntities(1);
|
||||
setSelectedEntity(null);
|
||||
}
|
||||
}, [currentBank]);
|
||||
@@ -105,13 +125,15 @@ export function EntitiesView() {
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">⏳</div>
|
||||
<div className="text-4xl mb-2">...</div>
|
||||
<div className="text-sm text-muted-foreground">Loading entities...</div>
|
||||
</div>
|
||||
</div>
|
||||
) : entities.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-4 text-sm text-muted-foreground">{entities.length} entities</div>
|
||||
<div className="mb-4 text-sm text-muted-foreground">
|
||||
{total} {total === 1 ? "entity" : "entities"}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -146,11 +168,61 @@ export function EntitiesView() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{offset + 1}-{Math.min(offset + ITEMS_PER_PAGE, total)} of {total}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<ChevronsLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-xs px-2">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<ChevronsRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">👥</div>
|
||||
<div className="text-4xl mb-2">...</div>
|
||||
<div className="text-sm text-muted-foreground">No entities found</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Entities are extracted from facts when memories are added.
|
||||
@@ -178,7 +250,7 @@ export function EntitiesView() {
|
||||
onClick={() => setSelectedEntity(null)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="text-lg">×</span>
|
||||
<span className="text-lg">x</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -127,11 +127,17 @@ export class ControlPlaneClient {
|
||||
/**
|
||||
* List entities
|
||||
*/
|
||||
async listEntities(params: { bank_id: string; limit?: number }) {
|
||||
async listEntities(params: { bank_id: string; limit?: number; offset?: number }) {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append("bank_id", params.bank_id);
|
||||
if (params.limit) queryParams.append("limit", params.limit.toString());
|
||||
return this.fetchApi(`/api/entities?${queryParams}`);
|
||||
if (params.offset) queryParams.append("offset", params.offset.toString());
|
||||
return this.fetchApi<{
|
||||
items: any[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}>(`/api/entities?${queryParams}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -189,7 +189,10 @@ Supported OpenAI embedding dimensions:
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_RERANKER_PROVIDER` | Provider: `local`, `tei`, or `cohere` | `local` |
|
||||
| `HINDSIGHT_API_RERANKER_LOCAL_MODEL` | Model for local provider | `cross-encoder/ms-marco-MiniLM-L-6-v2` |
|
||||
| `HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT` | Max concurrent local reranking (prevents CPU thrashing under load) | `4` |
|
||||
| `HINDSIGHT_API_RERANKER_TEI_URL` | TEI server URL | - |
|
||||
| `HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE` | Batch size for TEI reranking | `128` |
|
||||
| `HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT` | Max concurrent TEI reranking requests | `8` |
|
||||
| `HINDSIGHT_API_RERANKER_COHERE_MODEL` | Cohere rerank model | `rerank-english-v3.0` |
|
||||
|
||||
```bash
|
||||
@@ -207,15 +210,6 @@ export HINDSIGHT_API_COHERE_API_KEY=your-api-key # shared with embeddings
|
||||
export HINDSIGHT_API_RERANKER_COHERE_MODEL=rerank-english-v3.0
|
||||
```
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_HOST` | Bind address | `0.0.0.0` |
|
||||
| `HINDSIGHT_API_PORT` | Server port | `8888` |
|
||||
| `HINDSIGHT_API_LOG_LEVEL` | Log level: `debug`, `info`, `warning`, `error` | `info` |
|
||||
| `HINDSIGHT_API_MCP_ENABLED` | Enable MCP server at `/mcp/{bank_id}/` | `true` |
|
||||
|
||||
### Authentication
|
||||
|
||||
By default, Hindsight runs without authentication. For production deployments, enable API key authentication using the built-in tenant extension:
|
||||
@@ -239,11 +233,29 @@ Requests without a valid API key receive a `401 Unauthorized` response.
|
||||
For advanced authentication (JWT, OAuth, multi-tenant schemas), implement a custom `TenantExtension`. See the [Extensions documentation](./extensions.md) for details.
|
||||
:::
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_HOST` | Bind address | `0.0.0.0` |
|
||||
| `HINDSIGHT_API_PORT` | Server port | `8888` |
|
||||
| `HINDSIGHT_API_WORKERS` | Number of uvicorn worker processes | `1` |
|
||||
| `HINDSIGHT_API_LOG_LEVEL` | Log level: `debug`, `info`, `warning`, `error` | `info` |
|
||||
| `HINDSIGHT_API_MCP_ENABLED` | Enable MCP server at `/mcp/{bank_id}/` | `true` |
|
||||
|
||||
### Retrieval
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_GRAPH_RETRIEVER` | Graph retrieval algorithm: `bfs` or `mpfp` | `bfs` |
|
||||
| `HINDSIGHT_API_GRAPH_RETRIEVER` | Graph retrieval algorithm: `link_expansion`, `mpfp`, or `bfs` | `link_expansion` |
|
||||
| `HINDSIGHT_API_RECALL_MAX_CONCURRENT` | Max concurrent recall operations per worker (backpressure) | `32` |
|
||||
| `HINDSIGHT_API_RERANKER_MAX_CANDIDATES` | Max candidates to rerank per recall (RRF pre-filters the rest) | `300` |
|
||||
|
||||
#### Graph Retrieval Algorithms
|
||||
|
||||
- **`link_expansion`** (default): Fast, simple graph expansion from semantic seeds via entity co-occurrence and causal links. Target latency under 100ms. Recommended for most use cases.
|
||||
- **`mpfp`**: Multi-Path Fact Propagation - iterative graph traversal with activation spreading. More thorough but slower.
|
||||
- **`bfs`**: Breadth-first search from seed facts. Simple but less effective for large graphs.
|
||||
|
||||
### Entity Observations
|
||||
|
||||
@@ -262,6 +274,17 @@ Controls the retain (memory ingestion) pipeline.
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS` | Max completion tokens for fact extraction LLM calls | `64000` |
|
||||
| `HINDSIGHT_API_RETAIN_CHUNK_SIZE` | Max characters per chunk for fact extraction. Larger chunks extract fewer LLM calls but may lose context. | `3000` |
|
||||
| `HINDSIGHT_API_RETAIN_EXTRACTION_MODE` | Fact extraction mode: `concise` (selective, fewer high-quality facts) or `verbose` (detailed, more facts) | `concise` |
|
||||
| `HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS` | Extract causal relationships between facts | `true` |
|
||||
| `HINDSIGHT_API_RETAIN_OBSERVATIONS_ASYNC` | Run entity observation generation asynchronously (after retain completes) | `false` |
|
||||
|
||||
#### Extraction Modes
|
||||
|
||||
The extraction mode controls how aggressively facts are extracted from content:
|
||||
|
||||
- **`concise`** (default): Selective extraction that focuses on significant, long-term valuable facts. Filters out greetings, filler, and trivial information. Produces fewer but higher-quality facts with better performance.
|
||||
|
||||
- **`verbose`**: Detailed extraction that captures every piece of information with maximum verbosity. Produces more facts with extensive detail but slower performance and higher token usage.
|
||||
|
||||
### Local MCP Server
|
||||
|
||||
@@ -283,8 +306,9 @@ Controls background task processing for async operations like opinion formation
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_TASK_BATCH_SIZE` | Max tasks to process in one batch | `10` |
|
||||
| `HINDSIGHT_API_TASK_BATCH_INTERVAL` | Interval between batch processing in seconds | `1.0` |
|
||||
| `HINDSIGHT_API_TASK_BACKEND` | Task backend implementation: `memory` (in-process queue) or `noop` (discard tasks, useful for tests) | `memory` |
|
||||
| `HINDSIGHT_API_TASK_BACKEND_MEMORY_BATCH_SIZE` | Max tasks to process in one batch (memory backend only) | `10` |
|
||||
| `HINDSIGHT_API_TASK_BACKEND_MEMORY_BATCH_INTERVAL` | Interval between batch processing in seconds (memory backend only) | `1.0` |
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
|
||||
@@ -171,4 +171,4 @@ PORT=80 HINDSIGHT_CP_DATAPLANE_API_URL=https://api.hindsight.io npx @vectorize-i
|
||||
|
||||
- [Configuration](./configuration.md) — Environment variables and settings
|
||||
- [Models](./models.md) — ML models and providers
|
||||
- [Metrics](./metrics.md) — Monitoring and observability
|
||||
- [Monitoring](./monitoring.md) — Metrics and observability
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# Metrics
|
||||
|
||||
Hindsight exposes Prometheus metrics at `/metrics` for monitoring.
|
||||
|
||||
```bash
|
||||
curl http://localhost:8888/metrics
|
||||
```
|
||||
|
||||
## Available Metrics
|
||||
|
||||
### Operation Metrics
|
||||
|
||||
| Metric | Type | Labels | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| `hindsight.operation.duration` | Histogram | operation, bank_id, source, budget, max_tokens, success | Duration of operations in seconds |
|
||||
| `hindsight.operation.total` | Counter | operation, bank_id, source, budget, max_tokens, success | Total number of operations executed |
|
||||
|
||||
**Labels:**
|
||||
- `operation`: Operation type (`retain`, `recall`, `reflect`)
|
||||
- `bank_id`: Memory bank identifier
|
||||
- `source`: Where the operation was triggered from (`api`, `reflect`, `internal`)
|
||||
- `budget`: Budget level if specified (`low`, `mid`, `high`)
|
||||
- `max_tokens`: Max tokens if specified
|
||||
- `success`: Whether the operation succeeded (`true`, `false`)
|
||||
|
||||
The `source` label allows distinguishing between:
|
||||
- `api`: Direct API calls from clients
|
||||
- `reflect`: Internal recall calls made during reflect operations
|
||||
- `internal`: Other internal operations
|
||||
|
||||
### LLM Metrics
|
||||
|
||||
| Metric | Type | Labels | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| `hindsight.llm.duration` | Histogram | provider, model, scope, success | Duration of LLM API calls in seconds |
|
||||
| `hindsight.llm.calls.total` | Counter | provider, model, scope, success | Total number of LLM API calls |
|
||||
| `hindsight.llm.tokens.input` | Counter | provider, model, scope, success, token_bucket | Input tokens for LLM calls |
|
||||
| `hindsight.llm.tokens.output` | Counter | provider, model, scope, success, token_bucket | Output tokens from LLM calls |
|
||||
|
||||
**Labels:**
|
||||
- `provider`: LLM provider (`openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio`)
|
||||
- `model`: Model name (e.g., `gpt-4`, `claude-3-sonnet`)
|
||||
- `scope`: What the LLM call is for (`memory`, `reflect`, `entity_observation`, `answer`)
|
||||
- `success`: Whether the call succeeded (`true`, `false`)
|
||||
- `token_bucket`: Token count bucket for cardinality control (`0-100`, `100-500`, `500-1k`, `1k-5k`, `5k-10k`, `10k-50k`, `50k+`)
|
||||
|
||||
### Histogram Buckets
|
||||
|
||||
Custom bucket boundaries are configured for better percentile accuracy:
|
||||
|
||||
**Operation Duration Buckets (seconds):**
|
||||
```
|
||||
0.1, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 5.0, 7.5, 10.0, 15.0, 20.0, 30.0, 60.0, 120.0
|
||||
```
|
||||
|
||||
**LLM Duration Buckets (seconds):**
|
||||
```
|
||||
0.1, 0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 30.0, 60.0, 120.0
|
||||
```
|
||||
|
||||
## Prometheus Configuration
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: 'hindsight'
|
||||
static_configs:
|
||||
- targets: ['localhost:8888']
|
||||
```
|
||||
|
||||
## Example Queries
|
||||
|
||||
### Average operation latency by type
|
||||
```promql
|
||||
rate(hindsight_operation_duration_sum[5m]) / rate(hindsight_operation_duration_count[5m])
|
||||
```
|
||||
|
||||
### LLM calls per minute by provider
|
||||
```promql
|
||||
rate(hindsight_llm_calls_total[1m]) * 60
|
||||
```
|
||||
|
||||
### P95 LLM latency
|
||||
```promql
|
||||
histogram_quantile(0.95, rate(hindsight_llm_duration_bucket[5m]))
|
||||
```
|
||||
|
||||
### Total tokens consumed by model
|
||||
```promql
|
||||
sum by (model) (hindsight_llm_tokens_input_total + hindsight_llm_tokens_output_total)
|
||||
```
|
||||
|
||||
### Internal vs API recall operations
|
||||
```promql
|
||||
sum by (source) (rate(hindsight_operation_total{operation="recall"}[5m]))
|
||||
```
|
||||
@@ -0,0 +1,199 @@
|
||||
# Monitoring
|
||||
|
||||
Hindsight provides comprehensive monitoring through Prometheus metrics and pre-built Grafana dashboards.
|
||||
|
||||
## Local Development
|
||||
|
||||
For local metrics visualization, a convenience script downloads and runs Prometheus and Grafana:
|
||||
|
||||
```bash
|
||||
./scripts/dev/start-monitoring.sh
|
||||
```
|
||||
|
||||
This will start:
|
||||
- **Grafana**: http://localhost:8890 (anonymous access enabled)
|
||||
- **Prometheus**: http://localhost:8889
|
||||
- **API Metrics**: http://localhost:8888/metrics
|
||||
|
||||
:::note Production Deployment
|
||||
The local monitoring script is for development only. In production, you need to install and configure Prometheus and Grafana separately, then point Prometheus to scrape your Hindsight API's `/metrics` endpoint.
|
||||
:::
|
||||
|
||||
## Grafana Dashboards
|
||||
|
||||
Pre-built dashboards are available in [`monitoring/grafana/dashboards/`](https://github.com/anthropics/hindsight/tree/main/monitoring/grafana/dashboards). Import these JSON files into your Grafana instance:
|
||||
|
||||
| Dashboard | Description |
|
||||
|-----------|-------------|
|
||||
| **Hindsight Operations** | Operation rates, latency percentiles, per-bank metrics |
|
||||
| **Hindsight LLM Metrics** | LLM calls, token usage, latency by scope/provider |
|
||||
| **Hindsight API Service** | HTTP requests, error rates, DB pool, process metrics |
|
||||
|
||||
The dashboards are automatically provisioned when using the monitoring stack script.
|
||||
|
||||
## Metrics Endpoint
|
||||
|
||||
Hindsight exposes Prometheus metrics at `/metrics`:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8888/metrics
|
||||
```
|
||||
|
||||
## Available Metrics
|
||||
|
||||
### Operation Metrics
|
||||
|
||||
| Metric | Type | Labels | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| `hindsight.operation.duration` | Histogram | operation, bank_id, source, budget, max_tokens, success | Duration of operations in seconds |
|
||||
| `hindsight.operation.total` | Counter | operation, bank_id, source, budget, max_tokens, success | Total number of operations executed |
|
||||
|
||||
**Labels:**
|
||||
- `operation`: Operation type (`retain`, `recall`, `reflect`)
|
||||
- `bank_id`: Memory bank identifier
|
||||
- `source`: Where the operation was triggered from (`api`, `reflect`, `internal`)
|
||||
- `budget`: Budget level if specified (`low`, `mid`, `high`)
|
||||
- `max_tokens`: Max tokens if specified
|
||||
- `success`: Whether the operation succeeded (`true`, `false`)
|
||||
|
||||
The `source` label allows distinguishing between:
|
||||
- `api`: Direct API calls from clients
|
||||
- `reflect`: Internal recall calls made during reflect operations
|
||||
- `internal`: Other internal operations
|
||||
|
||||
### LLM Metrics
|
||||
|
||||
| Metric | Type | Labels | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| `hindsight.llm.duration` | Histogram | provider, model, scope, success | Duration of LLM API calls in seconds |
|
||||
| `hindsight.llm.calls.total` | Counter | provider, model, scope, success | Total number of LLM API calls |
|
||||
| `hindsight.llm.tokens.input` | Counter | provider, model, scope, success, token_bucket | Input tokens for LLM calls |
|
||||
| `hindsight.llm.tokens.output` | Counter | provider, model, scope, success, token_bucket | Output tokens from LLM calls |
|
||||
|
||||
**Labels:**
|
||||
- `provider`: LLM provider (`openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio`)
|
||||
- `model`: Model name (e.g., `gpt-4`, `claude-3-sonnet`)
|
||||
- `scope`: What the LLM call is for (`memory`, `reflect`, `entity_observation`, `answer`)
|
||||
- `success`: Whether the call succeeded (`true`, `false`)
|
||||
- `token_bucket`: Token count bucket for cardinality control (`0-100`, `100-500`, `500-1k`, `1k-5k`, `5k-10k`, `10k-50k`, `50k+`)
|
||||
|
||||
### HTTP Request Metrics
|
||||
|
||||
| Metric | Type | Labels | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| `hindsight.http.duration` | Histogram | method, endpoint, status_code, status_class | Duration of HTTP requests in seconds |
|
||||
| `hindsight.http.requests.total` | Counter | method, endpoint, status_code, status_class | Total number of HTTP requests |
|
||||
| `hindsight.http.requests.in_progress` | UpDownCounter | method, endpoint | Number of HTTP requests currently being processed |
|
||||
|
||||
**Labels:**
|
||||
- `method`: HTTP method (`GET`, `POST`, `PUT`, `DELETE`)
|
||||
- `endpoint`: Request path (normalized to reduce cardinality - UUIDs replaced with `{id}`)
|
||||
- `status_code`: HTTP status code (`200`, `400`, `500`, etc.)
|
||||
- `status_class`: Status code class (`2xx`, `4xx`, `5xx`)
|
||||
|
||||
### Database Pool Metrics
|
||||
|
||||
| Metric | Type | Labels | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| `hindsight.db.pool.size` | Gauge | - | Current number of connections in the pool |
|
||||
| `hindsight.db.pool.idle` | Gauge | - | Number of idle connections in the pool |
|
||||
| `hindsight.db.pool.min` | Gauge | - | Minimum pool size |
|
||||
| `hindsight.db.pool.max` | Gauge | - | Maximum pool size |
|
||||
|
||||
### Process Metrics
|
||||
|
||||
| Metric | Type | Labels | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| `hindsight.process.cpu.seconds` | Gauge | type | Process CPU time in seconds |
|
||||
| `hindsight.process.memory.bytes` | Gauge | type | Process memory usage in bytes |
|
||||
| `hindsight.process.open_fds` | Gauge | - | Number of open file descriptors |
|
||||
| `hindsight.process.threads` | Gauge | - | Number of active threads |
|
||||
|
||||
**Labels:**
|
||||
- `type` (CPU): `user` or `system`
|
||||
- `type` (Memory): `rss_max` (maximum resident set size)
|
||||
|
||||
### Histogram Buckets
|
||||
|
||||
Custom bucket boundaries are configured for better percentile accuracy:
|
||||
|
||||
**Operation Duration Buckets (seconds):**
|
||||
```
|
||||
0.1, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 5.0, 7.5, 10.0, 15.0, 20.0, 30.0, 60.0, 120.0
|
||||
```
|
||||
|
||||
**LLM Duration Buckets (seconds):**
|
||||
```
|
||||
0.1, 0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 30.0, 60.0, 120.0
|
||||
```
|
||||
|
||||
**HTTP Duration Buckets (seconds):**
|
||||
```
|
||||
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0
|
||||
```
|
||||
|
||||
## Prometheus Configuration
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: 'hindsight'
|
||||
static_configs:
|
||||
- targets: ['localhost:8888']
|
||||
```
|
||||
|
||||
## Example Queries
|
||||
|
||||
### Average operation latency by type
|
||||
```promql
|
||||
rate(hindsight_operation_duration_sum[5m]) / rate(hindsight_operation_duration_count[5m])
|
||||
```
|
||||
|
||||
### LLM calls per minute by provider
|
||||
```promql
|
||||
rate(hindsight_llm_calls_total[1m]) * 60
|
||||
```
|
||||
|
||||
### P95 LLM latency
|
||||
```promql
|
||||
histogram_quantile(0.95, rate(hindsight_llm_duration_bucket[5m]))
|
||||
```
|
||||
|
||||
### Total tokens consumed by model
|
||||
```promql
|
||||
sum by (model) (hindsight_llm_tokens_input_total + hindsight_llm_tokens_output_total)
|
||||
```
|
||||
|
||||
### Internal vs API recall operations
|
||||
```promql
|
||||
sum by (source) (rate(hindsight_operation_total{operation="recall"}[5m]))
|
||||
```
|
||||
|
||||
### HTTP requests per second by endpoint
|
||||
```promql
|
||||
sum by (endpoint) (rate(hindsight_http_requests_total[1m]))
|
||||
```
|
||||
|
||||
### HTTP error rate (5xx)
|
||||
```promql
|
||||
sum(rate(hindsight_http_requests_total{status_class="5xx"}[5m])) / sum(rate(hindsight_http_requests_total[5m]))
|
||||
```
|
||||
|
||||
### P95 HTTP latency
|
||||
```promql
|
||||
histogram_quantile(0.95, sum by (le) (rate(hindsight_http_duration_seconds_bucket[5m])))
|
||||
```
|
||||
|
||||
### Database pool utilization
|
||||
```promql
|
||||
hindsight_db_pool_size / hindsight_db_pool_max
|
||||
```
|
||||
|
||||
### Active database connections
|
||||
```promql
|
||||
hindsight_db_pool_size - hindsight_db_pool_idle
|
||||
```
|
||||
|
||||
### CPU usage rate
|
||||
```promql
|
||||
rate(hindsight_process_cpu_seconds{type="user"}[1m])
|
||||
```
|
||||
@@ -229,6 +229,14 @@ Budget and max_tokens control different aspects of recall:
|
||||
|
||||
---
|
||||
|
||||
## Graph Retrieval Algorithms
|
||||
|
||||
Hindsight supports multiple graph traversal algorithms. The default (`link_expansion`) is optimized for fast retrieval with target latency under 100ms.
|
||||
|
||||
See [Configuration → Retrieval](./configuration#retrieval) for available algorithms and how to configure them.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [**Retain**](./retain) — How memories are stored with rich context
|
||||
|
||||
@@ -133,8 +133,8 @@ const sidebars: SidebarsConfig = {
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
id: 'developer/metrics',
|
||||
label: 'Metrics',
|
||||
id: 'developer/monitoring',
|
||||
label: 'Monitoring',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
|
||||
@@ -454,6 +454,22 @@
|
||||
"type": "string",
|
||||
"title": "Bank Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "authorization",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Authorization"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -486,7 +502,7 @@
|
||||
"Entities"
|
||||
],
|
||||
"summary": "List entities",
|
||||
"description": "List all entities (people, organizations, etc.) known by the bank, ordered by mention count.",
|
||||
"description": "List all entities (people, organizations, etc.) known by the bank, ordered by mention count. Supports pagination.",
|
||||
"operationId": "list_entities",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -510,6 +526,18 @@
|
||||
},
|
||||
"description": "Maximum number of entities to return"
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"description": "Offset for pagination",
|
||||
"default": 0,
|
||||
"title": "Offset"
|
||||
},
|
||||
"description": "Offset for pagination"
|
||||
},
|
||||
{
|
||||
"name": "authorization",
|
||||
"in": "header",
|
||||
@@ -2407,11 +2435,26 @@
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Items"
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"title": "Total"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"title": "Limit"
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"title": "Offset"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"items"
|
||||
"items",
|
||||
"total",
|
||||
"limit",
|
||||
"offset"
|
||||
],
|
||||
"title": "EntityListResponse",
|
||||
"description": "Response model for entity list endpoint.",
|
||||
@@ -2424,7 +2467,10 @@
|
||||
"last_seen": "2024-02-01T14:00:00Z",
|
||||
"mention_count": 15
|
||||
}
|
||||
]
|
||||
],
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"total": 150
|
||||
}
|
||||
},
|
||||
"EntityObservationResponse": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,541 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
|
||||
"id": 1,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(hindsight_llm_calls_total)",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Total LLM Calls",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
|
||||
"id": 2,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(hindsight_llm_tokens_input_tokens_total) + sum(hindsight_llm_tokens_output_tokens_total)",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Total Tokens Used",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"fixedColor": "blue",
|
||||
"mode": "fixed"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 },
|
||||
"id": 3,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(hindsight_llm_tokens_input_tokens_total)",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Input Tokens",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"fixedColor": "green",
|
||||
"mode": "fixed"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 },
|
||||
"id": 4,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(hindsight_llm_tokens_output_tokens_total)",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Output Tokens",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 20,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 4 },
|
||||
"id": 5,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": ["mean", "max"],
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum by (scope) (rate(hindsight_llm_calls_total[1m]))",
|
||||
"legendFormat": "{{scope}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "LLM Calls per Second by Scope",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 20,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "Input" },
|
||||
"properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "Output" },
|
||||
"properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }]
|
||||
}
|
||||
]
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 },
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": ["mean", "max"],
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(hindsight_llm_tokens_input_tokens_total[1m]))",
|
||||
"legendFormat": "Input",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "sum(rate(hindsight_llm_tokens_output_tokens_total[1m]))",
|
||||
"legendFormat": "Output",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Token Usage Rate (tokens/sec)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 },
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": ["mean", "max"],
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "histogram_quantile(0.95, sum by (scope, le) (rate(hindsight_llm_duration_seconds_bucket[5m])))",
|
||||
"legendFormat": "{{scope}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "LLM Call Latency p95 by Scope",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 20,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "normal"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 20 },
|
||||
"id": 8,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": ["mean", "max"],
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum by (scope) (rate(hindsight_llm_tokens_input_tokens_total[1m]))",
|
||||
"legendFormat": "{{scope}} (input)",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "sum by (scope) (rate(hindsight_llm_tokens_output_tokens_total[1m]))",
|
||||
"legendFormat": "{{scope}} (output)",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Token Usage by Scope (tokens/sec)",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "5s",
|
||||
"schemaVersion": 38,
|
||||
"tags": ["hindsight", "llm"],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-30m",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "Hindsight LLM Metrics",
|
||||
"uid": "hindsight-llm",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
|
||||
"id": 1,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(hindsight_operation_operations_total)",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Total Operations",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "ops"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
|
||||
"id": 2,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(hindsight_operation_operations_total[1m]))",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Operations/sec",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"fixedColor": "green",
|
||||
"mode": "fixed"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "ops"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 12, "y": 0 },
|
||||
"id": 3,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(hindsight_operation_operations_total{operation=\"retain\"}[1m]))",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Retain/sec",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"fixedColor": "blue",
|
||||
"mode": "fixed"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "ops"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 16, "y": 0 },
|
||||
"id": 4,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(hindsight_operation_operations_total{operation=\"recall\"}[1m]))",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Recall/sec",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"fixedColor": "orange",
|
||||
"mode": "fixed"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "ops"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 20, "y": 0 },
|
||||
"id": 5,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(hindsight_operation_operations_total{operation=\"reflect\"}[1m]))",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Reflect/sec",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 20,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "ops"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "retain" },
|
||||
"properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "recall" },
|
||||
"properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "reflect" },
|
||||
"properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }]
|
||||
}
|
||||
]
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 4 },
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": ["mean", "max"],
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum by (operation) (rate(hindsight_operation_operations_total[1m]))",
|
||||
"legendFormat": "{{operation}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Operations per Second by Type",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "p50" },
|
||||
"properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "p95" },
|
||||
"properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "p99" },
|
||||
"properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }]
|
||||
}
|
||||
]
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 },
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": ["mean", "max"],
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "histogram_quantile(0.50, sum by (le) (rate(hindsight_operation_duration_seconds_bucket{operation=\"recall\"}[5m])))",
|
||||
"legendFormat": "p50",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "histogram_quantile(0.95, sum by (le) (rate(hindsight_operation_duration_seconds_bucket{operation=\"recall\"}[5m])))",
|
||||
"legendFormat": "p95",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"expr": "histogram_quantile(0.99, sum by (le) (rate(hindsight_operation_duration_seconds_bucket{operation=\"recall\"}[5m])))",
|
||||
"legendFormat": "p99",
|
||||
"refId": "C"
|
||||
}
|
||||
],
|
||||
"title": "Recall Latency Percentiles",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 },
|
||||
"id": 8,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": ["mean", "max"],
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "histogram_quantile(0.95, sum by (operation, le) (rate(hindsight_operation_duration_seconds_bucket[5m])))",
|
||||
"legendFormat": "{{operation}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "p95 Latency by Operation Type",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "ops"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 20 },
|
||||
"id": 9,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": ["mean", "max"],
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum by (bank_id) (rate(hindsight_operation_operations_total[1m]))",
|
||||
"legendFormat": "{{bank_id}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Operations per Second by Bank",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "5s",
|
||||
"schemaVersion": 38,
|
||||
"tags": ["hindsight"],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-30m",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "Hindsight Operations",
|
||||
"uid": "hindsight-operations",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
Executable
+222
@@ -0,0 +1,222 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Script to start Prometheus and Grafana for Hindsight metrics
|
||||
# This provides a single command for the full monitoring stack
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
MONITORING_DATA_DIR="$PROJECT_ROOT/.monitoring"
|
||||
API_PORT="${API_PORT:-8888}"
|
||||
PROMETHEUS_PORT="${PROMETHEUS_PORT:-8889}"
|
||||
GRAFANA_PORT="${GRAFANA_PORT:-8890}"
|
||||
|
||||
# Versions
|
||||
PROMETHEUS_VERSION="2.48.0"
|
||||
GRAFANA_VERSION="10.2.2"
|
||||
|
||||
# Detect OS and architecture
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
ARCH=$(uname -m)
|
||||
|
||||
case "$OS" in
|
||||
darwin) OS_NAME="darwin" ;;
|
||||
linux) OS_NAME="linux" ;;
|
||||
*) echo "Unsupported OS: $OS"; exit 1 ;;
|
||||
esac
|
||||
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH_NAME="amd64" ;;
|
||||
arm64|aarch64) ARCH_NAME="arm64" ;;
|
||||
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Prometheus paths
|
||||
PROMETHEUS_DIR="$MONITORING_DATA_DIR/prometheus"
|
||||
PROMETHEUS_ARCHIVE="prometheus-${PROMETHEUS_VERSION}.${OS_NAME}-${ARCH_NAME}.tar.gz"
|
||||
PROMETHEUS_URL="https://github.com/prometheus/prometheus/releases/download/v${PROMETHEUS_VERSION}/${PROMETHEUS_ARCHIVE}"
|
||||
PROMETHEUS_BIN="$PROMETHEUS_DIR/prometheus-${PROMETHEUS_VERSION}.${OS_NAME}-${ARCH_NAME}/prometheus"
|
||||
|
||||
# Grafana paths
|
||||
GRAFANA_DIR="$MONITORING_DATA_DIR/grafana"
|
||||
GRAFANA_ARCHIVE="grafana-${GRAFANA_VERSION}.${OS_NAME}-${ARCH_NAME}.tar.gz"
|
||||
GRAFANA_URL="https://dl.grafana.com/oss/release/${GRAFANA_ARCHIVE}"
|
||||
GRAFANA_HOME="$GRAFANA_DIR/grafana-v${GRAFANA_VERSION}"
|
||||
GRAFANA_BIN="$GRAFANA_HOME/bin/grafana"
|
||||
|
||||
# Cleanup function
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "Shutting down monitoring stack..."
|
||||
|
||||
if [ -n "$PROM_PID" ] && kill -0 "$PROM_PID" 2>/dev/null; then
|
||||
kill "$PROM_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -n "$GRAFANA_PID" ] && kill -0 "$GRAFANA_PID" 2>/dev/null; then
|
||||
kill "$GRAFANA_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "Monitoring stack stopped"
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup SIGINT SIGTERM
|
||||
|
||||
# Download Prometheus if needed
|
||||
if [ ! -f "$PROMETHEUS_BIN" ]; then
|
||||
echo "Downloading Prometheus ${PROMETHEUS_VERSION}..."
|
||||
mkdir -p "$PROMETHEUS_DIR"
|
||||
cd "$PROMETHEUS_DIR"
|
||||
curl -sL -o "$PROMETHEUS_ARCHIVE" "$PROMETHEUS_URL"
|
||||
tar xzf "$PROMETHEUS_ARCHIVE"
|
||||
rm "$PROMETHEUS_ARCHIVE"
|
||||
echo "Prometheus ready"
|
||||
fi
|
||||
|
||||
# Download Grafana if needed
|
||||
if [ ! -f "$GRAFANA_BIN" ]; then
|
||||
echo "Downloading Grafana ${GRAFANA_VERSION}..."
|
||||
mkdir -p "$GRAFANA_DIR"
|
||||
cd "$GRAFANA_DIR"
|
||||
curl -sL -o "$GRAFANA_ARCHIVE" "$GRAFANA_URL"
|
||||
tar xzf "$GRAFANA_ARCHIVE"
|
||||
rm "$GRAFANA_ARCHIVE"
|
||||
echo "Grafana ready"
|
||||
fi
|
||||
|
||||
# Create Prometheus config
|
||||
mkdir -p "$PROMETHEUS_DIR"
|
||||
cat > "$PROMETHEUS_DIR/prometheus.yml" <<EOF
|
||||
global:
|
||||
scrape_interval: 5s
|
||||
evaluation_interval: 5s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'hindsight-api'
|
||||
scrape_interval: 5s
|
||||
static_configs:
|
||||
- targets: ['localhost:$API_PORT']
|
||||
metrics_path: '/metrics'
|
||||
EOF
|
||||
|
||||
# Create Grafana provisioning directories
|
||||
GRAFANA_PROV_DIR="$GRAFANA_DIR/provisioning"
|
||||
mkdir -p "$GRAFANA_PROV_DIR/datasources"
|
||||
mkdir -p "$GRAFANA_PROV_DIR/dashboards"
|
||||
mkdir -p "$GRAFANA_DIR/dashboards"
|
||||
mkdir -p "$GRAFANA_DIR/data"
|
||||
|
||||
# Copy dashboards from project root monitoring directory
|
||||
cp "$PROJECT_ROOT/monitoring/grafana/dashboards/"*.json "$GRAFANA_DIR/dashboards/"
|
||||
|
||||
# Create Grafana datasource config
|
||||
cat > "$GRAFANA_PROV_DIR/datasources/prometheus.yaml" <<EOF
|
||||
apiVersion: 1
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://localhost:$PROMETHEUS_PORT
|
||||
isDefault: true
|
||||
editable: false
|
||||
uid: prometheus
|
||||
EOF
|
||||
|
||||
# Create Grafana dashboard provisioning config
|
||||
cat > "$GRAFANA_PROV_DIR/dashboards/dashboards.yaml" <<EOF
|
||||
apiVersion: 1
|
||||
providers:
|
||||
- name: 'Hindsight'
|
||||
orgId: 1
|
||||
folder: 'Hindsight'
|
||||
folderUid: 'hindsight'
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 10
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: $GRAFANA_DIR/dashboards
|
||||
EOF
|
||||
|
||||
# Create Grafana config
|
||||
cat > "$GRAFANA_DIR/grafana.ini" <<EOF
|
||||
[server]
|
||||
http_port = $GRAFANA_PORT
|
||||
root_url = http://localhost:$GRAFANA_PORT
|
||||
|
||||
[security]
|
||||
admin_user = admin
|
||||
admin_password = admin
|
||||
disable_initial_admin_creation = false
|
||||
|
||||
[auth.anonymous]
|
||||
enabled = true
|
||||
org_name = Main Org.
|
||||
org_role = Viewer
|
||||
|
||||
[paths]
|
||||
data = $GRAFANA_DIR/data
|
||||
logs = $GRAFANA_DIR/logs
|
||||
plugins = $GRAFANA_DIR/plugins
|
||||
provisioning = $GRAFANA_PROV_DIR
|
||||
|
||||
[log]
|
||||
mode = console
|
||||
level = warn
|
||||
|
||||
[dashboards]
|
||||
default_home_dashboard_path = $GRAFANA_DIR/dashboards/hindsight-operations.json
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "=================================="
|
||||
echo " Hindsight Monitoring Stack"
|
||||
echo "=================================="
|
||||
echo ""
|
||||
echo " Grafana: http://localhost:$GRAFANA_PORT"
|
||||
echo " Prometheus: http://localhost:$PROMETHEUS_PORT"
|
||||
echo " API Metrics: http://localhost:$API_PORT/metrics"
|
||||
echo ""
|
||||
echo " Dashboards:"
|
||||
echo " - Hindsight Operations"
|
||||
echo " - Hindsight LLM Metrics"
|
||||
echo " - Hindsight API Service"
|
||||
echo ""
|
||||
echo "=================================="
|
||||
echo ""
|
||||
|
||||
# Check if API is running
|
||||
if ! curl -s "http://localhost:$API_PORT/metrics" > /dev/null 2>&1; then
|
||||
echo "WARNING: Hindsight API not detected at localhost:$API_PORT"
|
||||
echo " Start the API first: ./scripts/dev/start-api.sh"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Start Prometheus in background
|
||||
cd "$(dirname "$PROMETHEUS_BIN")"
|
||||
"$PROMETHEUS_BIN" \
|
||||
--config.file="$PROMETHEUS_DIR/prometheus.yml" \
|
||||
--storage.tsdb.path="$PROMETHEUS_DIR/data" \
|
||||
--web.console.templates="$(dirname "$PROMETHEUS_BIN")/consoles" \
|
||||
--web.console.libraries="$(dirname "$PROMETHEUS_BIN")/console_libraries" \
|
||||
--web.listen-address="0.0.0.0:$PROMETHEUS_PORT" \
|
||||
--web.enable-lifecycle \
|
||||
--log.level=warn &
|
||||
PROM_PID=$!
|
||||
|
||||
# Start Grafana in background
|
||||
cd "$GRAFANA_HOME"
|
||||
"$GRAFANA_BIN" server \
|
||||
--homepath="$GRAFANA_HOME" \
|
||||
--config="$GRAFANA_DIR/grafana.ini" &
|
||||
GRAFANA_PID=$!
|
||||
|
||||
echo "Monitoring stack running. Press Ctrl+C to stop."
|
||||
echo ""
|
||||
|
||||
# Wait for processes
|
||||
wait "$PROM_PID" "$GRAFANA_PID" 2>/dev/null || true
|
||||
|
||||
# If we get here, clean up
|
||||
cleanup
|
||||
@@ -17,4 +17,4 @@ set -a
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
uv run hindsight-api "${SERVER_ARGS[@]}"
|
||||
uv run hindsight-api "$@"
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
# Convenience wrapper to start the monitoring stack
|
||||
exec "$(dirname "${BASH_SOURCE[0]}")/monitoring/start.sh" "$@"
|
||||
@@ -1,384 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hindsight Metrics Dashboard</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0f1419;
|
||||
color: #e6e6e6;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
opacity: 0.9;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #1a1f2e;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
border: 1px solid #2d3548;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: #1a1f2e;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2d3548;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #a0aec0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.stat-unit {
|
||||
font-size: 14px;
|
||||
color: #a0aec0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #2d1b1b;
|
||||
border: 1px solid #7d2828;
|
||||
color: #f56565;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #a0aec0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🧠 Hindsight Metrics Dashboard</h1>
|
||||
<div class="subtitle">Real-time performance monitoring • Updates every 15s</div>
|
||||
</div>
|
||||
|
||||
<div id="error-container"></div>
|
||||
<div id="loading" class="loading">Loading metrics...</div>
|
||||
|
||||
<div class="stats" id="stats" style="display: none;"></div>
|
||||
|
||||
<div class="grid" id="charts" style="display: none;">
|
||||
<div class="card">
|
||||
<div class="card-title">📊 Recall Latency Percentiles</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="latencyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">⚡ Operations per Second</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="opsChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">🎯 Latency by Bank</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="bankLatencyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">💰 Token Usage Rate</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="tokenChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const PROMETHEUS_URL = 'http://localhost:9090';
|
||||
const UPDATE_INTERVAL = 15000; // 15 seconds
|
||||
|
||||
// Chart configurations
|
||||
const chartConfig = {
|
||||
type: 'line',
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: { color: '#e6e6e6' }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: '#a0aec0' },
|
||||
grid: { color: '#2d3548' }
|
||||
},
|
||||
y: {
|
||||
ticks: { color: '#a0aec0' },
|
||||
grid: { color: '#2d3548' }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let charts = {};
|
||||
|
||||
async function queryPrometheus(query) {
|
||||
const url = `${PROMETHEUS_URL}/api/v1/query?query=${encodeURIComponent(query)}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (data.status !== 'success') throw new Error('Query failed');
|
||||
return data.data.result;
|
||||
}
|
||||
|
||||
async function queryRange(query, minutes = 30) {
|
||||
const end = Math.floor(Date.now() / 1000);
|
||||
const start = end - (minutes * 60);
|
||||
const url = `${PROMETHEUS_URL}/api/v1/query_range?query=${encodeURIComponent(query)}&start=${start}&end=${end}&step=30`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (data.status !== 'success') throw new Error('Query failed');
|
||||
return data.data.result;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const container = document.getElementById('error-container');
|
||||
container.innerHTML = `
|
||||
<div class="error">
|
||||
<strong>⚠️ Error:</strong> ${message}<br>
|
||||
<small>Make sure Prometheus is running at ${PROMETHEUS_URL}</small>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('stats').style.display = 'grid';
|
||||
document.getElementById('charts').style.display = 'grid';
|
||||
}
|
||||
|
||||
async function updateStats() {
|
||||
try {
|
||||
// Query current stats
|
||||
const totalOps = await queryPrometheus('sum(hindsight_operation_total)');
|
||||
const successRate = await queryPrometheus('sum(hindsight_operation_total{success="true"}) / sum(hindsight_operation_total)');
|
||||
const opsRate = await queryPrometheus('sum(rate(hindsight_operation_total[5m]))');
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Total Operations',
|
||||
value: totalOps[0]?.value[1] || '0',
|
||||
unit: ''
|
||||
},
|
||||
{
|
||||
label: 'Success Rate',
|
||||
value: ((parseFloat(successRate[0]?.value[1] || 1) * 100).toFixed(1)),
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
label: 'Ops/sec',
|
||||
value: (parseFloat(opsRate[0]?.value[1] || 0).toFixed(2)),
|
||||
unit: 'ops/s'
|
||||
}
|
||||
];
|
||||
|
||||
document.getElementById('stats').innerHTML = stats.map(stat => `
|
||||
<div class="stat">
|
||||
<div class="stat-label">${stat.label}</div>
|
||||
<div class="stat-value">
|
||||
${stat.value}
|
||||
<span class="stat-unit">${stat.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCharts() {
|
||||
try {
|
||||
// Latency percentiles over time
|
||||
const p50Data = await queryRange('histogram_quantile(0.50, rate(hindsight_operation_duration_seconds_bucket{operation="recall"}[5m]))');
|
||||
const p95Data = await queryRange('histogram_quantile(0.95, rate(hindsight_operation_duration_seconds_bucket{operation="recall"}[5m]))');
|
||||
const p99Data = await queryRange('histogram_quantile(0.99, rate(hindsight_operation_duration_seconds_bucket{operation="recall"}[5m]))');
|
||||
|
||||
if (p50Data.length > 0) {
|
||||
const timestamps = p50Data[0].values.map(v => new Date(v[0] * 1000).toLocaleTimeString());
|
||||
|
||||
if (!charts.latency) {
|
||||
charts.latency = new Chart(document.getElementById('latencyChart'), {
|
||||
...chartConfig,
|
||||
data: {
|
||||
labels: timestamps,
|
||||
datasets: [
|
||||
{ label: 'p50', data: p50Data[0].values.map(v => parseFloat(v[1])), borderColor: '#48bb78', tension: 0.4 },
|
||||
{ label: 'p95', data: p95Data[0].values.map(v => parseFloat(v[1])), borderColor: '#ed8936', tension: 0.4 },
|
||||
{ label: 'p99', data: p99Data[0].values.map(v => parseFloat(v[1])), borderColor: '#f56565', tension: 0.4 }
|
||||
]
|
||||
}
|
||||
});
|
||||
} else {
|
||||
charts.latency.data.labels = timestamps;
|
||||
charts.latency.data.datasets[0].data = p50Data[0].values.map(v => parseFloat(v[1]));
|
||||
charts.latency.data.datasets[1].data = p95Data[0].values.map(v => parseFloat(v[1]));
|
||||
charts.latency.data.datasets[2].data = p99Data[0].values.map(v => parseFloat(v[1]));
|
||||
charts.latency.update();
|
||||
}
|
||||
}
|
||||
|
||||
// Operations per second
|
||||
const opsData = await queryRange('sum by (operation) (rate(hindsight_operation_total[5m]))');
|
||||
if (opsData.length > 0) {
|
||||
const timestamps = opsData[0].values.map(v => new Date(v[0] * 1000).toLocaleTimeString());
|
||||
const datasets = opsData.map((series, i) => ({
|
||||
label: series.metric.operation,
|
||||
data: series.values.map(v => parseFloat(v[1])),
|
||||
borderColor: ['#667eea', '#48bb78', '#ed8936'][i % 3],
|
||||
tension: 0.4
|
||||
}));
|
||||
|
||||
if (!charts.ops) {
|
||||
charts.ops = new Chart(document.getElementById('opsChart'), {
|
||||
...chartConfig,
|
||||
data: { labels: timestamps, datasets }
|
||||
});
|
||||
} else {
|
||||
charts.ops.data.labels = timestamps;
|
||||
charts.ops.data.datasets = datasets;
|
||||
charts.ops.update();
|
||||
}
|
||||
}
|
||||
|
||||
// Latency by bank
|
||||
const bankLatency = await queryRange('histogram_quantile(0.95, sum by (bank_id, le) (rate(hindsight_operation_duration_seconds_bucket{operation="recall"}[5m])))');
|
||||
if (bankLatency.length > 0) {
|
||||
const timestamps = bankLatency[0].values.map(v => new Date(v[0] * 1000).toLocaleTimeString());
|
||||
const datasets = bankLatency.map((series, i) => ({
|
||||
label: series.metric.bank_id,
|
||||
data: series.values.map(v => parseFloat(v[1])),
|
||||
borderColor: ['#667eea', '#48bb78', '#ed8936', '#f56565'][i % 4],
|
||||
tension: 0.4
|
||||
}));
|
||||
|
||||
if (!charts.bankLatency) {
|
||||
charts.bankLatency = new Chart(document.getElementById('bankLatencyChart'), {
|
||||
...chartConfig,
|
||||
data: { labels: timestamps, datasets }
|
||||
});
|
||||
} else {
|
||||
charts.bankLatency.data.labels = timestamps;
|
||||
charts.bankLatency.data.datasets = datasets;
|
||||
charts.bankLatency.update();
|
||||
}
|
||||
}
|
||||
|
||||
// Token usage
|
||||
const tokenInput = await queryRange('sum(rate(hindsight_tokens_input_total[5m]))');
|
||||
const tokenOutput = await queryRange('sum(rate(hindsight_tokens_output_total[5m]))');
|
||||
if (tokenInput.length > 0) {
|
||||
const timestamps = tokenInput[0].values.map(v => new Date(v[0] * 1000).toLocaleTimeString());
|
||||
|
||||
if (!charts.tokens) {
|
||||
charts.tokens = new Chart(document.getElementById('tokenChart'), {
|
||||
...chartConfig,
|
||||
data: {
|
||||
labels: timestamps,
|
||||
datasets: [
|
||||
{ label: 'Input', data: tokenInput[0].values.map(v => parseFloat(v[1])), borderColor: '#667eea', tension: 0.4 },
|
||||
{ label: 'Output', data: tokenOutput[0]?.values.map(v => parseFloat(v[1])) || [], borderColor: '#48bb78', tension: 0.4 }
|
||||
]
|
||||
}
|
||||
});
|
||||
} else {
|
||||
charts.tokens.data.labels = timestamps;
|
||||
charts.tokens.data.datasets[0].data = tokenInput[0].values.map(v => parseFloat(v[1]));
|
||||
charts.tokens.data.datasets[1].data = tokenOutput[0]?.values.map(v => parseFloat(v[1])) || [];
|
||||
charts.tokens.update();
|
||||
}
|
||||
}
|
||||
|
||||
hideLoading();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating charts:', error);
|
||||
showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function update() {
|
||||
await updateStats();
|
||||
await updateCharts();
|
||||
}
|
||||
|
||||
// Initial update
|
||||
update();
|
||||
|
||||
// Update every 15 seconds
|
||||
setInterval(update, UPDATE_INTERVAL);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Script to download and start Prometheus for Hindsight metrics
|
||||
# This creates a local Prometheus instance that scrapes metrics from the Hindsight API
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
PROMETHEUS_DIR="$PROJECT_ROOT/.prometheus"
|
||||
PROMETHEUS_VERSION="2.48.0"
|
||||
|
||||
# Detect OS and architecture
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
ARCH=$(uname -m)
|
||||
|
||||
case "$OS" in
|
||||
darwin)
|
||||
OS_NAME="darwin"
|
||||
;;
|
||||
linux)
|
||||
OS_NAME="linux"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported OS: $OS"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$ARCH" in
|
||||
x86_64)
|
||||
ARCH_NAME="amd64"
|
||||
;;
|
||||
arm64|aarch64)
|
||||
ARCH_NAME="arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $ARCH"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
PROMETHEUS_ARCHIVE="prometheus-${PROMETHEUS_VERSION}.${OS_NAME}-${ARCH_NAME}.tar.gz"
|
||||
PROMETHEUS_URL="https://github.com/prometheus/prometheus/releases/download/v${PROMETHEUS_VERSION}/${PROMETHEUS_ARCHIVE}"
|
||||
PROMETHEUS_BIN="$PROMETHEUS_DIR/prometheus-${PROMETHEUS_VERSION}.${OS_NAME}-${ARCH_NAME}/prometheus"
|
||||
|
||||
echo "🔧 Setting up Prometheus for Hindsight metrics..."
|
||||
echo ""
|
||||
|
||||
# Create prometheus directory
|
||||
mkdir -p "$PROMETHEUS_DIR"
|
||||
cd "$PROMETHEUS_DIR"
|
||||
|
||||
# Download Prometheus if not exists
|
||||
if [ ! -f "$PROMETHEUS_BIN" ]; then
|
||||
echo "📥 Downloading Prometheus ${PROMETHEUS_VERSION} for ${OS_NAME}-${ARCH_NAME}..."
|
||||
curl -L -o "$PROMETHEUS_ARCHIVE" "$PROMETHEUS_URL"
|
||||
|
||||
echo "📦 Extracting..."
|
||||
tar xzf "$PROMETHEUS_ARCHIVE"
|
||||
|
||||
echo "✅ Prometheus downloaded successfully"
|
||||
echo ""
|
||||
else
|
||||
echo "✅ Prometheus already downloaded"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Create prometheus.yml configuration
|
||||
echo "📝 Creating Prometheus configuration..."
|
||||
cat > "$PROMETHEUS_DIR/prometheus.yml" <<EOF
|
||||
# Prometheus configuration for Hindsight API metrics
|
||||
global:
|
||||
scrape_interval: 15s # Scrape metrics every 15 seconds
|
||||
evaluation_interval: 15s # Evaluate rules every 15 seconds
|
||||
|
||||
# Scrape configuration
|
||||
scrape_configs:
|
||||
- job_name: 'hindsight-api'
|
||||
scrape_interval: 15s
|
||||
static_configs:
|
||||
- targets: ['localhost:8000'] # Hindsight API endpoint
|
||||
metrics_path: '/metrics' # Metrics endpoint path
|
||||
|
||||
# Optional: Add labels to all metrics from this job
|
||||
# relabeling_configs:
|
||||
# - source_labels: [__address__]
|
||||
# target_label: instance
|
||||
# replacement: 'hindsight-api'
|
||||
EOF
|
||||
|
||||
echo "✅ Configuration created at $PROMETHEUS_DIR/prometheus.yml"
|
||||
echo ""
|
||||
|
||||
# Check if Hindsight API is running
|
||||
echo "🔍 Checking if Hindsight API is running..."
|
||||
if curl -s http://localhost:8000/metrics > /dev/null 2>&1; then
|
||||
echo "✅ Hindsight API is running and serving metrics"
|
||||
echo ""
|
||||
else
|
||||
echo "⚠️ WARNING: Hindsight API is not reachable at http://localhost:8000/metrics"
|
||||
echo " Make sure to start the API before Prometheus can scrape metrics"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Start Prometheus
|
||||
echo "🚀 Starting Prometheus..."
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " Prometheus UI: http://localhost:9090"
|
||||
echo " Metrics source: http://localhost:8000/metrics"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "📊 Example queries to try in the UI:"
|
||||
echo ""
|
||||
echo " p95 latency (all operations):"
|
||||
echo " histogram_quantile(0.95, rate(hindsight_operation_duration_seconds_bucket[5m]))"
|
||||
echo ""
|
||||
echo " p95 latency by bank:"
|
||||
echo " histogram_quantile(0.95, sum by (bank_id, le) (rate(hindsight_operation_duration_seconds_bucket{operation=\"recall\"}[5m])))"
|
||||
echo ""
|
||||
echo " Operations per second:"
|
||||
echo " rate(hindsight_operation_total[5m])"
|
||||
echo ""
|
||||
echo " Token usage rate:"
|
||||
echo " rate(hindsight_tokens_input_total[5m]) + rate(hindsight_tokens_output_total[5m])"
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "Press Ctrl+C to stop Prometheus"
|
||||
echo ""
|
||||
|
||||
# Start Prometheus with config
|
||||
cd "$(dirname "$PROMETHEUS_BIN")"
|
||||
exec "$PROMETHEUS_BIN" \
|
||||
--config.file="$PROMETHEUS_DIR/prometheus.yml" \
|
||||
--storage.tsdb.path="$PROMETHEUS_DIR/data" \
|
||||
--web.console.templates="$PROMETHEUS_DIR/prometheus-${PROMETHEUS_VERSION}.${OS_NAME}-${ARCH_NAME}/consoles" \
|
||||
--web.console.libraries="$PROMETHEUS_DIR/prometheus-${PROMETHEUS_VERSION}.${OS_NAME}-${ARCH_NAME}/console_libraries"
|
||||
@@ -621,6 +621,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coloredlogs"
|
||||
version = "15.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "humanfriendly" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.3"
|
||||
@@ -955,6 +967,30 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flashrank"
|
||||
version = "0.2.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "onnxruntime" },
|
||||
{ name = "requests" },
|
||||
{ name = "tokenizers" },
|
||||
{ name = "tqdm" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/1f/176cb4a857a70c3538f637e19389ab6aed21548a1ba1d1424fccc8bba108/FlashRank-0.2.10.tar.gz", hash = "sha256:f8f82a25c32fdfc668a09dc4089421d6aab8e7f71308424b541f40bb3f01d9db", size = 18905 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/99/72639cc1c9221c5bc77a2df1c2d352fe11965553bdf7d3e0856e7fcc8fd6/FlashRank-0.2.10-py3-none-any.whl", hash = "sha256:5d3272ae657d793c132d1e7917ed9e2adf49e0e1c60735583a67b051c6f0434a", size = 14511 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flatbuffers"
|
||||
version = "25.12.19"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "frozenlist"
|
||||
version = "1.8.0"
|
||||
@@ -1255,6 +1291,7 @@ dependencies = [
|
||||
{ name = "dateparser" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "fastmcp" },
|
||||
{ name = "flashrank" },
|
||||
{ name = "google-genai" },
|
||||
{ name = "greenlet" },
|
||||
{ name = "httpx" },
|
||||
@@ -1278,6 +1315,7 @@ dependencies = [
|
||||
{ name = "transformers" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "uvloop" },
|
||||
{ name = "wsproto" },
|
||||
]
|
||||
|
||||
@@ -1312,6 +1350,7 @@ requires-dist = [
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.120.3" },
|
||||
{ name = "fastmcp", specifier = ">=2.3.0" },
|
||||
{ name = "filelock", marker = "extra == 'test'", specifier = ">=3.0.0" },
|
||||
{ name = "flashrank", specifier = ">=0.2.0" },
|
||||
{ name = "google-genai", specifier = ">=1.0.0" },
|
||||
{ name = "greenlet", specifier = ">=3.2.4" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
@@ -1339,6 +1378,7 @@ requires-dist = [
|
||||
{ name = "transformers", specifier = ">=4.30.0,<4.46.0" },
|
||||
{ name = "typer", specifier = ">=0.9.0" },
|
||||
{ name = "uvicorn", specifier = ">=0.38.0" },
|
||||
{ name = "uvloop", specifier = ">=0.22.1" },
|
||||
{ name = "wsproto", specifier = ">=1.0.0" },
|
||||
]
|
||||
provides-extras = ["test"]
|
||||
@@ -1539,6 +1579,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl", hash = "sha256:7bcc9ad17d5b3f07b57c78e79d527102d08313caa278a641993acddcb894548d", size = 566094 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "humanfriendly"
|
||||
version = "10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
@@ -2341,6 +2393,38 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime"
|
||||
version = "1.23.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coloredlogs" },
|
||||
{ name = "flatbuffers" },
|
||||
{ name = "numpy" },
|
||||
{ name = "packaging" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "sympy" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/44/be/467b00f09061572f022ffd17e49e49e5a7a789056bad95b54dfd3bee73ff/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c", size = 17196113 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/a8/3c23a8f75f93122d2b3410bfb74d06d0f8da4ac663185f91866b03f7da1b/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612", size = 19153857 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/d8/506eed9af03d86f8db4880a4c47cd0dffee973ef7e4f4cff9f1d4bcf7d22/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6", size = 15220095 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/80/113381ba832d5e777accedc6cb41d10f9eca82321ae31ebb6bcede530cea/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e", size = 17372080 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/db/1b4a62e23183a0c3fe441782462c0ede9a2a65c6bbffb9582fab7c7a0d38/onnxruntime-1.23.2-cp311-cp311-win_amd64.whl", hash = "sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e", size = 13468349 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321", size = 17195929 },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", size = 19157705 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c", size = 15226915 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77", size = 17382649 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435", size = 13470528 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/41/fba0cabccecefe4a1b5fc8020c44febb334637f133acefc7ec492029dd2c/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f", size = 17196337 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/f9/2d49ca491c6a986acce9f1d1d5fc2099108958cc1710c28e89a032c9cfe9/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95", size = 19157691 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/a1/428ee29c6eaf09a6f6be56f836213f104618fb35ac6cc586ff0f477263eb/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b", size = 15226898 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/2b/b57c8a2466a3126dbe0a792f56ad7290949b02f47b86216cd47d857e4b77/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872", size = 17382518 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/93/aba75358133b3a941d736816dd392f687e7eab77215a6e429879080b76b6/onnxruntime-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088", size = 13470276 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/3d/6830fa61c69ca8e905f237001dbfc01689a4e4ab06147020a4518318881f/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466", size = 15229610 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145", size = 17394184 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.7.2"
|
||||
@@ -3215,6 +3299,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyreadline3"
|
||||
version = "3.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.0"
|
||||
|
||||
Reference in New Issue
Block a user