know-do-graph 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.
Files changed (63) hide show
  1. agents/__init__.py +0 -0
  2. agents/extraction_agent/__init__.py +0 -0
  3. agents/extraction_agent/agent.py +170 -0
  4. agents/graph_agent/__init__.py +5 -0
  5. agents/graph_agent/agent.py +373 -0
  6. agents/graph_agent/tools.py +2106 -0
  7. agents/maintenance_agent/__init__.py +0 -0
  8. agents/maintenance_agent/agent.py +283 -0
  9. agents/orchestrator/__init__.py +0 -0
  10. agents/orchestrator/agent.py +217 -0
  11. agents/review_agent/__init__.py +0 -0
  12. agents/review_agent/agent.py +188 -0
  13. agents/review_agent/tools.py +472 -0
  14. api/__init__.py +0 -0
  15. api/main.py +136 -0
  16. api/routes/__init__.py +0 -0
  17. api/routes/agent.py +81 -0
  18. api/routes/entries.py +411 -0
  19. api/routes/graph.py +132 -0
  20. api/routes/mem.py +179 -0
  21. api/routes/remote.py +815 -0
  22. api/routes/remote_sync.py +230 -0
  23. api/routes/retrieve.py +88 -0
  24. core/__init__.py +0 -0
  25. core/app_state.py +9 -0
  26. core/events.py +84 -0
  27. core/extraction/__init__.py +0 -0
  28. core/extraction/wikilink_parser.py +48 -0
  29. core/graph/__init__.py +0 -0
  30. core/graph/graph.py +204 -0
  31. core/memory/__init__.py +0 -0
  32. core/memory/memgraph.py +458 -0
  33. core/resources/starter.db +0 -0
  34. core/retrieval/__init__.py +0 -0
  35. core/retrieval/embedder.py +122 -0
  36. core/retrieval/fusion.py +52 -0
  37. core/retrieval/progressive.py +399 -0
  38. core/retrieval/retrieval.py +346 -0
  39. core/retrieval/vector_store.py +91 -0
  40. core/schemas/__init__.py +0 -0
  41. core/schemas/edge.py +46 -0
  42. core/schemas/entry.py +388 -0
  43. core/storage/__init__.py +0 -0
  44. core/storage/database.py +104 -0
  45. core/storage/models.py +66 -0
  46. core/storage/repository.py +243 -0
  47. core/sync/__init__.py +20 -0
  48. core/sync/autolink.py +301 -0
  49. core/sync/db_merge.py +297 -0
  50. core/sync/db_watcher.py +84 -0
  51. core/sync/remote_sync.py +345 -0
  52. examples/__init__.py +0 -0
  53. examples/example_entries.py +206 -0
  54. examples/pymatgen_interface_examples.py +811 -0
  55. frontend/dist/assets/index-BLfo7ZZu.css +1 -0
  56. frontend/dist/assets/index-G-mYbZ9R.js +83 -0
  57. frontend/dist/assets/index-G-mYbZ9R.js.map +1 -0
  58. frontend/dist/index.html +92 -0
  59. know_do_graph-0.1.0.dist-info/METADATA +765 -0
  60. know_do_graph-0.1.0.dist-info/RECORD +63 -0
  61. know_do_graph-0.1.0.dist-info/WHEEL +4 -0
  62. know_do_graph-0.1.0.dist-info/entry_points.txt +2 -0
  63. main.py +944 -0
@@ -0,0 +1,458 @@
1
+ """Mem-Graph: lightweight, mutable session memory traces.
2
+
3
+ Mem-Graph entries are shallow, episodic notes captured during agent
4
+ interactions. Over time, stable patterns may be promoted into full
5
+ Know-Do Graph entries.
6
+
7
+ Storage is flat JSON files per session, kept under data/memory/.
8
+
9
+ Connecting external agent frameworks
10
+ --------------------------------------
11
+ Any agent framework can write to MemGraph as long as it can produce one
12
+ of the following shapes. Use the matching ``ingest_*`` helper, or call
13
+ ``add()`` directly with a plain string.
14
+
15
+ **1. Plain text / single observation**
16
+
17
+ mg = MemGraph("my-session")
18
+ mg.add("user asked about MACE relaxation; answered with [[ASE Relaxation]]",
19
+ tags=["qa"], success=True)
20
+
21
+ **2. OpenAI-style chat messages** – a list of ``{"role": ..., "content": ...}`` dicts.
22
+ Roles are concatenated into a readable transcript.
23
+
24
+ mg.ingest_openai_messages(openai_response["messages"], tags=["openai"])
25
+
26
+ **3. LangChain / generic message objects** – any object with ``.content`` and
27
+ optionally ``.type`` attributes (HumanMessage, AIMessage, SystemMessage …).
28
+
29
+ mg.ingest_langchain_messages(chain.memory.chat_memory.messages)
30
+
31
+ **4. AutoGen / multi-agent conversation list** – list of dicts with at minimum
32
+ ``"name"`` (or ``"role"``) and ``"content"`` keys.
33
+
34
+ mg.ingest_autogen_messages(groupchat.messages)
35
+
36
+ **5. Raw JSON file** – a path to a file containing one of the above schemas:
37
+ - a JSON array → treated as a message list (OpenAI / AutoGen format)
38
+ - a JSON object → its ``messages`` or ``history`` key is extracted; otherwise
39
+ the whole object is serialised as a single trace
40
+
41
+ mg.ingest_file(Path("session_dump.json"))
42
+
43
+ **6. Raw text file** – split into chunks or stored as one entry.
44
+
45
+ mg.ingest_text_file(Path("agent_log.txt"), chunk_by="paragraph")
46
+
47
+ The resulting MemEntry objects are identical regardless of source and can all
48
+ be listed, queried, and promoted into full Know-Do Graph entries.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import json
54
+ import uuid
55
+ from datetime import datetime, timezone
56
+ from enum import Enum
57
+ from pathlib import Path
58
+ from typing import Any, Optional
59
+
60
+ from pydantic import BaseModel, Field
61
+
62
+ _MEMORY_DIR = Path(__file__).parent.parent.parent / "data" / "memory"
63
+ _MEMORY_DIR.mkdir(parents=True, exist_ok=True)
64
+
65
+
66
+ class MemSourceFormat(str, Enum):
67
+ """Describes where / how a MemEntry was ingested."""
68
+ manual = "manual"
69
+ openai_messages = "openai_messages"
70
+ langchain_messages = "langchain_messages"
71
+ autogen_messages = "autogen_messages"
72
+ raw_text = "raw_text"
73
+ json_file = "json_file"
74
+ text_file = "text_file"
75
+ api = "api"
76
+
77
+
78
+ class MemEntry(BaseModel):
79
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
80
+ session_id: str = "default"
81
+ content: str
82
+ tags: list[str] = Field(default_factory=list)
83
+ source_entry_ids: list[str] = Field(default_factory=list)
84
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
85
+ success: Optional[bool] = None
86
+ promoted: bool = False
87
+ promotion_target_id: Optional[str] = None
88
+ source_format: MemSourceFormat = MemSourceFormat.manual
89
+ raw_source: Optional[dict] = None
90
+
91
+
92
+ class MemGraph:
93
+ """Session-scoped memory graph persisted as a JSON file.
94
+
95
+ Parameters
96
+ ----------
97
+ session_id:
98
+ Logical name for the session. Determines the storage filename
99
+ (``data/memory/<session_id>.json``). Use a stable identifier when
100
+ you want to resume or extend a session across process restarts.
101
+ """
102
+
103
+ def __init__(self, session_id: str = "default") -> None:
104
+ self.session_id = session_id
105
+ self._entries: dict[str, MemEntry] = {}
106
+ self._load()
107
+
108
+ @property
109
+ def _path(self) -> Path:
110
+ return _MEMORY_DIR / f"{self.session_id}.json"
111
+
112
+ def _load(self) -> None:
113
+ if self._path.exists():
114
+ raw = json.loads(self._path.read_text(encoding="utf-8"))
115
+ self._entries = {k: MemEntry(**v) for k, v in raw.items()}
116
+
117
+ def _save(self) -> None:
118
+ self._path.write_text(
119
+ json.dumps(
120
+ {k: v.model_dump(mode="json") for k, v in self._entries.items()},
121
+ indent=2,
122
+ default=str,
123
+ ),
124
+ encoding="utf-8",
125
+ )
126
+
127
+ def _make_entry(
128
+ self,
129
+ content: str,
130
+ tags: Optional[list[str]] = None,
131
+ source_entry_ids: Optional[list[str]] = None,
132
+ success: Optional[bool] = None,
133
+ source_format: MemSourceFormat = MemSourceFormat.manual,
134
+ raw_source: Optional[dict] = None,
135
+ ) -> MemEntry:
136
+ entry = MemEntry(
137
+ session_id=self.session_id,
138
+ content=content,
139
+ tags=tags or [],
140
+ source_entry_ids=source_entry_ids or [],
141
+ success=success,
142
+ source_format=source_format,
143
+ raw_source=raw_source,
144
+ )
145
+ self._entries[entry.id] = entry
146
+ return entry
147
+
148
+ # ------------------------------------------------------------------
149
+ # Core write API
150
+ # ------------------------------------------------------------------
151
+
152
+ def add(
153
+ self,
154
+ content: str,
155
+ tags: Optional[list[str]] = None,
156
+ source_entry_ids: Optional[list[str]] = None,
157
+ success: Optional[bool] = None,
158
+ ) -> MemEntry:
159
+ """Record a single free-text observation or note."""
160
+ entry = self._make_entry(
161
+ content=content,
162
+ tags=tags,
163
+ source_entry_ids=source_entry_ids,
164
+ success=success,
165
+ source_format=MemSourceFormat.manual,
166
+ )
167
+ self._save()
168
+ return entry
169
+
170
+ # ------------------------------------------------------------------
171
+ # Framework ingestion helpers
172
+ # ------------------------------------------------------------------
173
+
174
+ def ingest_openai_messages(
175
+ self,
176
+ messages: list[dict[str, Any]],
177
+ tags: Optional[list[str]] = None,
178
+ as_single_trace: bool = True,
179
+ ) -> list[MemEntry]:
180
+ """Ingest an OpenAI-style messages list.
181
+
182
+ Parameters
183
+ ----------
184
+ messages:
185
+ ``[{"role": "user"|"assistant"|"system", "content": "..."}]``
186
+ as_single_trace:
187
+ If True (default) the whole conversation is stored as one
188
+ MemEntry transcript. If False, each message becomes its own entry.
189
+ """
190
+ tags = (tags or []) + ["openai"]
191
+ created: list[MemEntry] = []
192
+
193
+ if as_single_trace:
194
+ lines = [
195
+ f"[{m.get('role', 'unknown')}] {_extract_content(m)}"
196
+ for m in messages
197
+ if _extract_content(m)
198
+ ]
199
+ entry = self._make_entry(
200
+ content="\n".join(lines),
201
+ tags=tags,
202
+ source_format=MemSourceFormat.openai_messages,
203
+ raw_source={"messages": messages},
204
+ )
205
+ created.append(entry)
206
+ else:
207
+ for m in messages:
208
+ content = _extract_content(m)
209
+ if not content:
210
+ continue
211
+ role_tag = m.get("role", "unknown")
212
+ entry = self._make_entry(
213
+ content=content,
214
+ tags=tags + [role_tag],
215
+ source_format=MemSourceFormat.openai_messages,
216
+ raw_source=m,
217
+ )
218
+ created.append(entry)
219
+
220
+ self._save()
221
+ return created
222
+
223
+ def ingest_langchain_messages(
224
+ self,
225
+ messages: list[Any],
226
+ tags: Optional[list[str]] = None,
227
+ as_single_trace: bool = True,
228
+ ) -> list[MemEntry]:
229
+ """Ingest LangChain message objects (HumanMessage, AIMessage, etc.).
230
+
231
+ Accepts any object that has a ``.content`` attribute and optionally
232
+ a ``.type`` attribute (e.g. ``"human"``, ``"ai"``, ``"system"``).
233
+ Plain dicts with ``"content"`` keys are also accepted.
234
+ """
235
+ tags = (tags or []) + ["langchain"]
236
+ dicts = [_langchain_to_dict(m) for m in messages]
237
+ created: list[MemEntry] = []
238
+
239
+ if as_single_trace:
240
+ lines = [
241
+ f"[{d['role']}] {d['content']}" for d in dicts if d["content"]
242
+ ]
243
+ entry = self._make_entry(
244
+ content="\n".join(lines),
245
+ tags=tags,
246
+ source_format=MemSourceFormat.langchain_messages,
247
+ raw_source={"messages": dicts},
248
+ )
249
+ created.append(entry)
250
+ else:
251
+ for d in dicts:
252
+ if not d["content"]:
253
+ continue
254
+ entry = self._make_entry(
255
+ content=d["content"],
256
+ tags=tags + [d["role"]],
257
+ source_format=MemSourceFormat.langchain_messages,
258
+ raw_source=d,
259
+ )
260
+ created.append(entry)
261
+
262
+ self._save()
263
+ return created
264
+
265
+ def ingest_autogen_messages(
266
+ self,
267
+ messages: list[dict[str, Any]],
268
+ tags: Optional[list[str]] = None,
269
+ as_single_trace: bool = True,
270
+ ) -> list[MemEntry]:
271
+ """Ingest AutoGen / multi-agent conversation records.
272
+
273
+ Expects dicts with at minimum a ``"content"`` key and optionally
274
+ ``"name"`` or ``"role"`` identifying the speaker.
275
+ """
276
+ tags = (tags or []) + ["autogen"]
277
+ created: list[MemEntry] = []
278
+
279
+ if as_single_trace:
280
+ lines = []
281
+ for m in messages:
282
+ speaker = m.get("name") or m.get("role", "agent")
283
+ content = _extract_content(m)
284
+ if content:
285
+ lines.append(f"[{speaker}] {content}")
286
+ entry = self._make_entry(
287
+ content="\n".join(lines),
288
+ tags=tags,
289
+ source_format=MemSourceFormat.autogen_messages,
290
+ raw_source={"messages": messages},
291
+ )
292
+ created.append(entry)
293
+ else:
294
+ for m in messages:
295
+ content = _extract_content(m)
296
+ if not content:
297
+ continue
298
+ speaker = m.get("name") or m.get("role", "agent")
299
+ entry = self._make_entry(
300
+ content=content,
301
+ tags=tags + [speaker],
302
+ source_format=MemSourceFormat.autogen_messages,
303
+ raw_source=m,
304
+ )
305
+ created.append(entry)
306
+
307
+ self._save()
308
+ return created
309
+
310
+ def ingest_file(
311
+ self,
312
+ path: Path,
313
+ tags: Optional[list[str]] = None,
314
+ as_single_trace: bool = True,
315
+ ) -> list[MemEntry]:
316
+ """Ingest a JSON session dump.
317
+
318
+ Accepted shapes
319
+ ---------------
320
+ - JSON array of message dicts → treated as OpenAI/AutoGen format
321
+ - JSON object with ``"messages"`` or ``"history"`` key → that list is extracted
322
+ - Any other JSON object → serialised as a single trace
323
+ """
324
+ path = Path(path)
325
+ raw = json.loads(path.read_text(encoding="utf-8"))
326
+ file_tags = (tags or []) + [f"file:{path.name}"]
327
+
328
+ if isinstance(raw, list):
329
+ return self.ingest_openai_messages(raw, tags=file_tags, as_single_trace=as_single_trace)
330
+
331
+ if isinstance(raw, dict):
332
+ for key in ("messages", "history", "conversation", "turns"):
333
+ if key in raw and isinstance(raw[key], list):
334
+ return self.ingest_openai_messages(
335
+ raw[key], tags=file_tags, as_single_trace=as_single_trace
336
+ )
337
+ # Fallback: serialise entire object as one trace
338
+ entry = self._make_entry(
339
+ content=json.dumps(raw, indent=2, default=str),
340
+ tags=file_tags,
341
+ source_format=MemSourceFormat.json_file,
342
+ raw_source={"file": str(path), "data": raw},
343
+ )
344
+ self._save()
345
+ return [entry]
346
+
347
+ # Unexpected shape — store as raw text
348
+ entry = self._make_entry(
349
+ content=str(raw),
350
+ tags=file_tags,
351
+ source_format=MemSourceFormat.json_file,
352
+ )
353
+ self._save()
354
+ return [entry]
355
+
356
+ def ingest_text_file(
357
+ self,
358
+ path: Path,
359
+ tags: Optional[list[str]] = None,
360
+ chunk_by: str = "none",
361
+ ) -> list[MemEntry]:
362
+ """Ingest a plain-text file.
363
+
364
+ Parameters
365
+ ----------
366
+ chunk_by:
367
+ ``"none"`` — store as one entry (default).
368
+ ``"line"`` — one entry per non-empty line.
369
+ ``"paragraph"`` — split on blank lines; one entry per paragraph.
370
+ """
371
+ path = Path(path)
372
+ text = path.read_text(encoding="utf-8", errors="replace")
373
+ file_tags = (tags or []) + [f"file:{path.name}"]
374
+ created: list[MemEntry] = []
375
+
376
+ chunks: list[str] = []
377
+ if chunk_by == "line":
378
+ chunks = [ln.strip() for ln in text.splitlines() if ln.strip()]
379
+ elif chunk_by == "paragraph":
380
+ chunks = [
381
+ p.strip()
382
+ for p in text.split("\n\n")
383
+ if p.strip()
384
+ ]
385
+ else:
386
+ chunks = [text]
387
+
388
+ for chunk in chunks:
389
+ entry = self._make_entry(
390
+ content=chunk,
391
+ tags=file_tags,
392
+ source_format=MemSourceFormat.text_file,
393
+ raw_source={"file": str(path)},
394
+ )
395
+ created.append(entry)
396
+
397
+ self._save()
398
+ return created
399
+
400
+ # ------------------------------------------------------------------
401
+ # Read / management API
402
+ # ------------------------------------------------------------------
403
+
404
+ def list(self) -> list[MemEntry]:
405
+ return list(self._entries.values())
406
+
407
+ def get(self, mem_id: str) -> Optional[MemEntry]:
408
+ return self._entries.get(mem_id)
409
+
410
+ def mark_promoted(self, mem_id: str, target_id: str) -> None:
411
+ if mem_id in self._entries:
412
+ self._entries[mem_id].promoted = True
413
+ self._entries[mem_id].promotion_target_id = target_id
414
+ self._save()
415
+
416
+ def delete(self, mem_id: str) -> bool:
417
+ if mem_id not in self._entries:
418
+ return False
419
+ del self._entries[mem_id]
420
+ self._save()
421
+ return True
422
+
423
+ @staticmethod
424
+ def list_sessions() -> list[str]:
425
+ return [p.stem for p in _MEMORY_DIR.glob("*.json")]
426
+
427
+
428
+ # ------------------------------------------------------------------
429
+ # Internal helpers
430
+ # ------------------------------------------------------------------
431
+
432
+ def _extract_content(m: Any) -> str:
433
+ """Pull text content out of a message dict or object."""
434
+ if isinstance(m, dict):
435
+ content = m.get("content", "")
436
+ if isinstance(content, list):
437
+ # OpenAI vision-style: content is a list of parts
438
+ parts = [
439
+ p.get("text", "") if isinstance(p, dict) else str(p)
440
+ for p in content
441
+ ]
442
+ return " ".join(p for p in parts if p).strip()
443
+ return str(content).strip()
444
+ if hasattr(m, "content"):
445
+ return str(m.content).strip()
446
+ return str(m).strip()
447
+
448
+
449
+ def _langchain_to_dict(m: Any) -> dict[str, str]:
450
+ """Normalise a LangChain message object to a plain dict."""
451
+ if isinstance(m, dict):
452
+ return {
453
+ "role": m.get("type", m.get("role", "unknown")),
454
+ "content": str(m.get("content", "")),
455
+ }
456
+ role = getattr(m, "type", None) or getattr(m, "role", "unknown")
457
+ content = getattr(m, "content", str(m))
458
+ return {"role": str(role), "content": str(content)}
Binary file
File without changes
@@ -0,0 +1,122 @@
1
+ """Embedding service for hybrid retrieval.
2
+
3
+ Pluggable via the ``Embedder`` protocol. Default backend is a local
4
+ sentence-transformers model (MiniLM, 384 dim, CPU). Override the model via
5
+ the ``KDG_EMBED_MODEL`` env var.
6
+
7
+ Failures (missing dependency, model download error, etc.) are logged once
8
+ and the embedder reports ``available=False`` so retrieval falls back to
9
+ keyword search rather than raising.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import logging
16
+ import os
17
+ from typing import Optional, Protocol
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def build_embedding_text(
23
+ title: str,
24
+ aliases: list[str],
25
+ tags: list[str],
26
+ content: str,
27
+ content_chars: int = 2000,
28
+ ) -> str:
29
+ """Canonical text representation used to embed an entry."""
30
+ parts = [title.strip()]
31
+ if aliases:
32
+ parts.append(" | ".join(a.strip() for a in aliases if a.strip()))
33
+ if tags:
34
+ parts.append("tags: " + ", ".join(t.strip() for t in tags if t.strip()))
35
+ body = (content or "").strip()
36
+ if body:
37
+ parts.append("")
38
+ parts.append(body[:content_chars])
39
+ return "\n".join(parts)
40
+
41
+
42
+ def text_hash(text: str) -> str:
43
+ return hashlib.sha1(text.encode("utf-8")).hexdigest()
44
+
45
+
46
+ class Embedder(Protocol):
47
+ dim: int
48
+ available: bool
49
+
50
+ def embed(self, texts: list[str]) -> list[list[float]]: ...
51
+
52
+
53
+ class _NullEmbedder:
54
+ """Stand-in used when sentence-transformers is not installed."""
55
+
56
+ dim = 0
57
+ available = False
58
+
59
+ def embed(self, texts: list[str]) -> list[list[float]]:
60
+ return [[] for _ in texts]
61
+
62
+
63
+ class SentenceTransformerEmbedder:
64
+ """Local CPU embedder using sentence-transformers."""
65
+
66
+ def __init__(self, model_name: Optional[str] = None) -> None:
67
+ self.model_name = model_name or os.environ.get(
68
+ "KDG_EMBED_MODEL", "sentence-transformers/all-MiniLM-L6-v2"
69
+ )
70
+ self._model = None
71
+ self._dim: Optional[int] = None
72
+ self.available = True # set to False on first failed load
73
+
74
+ def _load(self) -> None:
75
+ if self._model is not None or not self.available:
76
+ return
77
+ try:
78
+ from sentence_transformers import SentenceTransformer # type: ignore
79
+ except ImportError:
80
+ logger.warning(
81
+ "sentence-transformers not installed; hybrid retrieval disabled. "
82
+ "Install with: pip install 'know-do-graph[embeddings]'"
83
+ )
84
+ self.available = False
85
+ return
86
+ try:
87
+ self._model = SentenceTransformer(self.model_name)
88
+ self._dim = int(self._model.get_sentence_embedding_dimension())
89
+ except Exception as exc:
90
+ logger.warning("Failed to load embedding model %s: %s", self.model_name, exc)
91
+ self.available = False
92
+
93
+ @property
94
+ def dim(self) -> int:
95
+ self._load()
96
+ return self._dim or 0
97
+
98
+ def embed(self, texts: list[str]) -> list[list[float]]:
99
+ self._load()
100
+ if not self.available or self._model is None:
101
+ return [[] for _ in texts]
102
+ vecs = self._model.encode(
103
+ texts,
104
+ normalize_embeddings=True,
105
+ convert_to_numpy=True,
106
+ show_progress_bar=False,
107
+ )
108
+ return [v.tolist() for v in vecs]
109
+
110
+
111
+ _default: Optional[Embedder] = None
112
+
113
+
114
+ def get_default_embedder() -> Embedder:
115
+ """Process-wide singleton embedder."""
116
+ global _default
117
+ if _default is None:
118
+ candidate = SentenceTransformerEmbedder()
119
+ # Trigger load eagerly so we know whether it works; downgrade to null if not.
120
+ _ = candidate.dim
121
+ _default = candidate if candidate.available else _NullEmbedder()
122
+ return _default
@@ -0,0 +1,52 @@
1
+ """Ranking helpers: Reciprocal Rank Fusion and trust-score multipliers.
2
+
3
+ Kept dependency-free so retrieval works without numpy.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ from typing import Iterable
10
+
11
+ from core.schemas.entry import VerificationStatus
12
+
13
+ # Multiplier applied to fused RRF scores based on verification status.
14
+ # Trusted entries float up, broken/deprecated ones sink — but nothing is hidden.
15
+ _TRUST_MULTIPLIER: dict[str, float] = {
16
+ VerificationStatus.community_tested.value: 1.30,
17
+ VerificationStatus.peer_reviewed.value: 1.15,
18
+ VerificationStatus.self_tested.value: 1.00,
19
+ VerificationStatus.unverified.value: 0.90,
20
+ VerificationStatus.bugged.value: 0.50,
21
+ VerificationStatus.deprecated.value: 0.30,
22
+ }
23
+
24
+
25
+ def reciprocal_rank_fusion(
26
+ ranked_lists: Iterable[list[str]],
27
+ k: int = 60,
28
+ ) -> dict[str, float]:
29
+ """Standard RRF: each list contributes 1/(k + rank) per id (1-indexed)."""
30
+ scores: dict[str, float] = {}
31
+ for ranked in ranked_lists:
32
+ for rank, entry_id in enumerate(ranked, start=1):
33
+ scores[entry_id] = scores.get(entry_id, 0.0) + 1.0 / (k + rank)
34
+ return scores
35
+
36
+
37
+ def trust_multiplier(verification_status: str, trust_score_override: float | None = None) -> float:
38
+ """Pick the multiplier from explicit override (if set) or verification status."""
39
+ if trust_score_override is not None:
40
+ # Treat user-set trust_score as a direct multiplier, clamped to a sane range.
41
+ return max(0.1, min(2.0, float(trust_score_override)))
42
+ return _TRUST_MULTIPLIER.get(verification_status, 1.0)
43
+
44
+
45
+ def usage_bump(usage_count: int) -> float:
46
+ """Small log-scaled multiplier rewarding entries that have actually been used.
47
+
48
+ 1 use → ×1.02, 10 uses → ×1.06, 100 uses → ×1.10, capped at ×1.15.
49
+ """
50
+ if usage_count <= 0:
51
+ return 1.0
52
+ return min(1.15, 1.0 + 0.02 * math.log10(usage_count + 1) * 2)