coppermind-cmo 0.1.0__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.
- coppermind_cmo/__init__.py +3 -0
- coppermind_cmo/__main__.py +6 -0
- coppermind_cmo/config.py +82 -0
- coppermind_cmo/db.py +121 -0
- coppermind_cmo/embedding_client.py +120 -0
- coppermind_cmo/embedding_server.py +71 -0
- coppermind_cmo/errors.py +44 -0
- coppermind_cmo/llm_client.py +197 -0
- coppermind_cmo/log.py +58 -0
- coppermind_cmo/server.py +203 -0
- coppermind_cmo/supabase-ca.pem +23 -0
- coppermind_cmo/tools/__init__.py +12 -0
- coppermind_cmo/tools/brand.py +221 -0
- coppermind_cmo/tools/intelligence.py +350 -0
- coppermind_cmo/tools/memories.py +306 -0
- coppermind_cmo/tools/minds.py +141 -0
- coppermind_cmo/utils.py +13 -0
- coppermind_cmo-0.1.0.dist-info/METADATA +17 -0
- coppermind_cmo-0.1.0.dist-info/RECORD +21 -0
- coppermind_cmo-0.1.0.dist-info/WHEEL +5 -0
- coppermind_cmo-0.1.0.dist-info/top_level.txt +1 -0
coppermind_cmo/config.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Configuration from COPPERMIND_* environment variables.
|
|
2
|
+
|
|
3
|
+
All numeric env vars are validated as integers at parse time (not on first use)
|
|
4
|
+
per SERVER_SPEC.md startup requirements.
|
|
5
|
+
|
|
6
|
+
Provider detection:
|
|
7
|
+
COPPERMIND_API_KEY set → hosted mode (gateway for LLM+embedding)
|
|
8
|
+
COPPERMIND_OLLAMA_HOST set → self-hosted LLM (Ollama)
|
|
9
|
+
COPPERMIND_EMBEDDING_HOST set → self-hosted embeddings (local server)
|
|
10
|
+
Neither → degraded mode (same as Sprint 1 when services are down)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _int_env(name: str, default: int) -> int:
|
|
17
|
+
val = os.environ.get(name)
|
|
18
|
+
if val is None:
|
|
19
|
+
return default
|
|
20
|
+
try:
|
|
21
|
+
return int(val)
|
|
22
|
+
except ValueError:
|
|
23
|
+
raise ValueError(f"{name} must be an integer, got: {val!r}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Config:
|
|
27
|
+
"""Parsed configuration from environment variables."""
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
# --- Hosted mode ---
|
|
31
|
+
self.api_key: str = os.environ.get("COPPERMIND_API_KEY", "")
|
|
32
|
+
self.gateway_url: str = os.environ.get(
|
|
33
|
+
"COPPERMIND_GATEWAY_URL",
|
|
34
|
+
"https://api.coppermind.app",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# --- Self-hosted PostgreSQL ---
|
|
38
|
+
self.pg_host: str = os.environ.get("COPPERMIND_PG_HOST", "localhost")
|
|
39
|
+
self.pg_port: int = _int_env("COPPERMIND_PG_PORT", 5432)
|
|
40
|
+
self.pg_db: str = os.environ.get("COPPERMIND_PG_DB", "coppermind_cmo")
|
|
41
|
+
self.pg_user: str = os.environ.get("COPPERMIND_PG_USER", "coppermind")
|
|
42
|
+
self.pg_password: str = os.environ.get("COPPERMIND_PG_PASSWORD", "")
|
|
43
|
+
|
|
44
|
+
# --- Self-hosted embedding service ---
|
|
45
|
+
self.embedding_host: str = os.environ.get("COPPERMIND_EMBEDDING_HOST", "localhost")
|
|
46
|
+
self.embedding_port: int = _int_env("COPPERMIND_EMBEDDING_PORT", 8400)
|
|
47
|
+
|
|
48
|
+
# --- Self-hosted Ollama ---
|
|
49
|
+
self.ollama_host: str = os.environ.get("COPPERMIND_OLLAMA_HOST", "localhost")
|
|
50
|
+
self.ollama_port: int = _int_env("COPPERMIND_OLLAMA_PORT", 11434)
|
|
51
|
+
self.ollama_model: str = os.environ.get("COPPERMIND_OLLAMA_MODEL", "gemma3:12b")
|
|
52
|
+
|
|
53
|
+
# --- Claude model configuration (hosted mode) ---
|
|
54
|
+
self.claude_fast_model: str = os.environ.get(
|
|
55
|
+
"COPPERMIND_CLAUDE_FAST_MODEL", "claude-haiku-4-5-20251001"
|
|
56
|
+
)
|
|
57
|
+
self.claude_synthesis_model: str = os.environ.get(
|
|
58
|
+
"COPPERMIND_CLAUDE_SYNTHESIS_MODEL", "claude-sonnet-4-20250514"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# --- Provider detection ---
|
|
62
|
+
if self.api_key:
|
|
63
|
+
self.llm_provider: str | None = "gateway"
|
|
64
|
+
self.embedding_provider: str | None = "gateway"
|
|
65
|
+
self.embedding_dims: int = 512
|
|
66
|
+
self.max_prompt_chars: int = 40000
|
|
67
|
+
else:
|
|
68
|
+
# Self-hosted: detect individually
|
|
69
|
+
has_ollama = bool(os.environ.get("COPPERMIND_OLLAMA_HOST"))
|
|
70
|
+
has_embedding = bool(os.environ.get("COPPERMIND_EMBEDDING_HOST"))
|
|
71
|
+
self.llm_provider = "ollama" if has_ollama else None
|
|
72
|
+
self.embedding_provider = "local" if has_embedding else None
|
|
73
|
+
self.embedding_dims = 384
|
|
74
|
+
self.max_prompt_chars = 13000
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def embedding_url(self) -> str:
|
|
78
|
+
return f"http://{self.embedding_host}:{self.embedding_port}"
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def ollama_url(self) -> str:
|
|
82
|
+
return f"http://{self.ollama_host}:{self.ollama_port}"
|
coppermind_cmo/db.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Database connection pool and query helpers.
|
|
2
|
+
|
|
3
|
+
Adapted from old Coppermind lib/database.py. Simplified for CMO use case:
|
|
4
|
+
- Single config source (COPPERMIND_PG_* env vars via Config)
|
|
5
|
+
- psycopg2 ThreadedConnectionPool
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import threading
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import psycopg2
|
|
16
|
+
from psycopg2.pool import PoolError, ThreadedConnectionPool
|
|
17
|
+
|
|
18
|
+
from coppermind_cmo.config import Config
|
|
19
|
+
from coppermind_cmo.log import ToolLogger
|
|
20
|
+
|
|
21
|
+
logger = ToolLogger("db")
|
|
22
|
+
|
|
23
|
+
POOL_MIN = 1
|
|
24
|
+
POOL_MAX = 10
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Database:
|
|
28
|
+
"""PostgreSQL connection pool wrapper."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, config: Config):
|
|
31
|
+
self._config = config
|
|
32
|
+
self._pool: ThreadedConnectionPool | None = None
|
|
33
|
+
self._pool_lock = threading.Lock()
|
|
34
|
+
|
|
35
|
+
def _ensure_pool(self):
|
|
36
|
+
if self._pool is not None:
|
|
37
|
+
return
|
|
38
|
+
with self._pool_lock:
|
|
39
|
+
if self._pool is None:
|
|
40
|
+
ssl_kwargs = {}
|
|
41
|
+
if self._config.api_key:
|
|
42
|
+
ca_path = os.path.join(
|
|
43
|
+
os.path.dirname(__file__), "supabase-ca.pem"
|
|
44
|
+
)
|
|
45
|
+
ssl_kwargs = {
|
|
46
|
+
"sslmode": "verify-ca",
|
|
47
|
+
"sslrootcert": ca_path,
|
|
48
|
+
}
|
|
49
|
+
self._pool = ThreadedConnectionPool(
|
|
50
|
+
POOL_MIN, POOL_MAX,
|
|
51
|
+
host=self._config.pg_host,
|
|
52
|
+
port=self._config.pg_port,
|
|
53
|
+
dbname=self._config.pg_db,
|
|
54
|
+
user=self._config.pg_user,
|
|
55
|
+
password=self._config.pg_password,
|
|
56
|
+
connect_timeout=5,
|
|
57
|
+
keepalives=1,
|
|
58
|
+
keepalives_idle=30,
|
|
59
|
+
keepalives_interval=10,
|
|
60
|
+
keepalives_count=5,
|
|
61
|
+
**ssl_kwargs,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@contextmanager
|
|
65
|
+
def connection(self):
|
|
66
|
+
"""Borrow a connection from the pool. Auto-returned on exit."""
|
|
67
|
+
self._ensure_pool()
|
|
68
|
+
try:
|
|
69
|
+
conn = self._pool.getconn()
|
|
70
|
+
except PoolError:
|
|
71
|
+
logger.warn("Connection pool exhausted")
|
|
72
|
+
raise
|
|
73
|
+
_force_close = False
|
|
74
|
+
try:
|
|
75
|
+
yield conn
|
|
76
|
+
except Exception:
|
|
77
|
+
try:
|
|
78
|
+
conn.rollback()
|
|
79
|
+
except Exception:
|
|
80
|
+
_force_close = True # rollback failed — connection is dead
|
|
81
|
+
raise
|
|
82
|
+
finally:
|
|
83
|
+
if conn.closed or _force_close:
|
|
84
|
+
self._pool.putconn(conn, close=True)
|
|
85
|
+
else:
|
|
86
|
+
self._pool.putconn(conn)
|
|
87
|
+
|
|
88
|
+
def fetch_all(self, sql: str, params: list[Any] | None = None) -> list[tuple]:
|
|
89
|
+
with self.connection() as conn:
|
|
90
|
+
with conn.cursor() as cur:
|
|
91
|
+
cur.execute(sql, params)
|
|
92
|
+
result = cur.fetchall()
|
|
93
|
+
conn.commit()
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
def fetch_one(self, sql: str, params: list[Any] | None = None) -> tuple | None:
|
|
97
|
+
with self.connection() as conn:
|
|
98
|
+
with conn.cursor() as cur:
|
|
99
|
+
cur.execute(sql, params)
|
|
100
|
+
result = cur.fetchone()
|
|
101
|
+
conn.commit()
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
def execute(self, sql: str, params: list[Any] | None = None):
|
|
105
|
+
with self.connection() as conn:
|
|
106
|
+
with conn.cursor() as cur:
|
|
107
|
+
cur.execute(sql, params)
|
|
108
|
+
conn.commit()
|
|
109
|
+
|
|
110
|
+
def execute_returning(self, sql: str, params: list[Any] | None = None) -> tuple | None:
|
|
111
|
+
with self.connection() as conn:
|
|
112
|
+
with conn.cursor() as cur:
|
|
113
|
+
cur.execute(sql, params)
|
|
114
|
+
row = cur.fetchone()
|
|
115
|
+
conn.commit()
|
|
116
|
+
return row
|
|
117
|
+
|
|
118
|
+
def close(self):
|
|
119
|
+
if self._pool is not None:
|
|
120
|
+
self._pool.closeall()
|
|
121
|
+
self._pool = None
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""HTTP client for embedding services.
|
|
2
|
+
|
|
3
|
+
Supports two providers:
|
|
4
|
+
- Gateway (hosted): routes through Coppermind gateway → Voyage AI (512 dims)
|
|
5
|
+
- Local (self-hosted): sentence-transformers service on localhost:8400 (384 dims)
|
|
6
|
+
|
|
7
|
+
Returns None on any failure — callers handle degraded mode.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from coppermind_cmo.config import Config
|
|
15
|
+
from coppermind_cmo.log import ToolLogger
|
|
16
|
+
|
|
17
|
+
logger = ToolLogger("embedding")
|
|
18
|
+
|
|
19
|
+
# Legacy constant for backward compatibility in tests
|
|
20
|
+
EXPECTED_DIMS = 384
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_embedding_local(text: str, base_url: str, expected_dims: int, timeout: float = 10) -> Optional[list[float]]:
|
|
24
|
+
"""Get embedding from the local sentence-transformers service."""
|
|
25
|
+
try:
|
|
26
|
+
resp = httpx.post(
|
|
27
|
+
f"{base_url.rstrip('/')}/embed",
|
|
28
|
+
json={"text": text},
|
|
29
|
+
timeout=timeout,
|
|
30
|
+
)
|
|
31
|
+
resp.raise_for_status()
|
|
32
|
+
embedding = resp.json()["embedding"]
|
|
33
|
+
|
|
34
|
+
if not isinstance(embedding, list) or len(embedding) == 0:
|
|
35
|
+
logger.warn("Invalid embedding shape: expected non-empty list")
|
|
36
|
+
return None
|
|
37
|
+
if not all(isinstance(x, (int, float)) for x in embedding):
|
|
38
|
+
logger.warn("Invalid embedding: contains non-numeric elements")
|
|
39
|
+
return None
|
|
40
|
+
if len(embedding) != expected_dims:
|
|
41
|
+
logger.warn(f"Embedding dimension mismatch: got {len(embedding)}, expected {expected_dims}")
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
return embedding
|
|
45
|
+
except Exception as e:
|
|
46
|
+
logger.warn(f"Embedding request failed: {e}")
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_embedding_gateway(text: str, config: Config, timeout: float = 10) -> Optional[list[float]]:
|
|
51
|
+
"""Get embedding from the Coppermind gateway → Voyage AI."""
|
|
52
|
+
try:
|
|
53
|
+
resp = httpx.post(
|
|
54
|
+
f"{config.gateway_url.rstrip('/')}/v1/embed",
|
|
55
|
+
json={"text": text},
|
|
56
|
+
headers={"Authorization": f"Bearer {config.api_key}"},
|
|
57
|
+
timeout=timeout,
|
|
58
|
+
)
|
|
59
|
+
resp.raise_for_status()
|
|
60
|
+
data = resp.json()
|
|
61
|
+
embedding = data["embedding"]
|
|
62
|
+
|
|
63
|
+
if not isinstance(embedding, list) or len(embedding) == 0:
|
|
64
|
+
logger.warn("Invalid gateway embedding shape: expected non-empty list")
|
|
65
|
+
return None
|
|
66
|
+
if not all(isinstance(x, (int, float)) for x in embedding):
|
|
67
|
+
logger.warn("Invalid gateway embedding: contains non-numeric elements")
|
|
68
|
+
return None
|
|
69
|
+
expected = data.get("dimensions", config.embedding_dims)
|
|
70
|
+
if len(embedding) != expected:
|
|
71
|
+
logger.warn(f"Gateway embedding dimension mismatch: got {len(embedding)}, expected {expected}")
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
return embedding
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.warn(f"Gateway embedding request failed: {e}")
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_embedding(text: str, config: Config, timeout: float = 10) -> Optional[list[float]]:
|
|
81
|
+
"""Get embedding vector for text. Routes to gateway or local based on config."""
|
|
82
|
+
if config.embedding_provider == "gateway":
|
|
83
|
+
return _get_embedding_gateway(text, config, timeout)
|
|
84
|
+
elif config.embedding_provider == "local":
|
|
85
|
+
return _get_embedding_local(text, config.embedding_url, config.embedding_dims, timeout)
|
|
86
|
+
else:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def check_health(config: Config, timeout: float = 5) -> bool:
|
|
91
|
+
"""Check if the embedding service is healthy."""
|
|
92
|
+
if config.embedding_provider == "gateway":
|
|
93
|
+
try:
|
|
94
|
+
resp = httpx.get(
|
|
95
|
+
f"{config.gateway_url.rstrip('/')}/v1/health",
|
|
96
|
+
headers={"Authorization": f"Bearer {config.api_key}"},
|
|
97
|
+
timeout=timeout,
|
|
98
|
+
)
|
|
99
|
+
if resp.status_code != 200:
|
|
100
|
+
return False
|
|
101
|
+
return True
|
|
102
|
+
except Exception:
|
|
103
|
+
return False
|
|
104
|
+
elif config.embedding_provider == "local":
|
|
105
|
+
try:
|
|
106
|
+
resp = httpx.get(f"{config.embedding_url.rstrip('/')}/health", timeout=timeout)
|
|
107
|
+
if resp.status_code != 200:
|
|
108
|
+
return False
|
|
109
|
+
data = resp.json()
|
|
110
|
+
reported_dims = data.get("dimensions")
|
|
111
|
+
if reported_dims is not None and reported_dims != config.embedding_dims:
|
|
112
|
+
logger.warn(
|
|
113
|
+
f"Embedding service dimension mismatch: reports {reported_dims}, expected {config.embedding_dims}"
|
|
114
|
+
)
|
|
115
|
+
return False
|
|
116
|
+
return True
|
|
117
|
+
except Exception:
|
|
118
|
+
return False
|
|
119
|
+
else:
|
|
120
|
+
return False
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Standalone embedding HTTP server using sentence-transformers.
|
|
2
|
+
|
|
3
|
+
Run: python -m coppermind_cmo.embedding_server
|
|
4
|
+
Serves on port 8400 by default.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import logging
|
|
9
|
+
import threading
|
|
10
|
+
|
|
11
|
+
from flask import Flask, request, jsonify
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
app = Flask(__name__)
|
|
15
|
+
|
|
16
|
+
_model = None
|
|
17
|
+
_model_lock = threading.Lock()
|
|
18
|
+
MODEL_NAME = "all-MiniLM-L6-v2"
|
|
19
|
+
DIMENSIONS = 384
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_model():
|
|
23
|
+
global _model
|
|
24
|
+
if _model is not None:
|
|
25
|
+
return _model
|
|
26
|
+
with _model_lock:
|
|
27
|
+
if _model is None:
|
|
28
|
+
from sentence_transformers import SentenceTransformer
|
|
29
|
+
_model = SentenceTransformer(MODEL_NAME)
|
|
30
|
+
logger.info("Loaded embedding model: %s", MODEL_NAME)
|
|
31
|
+
return _model
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.route("/health")
|
|
35
|
+
def health():
|
|
36
|
+
try:
|
|
37
|
+
_get_model()
|
|
38
|
+
return jsonify({"status": "ok", "model": MODEL_NAME, "dimensions": DIMENSIONS})
|
|
39
|
+
except Exception as e:
|
|
40
|
+
logger.error("Model load failed: %s", e)
|
|
41
|
+
return jsonify({"status": "error", "message": "Embedding model unavailable"}), 503
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.route("/embed", methods=["POST"])
|
|
45
|
+
def embed():
|
|
46
|
+
data = request.get_json()
|
|
47
|
+
if not data or "text" not in data:
|
|
48
|
+
return jsonify({"error": "Missing 'text' field"}), 400
|
|
49
|
+
|
|
50
|
+
text = data["text"]
|
|
51
|
+
if not isinstance(text, str) or not text.strip():
|
|
52
|
+
return jsonify({"error": "'text' must be a non-empty string"}), 400
|
|
53
|
+
if len(text) > 50000:
|
|
54
|
+
return jsonify({"error": "Text exceeds 50,000 character limit"}), 400
|
|
55
|
+
|
|
56
|
+
model = _get_model()
|
|
57
|
+
embedding = model.encode(text, convert_to_numpy=True).tolist()
|
|
58
|
+
return jsonify({"embedding": embedding})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main():
|
|
62
|
+
port = int(os.environ.get("COPPERMIND_EMBEDDING_PORT", "8400"))
|
|
63
|
+
logger.info("Starting embedding server on port %d", port)
|
|
64
|
+
_get_model() # Pre-load model
|
|
65
|
+
from waitress import serve
|
|
66
|
+
serve(app, host="127.0.0.1", port=port, threads=4)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
logging.basicConfig(level=logging.INFO)
|
|
71
|
+
main()
|
coppermind_cmo/errors.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Error response helpers per SERVER_SPEC.md error format."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def tool_error(code: str, message: str) -> dict[str, Any]:
|
|
7
|
+
return {"error": True, "code": code, "message": message}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def NO_ACTIVE_MIND() -> dict[str, Any]:
|
|
11
|
+
return tool_error("NO_ACTIVE_MIND", "No active client. Call switch_client first.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def MIND_NOT_FOUND() -> dict[str, Any]:
|
|
15
|
+
return tool_error("MIND_NOT_FOUND", "Mind not found. Use list_minds to see available clients.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def MIND_ARCHIVED(name: str) -> dict[str, Any]:
|
|
19
|
+
return tool_error("MIND_ARCHIVED", f"Mind '{name}' is archived. Unarchive via direct DB access.")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def INVALID_INPUT(message: str) -> dict[str, Any]:
|
|
23
|
+
return tool_error("INVALID_INPUT", message)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def INVALID_MEMORY_TYPE(value: str) -> dict[str, Any]:
|
|
27
|
+
valid = "decision, preference, campaign_outcome, commitment, stakeholder, fact"
|
|
28
|
+
return tool_error("INVALID_MEMORY_TYPE", f"Invalid memory_type '{value}'. Valid types: {valid}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def INVALID_BRAND_DNA(message: str) -> dict[str, Any]:
|
|
32
|
+
return tool_error("INVALID_BRAND_DNA", message)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def SERVICE_UNAVAILABLE(service: str) -> dict[str, Any]:
|
|
36
|
+
return tool_error("SERVICE_UNAVAILABLE", f"{service} is unreachable.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def MEMORY_NOT_FOUND(memory_id: str) -> dict[str, Any]:
|
|
40
|
+
return tool_error("MEMORY_NOT_FOUND", f"Memory '{memory_id}' not found or belongs to a different mind.")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def INVALID_QUERY() -> dict[str, Any]:
|
|
44
|
+
return tool_error("INVALID_QUERY", "Query cannot be empty.")
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""LLM client for classification and summarization.
|
|
2
|
+
|
|
3
|
+
Supports two providers:
|
|
4
|
+
- Gateway (hosted): routes through Coppermind gateway → Claude API
|
|
5
|
+
- Ollama (self-hosted): direct Ollama HTTP API, 30s timeout, temperature=0
|
|
6
|
+
|
|
7
|
+
The dispatcher function call_llm() routes to the correct provider based on config.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import urllib.request
|
|
13
|
+
import urllib.error
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from coppermind_cmo.config import Config
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
VALID_MEMORY_TYPES = {
|
|
23
|
+
"decision", "preference", "campaign_outcome",
|
|
24
|
+
"commitment", "stakeholder", "fact",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
CLASSIFY_PROMPT = (
|
|
28
|
+
"Classify this client memory into exactly one type.\n\n"
|
|
29
|
+
"Types:\n"
|
|
30
|
+
'- decision: A choice that was made ("We\'re pausing LinkedIn ads")\n'
|
|
31
|
+
'- preference: A stated or implied preference ("Prefers casual brand voice")\n'
|
|
32
|
+
'- campaign_outcome: Results or metrics from a campaign ("Email open rate was 34%")\n'
|
|
33
|
+
'- commitment: An action item someone committed to ("Ben will send messaging by Friday")\n'
|
|
34
|
+
'- stakeholder: Information about a key person ("Sarah Chen is VP Marketing")\n'
|
|
35
|
+
"- fact: Any other noteworthy fact about the client\n\n"
|
|
36
|
+
"Examples:\n"
|
|
37
|
+
'Memory: "Pausing LinkedIn ads through Q2, reallocating to content marketing." \u2192 decision\n'
|
|
38
|
+
'Memory: "Sarah prefers async updates via Slack." \u2192 preference\n'
|
|
39
|
+
'Memory: "Q1 email: 34% open rate, curiosity gap won." \u2192 campaign_outcome\n'
|
|
40
|
+
'Memory: "Ben will send revised messaging by Friday." \u2192 commitment\n'
|
|
41
|
+
'Memory: "James Park is the new CEO, started January 2026." \u2192 stakeholder\n'
|
|
42
|
+
'Memory: "Series B, 45 employees, developer-first API platform." \u2192 fact\n\n'
|
|
43
|
+
"Return ONLY the type name, nothing else.\n\n"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
SUMMARIZE_PROMPT = (
|
|
47
|
+
"Summarize this client memory in one sentence (max 100 chars). "
|
|
48
|
+
"Return ONLY the summary.\n\n"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Ollama provider (self-hosted)
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def call_ollama(
|
|
57
|
+
prompt: str,
|
|
58
|
+
base_url: str,
|
|
59
|
+
model: str,
|
|
60
|
+
timeout: int = 30,
|
|
61
|
+
) -> Optional[str]:
|
|
62
|
+
"""Call Ollama API. Returns response text or None on failure."""
|
|
63
|
+
url = f"{base_url.rstrip('/')}/api/generate"
|
|
64
|
+
payload = json.dumps({
|
|
65
|
+
"model": model,
|
|
66
|
+
"prompt": prompt,
|
|
67
|
+
"stream": False,
|
|
68
|
+
"options": {"temperature": 0},
|
|
69
|
+
}).encode("utf-8")
|
|
70
|
+
|
|
71
|
+
req = urllib.request.Request(
|
|
72
|
+
url, data=payload,
|
|
73
|
+
headers={"Content-Type": "application/json"},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
78
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
79
|
+
return data.get("response", "")
|
|
80
|
+
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as e:
|
|
81
|
+
logger.warning("Ollama call failed: %s", e)
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Gateway provider (hosted)
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def call_gateway_llm(
|
|
90
|
+
endpoint: str,
|
|
91
|
+
body: dict,
|
|
92
|
+
config: Config,
|
|
93
|
+
timeout: float = 30,
|
|
94
|
+
) -> Optional[dict]:
|
|
95
|
+
"""Call a gateway LLM endpoint. Returns parsed JSON response or None on failure."""
|
|
96
|
+
url = f"{config.gateway_url.rstrip('/')}/v1/{endpoint}"
|
|
97
|
+
try:
|
|
98
|
+
resp = httpx.post(
|
|
99
|
+
url,
|
|
100
|
+
json=body,
|
|
101
|
+
headers={"Authorization": f"Bearer {config.api_key}"},
|
|
102
|
+
timeout=timeout,
|
|
103
|
+
)
|
|
104
|
+
resp.raise_for_status()
|
|
105
|
+
return resp.json()
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.warning("Gateway LLM call failed (%s): %s", endpoint, e)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Dispatcher
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def call_llm(
|
|
116
|
+
prompt: str,
|
|
117
|
+
config: Config,
|
|
118
|
+
use_synthesis_model: bool = False,
|
|
119
|
+
) -> Optional[str]:
|
|
120
|
+
"""Route LLM call to gateway or Ollama based on config.
|
|
121
|
+
|
|
122
|
+
Returns response text or None on failure.
|
|
123
|
+
"""
|
|
124
|
+
if config.llm_provider == "gateway":
|
|
125
|
+
if use_synthesis_model:
|
|
126
|
+
result = call_gateway_llm("synthesize", {"prompt": prompt}, config)
|
|
127
|
+
if result is not None:
|
|
128
|
+
return result.get("text")
|
|
129
|
+
else:
|
|
130
|
+
# For classify/summarize, we send the raw prompt and let the gateway handle it
|
|
131
|
+
result = call_gateway_llm("synthesize", {"prompt": prompt}, config)
|
|
132
|
+
if result is not None:
|
|
133
|
+
return result.get("text")
|
|
134
|
+
return None
|
|
135
|
+
elif config.llm_provider == "ollama":
|
|
136
|
+
return call_ollama(prompt, config.ollama_url, config.ollama_model)
|
|
137
|
+
else:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# High-level functions
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def classify_memory_type(content: str, config: Config) -> tuple[str, str]:
|
|
146
|
+
"""Classify content into a memory type.
|
|
147
|
+
|
|
148
|
+
Returns (memory_type, classified_by) where classified_by is
|
|
149
|
+
'llm' or 'fallback'.
|
|
150
|
+
"""
|
|
151
|
+
if config.llm_provider == "gateway":
|
|
152
|
+
result = call_gateway_llm("classify", {"content": content}, config)
|
|
153
|
+
if result is None:
|
|
154
|
+
return ("fact", "fallback")
|
|
155
|
+
memory_type = result.get("memory_type", "fact")
|
|
156
|
+
cleaned = memory_type.strip().lower().rstrip(".,;:!?").replace(" ", "_")
|
|
157
|
+
if cleaned in VALID_MEMORY_TYPES:
|
|
158
|
+
return (cleaned, "llm")
|
|
159
|
+
return ("fact", "llm")
|
|
160
|
+
|
|
161
|
+
# Ollama or no provider
|
|
162
|
+
prompt = CLASSIFY_PROMPT + f'Memory: "{content}"\nType:'
|
|
163
|
+
|
|
164
|
+
if config.llm_provider == "ollama":
|
|
165
|
+
response = call_ollama(prompt, config.ollama_url, config.ollama_model)
|
|
166
|
+
else:
|
|
167
|
+
response = None
|
|
168
|
+
|
|
169
|
+
if response is None:
|
|
170
|
+
return ("fact", "fallback")
|
|
171
|
+
|
|
172
|
+
cleaned = response.strip().lower().rstrip(".,;:!?").replace(" ", "_")
|
|
173
|
+
if cleaned in VALID_MEMORY_TYPES:
|
|
174
|
+
return (cleaned, "llm")
|
|
175
|
+
return ("fact", "llm")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def summarize(content: str, config: Config) -> str:
|
|
179
|
+
"""Generate a one-line summary. Falls back to truncation if LLM is down."""
|
|
180
|
+
if config.llm_provider == "gateway":
|
|
181
|
+
result = call_gateway_llm("summarize", {"content": content}, config)
|
|
182
|
+
if result is not None:
|
|
183
|
+
summary = result.get("summary", "")
|
|
184
|
+
return summary.strip()[:200]
|
|
185
|
+
return content[:100]
|
|
186
|
+
|
|
187
|
+
# Ollama or no provider
|
|
188
|
+
prompt = SUMMARIZE_PROMPT + f'Memory: "{content}"\nSummary:'
|
|
189
|
+
|
|
190
|
+
if config.llm_provider == "ollama":
|
|
191
|
+
response = call_ollama(prompt, config.ollama_url, config.ollama_model)
|
|
192
|
+
else:
|
|
193
|
+
response = None
|
|
194
|
+
|
|
195
|
+
if response is not None:
|
|
196
|
+
return response.strip()[:200]
|
|
197
|
+
return content[:100]
|