Coverage for cli / vector_store.py: 100.00%

264 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1"""Operator client for the GCO vector store (``gco vector``). 

2 

3:class:`VectorStoreClient` is the CLI-side runtime for the 

4``{project}-vector-store`` DynamoDB global table provisioned by 

5``gco/stacks/global_stack.py`` when ``vector_store.enabled`` is set: an 

6S3-ingested, embedded document corpus searchable by similarity through 

7the ``corpus-embedding-index`` vector index, replicated to every 

8deployment region. 

9 

10Three operations: 

11 

12* :meth:`VectorStoreClient.ingest` — upload documents to the 

13 cluster-shared bucket's corpus prefix; the S3-triggered 

14 ``lambda/vector-ingest`` handler chunks, embeds, and writes them. 

15 Optionally wait until every uploaded document is searchable. 

16* :meth:`VectorStoreClient.search` — embed a query with the corpus's own 

17 model and ``SearchVectors`` the index, optionally against a specific 

18 replica region and/or filtered to one source document. 

19* :meth:`VectorStoreClient.status` — table/replica/index state plus the 

20 resolved names, for "is it ready yet?" (the index takes several 

21 minutes to reach ACTIVE after first deploy). 

22 

23Resolution conventions, copied from their precedents: 

24 

25* **Region** follows the store convention documented in 

26 ``gco/services/template_store.py``: ``DYNAMODB_REGION`` → 

27 ``GLOBAL_REGION`` → ``AWS_REGION``, else the SDK default chain. A 

28 ``query_region`` override retargets ONLY the DynamoDB data client — 

29 that is how ``gco vector search --region`` reads a specific replica — 

30 while SSM discovery and embedding stay on the default chain (the 

31 parameters live in the global region; the query vector can be 

32 produced anywhere). 

33* **Names** resolve lazily from SSM (``/{project}/vector-store-table-name``, 

34 ``/{project}/vector-store-index-name``, and the cluster-shared bucket 

35 metadata under ``/{project}/cluster-shared-bucket/``) on first use and 

36 are cached on the instance, the same shape as 

37 ``mcp.mission.memory.MissionMemoryStore``. 

38 

39Request-shape gotchas, live-verified in the Phase 2 spike: 

40``SearchVectors``' ``SearchVector`` parameter is a plain list of 

41``{"N": "..."}`` attribute values (the ``{"L": ...}`` wrapper is a 

42write-side shape), and the response carries hits under ``SearchResults`` 

43as ``{"Item": ..., "Score": float}`` — a lower COSINE score is closer. 

44 

45Failure contract: infrastructure that is absent or not yet queryable — 

46table/index missing, the index still building after first deploy 

47(``SearchVectors`` answers ValidationException for several minutes), SSM 

48parameters never published because the feature is disabled, missing 

49credentials, unreachable endpoint — raises 

50:class:`VectorStoreUnavailableError`; everything else raises 

51:class:`VectorStoreError`. The CLI surfaces both messages. 

52 

53The defaults below mirror the ``vector_store`` block in ``cdk.json`` — 

54the block drives the *deployed index and ingest pipeline*, these drive 

55the *query vectors sent to it*, and ``tests/test_vector_cli.py`` asserts 

56the two stay in agreement. ``dimensions`` is a one-way door: immutable 

57after index creation, and query vectors must come from the same model at 

58the same width or similarity scores are meaningless. 

59""" 

60 

61from __future__ import annotations 

62 

63import json 

64import logging 

65import os 

66import subprocess 

67import time 

68import tomllib 

69from pathlib import Path 

70from typing import Any 

71 

72logger = logging.getLogger(__name__) 

73 

74__all__ = [ 

75 "DEFAULT_CORPUS_PREFIX", 

76 "DEFAULT_DIMENSIONS", 

77 "DEFAULT_EMBEDDING_MODEL_ID", 

78 "DEFAULT_TOP_K", 

79 "VectorStoreClient", 

80 "VectorStoreError", 

81 "VectorStoreUnavailableError", 

82] 

83 

84#: Defaults mirroring the ``vector_store`` block in ``cdk.json``. 

85DEFAULT_DIMENSIONS = 1024 

86DEFAULT_EMBEDDING_MODEL_ID = "amazon.titan-embed-text-v2:0" 

87DEFAULT_CORPUS_PREFIX = "vector-corpus/" 

88DEFAULT_TOP_K = 5 

89 

90#: Model-id substrings whose Titan request body accepts the V2-only 

91#: ``dimensions`` key — the identical contract the ingest Lambda applies 

92#: (``lambda/vector-ingest/handler.py``), pinned against it by 

93#: ``tests/test_vector_cli.py`` so query and corpus vectors can never 

94#: diverge in request shape. 

95DIMENSIONS_CAPABLE_MODEL_MARKERS = ("titan-embed-text-v2",) 

96 

97_TABLE_NAME_PARAM_SUFFIX = "vector-store-table-name" 

98_INDEX_NAME_PARAM_SUFFIX = "vector-store-index-name" 

99_BUCKET_NAME_PARAM_SUFFIX = "cluster-shared-bucket/name" 

100_BUCKET_REGION_PARAM_SUFFIX = "cluster-shared-bucket/region" 

101 

102#: Suffixes the ingest pipeline understands; everything else is skipped 

103#: server-side, so refusing the upload client-side is kinder. 

104_INGESTIBLE_SUFFIXES = (".txt", ".md", ".jsonl") 

105 

106_UNAVAILABLE_HINT = ( 

107 "the vector store is unavailable — the table or index may not be " 

108 "provisioned (deploy with vector_store.enabled: true), or the vector " 

109 "index may still be building after the first deploy (several minutes)" 

110) 

111 

112#: Region resolution order shared with the sibling DynamoDB stores. 

113_REGION_ENV_ORDER = ("DYNAMODB_REGION", "GLOBAL_REGION", "AWS_REGION") 

114 

115 

116class VectorStoreError(RuntimeError): 

117 """A vector-store operation failed.""" 

118 

119 

120class VectorStoreUnavailableError(VectorStoreError): 

121 """The vector-store infrastructure is absent or not yet queryable. 

122 

123 Raised for the degradable cases: table/index not provisioned, SSM 

124 name parameters never published, the vector index still building 

125 after creation, missing credentials, or an unreachable endpoint — 

126 so an operator can tell "no matches" from "not there". 

127 """ 

128 

129 

130def _resolve_region() -> str | None: 

131 """Return the first configured region env var, else ``None``.""" 

132 for env_var in _REGION_ENV_ORDER: 

133 value = os.environ.get(env_var) 

134 if value: 

135 return value 

136 return None 

137 

138 

139def _number_attr(value: float) -> dict[str, str]: 

140 """Render one vector component as a DynamoDB number attribute value.""" 

141 return {"N": repr(float(value))} 

142 

143 

144def _plain(value: Any) -> Any: 

145 """Collapse ``TypeDeserializer`` output into JSON-friendly primitives.""" 

146 from decimal import Decimal 

147 

148 if isinstance(value, Decimal): 

149 return int(value) if value == value.to_integral_value() else float(value) 

150 if isinstance(value, list): 

151 return [_plain(entry) for entry in value] 

152 if isinstance(value, dict): 

153 return {key: _plain(entry) for key, entry in value.items()} 

154 return value 

155 

156 

157def _embedding_request_body(text: str, model_id: str, dimensions: int) -> str: 

158 """Build the Titan-contract request body for one query. 

159 

160 Byte-identical to the ingest Lambda's builder: the ``dimensions`` 

161 key rides only for model families known to accept it, so a V1-style 

162 corpus keeps working and a V2 corpus always pins the width. 

163 """ 

164 body: dict[str, Any] = {"inputText": text} 

165 if any(marker in model_id for marker in DIMENSIONS_CAPABLE_MODEL_MARKERS): 

166 body["dimensions"] = dimensions 

167 return json.dumps(body) 

168 

169 

170class VectorStoreClient: 

171 """Operator client for ingest, search, and status. 

172 

173 Constructor arguments exist for tests and for callers that already 

174 know the deployed names; with no arguments every name resolves 

175 lazily from SSM on first use, so constructing a client on a host 

176 without AWS credentials is free. 

177 

178 Args: 

179 table_name: Table name override; ``None`` resolves from SSM. 

180 index_name: Index name override; ``None`` resolves from SSM. 

181 bucket_name: Corpus bucket override; ``None`` resolves from SSM. 

182 query_region: Region whose replica the DynamoDB data client 

183 reads (``gco vector search --region``). ``None`` follows the 

184 default chain. SSM and embedding never follow this override. 

185 embedding_model_id: Embedding model override. Must match the 

186 model the corpus was ingested with. 

187 dimensions: Requested embedding width. Must match the deployed 

188 index width (one-way door). 

189 corpus_prefix: S3 key prefix the ingest pipeline watches. 

190 """ 

191 

192 def __init__( 

193 self, 

194 table_name: str | None = None, 

195 index_name: str | None = None, 

196 *, 

197 bucket_name: str | None = None, 

198 query_region: str | None = None, 

199 embedding_model_id: str = DEFAULT_EMBEDDING_MODEL_ID, 

200 dimensions: int = DEFAULT_DIMENSIONS, 

201 corpus_prefix: str = DEFAULT_CORPUS_PREFIX, 

202 ) -> None: 

203 self._table_name = table_name 

204 self._index_name = index_name 

205 self._bucket_name = bucket_name 

206 self._bucket_region: str | None = None 

207 self._query_region = query_region 

208 self._embedding_model_id = embedding_model_id 

209 self._dimensions = int(dimensions) 

210 self._corpus_prefix = corpus_prefix 

211 self._dynamodb_client: Any = None 

212 self._bedrock_client: Any = None 

213 self._s3_client: Any = None 

214 

215 # ------------------------------------------------------------------ # 

216 # internals 

217 # ------------------------------------------------------------------ # 

218 

219 def _resolve_ssm_name(self, suffix: str) -> str: 

220 """Fetch ``/{project}/{suffix}`` from SSM, mapping absence to unavailable.""" 

221 from botocore.exceptions import BotoCoreError, ClientError 

222 

223 from gco.services.aws_ssm import get_ssm_parameter 

224 

225 project_name = os.environ.get("GCO_PROJECT_NAME", "gco") 

226 param_name = f"/{project_name}/{suffix}" 

227 try: 

228 return get_ssm_parameter(param_name, region=_resolve_region()) 

229 except ClientError as err: 

230 code = err.response.get("Error", {}).get("Code") 

231 if code == "ParameterNotFound": 

232 raise VectorStoreUnavailableError( 

233 f"SSM parameter {param_name} not found — {_UNAVAILABLE_HINT}" 

234 ) from err 

235 raise VectorStoreError(f"SSM lookup for {param_name} failed: {err}") from err 

236 except BotoCoreError as err: 

237 raise VectorStoreUnavailableError( 

238 f"SSM unreachable resolving {param_name}{_UNAVAILABLE_HINT}" 

239 ) from err 

240 

241 def _resolve_table_name(self) -> str: 

242 if self._table_name is None: 

243 self._table_name = self._resolve_ssm_name(_TABLE_NAME_PARAM_SUFFIX) 

244 return self._table_name 

245 

246 def _resolve_index_name(self) -> str: 

247 if self._index_name is None: 

248 self._index_name = self._resolve_ssm_name(_INDEX_NAME_PARAM_SUFFIX) 

249 return self._index_name 

250 

251 def _resolve_bucket(self) -> tuple[str, str | None]: 

252 """Return ``(bucket_name, bucket_region)`` for corpus uploads.""" 

253 if self._bucket_name is None: 

254 self._bucket_name = self._resolve_ssm_name(_BUCKET_NAME_PARAM_SUFFIX) 

255 self._bucket_region = self._resolve_ssm_name(_BUCKET_REGION_PARAM_SUFFIX) 

256 return self._bucket_name, self._bucket_region 

257 

258 def _get_dynamodb_client(self) -> Any: 

259 if self._dynamodb_client is None: 

260 import boto3 

261 

262 self._dynamodb_client = boto3.client( 

263 "dynamodb", region_name=self._query_region or _resolve_region() 

264 ) 

265 return self._dynamodb_client 

266 

267 def _get_bedrock_client(self) -> Any: 

268 if self._bedrock_client is None: 

269 import boto3 

270 

271 self._bedrock_client = boto3.client("bedrock-runtime", region_name=_resolve_region()) 

272 return self._bedrock_client 

273 

274 def _get_s3_client(self, bucket_region: str | None) -> Any: 

275 if self._s3_client is None: 

276 import boto3 

277 

278 self._s3_client = boto3.client("s3", region_name=bucket_region or _resolve_region()) 

279 return self._s3_client 

280 

281 def _embed(self, text: str) -> list[float]: 

282 """Embed ``text`` at the configured width and guard the one-way door.""" 

283 from botocore.exceptions import ( 

284 BotoCoreError, 

285 ClientError, 

286 NoCredentialsError, 

287 PartialCredentialsError, 

288 ) 

289 

290 if not text.strip(): 

291 raise VectorStoreError("query text must be non-empty") 

292 

293 try: 

294 response = self._get_bedrock_client().invoke_model( 

295 modelId=self._embedding_model_id, 

296 body=_embedding_request_body(text, self._embedding_model_id, self._dimensions), 

297 contentType="application/json", 

298 accept="application/json", 

299 ) 

300 except (NoCredentialsError, PartialCredentialsError) as err: 

301 raise VectorStoreUnavailableError(f"no AWS credentials — {_UNAVAILABLE_HINT}") from err 

302 except ClientError as err: 

303 code = err.response.get("Error", {}).get("Code") or "ClientError" 

304 raise VectorStoreError(f"query embedding failed (bedrock {code}): {err}") from err 

305 except BotoCoreError as err: 

306 raise VectorStoreUnavailableError(f"Bedrock unreachable — {_UNAVAILABLE_HINT}") from err 

307 

308 payload = json.loads(response["body"].read()) 

309 vector = payload.get("embedding") if isinstance(payload, dict) else None 

310 if not isinstance(vector, list) or not vector: 

311 raise VectorStoreError( 

312 f"embedding response from {self._embedding_model_id!r} carried no vector" 

313 ) 

314 if len(vector) != self._dimensions: 

315 raise VectorStoreError( 

316 f"embedding width {len(vector)} does not match the configured index " 

317 f"width {self._dimensions}; the model {self._embedding_model_id!r} " 

318 "ignored the requested dimensions — align vector_store.dimensions " 

319 "with the model's output width (both are one-way doors on the " 

320 "deployed index)" 

321 ) 

322 return [float(value) for value in vector] 

323 

324 # ------------------------------------------------------------------ # 

325 # operations 

326 # ------------------------------------------------------------------ # 

327 

328 def search( 

329 self, 

330 query: str, 

331 top_k: int = DEFAULT_TOP_K, 

332 source: str | None = None, 

333 ) -> list[dict[str, Any]]: 

334 """Return the corpus chunks most similar to ``query``. 

335 

336 Args: 

337 query: Query text; embedded with the same model and width 

338 the index was built for. 

339 top_k: Number of results to request. 

340 source: When set, an inline equality filter on the chunk's 

341 ``source`` (the full S3 object key) — the one attribute 

342 the index declares as an ``INLINE_FILTER``. 

343 

344 Returns: 

345 A list of plain dicts — the index's ``INCLUDE`` projection 

346 (``text``, ``source``, ``chunk_index``, ``title``, 

347 ``embedding_model_id``) plus ``doc_id`` and a float 

348 ``score`` (lower is closer under COSINE). 

349 

350 Raises: 

351 VectorStoreUnavailableError: Table/index absent, index still 

352 building, no credentials, or endpoint unreachable. 

353 VectorStoreError: ``top_k`` < 1 or any other failure. 

354 """ 

355 from botocore.exceptions import ( 

356 BotoCoreError, 

357 ClientError, 

358 NoCredentialsError, 

359 PartialCredentialsError, 

360 ) 

361 

362 if int(top_k) < 1: 

363 raise VectorStoreError(f"top_k must be a positive integer, got {top_k!r}") 

364 

365 # Resolve names before embedding: a deployment without the feature 

366 # fails fast on the SSM lookup instead of paying a Bedrock call. 

367 table_name = self._resolve_table_name() 

368 index_name = self._resolve_index_name() 

369 vector = self._embed(query) 

370 

371 request: dict[str, Any] = { 

372 "TableName": table_name, 

373 "IndexName": index_name, 

374 "SearchVector": [_number_attr(value) for value in vector], 

375 "TopK": int(top_k), 

376 } 

377 if source is not None: 

378 # ``source`` is a DynamoDB reserved keyword (live-verified: the 

379 # bare name is a ValidationException), so it rides behind an 

380 # ExpressionAttributeNames alias — same as the ingest-wait Scan. 

381 request["SearchConditionExpression"] = "#source = :source" 

382 request["ExpressionAttributeNames"] = {"#source": "source"} 

383 request["ExpressionAttributeValues"] = {":source": {"S": str(source)}} 

384 

385 try: 

386 response = self._get_dynamodb_client().search_vectors(**request) 

387 except (NoCredentialsError, PartialCredentialsError) as err: 

388 raise VectorStoreUnavailableError(f"no AWS credentials — {_UNAVAILABLE_HINT}") from err 

389 except ClientError as err: 

390 code = err.response.get("Error", {}).get("Code") 

391 if code in ("ResourceNotFoundException", "ValidationException"): 

392 # ValidationException is what SearchVectors answers while 

393 # the index is still building after creation. 

394 raise VectorStoreUnavailableError( 

395 f"SearchVectors failed ({code}) — {_UNAVAILABLE_HINT}: {err}" 

396 ) from err 

397 raise VectorStoreError(f"vector-store search failed: {err}") from err 

398 except BotoCoreError as err: 

399 raise VectorStoreUnavailableError( 

400 f"DynamoDB unreachable — {_UNAVAILABLE_HINT}" 

401 ) from err 

402 

403 from boto3.dynamodb.types import TypeDeserializer 

404 

405 deserializer = TypeDeserializer() 

406 results: list[dict[str, Any]] = [] 

407 for entry in response.get("SearchResults", []): 

408 item = { 

409 key: _plain(deserializer.deserialize(value)) 

410 for key, value in (entry.get("Item") or {}).items() 

411 } 

412 # Defensive: the INCLUDE projection excludes the vector, but a 

413 # recreated index with a different projection must not flood 

414 # terminal output with a thousand floats. 

415 item.pop("embedding", None) 

416 score = entry.get("Score") 

417 if score is not None: 

418 item["score"] = float(score) 

419 results.append(item) 

420 return results 

421 

422 def status(self) -> dict[str, Any]: 

423 """Return table, replica, and index state for the store. 

424 

425 The index takes several minutes to reach ACTIVE after the first 

426 deploy (queries answer ValidationException until then), so this 

427 is the "is it ready yet?" surface. 

428 

429 Raises: 

430 VectorStoreUnavailableError: SSM names absent (feature not 

431 provisioned), the table missing, no credentials, or the 

432 endpoint unreachable. 

433 VectorStoreError: Any other failure. 

434 """ 

435 from botocore.exceptions import ( 

436 BotoCoreError, 

437 ClientError, 

438 NoCredentialsError, 

439 PartialCredentialsError, 

440 ) 

441 

442 table_name = self._resolve_table_name() 

443 index_name = self._resolve_index_name() 

444 try: 

445 table = self._get_dynamodb_client().describe_table(TableName=table_name)["Table"] 

446 except (NoCredentialsError, PartialCredentialsError) as err: 

447 raise VectorStoreUnavailableError(f"no AWS credentials — {_UNAVAILABLE_HINT}") from err 

448 except ClientError as err: 

449 code = err.response.get("Error", {}).get("Code") 

450 if code == "ResourceNotFoundException": 

451 raise VectorStoreUnavailableError( 

452 f"table {table_name} not found in this region — {_UNAVAILABLE_HINT}" 

453 ) from err 

454 raise VectorStoreError(f"DescribeTable failed: {err}") from err 

455 except BotoCoreError as err: 

456 raise VectorStoreUnavailableError( 

457 f"DynamoDB unreachable — {_UNAVAILABLE_HINT}" 

458 ) from err 

459 

460 # Vector indexes surface under a dedicated describe key; read it 

461 # defensively (any key naming "Vector") the way the Phase 2 spike 

462 # did, so a rename in the evolving API degrades to NOT_VISIBLE 

463 # instead of a KeyError. 

464 index_status = "NOT_VISIBLE" 

465 for key, value in table.items(): 

466 if "Vector" in key and isinstance(value, list): 

467 for index in value: 

468 if isinstance(index, dict) and index.get("IndexName") == index_name: 

469 index_status = str(index.get("IndexStatus", "UNKNOWN")) 

470 return { 

471 "table_name": table_name, 

472 "index_name": index_name, 

473 "region": self._query_region or _resolve_region() or "sdk-default", 

474 "table_status": table.get("TableStatus"), 

475 "item_count": int(table.get("ItemCount", 0)), 

476 "index_status": index_status, 

477 "replicas": [ 

478 { 

479 "region": replica.get("RegionName"), 

480 "status": replica.get("ReplicaStatus"), 

481 } 

482 for replica in table.get("Replicas", []) 

483 ], 

484 } 

485 

486 def ingest( 

487 self, 

488 paths: list[Path], 

489 *, 

490 wait_timeout_seconds: int = 0, 

491 ) -> dict[str, Any]: 

492 """Upload documents to the corpus prefix and optionally wait. 

493 

494 Uploading IS ingestion: the S3 notification invokes the ingest 

495 Lambda, which chunks, embeds, and writes the items. With 

496 ``wait_timeout_seconds`` > 0, polls the table until every 

497 uploaded document has at least one chunk item (matching on the 

498 chunk's ``source`` key) or the timeout elapses. 

499 

500 Args: 

501 paths: Files to upload. Every path must exist and carry an 

502 ingestible suffix (.txt, .md, .jsonl). 

503 wait_timeout_seconds: 0 returns right after the uploads. 

504 

505 Returns: 

506 A summary dict: the bucket, per-file uploaded keys, and — 

507 when waiting — per-source chunk counts and whether the wait 

508 timed out. 

509 

510 Raises: 

511 VectorStoreUnavailableError: Bucket/table SSM names absent, 

512 no credentials, or endpoint unreachable. 

513 VectorStoreError: A path is missing or not ingestible, or 

514 the upload fails. 

515 """ 

516 from botocore.exceptions import BotoCoreError, ClientError 

517 

518 if not paths: 

519 raise VectorStoreError("nothing to ingest: no files given") 

520 for path in paths: 

521 if not path.is_file(): 

522 raise VectorStoreError(f"not a file: {path}") 

523 if path.suffix.lower() not in _INGESTIBLE_SUFFIXES: 

524 raise VectorStoreError( 

525 f"{path.name}: unsupported suffix {path.suffix!r} — the ingest " 

526 f"pipeline reads {', '.join(_INGESTIBLE_SUFFIXES)}" 

527 ) 

528 

529 bucket_name, bucket_region = self._resolve_bucket() 

530 client = self._get_s3_client(bucket_region) 

531 uploaded: list[str] = [] 

532 for path in paths: 

533 key = f"{self._corpus_prefix}{path.name}" 

534 try: 

535 client.put_object(Bucket=bucket_name, Key=key, Body=path.read_bytes()) 

536 except (ClientError, BotoCoreError) as err: 

537 raise VectorStoreError(f"upload of {path.name} failed: {err}") from err 

538 uploaded.append(key) 

539 

540 summary: dict[str, Any] = {"bucket": bucket_name, "uploaded": uploaded} 

541 if wait_timeout_seconds > 0: 

542 summary.update(self._wait_for_sources(uploaded, wait_timeout_seconds)) 

543 return summary 

544 

545 def _wait_for_sources(self, keys: list[str], timeout_seconds: int) -> dict[str, Any]: 

546 """Poll until every source key has at least one chunk item. 

547 

548 A filtered ``Scan`` per poll is deliberate: there is no 

549 by-source key schema, corpora are document-scale (not 

550 row-scale), and this runs only in the interactive --wait path. 

551 """ 

552 from botocore.exceptions import BotoCoreError, ClientError 

553 

554 table_name = self._resolve_table_name() 

555 client = self._get_dynamodb_client() 

556 deadline = time.monotonic() + timeout_seconds 

557 counts: dict[str, int] = dict.fromkeys(keys, 0) 

558 while True: 

559 for key in keys: 

560 try: 

561 counts[key] = self._count_source_chunks(client, table_name, key) 

562 except ClientError as err: 

563 code = err.response.get("Error", {}).get("Code") 

564 if code == "ResourceNotFoundException": 

565 raise VectorStoreUnavailableError( 

566 f"table {table_name} not found — {_UNAVAILABLE_HINT}" 

567 ) from err 

568 raise VectorStoreError(f"ingest wait failed: {err}") from err 

569 except BotoCoreError as err: 

570 raise VectorStoreUnavailableError( 

571 f"DynamoDB unreachable — {_UNAVAILABLE_HINT}" 

572 ) from err 

573 if all(count > 0 for count in counts.values()): 

574 return {"chunks_by_source": counts, "timed_out": False} 

575 if time.monotonic() >= deadline: 

576 return {"chunks_by_source": counts, "timed_out": True} 

577 time.sleep(3) 

578 

579 @staticmethod 

580 def _count_source_chunks(client: Any, table_name: str, source_key: str) -> int: 

581 """Count chunk items for one source key with a paginated filtered Scan.""" 

582 count = 0 

583 start_key: dict[str, Any] | None = None 

584 while True: 

585 request: dict[str, Any] = { 

586 "TableName": table_name, 

587 "Select": "COUNT", 

588 "FilterExpression": "#source = :source", 

589 "ExpressionAttributeNames": {"#source": "source"}, 

590 "ExpressionAttributeValues": {":source": {"S": source_key}}, 

591 } 

592 if start_key: 

593 request["ExclusiveStartKey"] = start_key 

594 response = client.scan(**request) 

595 count += int(response.get("Count", 0)) 

596 start_key = response.get("LastEvaluatedKey") 

597 if not start_key: 

598 return count 

599 

600 

601def _is_gco_checkout_root(root: Path) -> bool: 

602 """Return whether *root* is the top level of a GCO Git checkout.""" 

603 file_markers = (root / "cdk.json", root / "app.py", root / "pyproject.toml") 

604 if not (root / ".git").exists() or not all(marker.is_file() for marker in file_markers): 

605 return False 

606 try: 

607 result = subprocess.run( 

608 ["git", "rev-parse", "--show-toplevel"], 

609 cwd=root, 

610 capture_output=True, 

611 text=True, 

612 check=False, 

613 ) 

614 except OSError: 

615 return False 

616 top_level = result.stdout.strip() 

617 if result.returncode != 0 or not top_level or Path(top_level).resolve() != root: 

618 return False 

619 try: 

620 project = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) 

621 except OSError, UnicodeDecodeError, tomllib.TOMLDecodeError: 

622 return False 

623 project_metadata = project.get("project") 

624 return isinstance(project_metadata, dict) and project_metadata.get("name") == "gco-cli" 

625 

626 

627def demo_corpus_paths() -> list[Path]: 

628 """Return the checkout's ``docs/*.md`` as a self-contained demo corpus. 

629 

630 ``gco vector ingest --demo`` seeds the store with GCO's own feature 

631 documentation — always present in a source checkout, meaningful to 

632 search ("how does capacity history work?"), and free of licensing 

633 questions. Installed (pip/uvx) distributions do not carry ``docs/``, 

634 so the demo requires a checkout as the process working directory. 

635 """ 

636 root = Path.cwd().resolve() 

637 docs_dir = root / "docs" 

638 if not _is_gco_checkout_root(root) or not docs_dir.is_dir(): 

639 raise VectorStoreError( 

640 "the demo corpus is the checkout's docs/*.md — run from a GCO " 

641 "source checkout, or pass explicit files to ingest instead" 

642 ) 

643 paths = sorted(docs_dir.glob("*.md")) 

644 if docs_dir.is_symlink() or any(path.is_symlink() or not path.is_file() for path in paths): 

645 raise VectorStoreError( 

646 "the demo corpus must contain only regular docs/*.md files inside the GCO checkout" 

647 ) 

648 if not paths: 

649 raise VectorStoreError(f"no *.md files found under {docs_dir}") 

650 return paths