huri-module-rag 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.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: huri-module-rag
3
+ Version: 0.1.0
4
+ Summary: HuRI module: Retrieval-augmented answers with long-term memory (Qdrant + an OpenAI-compatible LLM), and the question aggregator
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: huri-core[server]<0.5,>=0.4
8
+ Requires-Dist: huri-events<0.2,>=0.1
9
+ Provides-Extra: rag
10
+ Requires-Dist: numpy<2,>=1.26; extra == "rag"
11
+ Requires-Dist: httpx<0.28,>=0.27.2; extra == "rag"
12
+ Requires-Dist: qdrant-client<1.19,>=1.18; extra == "rag"
13
+ Requires-Dist: sentence-transformers<3.3,>=3.2.1; extra == "rag"
14
+ Requires-Dist: pypdf<5.2,>=5.1; extra == "rag"
15
+ Requires-Dist: semantic_chunker==0.2.0; extra == "rag"
16
+ Requires-Dist: pydantic>=2; extra == "rag"
17
+
18
+ # huri-module-rag
19
+
20
+ Retrieval-augmented answers with long-term memory (Qdrant + an OpenAI-compatible LLM), and the question aggregator.
21
+
22
+ | Module | Topics | What it does |
23
+ |---|---|---|
24
+ | `rag` | `huri.question` → `huri.token` | Answers questions from memory with an LLM, streaming tokens |
25
+ | `qag` | `huri.partial_question` → `huri.question` | Joins a transcript and its emotion into a question |
26
+
27
+ ```sh
28
+ pip install -e 'packages/huri-module-rag[rag]' # from a huri-modules checkout, next to huri-core and huri-events
29
+ HURI_MODULES=rag,qag ... # enable it on the server
30
+ ```
31
+
32
+ `rag` and the ingestion and memory tools (`python -m huri_rag.ingestion`,
33
+ `memory_inspect`, `memory_maintenance`) need the `rag` extra (Qdrant client,
34
+ sentence-transformers, httpx, pypdf, semantic_chunker); `qag` only joins events
35
+ and needs none of it, so a host that runs `qag` alone installs the package
36
+ without the extra (`[tool.huri-modules.extras]` in `pyproject.toml`; `huri hub
37
+ install` and `scripts/install_local.sh` add it). Hardware pins of `rag`:
38
+ `requirements-amd.rag.txt` (install it before the package).
39
+
40
+ A distribution of
41
+ [huri-modules](https://github.com/Lucy-Robotics/huri-modules), versioned in its
42
+ `pyproject.toml` (see RELEASING.md there). It depends on `huri-core` and
43
+ `huri-events`, which are not on PyPI: without a checkout, install them from git
44
+ in the same pip command, so pip never looks them up on an index:
45
+
46
+ ```sh
47
+ ref=0.1.0 # a huri-modules release tag
48
+ core=0.4.0 # HURI_CORE_REF in that release's versions.env
49
+ pip install \
50
+ "huri-core[server] @ git+https://github.com/Lucy-Robotics/huri-core.git@$core" \
51
+ "huri-events @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-events" \
52
+ "huri-module-rag[rag] @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-module-rag"
53
+ ```
@@ -0,0 +1,15 @@
1
+ huri_rag/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ huri_rag/ingestion.py,sha256=vKpr-cDqBXbWPQUqnmP9K8X9y43CQ1RWmHLksw4eaxg,17914
3
+ huri_rag/memory_inspect.py,sha256=LLCSaJVxJiRLV5khpioyqEdGbthRyVIYcaHgF5Bi3cY,3494
4
+ huri_rag/memory_maintenance.py,sha256=271MHDhd3GErHHIFOtDphwP8TpUCzAZQzD2x3h1hHzM,5164
5
+ huri_rag/qdrant_utils.py,sha256=C-pSO8WYlevwO5-6b1ZapRVobfKg6sjbBJLAujum3cc,1079
6
+ huri_rag/question_aggregator.py,sha256=q8xPQmsnWmb9SFFoRtOJxxAf41rezYvqoIDspbC4kgg,2015
7
+ huri_rag/rag.py,sha256=X5kr1N3ujQwBYjAvJDBHf9fqfEk4klCEKQu_edJgodw,34117
8
+ huri_rag/semantic_chunker.py,sha256=QkEIXmwyiqMMVniXPnqrZN836HEx1ZWBhSik4nPNiuE,8554
9
+ huri_rag/manifests/qag/huri_module.toml,sha256=q84S7pgFkUjzdT0PvAefq0gQm1nPV-7Vyz4DRblCFG4,355
10
+ huri_rag/manifests/rag/huri_module.toml,sha256=Do3qVVL2Y1udu3H9oPAtwQ1XBkmHDl31K07WF7EMcc8,339
11
+ huri_module_rag-0.1.0.dist-info/METADATA,sha256=UxsMpxxrVW6oV8bk5F_iDvOI2nCB81E_gEWt5URXqFI,2586
12
+ huri_module_rag-0.1.0.dist-info/WHEEL,sha256=YLJXdYXQ2FQ0Uqn2J-6iEIC-3iOey8lH3xCtvFLkd8Q,91
13
+ huri_module_rag-0.1.0.dist-info/entry_points.txt,sha256=UWXo9g5_yFZNxy2fktKbVuB72oq9QSUMurn-hpzD2cg,77
14
+ huri_module_rag-0.1.0.dist-info/top_level.txt,sha256=jguIEsHI7dpw6zcYsVkMWKfUibgTwjYQXhyiK65_56A,9
15
+ huri_module_rag-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (81.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [huri.modules]
2
+ qag = huri_rag.question_aggregator:QAG
3
+ rag = huri_rag.rag:RAG
@@ -0,0 +1 @@
1
+ huri_rag
huri_rag/__init__.py ADDED
File without changes
huri_rag/ingestion.py ADDED
@@ -0,0 +1,589 @@
1
+ import argparse
2
+ import os
3
+ import re
4
+ import sys
5
+ import uuid
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any, List
9
+
10
+ import httpx
11
+ import numpy as np
12
+ from qdrant_client import QdrantClient
13
+ from qdrant_client.models import (
14
+ Distance,
15
+ FieldCondition,
16
+ Filter,
17
+ MatchValue,
18
+ PointStruct,
19
+ VectorParams,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from sentence_transformers import SentenceTransformer
24
+
25
+ USER_ID_FILE = os.path.expanduser("~/.huri_user_id")
26
+
27
+
28
+ class RemoteEmbedder:
29
+ """Embed via an OpenAI-compatible ``/v1/embeddings`` endpoint (e.g. llama.cpp).
30
+
31
+ Drop-in for the subset of ``SentenceTransformer`` this tool uses: a single
32
+ ``.encode(text, normalize_embeddings=...)`` returning a 1-D numpy array, so
33
+ the existing ``.tolist()`` / ``len(...)`` call sites keep working unchanged.
34
+ """
35
+
36
+ def __init__(self, url: str, model_name: str):
37
+ self.url = url.rstrip("/")
38
+ self.model_name = model_name
39
+ self._client = httpx.Client(timeout=60.0, verify=False)
40
+
41
+ def encode(self, text: str, normalize_embeddings: bool = True) -> np.ndarray:
42
+ resp = self._client.post(
43
+ f"{self.url}/v1/embeddings",
44
+ json={"model": self.model_name, "input": str(text)},
45
+ )
46
+ resp.raise_for_status()
47
+ vec = np.asarray(resp.json()["data"][0]["embedding"], dtype=np.float32)
48
+ if normalize_embeddings:
49
+ norm = np.linalg.norm(vec)
50
+ if norm > 0:
51
+ vec = vec / norm
52
+ return vec
53
+
54
+
55
+ def _split_sentences(text: str) -> list[str]:
56
+ """Simple sentence splitter."""
57
+ result: List = []
58
+ sentences = re.split(r"(?<=[.!?])\s+", text)
59
+
60
+ for s in sentences:
61
+ parts = s.split("\n\n")
62
+ result.extend(parts)
63
+ return [s.strip() for s in result if s.strip()]
64
+
65
+
66
+ def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
67
+ """
68
+ Fallback: fixed-size chunking by sentences.
69
+ Used when --chunking=fixed.
70
+ """
71
+ chunks: List = []
72
+ current_chunk: List = []
73
+ current_length: int = 0
74
+ sentences = _split_sentences(text)
75
+
76
+ for sentence in sentences:
77
+ sentence = sentence.strip()
78
+ if not sentence:
79
+ continue
80
+
81
+ sentence_length = len(sentence.split())
82
+
83
+ if current_length + sentence_length > chunk_size and current_chunk:
84
+ overlap_words: int = 0
85
+ overlap_sentences: List = []
86
+ chunks.append(" ".join(current_chunk))
87
+
88
+ for s in reversed(current_chunk):
89
+ overlap_words += len(s.split())
90
+ overlap_sentences.insert(0, s)
91
+ if overlap_words >= overlap:
92
+ break
93
+
94
+ current_chunk = overlap_sentences
95
+ current_length = overlap_words
96
+
97
+ current_chunk.append(sentence)
98
+ current_length += sentence_length
99
+
100
+ if current_chunk:
101
+ chunks.append(" ".join(current_chunk))
102
+
103
+ return chunks
104
+
105
+
106
+ def extract_text_from_pdf(pdf_path: str) -> str:
107
+ """Extract text from a PDF file."""
108
+ try:
109
+ from pypdf import PdfReader
110
+
111
+ reader = PdfReader(pdf_path)
112
+ text = ""
113
+ for page in reader.pages:
114
+ text += page.extract_text() + "\n"
115
+ return text.strip()
116
+ except ImportError:
117
+ pass
118
+
119
+ print("ERROR: Install a PDF library: pip install pymupdf OR pip install pypdf")
120
+ sys.exit(1)
121
+
122
+
123
+ def get_user_id(provided_id: str | None = None) -> str:
124
+ if provided_id:
125
+ return provided_id
126
+ if os.path.exists(USER_ID_FILE):
127
+ with open(USER_ID_FILE) as f:
128
+ uid = f.read().strip()
129
+ if uid:
130
+ return uid
131
+ new_id = str(uuid.uuid4())
132
+ with open(USER_ID_FILE, "w") as f:
133
+ f.write(new_id)
134
+ print(f"Generated new user_id: {new_id}")
135
+ return new_id
136
+
137
+
138
+ def ensure_collection(client: QdrantClient, collection: str, vector_size: int):
139
+ collections = [c.name for c in client.get_collections().collections]
140
+ if collection not in collections:
141
+ client.create_collection(
142
+ collection_name=collection,
143
+ vectors_config=VectorParams(size=vector_size, distance=Distance.COSINE),
144
+ )
145
+ print(f"Created collection: {collection}")
146
+
147
+
148
+ def ingest_chunks(
149
+ client: QdrantClient,
150
+ model: Any,
151
+ collection: str,
152
+ chunks: list[str],
153
+ _user_id: str,
154
+ source: str,
155
+ doc_type: str = "document",
156
+ ):
157
+ """Embed chunks and upsert into Qdrant."""
158
+ points = []
159
+ timestamp = datetime.now().isoformat()
160
+
161
+ for i, chunk in enumerate(chunks):
162
+ vector = model.encode(chunk, normalize_embeddings=True).tolist()
163
+ points.append(
164
+ PointStruct(
165
+ id=str(uuid.uuid4()),
166
+ vector=vector,
167
+ payload={
168
+ "text": chunk,
169
+ "_user_id": _user_id,
170
+ "source": source,
171
+ "type": doc_type,
172
+ "chunk_index": i,
173
+ "timestamp": timestamp,
174
+ },
175
+ )
176
+ )
177
+
178
+ if points:
179
+ # Upsert in batches of 100
180
+ batch_size = 100
181
+ for i in range(0, len(points), batch_size):
182
+ batch = points[i : i + batch_size]
183
+ client.upsert(collection_name=collection, points=batch)
184
+
185
+ return len(points)
186
+
187
+
188
+ def chunk_strat(text: str, args, model: Any) -> list[str] | Any:
189
+ """Pick the right chunking strategy based on args."""
190
+ if args.chunking == "semantic":
191
+ # Thomas: I need to import here, bceause it takes too much time earlier, or
192
+ # use a jupyter notebook to do it instead
193
+ from .semantic_chunker import SemanticChunker
194
+
195
+ chunker = SemanticChunker(
196
+ model=model,
197
+ strategy=args.semantic_strategy,
198
+ )
199
+ return chunker.chunk(text)
200
+ else:
201
+ return chunk_text(text, chunk_size=args.chunk_size, overlap=args.overlap)
202
+
203
+
204
+ def cmd_pdf(args, client, model, _user_id):
205
+ """Ingest PDF files."""
206
+ files: List[Path] = []
207
+ for path in args.files:
208
+ p = Path(path)
209
+ if p.is_dir():
210
+ files.extend(p.glob("**/*.pdf"))
211
+ elif p.suffix.lower() == ".pdf":
212
+ files.append(p)
213
+ else:
214
+ print(f"Skipping non-PDF: {path}")
215
+
216
+ if not files:
217
+ print("No PDF files found.")
218
+ return
219
+
220
+ sample = model.encode("test", normalize_embeddings=True)
221
+ ensure_collection(client, args.collection, len(sample))
222
+
223
+ total = 0
224
+ for pdf_path in files:
225
+ print(f"\nProcessing: {pdf_path}")
226
+ text = extract_text_from_pdf(str(pdf_path))
227
+
228
+ if not text.strip():
229
+ print(f" WARNING: No text extracted from {pdf_path}")
230
+ continue
231
+
232
+ chunks = chunk_strat(text, args, model)
233
+ count = ingest_chunks(
234
+ client,
235
+ model,
236
+ args.collection,
237
+ chunks,
238
+ _user_id,
239
+ source=pdf_path.name,
240
+ doc_type="pdf",
241
+ )
242
+ print(f" -> {count} chunks ingested")
243
+ total += count
244
+
245
+ print(f"\nDone. Total: {total} chunks from {len(files)} PDF(s)")
246
+
247
+
248
+ def cmd_text(args, client, model, _user_id):
249
+ """Ingest text files."""
250
+ sample = model.encode("test", normalize_embeddings=True)
251
+ ensure_collection(client, args.collection, len(sample))
252
+
253
+ total = 0
254
+ for file_path in args.files:
255
+ p = Path(file_path)
256
+ if not p.exists():
257
+ print(f"File not found: {file_path}")
258
+ continue
259
+
260
+ print(f"\nProcessing: {file_path}")
261
+ text = p.read_text(encoding="utf-8")
262
+
263
+ if not text.strip():
264
+ print(f" WARNING: File is empty: {file_path}")
265
+ continue
266
+
267
+ chunks = chunk_strat(text, args, model)
268
+ count = ingest_chunks(
269
+ client,
270
+ model,
271
+ args.collection,
272
+ chunks,
273
+ _user_id,
274
+ source=p.name,
275
+ doc_type="text",
276
+ )
277
+ print(f" -> {count} chunks ingested")
278
+ total += count
279
+
280
+ print(f"\nDone. Total: {total} chunks from {len(args.files)} file(s)")
281
+
282
+
283
+ def cmd_write(args, client, model, _user_id):
284
+ """Write text interactively and ingest it."""
285
+ title = args.title or f"note_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
286
+
287
+ print(f"Write your text below (title: '{title}')")
288
+ print("Press Ctrl+D (Linux/Mac) or Ctrl+Z then Enter (Windows) when done.")
289
+ print("-" * 40)
290
+
291
+ lines = []
292
+ try:
293
+ while True:
294
+ line = input()
295
+ lines.append(line)
296
+ except EOFError:
297
+ pass
298
+
299
+ text = "\n".join(lines).strip()
300
+
301
+ if not text:
302
+ print("Nothing to ingest.")
303
+ return
304
+
305
+ print(f"\n{'-' * 40}")
306
+ print(f"Received {len(text)} characters")
307
+
308
+ sample = model.encode("test", normalize_embeddings=True)
309
+ ensure_collection(client, args.collection, len(sample))
310
+
311
+ chunks = chunk_strat(text, args, model)
312
+ count = ingest_chunks(
313
+ client,
314
+ model,
315
+ args.collection,
316
+ chunks,
317
+ _user_id,
318
+ source=title,
319
+ doc_type="manual",
320
+ )
321
+
322
+ print(f"Done. Ingested {count} chunks as '{title}'")
323
+
324
+
325
+ def cmd_profile(args, client, model, _user_id):
326
+ """Store always-on profile facts about the user (name, etc.).
327
+
328
+ Unlike regular documents, profile facts are NOT retrieved by vector
329
+ similarity. The RAG handle pulls them by filter (_user_id + type=profile)
330
+ on every query and injects them into the system prompt, so the character
331
+ always knows them.
332
+ """
333
+ sample = model.encode("test", normalize_embeddings=True)
334
+ ensure_collection(client, args.collection, len(sample))
335
+
336
+ facts: List[str] = []
337
+ if args.name:
338
+ facts.append(f"The user's name is {args.name}.")
339
+ for fact in args.fact or []:
340
+ facts.append(fact)
341
+
342
+ if not facts:
343
+ print("Nothing to store. Use --name and/or --fact 'some fact'.")
344
+ return
345
+
346
+ # Replace the existing profile so facts don't pile up across runs.
347
+ client.delete(
348
+ collection_name=args.collection,
349
+ points_selector=Filter(
350
+ must=[
351
+ FieldCondition(key="_user_id", match=MatchValue(value=_user_id)),
352
+ FieldCondition(key="type", match=MatchValue(value="profile")),
353
+ ]
354
+ ),
355
+ )
356
+
357
+ count = ingest_chunks(
358
+ client,
359
+ model,
360
+ args.collection,
361
+ facts,
362
+ _user_id,
363
+ source="profile",
364
+ doc_type="profile",
365
+ )
366
+ print(f"Stored {count} profile fact(s) for user {_user_id}")
367
+
368
+
369
+ def cmd_list(args, client, model, _user_id):
370
+ """List what's in the database for this user."""
371
+
372
+ try:
373
+ info = client.get_collection(args.collection)
374
+ print(f"Collection: {args.collection}")
375
+ print(f"Total points: {info.points_count}")
376
+ except Exception:
377
+ print(f"Collection '{args.collection}' doesn't exist.")
378
+ return
379
+
380
+ results = client.scroll(
381
+ collection_name=args.collection,
382
+ scroll_filter=Filter(
383
+ must=[
384
+ FieldCondition(key="_user_id", match=MatchValue(value=_user_id)),
385
+ ]
386
+ ),
387
+ limit=100,
388
+ with_payload=True,
389
+ with_vectors=False,
390
+ )
391
+
392
+ points = results[0]
393
+ if not points:
394
+ print(f"No documents found for user {_user_id}")
395
+ return
396
+
397
+ sources = {}
398
+ for p in points:
399
+ source = p.payload.get("source", "unknown")
400
+ doc_type = p.payload.get("type", "unknown")
401
+ if source not in sources:
402
+ sources[source] = {"count": 0, "type": doc_type}
403
+ sources[source]["count"] += 1
404
+
405
+ print(f"\nDocuments for user {_user_id}:")
406
+ print(f"{'Source':<40} {'Type':<10} {'Chunks':<8}")
407
+ print("-" * 60)
408
+ for source, info in sorted(sources.items()):
409
+ print(f"{source:<40} {info['type']:<10} {info['count']:<8}")
410
+ print(f"\nTotal: {len(points)} chunks across {len(sources)} sources")
411
+
412
+
413
+ def cmd_delete(args, client, model, _user_id):
414
+ """Delete documents by source name."""
415
+
416
+ if not args.source:
417
+ print("Specify --source to delete. Use 'list' command to see sources.")
418
+ return
419
+
420
+ filter_conditions: Any = [
421
+ FieldCondition(key="_user_id", match=MatchValue(value=_user_id)),
422
+ FieldCondition(key="source", match=MatchValue(value=args.source)),
423
+ ]
424
+
425
+ client.delete(
426
+ collection_name=args.collection,
427
+ points_selector=Filter(must=filter_conditions),
428
+ )
429
+ print(f"Deleted all chunks from source '{args.source}' for user {_user_id}")
430
+
431
+
432
+ def main():
433
+ parser = argparse.ArgumentParser(description="HuRI RAG Ingestion Tool")
434
+ parser.add_argument("--user-id", type=str, default=None)
435
+ parser.add_argument("--collection", type=str, default="documents")
436
+ parser.add_argument("--qdrant-url", type=str, default="http://localhost:6333")
437
+ parser.add_argument(
438
+ "--no-verify-ssl",
439
+ action="store_true",
440
+ default=False,
441
+ help="Disable SSL certificate verification (needed for self-signed LAN certs).",
442
+ )
443
+ parser.add_argument("--embedding-model", type=str, default="BAAI/bge-large-en-v1.5")
444
+ parser.add_argument(
445
+ "--embedding-url",
446
+ type=str,
447
+ default="",
448
+ help=(
449
+ "OpenAI-compatible embedding endpoint (e.g. llama.cpp at "
450
+ "http://localhost:8080). When set, embeddings are computed remotely "
451
+ "instead of with a local SentenceTransformer. Requires --chunking fixed."
452
+ ),
453
+ )
454
+ parser.add_argument(
455
+ "--chunk-size",
456
+ type=int,
457
+ default=500,
458
+ help="Target chunk size in words (fixed mode)",
459
+ )
460
+ parser.add_argument(
461
+ "--overlap",
462
+ type=int,
463
+ default=50,
464
+ help="Overlap between chunks in words (fixed mode)",
465
+ )
466
+ parser.add_argument(
467
+ "--chunking",
468
+ type=str,
469
+ default="fixed",
470
+ choices=["semantic", "fixed"],
471
+ help="Chunking strategy: 'semantic' (default) or 'fixed'",
472
+ )
473
+ parser.add_argument(
474
+ "--semantic-strategy",
475
+ type=str,
476
+ default="percentile",
477
+ choices=["percentile", "threshold", "stddev"],
478
+ help="Semantic chunking strategy (default: percentile)",
479
+ )
480
+
481
+ subparsers = parser.add_subparsers(dest="command", required=True)
482
+
483
+ p_pdf = subparsers.add_parser("pdf", help="Ingest PDF files")
484
+ p_pdf.add_argument("files", nargs="+", help="PDF files or directories")
485
+
486
+ p_text = subparsers.add_parser("text", help="Ingest text files (.txt, .md)")
487
+ p_text.add_argument("files", nargs="+", help="Text files")
488
+
489
+ p_write = subparsers.add_parser("write", help="Write text interactively")
490
+ p_write.add_argument("--title", type=str, default=None, help="Title/source name")
491
+
492
+ p_profile = subparsers.add_parser(
493
+ "profile", help="Store always-on profile facts (name, etc.)"
494
+ )
495
+ p_profile.add_argument("--name", type=str, default=None, help="User's name")
496
+ p_profile.add_argument(
497
+ "--fact",
498
+ action="append",
499
+ help="A fact about the user, e.g. --fact 'Likes cheese' (repeatable)",
500
+ )
501
+
502
+ subparsers.add_parser("list", help="List ingested documents")
503
+
504
+ p_delete = subparsers.add_parser("delete", help="Delete documents by source")
505
+ p_delete.add_argument(
506
+ "--source", type=str, required=True, help="Source name to delete"
507
+ )
508
+
509
+ args = parser.parse_args()
510
+
511
+ if args.embedding_url and args.chunking == "semantic":
512
+ parser.error(
513
+ "--chunking semantic needs a local SentenceTransformer model and "
514
+ "cannot run over --embedding-url. Use --chunking fixed."
515
+ )
516
+
517
+ _user_id = get_user_id(args.user_id)
518
+ print(f"User: {_user_id}")
519
+
520
+ verify_ssl = not args.no_verify_ssl
521
+ try:
522
+ from .qdrant_utils import make_qdrant_client
523
+ except ImportError:
524
+ from qdrant_utils import make_qdrant_client # type: ignore[no-redef]
525
+ client = make_qdrant_client(args.qdrant_url, verify_ssl)
526
+
527
+ # Lazy-load the model only if the command needs embeddings.
528
+ # Commands that don't need it: list, delete, profile (doesn't use embeddings).
529
+ model: RemoteEmbedder | SentenceTransformer | None
530
+ needs_embeddings = args.command in ("pdf", "text", "write", "profile")
531
+
532
+ if needs_embeddings:
533
+ if args.embedding_url:
534
+ print(
535
+ f"Embedding remotely via {args.embedding_url} "
536
+ f"(model={args.embedding_model})"
537
+ )
538
+ model = RemoteEmbedder(args.embedding_url, args.embedding_model)
539
+ else:
540
+ from sentence_transformers import SentenceTransformer
541
+
542
+ model = SentenceTransformer(args.embedding_model)
543
+ else:
544
+ model = None
545
+
546
+ commands = {
547
+ "pdf": cmd_pdf,
548
+ "text": cmd_text,
549
+ "write": cmd_write,
550
+ "profile": cmd_profile,
551
+ "list": cmd_list,
552
+ "delete": cmd_delete,
553
+ }
554
+ commands[args.command](args, client, model, _user_id)
555
+
556
+
557
+ if __name__ == "__main__":
558
+ """
559
+ Ingestion tool for HuRI RAG.
560
+
561
+ Usage:
562
+ # Ingest a PDF
563
+ python ingestion.py pdf report.pdf
564
+
565
+ # Ingest multiple PDFs
566
+ python ingestion.py pdf doc1.pdf doc2.pdf doc3.pdf
567
+
568
+ # Ingest a whole folder of PDFs
569
+ # TODO: To verify and to add the support of hole paths
570
+ python ingestion.py pdf ./my_documents/
571
+
572
+ # Write text interactively (type, then Ctrl+D to save)
573
+ python ingestion.py write --title "My meeting notes"
574
+
575
+ # Ingest a text file
576
+ python ingestion.py text notes.txt story.md
577
+
578
+ # Specify a user ID (otherwise reads from ~/.huri_user_id)
579
+ python ingestion.py --user-id "abc-123" pdf report.pdf
580
+
581
+ # Use a different collection
582
+ python ingestion.py --collection "my_docs" pdf report.pdf
583
+
584
+ # Use a different ingestion strategy
585
+ python huri_rag/ingestion.py \
586
+ --chunking semantic --semantic-strategy threshold pdf "EN.pdf"
587
+
588
+ """
589
+ main()
@@ -0,0 +1,7 @@
1
+ name = "qag"
2
+ summary = "Joins a transcript and its emotion into a question"
3
+ input = { topic = "huri.partial_question", event = "huri_events.question:PartialQuestion" }
4
+ output = { topic = "huri.question", event = "huri_events.question:RAGQuestion" }
5
+ events = ["huri-events>=0.1,<0.2"]
6
+ requires = { gpu = false, actuation = false }
7
+ huri_core = ">=0.4,<0.5"
@@ -0,0 +1,7 @@
1
+ name = "rag"
2
+ summary = "Answers questions from memory with an LLM, streaming tokens"
3
+ input = { topic = "huri.question", event = "huri_events.question:RAGQuestion" }
4
+ output = { topic = "huri.token", event = "huri_events.text:Token" }
5
+ events = ["huri-events>=0.1,<0.2"]
6
+ requires = { gpu = false, actuation = false }
7
+ huri_core = ">=0.4,<0.5"