firecloud-devnet 0.1.0__tar.gz → 0.2.0__tar.gz

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 (58) hide show
  1. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/.gitignore +4 -3
  2. firecloud_devnet-0.2.0/CHANGELOG.md +64 -0
  3. firecloud_devnet-0.2.0/Dockerfile +12 -0
  4. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/PKG-INFO +24 -1
  5. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/README.md +23 -0
  6. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/docker-compose.yml +4 -0
  7. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/fc_mlops/artifact_store.py +10 -1
  8. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/fc_mlops/simulate_failure.py +1 -1
  9. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/fc_rag/cli.py +4 -6
  10. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/fc_rag/config.py +15 -0
  11. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/fc_rag/indexer.py +31 -6
  12. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/fc_rag/query_engine.py +15 -4
  13. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/fc_rag/retriever.py +11 -8
  14. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/firecloud/cli.py +146 -143
  15. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/firecloud/crypto.py +4 -32
  16. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/firecloud/distributor.py +99 -33
  17. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/firecloud/manifest.py +56 -14
  18. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/firecloud/node.py +181 -10
  19. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/firecloud/storage.py +29 -9
  20. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/firecloud/transport.py +184 -35
  21. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/pyproject.toml +1 -1
  22. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/tests/conftest.py +0 -2
  23. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/tests/test_chunker.py +4 -4
  24. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/tests/test_cli.py +0 -3
  25. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/tests/test_discovery.py +0 -1
  26. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/tests/test_distributor.py +134 -3
  27. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/tests/test_integration.py +0 -4
  28. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/tests/test_manifest.py +88 -0
  29. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/tests/test_network.py +0 -1
  30. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/tests/test_node.py +136 -5
  31. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/tests/test_storage.py +52 -0
  32. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/tests/test_sync.py +1 -2
  33. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/tests/test_transport.py +115 -10
  34. firecloud_devnet-0.1.0/CHANGELOG.md +0 -21
  35. firecloud_devnet-0.1.0/Dockerfile +0 -18
  36. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/.dockerignore +0 -0
  37. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/.env.example +0 -0
  38. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/.github/workflows/ci.yml +0 -0
  39. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/.github/workflows/publish.yml +0 -0
  40. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/LICENSE +0 -0
  41. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/fc_mlops/__init__.py +0 -0
  42. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/fc_mlops/__main__.py +0 -0
  43. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/fc_mlops/anomaly.py +0 -0
  44. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/fc_mlops/cli.py +0 -0
  45. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/fc_mlops/telemetry.py +0 -0
  46. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/fc_rag/__init__.py +0 -0
  47. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/fc_rag/embedder.py +0 -0
  48. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/fc_rag/requirements.txt +0 -0
  49. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/firecloud/__init__.py +0 -0
  50. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/firecloud/chunker.py +0 -0
  51. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/firecloud/discovery.py +0 -0
  52. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/firecloud/exceptions.py +0 -0
  53. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/firecloud/fec.py +0 -0
  54. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/firecloud/network.py +0 -0
  55. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/firecloud/sync.py +0 -0
  56. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/tests/__init__.py +0 -0
  57. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/tests/test_crypto.py +0 -0
  58. {firecloud_devnet-0.1.0 → firecloud_devnet-0.2.0}/tests/test_fec.py +0 -0
@@ -38,8 +38,9 @@ htmlcov/
38
38
  .DS_Store
39
39
  Thumbs.db
40
40
 
41
- # AI tool metadata — do not commit
42
- .antigravitycli/
43
-
44
41
  # Qdrant local data (created by fc-rag)
45
42
  ~/.fc_rag/
43
+
44
+ # Test configurations
45
+ test_config/
46
+
@@ -0,0 +1,64 @@
1
+ # Changelog
2
+
3
+ All notable changes to FireCloud will be documented in this file.
4
+
5
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Fixed
10
+ - **Erasure-coded files are now downloadable after the cluster shrinks.**
11
+ Download derived the placement strategy from the *live* peer count, so a
12
+ file uploaded with erasure coding became unreadable once fewer than 5
13
+ nodes were online — even with enough shares reachable. The strategy and
14
+ reconstruction threshold now come from the manifest entry.
15
+ - Manifest merges with equal Lamport timestamps now converge on a
16
+ deterministic winner (tombstone first, then uploader node ID) instead of
17
+ each node keeping its own version forever.
18
+ - Chunk stores on peers are now acknowledged; placement metadata
19
+ (`stored_on`) only records nodes that actually persisted the chunk, and
20
+ upload falls back to other nodes when a peer is full.
21
+ - Chunk retrieval responses are matched to requests by chunk ID, so a late
22
+ reply can no longer be delivered to the wrong request; retrieval and
23
+ handshake have timeouts instead of hanging forever on a silent peer.
24
+ - Frame length on the wire is capped (64 MiB) so a corrupt or hostile
25
+ length prefix cannot trigger a multi-gigabyte allocation.
26
+ - Corrupt FEC shares are detected via their integrity hash and skipped
27
+ during reconstruction instead of poisoning the decoded file.
28
+ - Chunk writes are atomic (write-temp-then-rename) and storage usage is
29
+ tracked incrementally instead of re-walking the chunk tree on every store.
30
+ - Manifest and artifact-store JSON files are written atomically.
31
+ - Auth token comparison is constant-time.
32
+ - Re-saving an `fc-ml` artifact with the same name+version replaces the
33
+ record instead of duplicating it.
34
+ - `fc-rag` re-indexing no longer duplicates vector points (deterministic
35
+ point IDs + per-file cleanup), and indexing/retrieval share one embedded
36
+ Qdrant client.
37
+
38
+ ### Added
39
+ - `firecloud verify [FILE_ID]` — checks chunk availability and integrity
40
+ across the network and reports each file as healthy / degraded /
41
+ unrecoverable (non-zero exit on unrecoverable files).
42
+ - `FIRECLOUD_PASSPHRASE`, `FIRECLOUD_DATA_DIR`, and
43
+ `FIRECLOUD_MAX_STORAGE_GB` environment variables are honored by the CLI
44
+ (docker-compose already set them; the code now reads them).
45
+ - `--bootstrap host:port` option on `start`, `upload`, `download`,
46
+ `delete`, `verify`, and `sync` to connect to a peer without mDNS.
47
+ - Peer-side store/has-chunk protocol messages (`STORE_OK`, `STORE_FAIL`,
48
+ `HAS_CHUNK`) backing the fixes above.
49
+
50
+ ## [0.1.0] - 2025-06-01
51
+
52
+ ### Added
53
+ - XChaCha20-Poly1305 chunk encryption with HMAC-SHA-256 keyed addressing
54
+ - FastCDC content-defined chunking
55
+ - zfec erasure coding (configurable k/n)
56
+ - mDNS peer discovery via zeroconf with config file fallback
57
+ - TLS binary RPC transport with handshake and heartbeat
58
+ - Manifest with Lamport timestamps and tombstone support
59
+ - Watchdog-based folder sync (outbound upload, inbound download)
60
+ - Click CLI (`firecloud` entry point) — init, start, upload, download, status, peers
61
+ - Docker Compose multi-node setup with health checks
62
+ - GitHub Actions CI (lint → test → build)
63
+ - `fc-rag`: local RAG pipeline (fastembed + Qdrant + Ollama)
64
+ - `fc-ml`: ML artifact versioning, telemetry server, anomaly detection
@@ -0,0 +1,12 @@
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN pip install --no-cache-dir --upgrade pip
6
+
7
+ COPY . .
8
+ RUN pip install --no-cache-dir .
9
+
10
+ EXPOSE 7474
11
+
12
+ ENTRYPOINT ["firecloud"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firecloud-devnet
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Private, encrypted, distributed storage across your own machines
5
5
  Project-URL: Homepage, https://github.com/rajashekharsunkara/firecloud
6
6
  Project-URL: Repository, https://github.com/rajashekharsunkara/firecloud
@@ -87,8 +87,27 @@ docker exec firecloud-bootstrap firecloud upload /data/my-file.zip
87
87
 
88
88
  # 3. Download from any node
89
89
  docker exec firecloud-node-1 firecloud download <file_id> /data/restored.zip
90
+
91
+ # 4. Check replica/share health across the network
92
+ docker exec firecloud-bootstrap firecloud verify
93
+ ```
94
+
95
+ ### CLI essentials
96
+
97
+ ```bash
98
+ firecloud init # create a network keystore
99
+ firecloud start --bootstrap host:7474 # run a node, optionally joining a peer
100
+ firecloud upload <path> # chunk → encrypt → distribute
101
+ firecloud download <file_id> <output> # retrieve → verify → reassemble
102
+ firecloud verify [file_id] # health report: healthy / degraded / unrecoverable
103
+ firecloud sync <folder> # bi-directional folder sync
90
104
  ```
91
105
 
106
+ Environment variables (all optional): `FIRECLOUD_PASSPHRASE` (skip the
107
+ passphrase prompt), `FIRECLOUD_DATA_DIR` (default storage directory),
108
+ `FIRECLOUD_MAX_STORAGE_GB` (chunk-store quota), and `FIRECLOUD_BOOTSTRAP`
109
+ (comma-separated peers to connect to on start).
110
+
92
111
  ---
93
112
 
94
113
  ## Architecture
@@ -116,6 +135,10 @@ docker exec firecloud-node-1 firecloud download <file_id> /data/restored.zip
116
135
 
117
136
  FireCloud uses **HMAC-SHA-256 with a network-derived key** for chunk addressing instead of plain SHA-256. This raises the cost of confirmation-of-file attacks — an attacker who suspects a specific file is stored cannot verify its presence by computing chunk hashes from the plaintext, because valid chunk IDs require the network key. This protection holds as long as the network key remains confidential.
118
137
 
138
+ Chunks are encrypted with XChaCha20-Poly1305 before leaving the machine, so storage nodes only ever see authenticated ciphertext. Transport hardening includes a frame-size cap, handshake and request timeouts, and constant-time auth-token comparison.
139
+
140
+ **Known limitations (devnet):** node TLS certificates are self-signed and clients do not verify them, so the TLS layer protects against passive snooping but not an active man-in-the-middle on your LAN; the chunk payloads themselves remain end-to-end encrypted regardless. Deploy only on networks you control.
141
+
119
142
  ---
120
143
 
121
144
  ## AI/ML Extensions
@@ -35,8 +35,27 @@ docker exec firecloud-bootstrap firecloud upload /data/my-file.zip
35
35
 
36
36
  # 3. Download from any node
37
37
  docker exec firecloud-node-1 firecloud download <file_id> /data/restored.zip
38
+
39
+ # 4. Check replica/share health across the network
40
+ docker exec firecloud-bootstrap firecloud verify
41
+ ```
42
+
43
+ ### CLI essentials
44
+
45
+ ```bash
46
+ firecloud init # create a network keystore
47
+ firecloud start --bootstrap host:7474 # run a node, optionally joining a peer
48
+ firecloud upload <path> # chunk → encrypt → distribute
49
+ firecloud download <file_id> <output> # retrieve → verify → reassemble
50
+ firecloud verify [file_id] # health report: healthy / degraded / unrecoverable
51
+ firecloud sync <folder> # bi-directional folder sync
38
52
  ```
39
53
 
54
+ Environment variables (all optional): `FIRECLOUD_PASSPHRASE` (skip the
55
+ passphrase prompt), `FIRECLOUD_DATA_DIR` (default storage directory),
56
+ `FIRECLOUD_MAX_STORAGE_GB` (chunk-store quota), and `FIRECLOUD_BOOTSTRAP`
57
+ (comma-separated peers to connect to on start).
58
+
40
59
  ---
41
60
 
42
61
  ## Architecture
@@ -64,6 +83,10 @@ docker exec firecloud-node-1 firecloud download <file_id> /data/restored.zip
64
83
 
65
84
  FireCloud uses **HMAC-SHA-256 with a network-derived key** for chunk addressing instead of plain SHA-256. This raises the cost of confirmation-of-file attacks — an attacker who suspects a specific file is stored cannot verify its presence by computing chunk hashes from the plaintext, because valid chunk IDs require the network key. This protection holds as long as the network key remains confidential.
66
85
 
86
+ Chunks are encrypted with XChaCha20-Poly1305 before leaving the machine, so storage nodes only ever see authenticated ciphertext. Transport hardening includes a frame-size cap, handshake and request timeouts, and constant-time auth-token comparison.
87
+
88
+ **Known limitations (devnet):** node TLS certificates are self-signed and clients do not verify them, so the TLS layer protects against passive snooping but not an active man-in-the-middle on your LAN; the chunk payloads themselves remain end-to-end encrypted regardless. Deploy only on networks you control.
89
+
67
90
  ---
68
91
 
69
92
  ## AI/ML Extensions
@@ -6,6 +6,7 @@ services:
6
6
  - "7474:7474"
7
7
  volumes:
8
8
  - fc-bootstrap-data:/data
9
+ - ./test_config/.firecloud:/root/.firecloud:ro,z
9
10
  environment:
10
11
  - FIRECLOUD_PASSPHRASE=${FIRECLOUD_PASSPHRASE}
11
12
  - FIRECLOUD_MAX_STORAGE_GB=${FIRECLOUD_MAX_STORAGE_GB:-10}
@@ -27,6 +28,7 @@ services:
27
28
  - "7475:7475"
28
29
  volumes:
29
30
  - fc-node1-data:/data
31
+ - ./test_config/.firecloud:/root/.firecloud:ro,z
30
32
  environment:
31
33
  - FIRECLOUD_PASSPHRASE=${FIRECLOUD_PASSPHRASE}
32
34
  - FIRECLOUD_BOOTSTRAP=bootstrap-node:7474
@@ -51,6 +53,7 @@ services:
51
53
  - "7476:7476"
52
54
  volumes:
53
55
  - fc-node2-data:/data
56
+ - ./test_config/.firecloud:/root/.firecloud:ro,z
54
57
  environment:
55
58
  - FIRECLOUD_PASSPHRASE=${FIRECLOUD_PASSPHRASE}
56
59
  - FIRECLOUD_BOOTSTRAP=bootstrap-node:7474
@@ -75,6 +78,7 @@ services:
75
78
  - "7477:7477"
76
79
  volumes:
77
80
  - fc-node3-data:/data
81
+ - ./test_config/.firecloud:/root/.firecloud:ro,z
78
82
  environment:
79
83
  - FIRECLOUD_PASSPHRASE=${FIRECLOUD_PASSPHRASE}
80
84
  - FIRECLOUD_BOOTSTRAP=bootstrap-node:7474
@@ -36,10 +36,13 @@ def _load_manifest() -> list[dict]:
36
36
 
37
37
  def _save_manifest(entries: list[dict]) -> None:
38
38
  _MANIFEST_PATH.parent.mkdir(parents=True, exist_ok=True)
39
- _MANIFEST_PATH.write_text(
39
+ # Write-then-rename so a crash mid-write cannot truncate the manifest.
40
+ tmp_path = _MANIFEST_PATH.with_name(_MANIFEST_PATH.name + ".tmp")
41
+ tmp_path.write_text(
40
42
  json.dumps(entries, indent=2, default=str),
41
43
  encoding="utf-8",
42
44
  )
45
+ tmp_path.replace(_MANIFEST_PATH)
43
46
 
44
47
 
45
48
  async def save_artifact(
@@ -70,6 +73,12 @@ async def save_artifact(
70
73
  )
71
74
 
72
75
  entries = _load_manifest()
76
+ # Re-saving the same name+version replaces the old record — otherwise
77
+ # load_artifact would keep returning the stale first match forever.
78
+ entries = [
79
+ e for e in entries
80
+ if not (e.get("name") == name and e.get("version") == version)
81
+ ]
73
82
  entries.append(metadata.model_dump())
74
83
  _save_manifest(entries)
75
84
  return metadata
@@ -62,7 +62,7 @@ def main() -> None:
62
62
 
63
63
  # inject failures
64
64
  console.print("[bold yellow][Phase 2][/bold yellow] Injecting failure signatures...")
65
- for _ in range(10):
65
+ for _ in range(3):
66
66
  _write_reading(log_path, _anomalous_reading())
67
67
 
68
68
  # detect
@@ -36,14 +36,12 @@ def index(path: str):
36
36
  def query(question: str):
37
37
  """Query the local RAG pipeline with a natural-language question."""
38
38
  from fc_rag.query_engine import query as run_query
39
- from fc_rag.retriever import retrieve
40
39
 
41
- answer = run_query(question)
42
- click.echo(answer)
40
+ response = run_query(question)
41
+ click.echo(response.answer)
43
42
 
44
- results = retrieve(question)
45
- if results:
46
- sources = sorted(set(r.filename for r in results))
43
+ if response.sources:
44
+ sources = sorted(set(r.filename for r in response.sources))
47
45
  click.echo(f"\nSources: {', '.join(sources)}")
48
46
 
49
47
 
@@ -22,3 +22,18 @@ class Settings(BaseModel):
22
22
  @lru_cache(maxsize=1)
23
23
  def get_settings() -> Settings:
24
24
  return Settings()
25
+
26
+
27
+ @lru_cache(maxsize=1)
28
+ def get_client():
29
+ """Shared embedded-Qdrant client.
30
+
31
+ Embedded Qdrant locks its storage directory per client instance, so
32
+ indexing and retrieval within one process must reuse a single client
33
+ rather than each opening their own.
34
+ """
35
+ from qdrant_client import QdrantClient
36
+
37
+ settings = get_settings()
38
+ settings.qdrant_path.mkdir(parents=True, exist_ok=True)
39
+ return QdrantClient(path=str(settings.qdrant_path))
@@ -5,10 +5,18 @@ from datetime import datetime, timezone
5
5
  from pathlib import Path
6
6
 
7
7
  from qdrant_client import QdrantClient
8
- from qdrant_client.models import Distance, PointStruct, VectorParams
8
+ from qdrant_client.models import (
9
+ Distance,
10
+ FieldCondition,
11
+ Filter,
12
+ FilterSelector,
13
+ MatchValue,
14
+ PointStruct,
15
+ VectorParams,
16
+ )
9
17
  from rich.progress import Progress
10
18
 
11
- from fc_rag.config import get_settings
19
+ from fc_rag.config import get_client, get_settings
12
20
  from fc_rag.embedder import chunk_text, embed_chunks
13
21
 
14
22
  _SUPPORTED_EXTENSIONS = {".txt", ".md", ".py", ".json"}
@@ -64,9 +72,7 @@ def index_path(path: Path) -> int:
64
72
  _safety_check(path)
65
73
 
66
74
  settings = get_settings()
67
- settings.qdrant_path.mkdir(parents=True, exist_ok=True)
68
-
69
- client = QdrantClient(path=str(settings.qdrant_path))
75
+ client = get_client()
70
76
  _ensure_collection(client, settings.collection_name)
71
77
 
72
78
  if path.is_file():
@@ -99,9 +105,28 @@ def index_path(path: Path) -> int:
99
105
  vectors = embed_chunks(chunks)
100
106
  now = datetime.now(timezone.utc).isoformat()
101
107
 
108
+ # Drop previously indexed points for this file so re-indexing
109
+ # cannot accumulate duplicates or leave stale trailing chunks.
110
+ client.delete(
111
+ collection_name=settings.collection_name,
112
+ points_selector=FilterSelector(
113
+ filter=Filter(
114
+ must=[
115
+ FieldCondition(
116
+ key="filepath",
117
+ match=MatchValue(value=str(filepath)),
118
+ )
119
+ ]
120
+ )
121
+ ),
122
+ )
123
+
102
124
  points = [
103
125
  PointStruct(
104
- id=str(uuid.uuid4()),
126
+ # Deterministic ID per (file, chunk position) — an
127
+ # upsert of the same file overwrites instead of
128
+ # duplicating.
129
+ id=str(uuid.uuid5(uuid.NAMESPACE_URL, f"{filepath}::{i}")),
105
130
  vector=vec,
106
131
  payload={
107
132
  "filename": filepath.name,
@@ -6,12 +6,23 @@ import time
6
6
  from datetime import datetime, timezone
7
7
 
8
8
  import ollama
9
+ from pydantic import BaseModel, ConfigDict
10
+
9
11
  from fc_rag.config import get_settings
10
- from fc_rag.retriever import retrieve
12
+ from fc_rag.retriever import RetrievalResult, retrieve
13
+
14
+
15
+ class QueryResponse(BaseModel):
16
+ """Answer plus the retrieved chunks it was grounded on."""
17
+
18
+ model_config = ConfigDict(frozen=True)
19
+
20
+ answer: str
21
+ sources: list[RetrievalResult]
11
22
 
12
23
 
13
- def query(user_question: str) -> str:
14
- """Run the full RAG pipeline and return the LLM's answer."""
24
+ def query(user_question: str) -> QueryResponse:
25
+ """Run the full RAG pipeline and return the answer with its sources."""
15
26
  settings = get_settings()
16
27
  start = time.monotonic()
17
28
 
@@ -76,4 +87,4 @@ def query(user_question: str) -> str:
76
87
  "success": success,
77
88
  }, default=str) + "\n")
78
89
 
79
- return answer
90
+ return QueryResponse(answer=answer, sources=results)
@@ -1,9 +1,8 @@
1
1
  """Search the local Qdrant collection for relevant chunks."""
2
2
 
3
3
  from pydantic import BaseModel, ConfigDict
4
- from qdrant_client import QdrantClient
5
4
 
6
- from fc_rag.config import get_settings
5
+ from fc_rag.config import get_client, get_settings
7
6
  from fc_rag.embedder import embed_chunks
8
7
 
9
8
 
@@ -26,13 +25,17 @@ def retrieve(query: str, top_k: int | None = None) -> list[RetrievalResult]:
26
25
  return []
27
26
  query_vector = vectors[0]
28
27
 
29
- client = QdrantClient(path=str(settings.qdrant_path))
28
+ client = get_client()
30
29
 
31
- response = client.query_points(
32
- collection_name=settings.collection_name,
33
- query=query_vector,
34
- limit=k,
35
- )
30
+ try:
31
+ response = client.query_points(
32
+ collection_name=settings.collection_name,
33
+ query=query_vector,
34
+ limit=k,
35
+ )
36
+ except Exception:
37
+ # Collection does not exist yet — nothing has been indexed.
38
+ return []
36
39
  results = response.points
37
40
 
38
41
  return [