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
« 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``).
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.
10Three operations:
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).
23Resolution conventions, copied from their precedents:
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``.
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.
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.
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"""
61from __future__ import annotations
63import json
64import logging
65import os
66import subprocess
67import time
68import tomllib
69from pathlib import Path
70from typing import Any
72logger = logging.getLogger(__name__)
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]
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
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",)
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"
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")
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)
112#: Region resolution order shared with the sibling DynamoDB stores.
113_REGION_ENV_ORDER = ("DYNAMODB_REGION", "GLOBAL_REGION", "AWS_REGION")
116class VectorStoreError(RuntimeError):
117 """A vector-store operation failed."""
120class VectorStoreUnavailableError(VectorStoreError):
121 """The vector-store infrastructure is absent or not yet queryable.
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 """
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
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))}
144def _plain(value: Any) -> Any:
145 """Collapse ``TypeDeserializer`` output into JSON-friendly primitives."""
146 from decimal import Decimal
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
157def _embedding_request_body(text: str, model_id: str, dimensions: int) -> str:
158 """Build the Titan-contract request body for one query.
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)
170class VectorStoreClient:
171 """Operator client for ingest, search, and status.
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.
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 """
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
215 # ------------------------------------------------------------------ #
216 # internals
217 # ------------------------------------------------------------------ #
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
223 from gco.services.aws_ssm import get_ssm_parameter
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
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
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
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
258 def _get_dynamodb_client(self) -> Any:
259 if self._dynamodb_client is None:
260 import boto3
262 self._dynamodb_client = boto3.client(
263 "dynamodb", region_name=self._query_region or _resolve_region()
264 )
265 return self._dynamodb_client
267 def _get_bedrock_client(self) -> Any:
268 if self._bedrock_client is None:
269 import boto3
271 self._bedrock_client = boto3.client("bedrock-runtime", region_name=_resolve_region())
272 return self._bedrock_client
274 def _get_s3_client(self, bucket_region: str | None) -> Any:
275 if self._s3_client is None:
276 import boto3
278 self._s3_client = boto3.client("s3", region_name=bucket_region or _resolve_region())
279 return self._s3_client
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 )
290 if not text.strip():
291 raise VectorStoreError("query text must be non-empty")
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
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]
324 # ------------------------------------------------------------------ #
325 # operations
326 # ------------------------------------------------------------------ #
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``.
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``.
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).
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 )
362 if int(top_k) < 1:
363 raise VectorStoreError(f"top_k must be a positive integer, got {top_k!r}")
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)
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)}}
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
403 from boto3.dynamodb.types import TypeDeserializer
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
422 def status(self) -> dict[str, Any]:
423 """Return table, replica, and index state for the store.
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.
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 )
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
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 }
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.
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.
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.
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.
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
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 )
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)
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
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.
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
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)
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
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"
627def demo_corpus_paths() -> list[Path]:
628 """Return the checkout's ``docs/*.md`` as a self-contained demo corpus.
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