openchronicle-mcp 3.0.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openchronicle/__init__.py +1 -0
- openchronicle/core/__init__.py +0 -0
- openchronicle/core/application/__init__.py +0 -0
- openchronicle/core/application/config/__init__.py +1 -0
- openchronicle/core/application/config/env_helpers.py +117 -0
- openchronicle/core/application/config/paths.py +79 -0
- openchronicle/core/application/config/settings.py +56 -0
- openchronicle/core/application/models/__init__.py +1 -0
- openchronicle/core/application/models/diagnostics_report.py +32 -0
- openchronicle/core/application/services/__init__.py +1 -0
- openchronicle/core/application/services/embedding_service.py +325 -0
- openchronicle/core/application/services/git_onboard.py +473 -0
- openchronicle/core/application/services/maintenance_loop.py +266 -0
- openchronicle/core/application/use_cases/__init__.py +0 -0
- openchronicle/core/application/use_cases/add_memory.py +30 -0
- openchronicle/core/application/use_cases/create_project.py +12 -0
- openchronicle/core/application/use_cases/delete_memory.py +45 -0
- openchronicle/core/application/use_cases/delete_project.py +45 -0
- openchronicle/core/application/use_cases/diagnose_runtime.py +77 -0
- openchronicle/core/application/use_cases/export_memory.py +73 -0
- openchronicle/core/application/use_cases/import_memory.py +95 -0
- openchronicle/core/application/use_cases/init_config.py +179 -0
- openchronicle/core/application/use_cases/init_runtime.py +60 -0
- openchronicle/core/application/use_cases/list_memory.py +10 -0
- openchronicle/core/application/use_cases/list_projects.py +8 -0
- openchronicle/core/application/use_cases/pin_memory.py +12 -0
- openchronicle/core/application/use_cases/search_memory.py +39 -0
- openchronicle/core/application/use_cases/show_memory.py +13 -0
- openchronicle/core/application/use_cases/update_memory.py +36 -0
- openchronicle/core/application/use_cases/update_project.py +23 -0
- openchronicle/core/domain/__init__.py +0 -0
- openchronicle/core/domain/errors/__init__.py +37 -0
- openchronicle/core/domain/errors/error_codes.py +49 -0
- openchronicle/core/domain/exceptions.py +61 -0
- openchronicle/core/domain/models/__init__.py +0 -0
- openchronicle/core/domain/models/git_commit.py +29 -0
- openchronicle/core/domain/models/memory_item.py +19 -0
- openchronicle/core/domain/models/project.py +16 -0
- openchronicle/core/domain/ports/__init__.py +0 -0
- openchronicle/core/domain/ports/embedding_port.py +25 -0
- openchronicle/core/domain/ports/memory_store_port.py +79 -0
- openchronicle/core/domain/ports/storage_port.py +62 -0
- openchronicle/core/domain/time_utils.py +10 -0
- openchronicle/core/infrastructure/__init__.py +0 -0
- openchronicle/core/infrastructure/config/__init__.py +1 -0
- openchronicle/core/infrastructure/config/config_loader.py +58 -0
- openchronicle/core/infrastructure/embedding/__init__.py +0 -0
- openchronicle/core/infrastructure/embedding/ollama_adapter.py +84 -0
- openchronicle/core/infrastructure/embedding/openai_adapter.py +94 -0
- openchronicle/core/infrastructure/embedding/stub_adapter.py +53 -0
- openchronicle/core/infrastructure/maintenance/__init__.py +0 -0
- openchronicle/core/infrastructure/maintenance/jobs.py +148 -0
- openchronicle/core/infrastructure/persistence/__init__.py +0 -0
- openchronicle/core/infrastructure/persistence/backup.py +99 -0
- openchronicle/core/infrastructure/persistence/migrations/001_initial.sql +49 -0
- openchronicle/core/infrastructure/persistence/migrations/__init__.py +0 -0
- openchronicle/core/infrastructure/persistence/migrator.py +137 -0
- openchronicle/core/infrastructure/persistence/row_mappers.py +38 -0
- openchronicle/core/infrastructure/persistence/sqlite_store.py +578 -0
- openchronicle/core/infrastructure/wiring/__init__.py +0 -0
- openchronicle/core/infrastructure/wiring/container.py +176 -0
- openchronicle/interfaces/__init__.py +0 -0
- openchronicle/interfaces/api/__init__.py +3 -0
- openchronicle/interfaces/api/app.py +158 -0
- openchronicle/interfaces/api/config.py +54 -0
- openchronicle/interfaces/api/deps.py +19 -0
- openchronicle/interfaces/api/middleware/__init__.py +47 -0
- openchronicle/interfaces/api/middleware/auth.py +64 -0
- openchronicle/interfaces/api/middleware/rate_limit.py +71 -0
- openchronicle/interfaces/api/routes/__init__.py +3 -0
- openchronicle/interfaces/api/routes/memory.py +234 -0
- openchronicle/interfaces/api/routes/project.py +119 -0
- openchronicle/interfaces/api/routes/system.py +39 -0
- openchronicle/interfaces/cli/__init__.py +0 -0
- openchronicle/interfaces/cli/commands/__init__.py +50 -0
- openchronicle/interfaces/cli/commands/_helpers.py +48 -0
- openchronicle/interfaces/cli/commands/db.py +159 -0
- openchronicle/interfaces/cli/commands/maintenance.py +86 -0
- openchronicle/interfaces/cli/commands/memory.py +308 -0
- openchronicle/interfaces/cli/commands/onboard.py +100 -0
- openchronicle/interfaces/cli/commands/project.py +180 -0
- openchronicle/interfaces/cli/commands/system.py +217 -0
- openchronicle/interfaces/cli/main.py +225 -0
- openchronicle/interfaces/logging_setup.py +109 -0
- openchronicle/interfaces/mcp/__init__.py +3 -0
- openchronicle/interfaces/mcp/__main__.py +32 -0
- openchronicle/interfaces/mcp/config.py +105 -0
- openchronicle/interfaces/mcp/server.py +75 -0
- openchronicle/interfaces/mcp/tools/__init__.py +0 -0
- openchronicle/interfaces/mcp/tools/context.py +60 -0
- openchronicle/interfaces/mcp/tools/memory.py +329 -0
- openchronicle/interfaces/mcp/tools/onboard.py +155 -0
- openchronicle/interfaces/mcp/tools/project.py +144 -0
- openchronicle/interfaces/mcp/tools/system.py +38 -0
- openchronicle/interfaces/serializers.py +30 -0
- openchronicle/py.typed +0 -0
- openchronicle_mcp-3.0.0.dev0.dist-info/METADATA +187 -0
- openchronicle_mcp-3.0.0.dev0.dist-info/RECORD +102 -0
- openchronicle_mcp-3.0.0.dev0.dist-info/WHEEL +5 -0
- openchronicle_mcp-3.0.0.dev0.dist-info/entry_points.txt +2 -0
- openchronicle_mcp-3.0.0.dev0.dist-info/licenses/LICENSE +661 -0
- openchronicle_mcp-3.0.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__: list[str] = []
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application configuration loading."""
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Consolidated environment variable and config value parsing helpers.
|
|
2
|
+
|
|
3
|
+
These helpers handle three-layer precedence: dataclass defaults -> JSON file
|
|
4
|
+
values -> env var overrides. They work with both string values (from env vars)
|
|
5
|
+
and native JSON types (bool, int, float) from config files.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_bool(value: object, *, default: bool) -> bool:
|
|
14
|
+
"""Parse a boolean from string, native bool, or None.
|
|
15
|
+
|
|
16
|
+
Truthy strings: "1", "true", "yes", "on" (case-insensitive).
|
|
17
|
+
Falsy strings: "0", "false", "no", "off" (case-insensitive).
|
|
18
|
+
Native bools pass through. None returns default.
|
|
19
|
+
"""
|
|
20
|
+
if value is None:
|
|
21
|
+
return default
|
|
22
|
+
if isinstance(value, bool):
|
|
23
|
+
return value
|
|
24
|
+
if isinstance(value, str):
|
|
25
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
26
|
+
return default
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_int(value: object, *, default: int) -> int:
|
|
30
|
+
"""Parse an integer from string, native int, or None.
|
|
31
|
+
|
|
32
|
+
Native ints pass through. Strings are stripped and converted.
|
|
33
|
+
Invalid values return default. None returns default.
|
|
34
|
+
"""
|
|
35
|
+
if value is None:
|
|
36
|
+
return default
|
|
37
|
+
if isinstance(value, bool):
|
|
38
|
+
# bool is a subclass of int in Python — reject it
|
|
39
|
+
return default
|
|
40
|
+
if isinstance(value, int):
|
|
41
|
+
return value
|
|
42
|
+
if isinstance(value, str):
|
|
43
|
+
raw = value.strip()
|
|
44
|
+
try:
|
|
45
|
+
return int(raw)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return default
|
|
48
|
+
return default
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_float(value: object, *, default: float) -> float:
|
|
52
|
+
"""Parse a float from string, native float/int, or None.
|
|
53
|
+
|
|
54
|
+
Native floats/ints pass through. Strings are stripped and converted.
|
|
55
|
+
Invalid values return default. None returns default.
|
|
56
|
+
"""
|
|
57
|
+
if value is None:
|
|
58
|
+
return default
|
|
59
|
+
if isinstance(value, bool):
|
|
60
|
+
return default
|
|
61
|
+
if isinstance(value, int | float):
|
|
62
|
+
return float(value)
|
|
63
|
+
if isinstance(value, str):
|
|
64
|
+
raw = value.strip()
|
|
65
|
+
try:
|
|
66
|
+
return float(raw)
|
|
67
|
+
except ValueError:
|
|
68
|
+
return default
|
|
69
|
+
return default
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_str(value: object, *, default: str) -> str:
|
|
73
|
+
"""Parse a string value, returning default for None or empty."""
|
|
74
|
+
if value is None:
|
|
75
|
+
return default
|
|
76
|
+
if isinstance(value, str):
|
|
77
|
+
return value if value else default
|
|
78
|
+
return str(value)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_str_list(value: object, *, default: list[str]) -> list[str]:
|
|
82
|
+
"""Parse a list of strings from a JSON array or CSV string.
|
|
83
|
+
|
|
84
|
+
- list[str]: pass through (filtering empties)
|
|
85
|
+
- str: split on commas, strip, filter empties
|
|
86
|
+
- None: return default
|
|
87
|
+
"""
|
|
88
|
+
if value is None:
|
|
89
|
+
return list(default)
|
|
90
|
+
if isinstance(value, list):
|
|
91
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
92
|
+
if isinstance(value, str):
|
|
93
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
94
|
+
return list(default)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def env_override(env_name: str, file_value: object) -> object:
|
|
98
|
+
"""Return env var if set, otherwise file_value.
|
|
99
|
+
|
|
100
|
+
This implements the precedence: env var > JSON file > (caller's default).
|
|
101
|
+
Only returns the env var when it is explicitly set in the environment.
|
|
102
|
+
"""
|
|
103
|
+
env_val = os.getenv(env_name)
|
|
104
|
+
if env_val is not None:
|
|
105
|
+
return env_val
|
|
106
|
+
return file_value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_csv_tags(value: str | None) -> list[str] | None:
|
|
110
|
+
"""Parse a comma-separated string into a list of stripped, non-empty tags.
|
|
111
|
+
|
|
112
|
+
Returns None if the input is None or empty, which signals "no filter"
|
|
113
|
+
in search contexts.
|
|
114
|
+
"""
|
|
115
|
+
if not value:
|
|
116
|
+
return None
|
|
117
|
+
return [t.strip() for t in value.split(",") if t.strip()]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Canonical runtime path resolution for the entire project.
|
|
2
|
+
|
|
3
|
+
All data-directory paths flow through ``RuntimePaths.resolve()``.
|
|
4
|
+
Four-layer precedence: constructor param > per-path env var >
|
|
5
|
+
``OC_DATA_DIR``-derived > hardcoded default.
|
|
6
|
+
|
|
7
|
+
v3 manages three paths: the SQLite file, the config dir, and an
|
|
8
|
+
output dir. v2's plugin/assets/discord paths are gone with their
|
|
9
|
+
subsystems.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# ── Default constants ────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
DEFAULT_DB_PATH = "data/openchronicle.db"
|
|
21
|
+
DEFAULT_CONFIG_DIR = "config"
|
|
22
|
+
DEFAULT_OUTPUT_DIR = "output"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _resolve(
|
|
26
|
+
explicit: str | Path | None,
|
|
27
|
+
env_var: str,
|
|
28
|
+
data_dir: str | None,
|
|
29
|
+
data_dir_suffix: str,
|
|
30
|
+
fallback: str,
|
|
31
|
+
) -> Path:
|
|
32
|
+
"""Four-layer path resolution.
|
|
33
|
+
|
|
34
|
+
1. Constructor param (``explicit``) — wins unconditionally.
|
|
35
|
+
2. Per-path env var — checked next.
|
|
36
|
+
3. ``OC_DATA_DIR + suffix`` — if ``OC_DATA_DIR`` is set.
|
|
37
|
+
4. Hardcoded fallback — last resort.
|
|
38
|
+
"""
|
|
39
|
+
if explicit is not None:
|
|
40
|
+
return Path(explicit)
|
|
41
|
+
env_val = os.environ.get(env_var)
|
|
42
|
+
if env_val is not None:
|
|
43
|
+
return Path(env_val)
|
|
44
|
+
if data_dir is not None:
|
|
45
|
+
return Path(data_dir) / data_dir_suffix
|
|
46
|
+
return Path(fallback)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class RuntimePaths:
|
|
51
|
+
"""Resolved runtime paths for v3 data artifacts."""
|
|
52
|
+
|
|
53
|
+
db_path: Path
|
|
54
|
+
config_dir: Path
|
|
55
|
+
output_dir: Path
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def resolve(
|
|
59
|
+
cls,
|
|
60
|
+
*,
|
|
61
|
+
db_path: str | Path | None = None,
|
|
62
|
+
config_dir: str | Path | None = None,
|
|
63
|
+
output_dir: str | Path | None = None,
|
|
64
|
+
# Accepted but ignored for backwards-compatible callers (e.g.
|
|
65
|
+
# CLI flags / kwargs that haven't been pruned in lock step).
|
|
66
|
+
plugin_dir: str | Path | None = None, # noqa: ARG003 - accepted for kwarg stability
|
|
67
|
+
) -> RuntimePaths:
|
|
68
|
+
"""Build ``RuntimePaths`` with four-layer precedence.
|
|
69
|
+
|
|
70
|
+
Constructor params > per-path env vars > ``OC_DATA_DIR``-derived
|
|
71
|
+
> defaults.
|
|
72
|
+
"""
|
|
73
|
+
data_dir = os.environ.get("OC_DATA_DIR")
|
|
74
|
+
|
|
75
|
+
return cls(
|
|
76
|
+
db_path=_resolve(db_path, "OC_DB_PATH", data_dir, "openchronicle.db", DEFAULT_DB_PATH),
|
|
77
|
+
config_dir=_resolve(config_dir, "OC_CONFIG_DIR", data_dir, "config", DEFAULT_CONFIG_DIR),
|
|
78
|
+
output_dir=_resolve(output_dir, "OC_OUTPUT_DIR", data_dir, "output", DEFAULT_OUTPUT_DIR),
|
|
79
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from openchronicle.core.application.config.env_helpers import (
|
|
7
|
+
env_override,
|
|
8
|
+
parse_int,
|
|
9
|
+
parse_str,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class EmbeddingSettings:
|
|
15
|
+
"""Embedding provider configuration."""
|
|
16
|
+
|
|
17
|
+
provider: str = "none"
|
|
18
|
+
model: str = ""
|
|
19
|
+
dimensions: int | None = None
|
|
20
|
+
api_key: str = ""
|
|
21
|
+
timeout: float = 30.0
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
valid = {"none", "stub", "openai", "ollama"}
|
|
25
|
+
if self.provider not in valid:
|
|
26
|
+
raise ValueError(f"embedding provider must be one of {valid}, got {self.provider!r}")
|
|
27
|
+
if self.dimensions is not None and self.dimensions < 1:
|
|
28
|
+
raise ValueError(f"embedding dimensions must be >= 1, got {self.dimensions}")
|
|
29
|
+
if self.timeout <= 0:
|
|
30
|
+
raise ValueError(f"embedding timeout must be > 0, got {self.timeout}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_embedding_settings(
|
|
34
|
+
file_config: dict[str, Any] | None = None,
|
|
35
|
+
) -> EmbeddingSettings:
|
|
36
|
+
fc = file_config or {}
|
|
37
|
+
dims_raw = env_override("OC_EMBEDDING_DIMENSIONS", fc.get("dimensions"))
|
|
38
|
+
dims = parse_int(dims_raw, default=0) if dims_raw is not None else 0
|
|
39
|
+
timeout_raw = env_override("OC_EMBEDDING_TIMEOUT", fc.get("timeout"))
|
|
40
|
+
timeout = float(str(timeout_raw)) if timeout_raw is not None else 30.0
|
|
41
|
+
return EmbeddingSettings(
|
|
42
|
+
provider=parse_str(
|
|
43
|
+
env_override("OC_EMBEDDING_PROVIDER", fc.get("provider")),
|
|
44
|
+
default="none",
|
|
45
|
+
).lower(),
|
|
46
|
+
model=parse_str(
|
|
47
|
+
env_override("OC_EMBEDDING_MODEL", fc.get("model")),
|
|
48
|
+
default="",
|
|
49
|
+
),
|
|
50
|
+
dimensions=dims if dims != 0 else None,
|
|
51
|
+
api_key=parse_str(
|
|
52
|
+
env_override("OC_EMBEDDING_API_KEY", fc.get("api_key")),
|
|
53
|
+
default="",
|
|
54
|
+
),
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application models."""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Diagnostics report model.
|
|
2
|
+
|
|
3
|
+
Returned by ``diagnose_runtime.execute()`` and surfaced by the v3
|
|
4
|
+
health endpoints (``/health``, ``/api/v1/health``, MCP ``health`` tool).
|
|
5
|
+
v2's plugin_dir / model config discovery / OC_LLM_* env summary fields
|
|
6
|
+
are gone with the LLM stack.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class DiagnosticsReport:
|
|
18
|
+
"""Runtime diagnostics report."""
|
|
19
|
+
|
|
20
|
+
timestamp_utc: datetime
|
|
21
|
+
db_path: str
|
|
22
|
+
db_exists: bool
|
|
23
|
+
db_size_bytes: int | None
|
|
24
|
+
db_modified_utc: datetime | None
|
|
25
|
+
config_dir: str
|
|
26
|
+
config_dir_exists: bool
|
|
27
|
+
running_in_container_hint: bool
|
|
28
|
+
persistence_hint: str
|
|
29
|
+
# Embedding subsystem status — populated by interfaces with a
|
|
30
|
+
# CoreContainer in hand (the container injects its own
|
|
31
|
+
# `embedding_status_dict` here).
|
|
32
|
+
embedding_status: dict[str, Any] | None = field(default=None)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application services layer."""
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Embedding service — generates embeddings and performs hybrid search."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from openchronicle.core.domain.models.memory_item import MemoryItem
|
|
10
|
+
from openchronicle.core.domain.ports.embedding_port import EmbeddingPort
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from openchronicle.core.infrastructure.persistence.sqlite_store import SqliteStore
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# RRF constant — standard value from the original RRF paper
|
|
18
|
+
_RRF_K = 60
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class BackfillResult:
|
|
23
|
+
"""Outcome of a backfill run.
|
|
24
|
+
|
|
25
|
+
The per-item resilience in ``generate_missing`` keeps a single bad item
|
|
26
|
+
from blocking the rest, but it must NOT hide total failure from callers.
|
|
27
|
+
Carrying ``failed`` alongside ``generated`` lets the MCP/API/CLI surfaces
|
|
28
|
+
return an honest status to clients.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
generated: int
|
|
32
|
+
failed: int
|
|
33
|
+
elapsed_ms: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class EmbeddingService:
|
|
37
|
+
"""Coordinates embedding generation and hybrid (FTS5 + semantic) search."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, port: EmbeddingPort, store: SqliteStore) -> None:
|
|
40
|
+
self._port = port
|
|
41
|
+
self._store = store
|
|
42
|
+
# Degraded-search bookkeeping — flips on when the embedding
|
|
43
|
+
# provider raises during a search, flips off the next time
|
|
44
|
+
# it succeeds. Surfaced via container.embedding_status_dict
|
|
45
|
+
# for /api/v1/health.
|
|
46
|
+
self._search_failure_count: int = 0
|
|
47
|
+
self._last_failure_at: str | None = None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def port(self) -> EmbeddingPort:
|
|
51
|
+
return self._port
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def search_failure_count(self) -> int:
|
|
55
|
+
return self._search_failure_count
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def last_failure_at(self) -> str | None:
|
|
59
|
+
return self._last_failure_at
|
|
60
|
+
|
|
61
|
+
def generate_for_memory(
|
|
62
|
+
self,
|
|
63
|
+
memory_id: str,
|
|
64
|
+
content: str,
|
|
65
|
+
*,
|
|
66
|
+
force: bool = False,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Generate and store embedding for a single memory item.
|
|
69
|
+
|
|
70
|
+
Skips generation if an embedding already exists with the same model,
|
|
71
|
+
unless ``force`` is True (used when content changes).
|
|
72
|
+
"""
|
|
73
|
+
if not force:
|
|
74
|
+
existing_model = self._store.get_embedding_model(memory_id)
|
|
75
|
+
if existing_model == self._port.model_name():
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
vec = self._port.embed(content)
|
|
79
|
+
self._store.save_embedding(
|
|
80
|
+
memory_id,
|
|
81
|
+
vec,
|
|
82
|
+
model=self._port.model_name(),
|
|
83
|
+
dimensions=self._port.dimensions(),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def generate_missing(self, *, project_id: str | None = None, force: bool = False) -> BackfillResult:
|
|
87
|
+
"""Backfill embeddings for memories that don't have one.
|
|
88
|
+
|
|
89
|
+
If *force* is True, regenerate all embeddings (model change scenario).
|
|
90
|
+
Individual failures are logged and skipped so the backfill always
|
|
91
|
+
completes — but the failure count is returned so callers can surface
|
|
92
|
+
a partial/total-failure status instead of falsely reporting "ok".
|
|
93
|
+
"""
|
|
94
|
+
import time
|
|
95
|
+
|
|
96
|
+
items = self._store.list_memory(limit=None, pinned_only=False)
|
|
97
|
+
if project_id:
|
|
98
|
+
items = [i for i in items if i.project_id == project_id]
|
|
99
|
+
|
|
100
|
+
candidates = []
|
|
101
|
+
for item in items:
|
|
102
|
+
if not force:
|
|
103
|
+
existing_model = self._store.get_embedding_model(item.id)
|
|
104
|
+
if existing_model == self._port.model_name():
|
|
105
|
+
continue
|
|
106
|
+
candidates.append(item)
|
|
107
|
+
|
|
108
|
+
if not candidates:
|
|
109
|
+
logger.info("Embedding backfill: 0 candidates, nothing to do")
|
|
110
|
+
return BackfillResult(generated=0, failed=0, elapsed_ms=0)
|
|
111
|
+
|
|
112
|
+
logger.info(
|
|
113
|
+
"Embedding backfill started: %d candidates (model=%s, force=%s)",
|
|
114
|
+
len(candidates),
|
|
115
|
+
self._port.model_name(),
|
|
116
|
+
force,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
t0 = time.monotonic()
|
|
120
|
+
count = 0
|
|
121
|
+
failed = 0
|
|
122
|
+
for item in candidates:
|
|
123
|
+
try:
|
|
124
|
+
vec = self._port.embed(item.content)
|
|
125
|
+
self._store.save_embedding(
|
|
126
|
+
item.id,
|
|
127
|
+
vec,
|
|
128
|
+
model=self._port.model_name(),
|
|
129
|
+
dimensions=self._port.dimensions(),
|
|
130
|
+
)
|
|
131
|
+
count += 1
|
|
132
|
+
except Exception:
|
|
133
|
+
failed += 1
|
|
134
|
+
logger.warning("Embedding generation failed for memory %s", item.id, exc_info=True)
|
|
135
|
+
|
|
136
|
+
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
|
137
|
+
logger.info(
|
|
138
|
+
"Embedding backfill completed: %d generated, %d failed, %dms elapsed",
|
|
139
|
+
count,
|
|
140
|
+
failed,
|
|
141
|
+
elapsed_ms,
|
|
142
|
+
)
|
|
143
|
+
return BackfillResult(generated=count, failed=failed, elapsed_ms=elapsed_ms)
|
|
144
|
+
|
|
145
|
+
def embedding_status(self) -> dict[str, int]:
|
|
146
|
+
"""Return embedding coverage stats."""
|
|
147
|
+
total_memories = self._store.count_memory()
|
|
148
|
+
embedded = self._store.count_embeddings()
|
|
149
|
+
stale = self._store.count_stale_embeddings(self._port.model_name())
|
|
150
|
+
return {
|
|
151
|
+
"total_memories": total_memories,
|
|
152
|
+
"embedded": embedded,
|
|
153
|
+
"missing": total_memories - embedded,
|
|
154
|
+
"stale": stale,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
def search_hybrid(
|
|
158
|
+
self,
|
|
159
|
+
query: str,
|
|
160
|
+
*,
|
|
161
|
+
top_k: int = 8,
|
|
162
|
+
project_id: str | None = None,
|
|
163
|
+
include_pinned: bool = True,
|
|
164
|
+
tags: list[str] | None = None,
|
|
165
|
+
offset: int = 0,
|
|
166
|
+
) -> list[MemoryItem]:
|
|
167
|
+
"""Hybrid search: FTS5 keyword + embedding similarity via RRF.
|
|
168
|
+
|
|
169
|
+
1. Run keyword search (FTS5) for ranked list A
|
|
170
|
+
2. Embed query → cosine similarity → ranked list B
|
|
171
|
+
3. Combine via Reciprocal Rank Fusion
|
|
172
|
+
4. Return top_k results
|
|
173
|
+
"""
|
|
174
|
+
effective_top_k = top_k + offset
|
|
175
|
+
|
|
176
|
+
# ── Pinned items (always included) ──────────────────────────────
|
|
177
|
+
pinned_items: list[MemoryItem] = []
|
|
178
|
+
if include_pinned:
|
|
179
|
+
pinned_items = self._store.pinned_items(project_id)
|
|
180
|
+
if tags:
|
|
181
|
+
pinned_items = [i for i in pinned_items if all(t in i.tags for t in tags)]
|
|
182
|
+
|
|
183
|
+
# Pinned items have separate budget — don't reduce search/RRF limit
|
|
184
|
+
# (prevents pinned items from crowding out query-relevant results)
|
|
185
|
+
|
|
186
|
+
pinned_ids = {i.id for i in pinned_items}
|
|
187
|
+
|
|
188
|
+
# ── Keyword search (list A) ─────────────────────────────────────
|
|
189
|
+
keyword_results = self._store.search_memory(
|
|
190
|
+
query,
|
|
191
|
+
top_k=effective_top_k * 2, # over-fetch for RRF merge
|
|
192
|
+
project_id=project_id,
|
|
193
|
+
include_pinned=False,
|
|
194
|
+
tags=tags,
|
|
195
|
+
)
|
|
196
|
+
keyword_results = [i for i in keyword_results if i.id not in pinned_ids]
|
|
197
|
+
|
|
198
|
+
# ── Semantic search (list B) ─────────────────────────────────────
|
|
199
|
+
# Embedding-failure degradation: if the provider raises, log it,
|
|
200
|
+
# mark the service degraded, and return FTS5-only results. The
|
|
201
|
+
# caller never sees the exception; /api/v1/health surfaces the
|
|
202
|
+
# degraded state via the failure counters on the service.
|
|
203
|
+
try:
|
|
204
|
+
semantic_ranked = self._semantic_search(
|
|
205
|
+
query,
|
|
206
|
+
project_id=project_id,
|
|
207
|
+
tags=tags,
|
|
208
|
+
exclude_ids=pinned_ids,
|
|
209
|
+
limit=effective_top_k * 2,
|
|
210
|
+
)
|
|
211
|
+
# Successful call clears any prior degraded marker.
|
|
212
|
+
if self._search_failure_count:
|
|
213
|
+
logger.info(
|
|
214
|
+
"embedding search recovered after %d prior failures",
|
|
215
|
+
self._search_failure_count,
|
|
216
|
+
)
|
|
217
|
+
self._search_failure_count = 0
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
from datetime import UTC, datetime
|
|
220
|
+
|
|
221
|
+
self._search_failure_count += 1
|
|
222
|
+
self._last_failure_at = datetime.now(UTC).isoformat()
|
|
223
|
+
logger.warning(
|
|
224
|
+
"embedding search failed (%d total); degrading to FTS5-only: %s",
|
|
225
|
+
self._search_failure_count,
|
|
226
|
+
exc,
|
|
227
|
+
)
|
|
228
|
+
non_pinned_page = keyword_results[offset : offset + top_k]
|
|
229
|
+
if offset == 0:
|
|
230
|
+
return list(pinned_items) + non_pinned_page
|
|
231
|
+
return non_pinned_page
|
|
232
|
+
|
|
233
|
+
# ── RRF merge ──────────────────────────────────────────────────
|
|
234
|
+
keyword_rank: dict[str, int] = {item.id: rank for rank, item in enumerate(keyword_results, start=1)}
|
|
235
|
+
semantic_rank: dict[str, int] = {mid: rank for rank, mid in enumerate(semantic_ranked, start=1)}
|
|
236
|
+
|
|
237
|
+
all_ids = set(keyword_rank) | set(semantic_rank)
|
|
238
|
+
# Build lookup for MemoryItem objects
|
|
239
|
+
item_map: dict[str, MemoryItem] = {i.id: i for i in keyword_results}
|
|
240
|
+
|
|
241
|
+
# For semantic-only results, fetch MemoryItem from store
|
|
242
|
+
for mid in semantic_rank:
|
|
243
|
+
if mid not in item_map:
|
|
244
|
+
mem = self._store.get_memory(mid)
|
|
245
|
+
if mem:
|
|
246
|
+
item_map[mid] = mem
|
|
247
|
+
|
|
248
|
+
rrf_scores: list[tuple[str, float]] = []
|
|
249
|
+
for mid in all_ids:
|
|
250
|
+
if mid not in item_map:
|
|
251
|
+
continue
|
|
252
|
+
# Apply tag filter to semantic-only results
|
|
253
|
+
if tags and not all(t in item_map[mid].tags for t in tags):
|
|
254
|
+
continue
|
|
255
|
+
# Apply project filter to semantic-only results
|
|
256
|
+
if project_id and item_map[mid].project_id != project_id:
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
kr = keyword_rank.get(mid)
|
|
260
|
+
sr = semantic_rank.get(mid)
|
|
261
|
+
score = 0.0
|
|
262
|
+
if kr is not None:
|
|
263
|
+
score += 1.0 / (_RRF_K + kr)
|
|
264
|
+
if sr is not None:
|
|
265
|
+
score += 1.0 / (_RRF_K + sr)
|
|
266
|
+
rrf_scores.append((mid, score))
|
|
267
|
+
|
|
268
|
+
rrf_scores.sort(key=lambda x: x[1], reverse=True)
|
|
269
|
+
|
|
270
|
+
merged = [item_map[mid] for mid, _ in rrf_scores[:effective_top_k]]
|
|
271
|
+
|
|
272
|
+
# Pinned items prepended on first page only; offset paginates non-pinned results
|
|
273
|
+
non_pinned_page = merged[offset : offset + top_k]
|
|
274
|
+
if offset == 0:
|
|
275
|
+
return list(pinned_items) + non_pinned_page
|
|
276
|
+
return non_pinned_page
|
|
277
|
+
|
|
278
|
+
def _semantic_search(
|
|
279
|
+
self,
|
|
280
|
+
query: str,
|
|
281
|
+
*,
|
|
282
|
+
project_id: str | None = None,
|
|
283
|
+
tags: list[str] | None = None,
|
|
284
|
+
exclude_ids: set[str] | None = None,
|
|
285
|
+
limit: int = 16,
|
|
286
|
+
) -> list[str]:
|
|
287
|
+
"""Return memory IDs ranked by cosine similarity to query embedding.
|
|
288
|
+
|
|
289
|
+
All adapters normalize at output, so dot product = cosine similarity.
|
|
290
|
+
Numpy single-matmul replaces a per-item Python loop; for ~5k memories
|
|
291
|
+
at 1536 dims this is ~50-100x faster than the prior pure-Python path.
|
|
292
|
+
Memory cost is unchanged: list_embeddings still loads the full
|
|
293
|
+
embedding table — that's the architectural ceiling addressed by a
|
|
294
|
+
future move to a vector-indexed store (sqlite-vec).
|
|
295
|
+
"""
|
|
296
|
+
import numpy as np
|
|
297
|
+
|
|
298
|
+
query_vec = self._port.embed(query)
|
|
299
|
+
all_embeddings = self._store.list_embeddings()
|
|
300
|
+
|
|
301
|
+
if not all_embeddings:
|
|
302
|
+
return []
|
|
303
|
+
|
|
304
|
+
ids = [mid for mid in all_embeddings if mid not in exclude_ids] if exclude_ids else list(all_embeddings)
|
|
305
|
+
if not ids:
|
|
306
|
+
return []
|
|
307
|
+
|
|
308
|
+
matrix = np.asarray([all_embeddings[mid] for mid in ids], dtype=np.float32)
|
|
309
|
+
q = np.asarray(query_vec, dtype=np.float32)
|
|
310
|
+
scores = matrix @ q # (N,) cosine similarities
|
|
311
|
+
|
|
312
|
+
# argpartition gives top-k unsorted in O(N); sort the slice for ranks.
|
|
313
|
+
k = min(limit, scores.shape[0])
|
|
314
|
+
top_unsorted = np.argpartition(-scores, k - 1)[:k] if k < scores.shape[0] else np.arange(scores.shape[0])
|
|
315
|
+
top_sorted = top_unsorted[np.argsort(-scores[top_unsorted])]
|
|
316
|
+
return [ids[i] for i in top_sorted]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
|
320
|
+
"""Dot product of unit vectors = cosine similarity.
|
|
321
|
+
|
|
322
|
+
Kept as a small helper for tests + diagnostic callers. The hot search
|
|
323
|
+
path uses numpy (see _semantic_search).
|
|
324
|
+
"""
|
|
325
|
+
return sum(x * y for x, y in zip(a, b, strict=False))
|