Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,13 @@ job without validating the number. SAP's wording is translated, so matching on i
fragile — `EnsureObjectExists` (adt/object_exists.hpp) does a plain GET first, and every
one of those commands calls it before acting.

Not verifiable on the a4h trial: the **abapGit ADT backend is not installed** —
`/sap/bc/adt/abapgit` answers 404 and abapgit appears nowhere in the ADT discovery
document. So `abapgit.cpp` and the `deploy` workflow that drives it have never been
measured against a live system, and the audit that swept BW, the ADT read/write surface
and the MCP tools does not cover them. Treat their request shapes as unconfirmed rather
than as working.

BW discovery advertises several templates per type. The *first* one is `rel="self"` and
has no `{version}` segment; the versioned route is `rel="latest-version"`. Resolve by
relation (`BwResolveEndpointByRel`) — taking the first match silently drops a requested
Expand Down
6 changes: 4 additions & 2 deletions include/erpl_adt/core/gemini_embedding_provider.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace erpl_adt {

// ---------------------------------------------------------------------------
// GeminiEmbeddingProvider — default IEmbeddingProvider, calls the Gemini
// embeddings API (text-embedding-004, 768-d) over HTTPS. Requires an API
// embeddings API (gemini-embedding-001 truncated to 768-d) over HTTPS. Requires an API
// key (GEMINI_API_KEY env var, or passed explicitly to Create).
// ---------------------------------------------------------------------------
class GeminiEmbeddingProvider : public IEmbeddingProvider {
Expand All @@ -24,7 +24,9 @@ class GeminiEmbeddingProvider : public IEmbeddingProvider {

[[nodiscard]] Result<std::vector<float>, Error> EmbedText(const std::string& text) override;
[[nodiscard]] int Dimensions() const override { return 768; }
[[nodiscard]] std::string ModelName() const override { return "text-embedding-004"; }
// Defined in the .cpp: the name is configurable, because Google retires
// embedding models (text-embedding-004 now answers HTTP 404).
[[nodiscard]] std::string ModelName() const override;

private:
explicit GeminiEmbeddingProvider(std::string api_key);
Expand Down
57 changes: 52 additions & 5 deletions src/core/gemini_embedding_provider.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,46 @@
#include <httplib.h>
#include <nlohmann/json.hpp>

#include <cmath>
#include <cstdlib>

namespace erpl_adt {

namespace {
constexpr const char* kHost = "generativelanguage.googleapis.com";

// text-embedding-004 was retired: the endpoint answers HTTP 404, which is why
// `catalog search --mode vss` and `catalog build --embed` stopped working.
// gemini-embedding-001 replaces it and returns 3072 dimensions by default,
// so we ask for 768 — the width the catalog schema stores (FLOAT[768]) and the
// width every existing catalog was built at.
constexpr const char* kDefaultModel = "gemini-embedding-001";

// Truncated gemini-embedding-001 vectors are not unit length, unlike the ones
// text-embedding-004 returned. Normalising keeps cosine similarity meaningful
// and keeps new vectors comparable with those already stored.
void NormalizeInPlace(std::vector<float>& values) {
double sum_of_squares = 0.0;
for (const float v : values) {
sum_of_squares += static_cast<double>(v) * static_cast<double>(v);
}
const double length = std::sqrt(sum_of_squares);
if (length <= 0.0) {
return;
}
for (float& v : values) {
v = static_cast<float>(static_cast<double>(v) / length);
}
}
}

// Overridable so the next retirement does not need a new release.
std::string GeminiEmbeddingProvider::ModelName() const {
const char* override_name = std::getenv("ERPL_ADT_GEMINI_EMBED_MODEL");
if (override_name != nullptr && *override_name != '\0') {
return override_name;
}
return kDefaultModel;
}

GeminiEmbeddingProvider::GeminiEmbeddingProvider(std::string api_key)
Expand Down Expand Up @@ -39,11 +73,14 @@ Result<std::vector<float>, Error> GeminiEmbeddingProvider::EmbedText(const std::
client.set_connection_timeout(10, 0);
client.set_read_timeout(30, 0);

const auto model = ModelName();

nlohmann::json body;
body["model"] = "models/text-embedding-004";
body["model"] = "models/" + model;
body["content"]["parts"] = nlohmann::json::array({{{"text", text}}});
body["outputDimensionality"] = Dimensions();

auto path = "/v1beta/models/text-embedding-004:embedContent?key=" + api_key_;
auto path = "/v1beta/models/" + model + ":embedContent?key=" + api_key_;
auto response = client.Post(path, body.dump(), "application/json");

if (!response) {
Expand All @@ -53,9 +90,18 @@ Result<std::vector<float>, Error> GeminiEmbeddingProvider::EmbedText(const std::
ErrorCategory::Connection});
}
if (response->status != 200) {
return Result<std::vector<float>, Error>::Err(Error::FromHttpStatus(
"GeminiEmbeddingProvider::EmbedText", "/v1beta/models/text-embedding-004:embedContent",
response->status, response->body));
auto error = Error::FromHttpStatus(
"GeminiEmbeddingProvider::EmbedText",
"/v1beta/models/" + model + ":embedContent", response->status,
response->body);
if (response->status == 404) {
// This is what a retired model looks like, and it is the failure
// that took vss/hybrid search out.
error.message = "Gemini model '" + model + "' is not available";
error.hint = "Set ERPL_ADT_GEMINI_EMBED_MODEL to a model this key "
"can use; the API lists them at /v1beta/models.";
}
return Result<std::vector<float>, Error>::Err(std::move(error));
}

try {
Expand All @@ -69,6 +115,7 @@ Result<std::vector<float>, Error> GeminiEmbeddingProvider::EmbedText(const std::
"GeminiEmbeddingProvider::EmbedText", path, response->status,
"Gemini API returned an empty embedding", std::nullopt, ErrorCategory::Internal});
}
NormalizeInPlace(values);
return Result<std::vector<float>, Error>::Ok(std::move(values));
} catch (const std::exception& ex) {
return Result<std::vector<float>, Error>::Err(Error{
Expand Down
14 changes: 14 additions & 0 deletions src/storage/duckdb_catalog_store.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,20 @@ Result<void, Error> DuckDbCatalogStore::WriteEmbedding(const EntityId& entity_id
Result<std::vector<CatalogSearchHit>, Error> DuckDbCatalogStore::SearchVss(
const std::vector<float>& query_embedding, int max_results) {
try {
// A catalog built without --embed has no vectors, and a semantic
// search over none returns an empty list — indistinguishable from
// "nothing matched". Say which it is.
auto count = impl_->con->Query("SELECT count(*) FROM entity_embeddings");
if (!count->HasError() && count->RowCount() > 0 &&
count->GetValue(0, 0).GetValue<int64_t>() == 0) {
Error error{"SearchVss", "entity_embeddings", std::nullopt,
"This catalog holds no embeddings", std::nullopt,
ErrorCategory::NotFound};
error.hint = "Rebuild it with 'catalog build --embed' (needs "
"GEMINI_API_KEY), or search with --mode fts.";
return Result<std::vector<CatalogSearchHit>, Error>::Err(std::move(error));
}

auto stmt = impl_->con->Prepare(
"SELECT e.id, e.system_sid, e.domain, e.object_type, e.object_subtype, "
"e.technical_name, e.display_name, e.package_or_infoarea, e.extracted_at, "
Expand Down