File size: 12,924 Bytes
515a3fb
c61e3b2
 
515a3fb
 
 
 
10ce33e
 
515a3fb
4d5cbc7
515a3fb
 
 
 
 
4d5cbc7
c0dd1c5
b1f9886
515a3fb
 
 
 
 
 
 
 
 
 
 
 
4d5cbc7
c0dd1c5
b1f9886
d3974e0
4d5cbc7
d3974e0
 
10ce33e
 
 
 
b1f9886
 
 
4d5cbc7
c0dd1c5
b1f9886
 
bd719d5
 
 
 
 
 
b1f9886
bd719d5
 
 
 
 
 
 
 
 
b1f9886
 
 
 
 
 
 
10ce33e
 
515a3fb
10ce33e
bd719d5
 
 
 
 
 
515a3fb
 
4d5cbc7
c0dd1c5
b1f9886
515a3fb
bd719d5
 
 
 
 
 
10ce33e
bd719d5
515a3fb
 
c61e3b2
 
 
 
 
 
 
 
 
515a3fb
4d5cbc7
c61e3b2
 
 
 
4d5cbc7
c61e3b2
 
 
 
 
 
515a3fb
 
c61e3b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515a3fb
 
c61e3b2
 
 
 
 
 
 
 
 
 
 
10ce33e
515a3fb
c61e3b2
 
 
10ce33e
 
515a3fb
c61e3b2
 
 
 
 
 
 
 
 
10ce33e
c61e3b2
515a3fb
 
4d5cbc7
c61e3b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d5cbc7
c61e3b2
 
 
 
 
 
 
 
 
10ce33e
c61e3b2
4d5cbc7
 
 
 
c61e3b2
4d5cbc7
10ce33e
4d5cbc7
c61e3b2
10ce33e
c61e3b2
 
 
 
 
bd719d5
 
 
c61e3b2
bd719d5
 
 
c61e3b2
 
 
 
 
 
 
 
 
 
4d5cbc7
72bd44e
 
 
 
 
 
 
 
 
 
 
 
 
 
0352dbc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72bd44e
 
c61e3b2
 
72bd44e
c61e3b2
72bd44e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515a3fb
 
 
4d5cbc7
b1f9886
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302

import time

from pymongo import MongoClient
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance
from .config import settings
from ..core.logger import logger


# MongoDB
class MongoDB:
    client: MongoClient = None
    db = None
    users_col = None
    prompts_col = None
    saved_prompts_col = None
    feedback_col = None
    analytics_col = None
    
    @classmethod
    def connect(cls):
        try:
            cls.client = MongoClient(
                settings.MONGO_URI or "mongodb://localhost:27017",
                serverSelectionTimeoutMS=3000,
            )
            cls.client.admin.command("ping")
            cls.db = cls.client["prompt_engine_db"]
            cls.users_col = cls.db["users"]
            cls.prompts_col = cls.db["prompt_logs"]
            cls.saved_prompts_col = cls.db["saved_prompts"]
            cls.feedback_col = cls.db["user_feedback"]
            cls.analytics_col = cls.db["analytics_events"]

            # Indexes
            cls.users_col.create_index("user_id", unique=True)
            cls.prompts_col.create_index([("user_id", 1), ("timestamp", -1)])
            # Acceptance resolves an opaque log ID inside one user's history.
            # Keep this non-unique for compatibility with older log documents
            # that have no log_id at all.
            cls.prompts_col.create_index([("user_id", 1), ("log_id", 1)])
            # The private dashboard queries all users inside a time window, so
            # the existing user_id-first index cannot serve it efficiently.
            cls.prompts_col.create_index([("timestamp", -1)])
            cls.saved_prompts_col.create_index("user_id")
            cls.feedback_col.create_index([("user_id", 1), ("timestamp", -1)])
            cls.analytics_col.create_index([("timestamp", -1)])
            cls.analytics_col.create_index([("event", 1), ("timestamp", -1)])

            # get_user_feedback_summary() reads db["prompt_feedback"], which is a
            # DIFFERENT collection from feedback_col (db["user_feedback"]) above.
            # It was never indexed, so every /enhance ran an unindexed scan plus
            # an in-memory sort against it.
            cls.db["prompt_feedback"].create_index([("user_id", 1), ("timestamp", -1)])
            cls.db["prompt_feedback"].create_index([("timestamp", -1)])

            # Retention. Atlas M0 is 512 MB and nothing ever deleted a prompt
            # log, so the cluster filled and then failed writes silently.
            # expireAfterSeconds on a date field lets Mongo do the pruning.
            if settings.PROMPT_LOG_TTL_DAYS > 0:
                _ttl = settings.PROMPT_LOG_TTL_DAYS * 86400
                cls._ensure_ttl(cls.prompts_col, "timestamp", _ttl)
                cls._ensure_ttl(cls.db["prompt_feedback"], "timestamp", _ttl)

            if settings.ANALYTICS_EVENT_TTL_DAYS > 0:
                cls._ensure_ttl(
                    cls.analytics_col,
                    "timestamp",
                    settings.ANALYTICS_EVENT_TTL_DAYS * 86400,
                )

            logger.info("✅ MongoDB Indexes Verified")
            logger.info("✅ MongoDB Connected")
        except Exception as e:
            logger.warning(f"⚠️ MongoDB not available ({e}) — using in-memory fallback.")
            # db and client must be cleared too. Leaving them set produced a
            # split brain: feedback writes (which test `MongoDB.db is not None`)
            # kept targeting a dead client while everything else fell back to
            # RAM, so the two halves of a request disagreed about reality.
            cls.client = None
            cls.db = None
            cls.users_col = None
            cls.prompts_col = None
            cls.saved_prompts_col = None
            cls.feedback_col = None
            cls.analytics_col = None

    @staticmethod
    def _ensure_ttl(col, field: str, seconds: int):
        """Create a TTL index, tolerating one that already exists with a different span."""
        try:
            col.create_index(field, expireAfterSeconds=seconds)
        except Exception as e:
            logger.warning(f"⚠️ TTL index on {col.name}.{field} skipped: {e}")

# Qdrant
class QdrantDB:
    """
    Vector store access, with the failure modes made survivable.

    Every one of the behaviours below caused a real, months-long outage that was
    invisible from outside: the saved-prompt library appeared to save correctly
    and silently never matched anything, because a dead cluster and an empty
    result set were indistinguishable at every layer.
    """

    client: QdrantClient = None
    _collections_ready = False
    _last_error: str = None
    _last_attempt: float = 0.0
    _connected_at: float = None

    SAVED_COLLECTION = "saved_prompt_vectors"

    # Must match the embedding model's output width. get_embedding() uses
    # paraphrase-multilingual-MiniLM-L12-v2, which is 384-dimensional; a
    # collection built for a different width rejects every upsert.
    VECTOR_SIZE = 384

    @classmethod
    def get_client(cls):
        """
        A client that has actually talked to the cluster, or None.

        Previously this returned any successfully *constructed* client.
        QdrantClient does not dial on construction, so a cluster that was
        deleted, suspended or unreachable still produced a client object and a
        "Connected" log line, and every subsequent operation failed into an
        exception handler that returned an empty list.
        """
        if cls.client is not None and cls._collections_ready:
            return cls.client

        # Back off between attempts: retrying on every request hammers a
        # struggling cluster, never retrying leaves a transient blip permanent.
        now = time.monotonic()
        if cls.client is None and cls._last_attempt and \
                (now - cls._last_attempt) < settings.QDRANT_RETRY_SECONDS:
            return None
        cls._last_attempt = now

        if cls.client is None:
            try:
                candidate = QdrantClient(
                    url=settings.QDRANT_URL,
                    api_key=settings.QDRANT_API_KEY,
                    timeout=settings.QDRANT_TIMEOUT,
                )
                # The connectivity check that was missing. Cheap, and the only
                # thing that distinguishes a live cluster from a dead one.
                candidate.get_collections()
                cls.client = candidate
                cls._last_error = None
                cls._connected_at = time.time()
                logger.info(f"✅ Qdrant connected ({cls._host_only()})")
            except Exception as e:
                cls.client = None
                cls._collections_ready = False
                cls._last_error = f"{type(e).__name__}: {e}"[:200]
                logger.error(f"❌ Qdrant unreachable ({cls._host_only()}): {cls._last_error}")
                logger.info("   Saved-prompt search and passive memory are disabled until it recovers.")
                return None

        if not cls._collections_ready:
            # Only latch when provisioning actually succeeded. This used to be
            # set unconditionally, so one failed startup left the collections
            # uncreated for the life of the process.
            results = [cls._ensure_collection(settings.COLLECTION_NAME),
                       cls._ensure_collection(cls.SAVED_COLLECTION)]
            cls._collections_ready = all(results)
            if not cls._collections_ready:
                logger.warning("⚠️ Qdrant collections not ready — will retry on the next call.")

        return cls.client

    @classmethod
    def _host_only(cls) -> str:
        """Host without credentials, for logs."""
        raw = settings.QDRANT_URL or ""
        return raw.split("://")[-1].split("/")[0] or raw

    @classmethod
    def reset(cls):
        """Drop the cached client so the next call reconnects."""
        cls.client = None
        cls._collections_ready = False
        cls._last_attempt = 0.0

    @classmethod
    def _ensure_collection(cls, name: str) -> bool:
        """
        Make sure `name` exists with the right geometry. Returns success.

        Returned None before, and its caller ignored it either way.
        """
        try:
            info = cls.client.get_collection(name)
            size = cls._configured_size(info)
            if size is not None and size != cls.VECTOR_SIZE:
                # Loud on purpose: every upsert would be rejected, and the
                # symptom is once again "search returns nothing".
                cls._last_error = (
                    f"collection '{name}' is {size}-dim, embeddings are "
                    f"{cls.VECTOR_SIZE}-dim"
                )
                logger.error(f"❌ {cls._last_error}. Recreate the collection or change the model.")
                return False
        except Exception:
            try:
                cls.client.create_collection(
                    collection_name=name,
                    vectors_config=VectorParams(size=cls.VECTOR_SIZE, distance=Distance.COSINE),
                )
                logger.info(f"✅ Created Qdrant collection '{name}'")
            except Exception as e:
                cls._last_error = f"create '{name}' failed: {e}"[:200]
                logger.warning(f"⚠️ {cls._last_error}")
                return False

        # Filtering is by user_id on every search and by mongo_id on delete.
        # Unindexed payload filters still work but scan, so this is performance,
        # not correctness — a failure here should not fail provisioning.
        for field in ("user_id", "mongo_id"):
            try:
                cls.client.create_payload_index(
                    collection_name=name, field_name=field, field_schema="keyword",
                )
            except Exception:
                pass
        return True

    @staticmethod
    def _configured_size(info):
        """Vector width from a collection description, across client versions."""
        try:
            vectors = info.config.params.vectors
            return getattr(vectors, "size", None) if not isinstance(vectors, dict) else None
        except Exception:
            return None

    @classmethod
    def health(cls) -> dict:
        """
        Whether the vector store is actually usable, and what is in it.

        Saved-prompt search failed in production with no way to tell why from
        outside. The embedding model turned out to be fine, which left Qdrant —
        but "is it connected, do the collections exist, is anything in them"
        was unanswerable without shell access to the Space. Point counts are
        the decisive signal: an empty collection means writes never landed, a
        populated one means the query or its filter is at fault.

        Deliberately reports no URL or key.
        """
        # Structural facts about the configured URL, never the URL itself: a
        # missing :6333 port is the most common cause of "connection reset by
        # peer" against Qdrant Cloud, and it is indistinguishable from a dead
        # cluster without knowing which of the two you are looking at.
        raw = (settings.QDRANT_URL or "").strip()
        host_part = raw.split("://")[-1]
        out = {
            "connected": False,
            "collections": {},
            "error": None,
            "config": {
                "configured": bool(raw) and raw != ":memory:",
                "in_memory": raw == ":memory:",
                "scheme": raw.split("://")[0] if "://" in raw else None,
                "has_port": ":" in host_part.split("/")[0],
                "looks_like_cloud": "cloud.qdrant.io" in raw,
                "api_key_set": bool(settings.QDRANT_API_KEY),
            },
        }
        try:
            client = cls.get_client()
            out["last_error"] = cls._last_error
            out["collections_ready"] = cls._collections_ready
            if client is None:
                out["error"] = cls._last_error or "client unavailable"
                return out
            out["connected"] = True
            for name in (settings.COLLECTION_NAME, cls.SAVED_COLLECTION):
                try:
                    info = client.get_collection(name)
                    out["collections"][name] = {
                        "exists": True,
                        "points": getattr(info, "points_count", None),
                    }
                except Exception as e:
                    out["collections"][name] = {"exists": False, "error": str(e)[:120]}
        except Exception as e:
            out["error"] = str(e)[:160]
        return out


# In-Memory Fallbacks
in_memory_users = {}
in_memory_prompt_logs = []
in_memory_saved_prompts = {}  # {prompt_id: {doc}}
in_memory_analytics_events = []