Coverage for gco_mcp / mission / embeddings.py: 100.00%
41 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"""Directive embedding for mission memory.
3One free function, :func:`embed_text`, turns a Mission directive into the
4vector that the ``{project}-mission-memory`` DynamoDB vector index stores
5and queries. The Bedrock client is constructed exactly the way
6:class:`mcp.mission.sampling.BedrockSamplingBackend` builds its client
7(``boto3.Session().client("bedrock-runtime", config=Config(read_timeout=
8BEDROCK_READ_TIMEOUT_SECONDS))``), and the model id resolves through
9:func:`gco.bedrock.get_default_embedding_model_id` so the runtime and the
10deployed index share one configuration source.
12Failure contract: every failure raises the typed :class:`EmbeddingError`
13so callers decide whether to swallow. The engine's memory write/read
14paths are best-effort and do swallow; the CLI surfaces the message. The
15one deliberate exception is the Bedrock first-time-use gate —
16:func:`gco.bedrock.raise_if_bedrock_ftu_form_error` escalates that case
17to :class:`gco.bedrock.BedrockFTUFormNotAcceptedError` because it is a
18permanent, account-scoped misconfiguration with a one-line fix, and the
19established posture (see ``sampling.py``) is that it must never be
20absorbed by a graceful-degradation handler.
22Request shape note: the body follows the Amazon Titan Text Embeddings
23contract (``inputText`` plus, when supplied, the V2-only ``dimensions``
24key). ``dimensions`` is omitted from the request when the caller passes
25``None`` so V1-family models — which reject the key — keep working.
27``boto3`` / ``botocore`` are imported lazily inside the functions so
28pure-Python consumers of the mission package do not pay for the SDK
29import, and so tests can inject a fake ``boto3`` module through
30``sys.modules`` (the same pattern ``sampling.py`` uses).
31"""
33from __future__ import annotations
35import json
36import logging
37from typing import Any
39from gco.bedrock import (
40 BEDROCK_READ_TIMEOUT_SECONDS,
41 get_default_embedding_model_id,
42 raise_if_bedrock_ftu_form_error,
43)
45logger = logging.getLogger(__name__)
47__all__ = [
48 "EmbeddingError",
49 "embed_text",
50]
53class EmbeddingError(RuntimeError):
54 """A directive embedding could not be produced.
56 The message is a short machine-matchable code, mirroring the
57 ``SamplingTransportError`` convention in ``sampling.py``:
59 * ``embedding_empty_text`` — the input was empty or whitespace-only.
60 * ``embedding_no_credentials`` — ``boto3`` could not resolve
61 credentials at client-construction time.
62 * ``embedding_bedrock_<ErrorCode>`` — ``InvokeModel`` raised a
63 ``ClientError``; ``<ErrorCode>`` is the AWS error code.
64 * ``embedding_transport_failure`` — a non-``ClientError`` botocore
65 transport fault (endpoint unreachable, read timeout, ...).
66 * ``embedding_malformed_response`` — the response body was not JSON
67 or did not carry a non-empty numeric ``embedding`` list.
68 """
71def _build_client() -> Any:
72 """Return a fresh ``bedrock-runtime`` client.
74 Split out of :func:`embed_text` so tests can monkeypatch this one
75 seam instead of faking the whole ``boto3`` module (both work). The
76 imports are local — see the module docstring.
77 """
78 import boto3
79 from botocore.config import Config
80 from botocore.exceptions import (
81 NoCredentialsError,
82 PartialCredentialsError,
83 )
85 try:
86 return boto3.Session().client(
87 "bedrock-runtime",
88 config=Config(read_timeout=BEDROCK_READ_TIMEOUT_SECONDS),
89 )
90 except (NoCredentialsError, PartialCredentialsError) as err:
91 raise EmbeddingError("embedding_no_credentials") from err
94def embed_text(
95 text: str,
96 *,
97 model_id: str | None = None,
98 dimensions: int | None = None,
99) -> list[float]:
100 """Embed ``text`` and return the vector as a plain list of floats.
102 Args:
103 text: The text to embed — for mission memory, the verbatim
104 operator directive. Must be non-empty.
105 model_id: Bedrock model id override. ``None`` resolves the
106 checked-in default via
107 :func:`gco.bedrock.get_default_embedding_model_id`.
108 dimensions: Requested output width, passed through to the model
109 (Titan Text Embeddings V2 accepts 256/512/1024). ``None``
110 omits the key so the model's own default width applies.
111 The deployed vector index width is a one-way door — query
112 vectors must match it, so callers that know the configured
113 width should pass it.
115 Raises:
116 EmbeddingError: On any embedding failure; see the class
117 docstring for the code taxonomy.
118 gco.bedrock.BedrockFTUFormNotAcceptedError: The account has not
119 submitted Anthropic's first-time-use form (only reachable
120 when the embedding model is Anthropic-gated).
121 gco.bedrock.BedrockModelConfigurationError: The canonical
122 ``cdk.json`` could not supply a default model id.
123 """
124 from botocore.exceptions import BotoCoreError, ClientError
126 if not isinstance(text, str) or not text.strip():
127 raise EmbeddingError("embedding_empty_text")
129 resolved_model_id = model_id or get_default_embedding_model_id()
131 body: dict[str, Any] = {"inputText": text}
132 if dimensions is not None:
133 body["dimensions"] = int(dimensions)
135 client = _build_client()
136 try:
137 response = client.invoke_model(
138 modelId=resolved_model_id,
139 body=json.dumps(body),
140 contentType="application/json",
141 accept="application/json",
142 )
143 except ClientError as err:
144 raise_if_bedrock_ftu_form_error(err)
145 code = err.response.get("Error", {}).get("Code") or "ClientError"
146 raise EmbeddingError(f"embedding_bedrock_{code}") from err
147 except BotoCoreError as err:
148 raise EmbeddingError("embedding_transport_failure") from err
150 try:
151 payload = json.loads(response["body"].read())
152 except (AttributeError, KeyError, TypeError, ValueError) as err:
153 raise EmbeddingError("embedding_malformed_response") from err
155 vector = payload.get("embedding") if isinstance(payload, dict) else None
156 if (
157 not isinstance(vector, list)
158 or not vector
159 or not all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in vector)
160 ):
161 raise EmbeddingError("embedding_malformed_response")
162 return [float(v) for v in vector]