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
13 changes: 10 additions & 3 deletions helper-scripts/fetch-models.sh
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,19 @@ if [[ -z "$DL" ]]; then
fi

download() {
local url="$1" dest="$2"
# Download to a temporary name and atomically rename into place. A running
# container bind-mounts this directory read-only and mmaps the GGUF files;
# writing straight to the final name would let it map a half-written file
# (and later fault with SIGBUS), and an interrupted download would leave a
# partial file that looks complete. set -e aborts before the rename if the
# transfer fails, so a broken download never lands at the real path.
local url="$1" dest="$2" tmp="$2.part"
if [[ "$DL" == "curl" ]]; then
curl -L --progress-bar -o "$dest" "$url"
curl -L --fail --progress-bar -o "$tmp" "$url"
else
wget --progress=bar:force:noscroll -O "$dest" "$url"
wget --progress=bar:force:noscroll -O "$tmp" "$url"
fi
mv -f "$tmp" "$dest"
}

# ── LLM model ────────────────────────────────────────────────────────
Expand Down
6 changes: 4 additions & 2 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,10 @@

if (!s.llm_loaded) {
setPill('err', 'LLM missing');
setBanner('Language model not loaded — place the GGUF files in gguf_models/ and ' +
'restart. The library stays browsable; answers are unavailable.');
const where = s.gguf_dir || 'gguf_models/';
setBanner('Language model not loaded — drop the GGUF files into ' + where +
' and they load automatically within ~30s (no restart needed). ' +
'The library stays browsable; answers are unavailable.');
} else if (s.documents_indexed === 0) {
setPill('warn', 'Index empty');
setBanner('');
Expand Down
84 changes: 82 additions & 2 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
#include <string>
#include <cstdlib>
#include <iostream>
#include <filesystem>
#include <system_error>

// ── Version ──────────────────────────────────────────────────────────
#define JIC_VERSION "0.3.0"
Expand Down Expand Up @@ -70,16 +72,94 @@ inline std::string get_cors_origin() {
}

// ── Model paths (overridable via environment) ────────────────────────
// The directory that holds the GGUF model files. Overridable so a deploy
// whose bind mount lands somewhere other than ./gguf_models can point the
// server at the real location instead of silently running degraded.
inline std::string get_gguf_dir() {
return env_or("JIC_GGUF_DIR", "./gguf_models");
}

inline std::string get_llm_model_path() {
return std::string("./gguf_models/") +
return get_gguf_dir() + "/" +
env_or("LLM_GGUF_FILE", "Llama-3.2-3B-Instruct-Q4_K_M.gguf");
}

inline std::string get_embedding_model_path() {
return std::string("./gguf_models/") +
return get_gguf_dir() + "/" +
env_or("EMBEDDING_GGUF_FILE", "nomic-embed-text-v1.5.Q4_K_M.gguf");
}

// Absolute, resolved form of the model directory — computed once (cwd is
// stable at /app in the container). Never throws: a missing directory is a
// normal state (the whole point of degraded mode), so this must be safe to
// call from the /status handler on every poll.
inline std::string resolved_gguf_dir() {
static const std::string cached = [] {
std::error_code ec;
auto abs = std::filesystem::weakly_canonical(get_gguf_dir(), ec);
return ec ? get_gguf_dir() : abs.string();
}();
return cached;
}

// Human-readable diagnostic for logs when a model fails to load: the exact
// resolved path, whether the file is actually there (and its size), and if
// not, what the directory *does* contain — so "LLM missing" stops being a
// mystery. Error-code-safe throughout; never throws.
inline std::string describe_model_path(const std::string& path) {
namespace fs = std::filesystem;
std::error_code ec;
const fs::path p(path);

auto abs = fs::weakly_canonical(p, ec);
std::string out = "resolved='" + (ec ? path : abs.string()) + "'";
ec.clear();

if (fs::exists(p, ec) && !ec) {
auto sz = fs::file_size(p, ec);
out += ec ? " [present, size unknown]"
: " [present, " + std::to_string(sz) + " bytes]";
return out;
}

out += " [MISSING]";
const fs::path dir = p.parent_path();
ec.clear();
auto dir_abs = fs::weakly_canonical(dir, ec);
const std::string dir_shown = ec ? dir.string() : dir_abs.string();
ec.clear();

if (fs::is_directory(dir, ec) && !ec) {
out += "; dir '" + dir_shown + "' contains: ";
std::error_code it_ec;
// Iterate with the error_code increment(): directory_iterator's
// operator++ (used by range-for) THROWS on a mid-scan failure, which
// would break this helper's never-throws contract and, in the loader
// thread, terminate the process. increment(ec) reports instead.
fs::directory_iterator it(dir, it_ec), end;
if (it_ec) {
// e.g. the dir exists but is not readable by this uid — exactly
// the kind of thing that looks like "no models" but isn't.
out += "(unreadable: " + it_ec.message() + ")";
} else {
int n = 0;
for (; !it_ec && it != end; it.increment(it_ec)) {
if (n++) out += ", ";
out += it->path().filename().string();
if (n >= 20) { out += ", …"; break; }
}
if (n == 0) out += "(empty)";
}
} else if (ec) {
// is_directory() itself failed (e.g. the parent is not traversable):
// report the actual reason instead of mislabelling it "does not exist".
out += "; dir '" + dir_shown + "' is unreadable: " + ec.message();
} else {
out += "; dir '" + dir_shown + "' does not exist";
}
return out;
}

inline std::string get_llm_model_name() {
return env_or("LLM_MODEL", "llama3.2:3b");
}
Expand Down
27 changes: 22 additions & 5 deletions src/embeddings.h
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ class EmbeddingGenerator {
}

bool init() {
// Idempotent: the retry paths (server loader, ingestion wait-loop) may
// call this repeatedly. Free any partial state from a prior failed
// attempt so a reload never leaks a model/context.
if (ctx) { llama_free(ctx); ctx = nullptr; }
if (model) { llama_model_free(model); model = nullptr; }

llama_model_params mp = llama_model_default_params();
model = llama_model_load_from_file(
get_embedding_model_path().c_str(), mp);
Expand All @@ -50,14 +56,25 @@ class EmbeddingGenerator {
return ctx != nullptr;
}

// Returns an EMBEDDING_DIM-length vector on success, or an EMPTY vector if
// the text could not be embedded (context / tokenise / decode failure).
// Callers MUST treat empty as "no embedding" and skip it — never store a
// zero vector as if it were real, which silently poisons the search index.
std::vector<float> get_embedding(const std::string& text) {
std::lock_guard<std::mutex> lock(mutex);

// Defensive: if a prior reset failed and left ctx null, don't call into
// llama with a null context (crash). Try once to rebuild it first.
if (!ctx) {
reset_context();
if (!ctx) return {};
}

// Reset context if it's getting full
int n_ctx = llama_n_ctx(ctx);
if (n_past > n_ctx * 3 / 4) {
reset_context();
if (!ctx) return std::vector<float>(EMBEDDING_DIM, 0.0f);
if (!ctx) return {}; // could not embed → empty; caller skips it
}

const llama_vocab* vocab = llama_model_get_vocab(model);
Expand All @@ -66,27 +83,27 @@ class EmbeddingGenerator {
int n_prompt = -llama_tokenize(
vocab, text.c_str(), text.size(), NULL, 0, true, true);
if (n_prompt <= 0)
return std::vector<float>(EMBEDDING_DIM, 0.0f);
return {}; // could not embed → empty; caller skips it
if (n_prompt > 2048) n_prompt = 2048;

std::vector<llama_token> tokens(n_prompt);
int actual = llama_tokenize(
vocab, text.c_str(), text.size(),
tokens.data(), tokens.size(), true, true);
if (actual < 0)
return std::vector<float>(EMBEDDING_DIM, 0.0f);
return {}; // could not embed → empty; caller skips it
tokens.resize(actual);
if (tokens.size() > 2048) tokens.resize(2048);

llama_batch batch = llama_batch_get_one(tokens.data(), tokens.size());
if (llama_decode(ctx, batch) != 0)
return std::vector<float>(EMBEDDING_DIM, 0.0f);
return {}; // could not embed → empty; caller skips it

n_past += tokens.size();

const float* emb = llama_get_embeddings(ctx);
if (!emb)
return std::vector<float>(EMBEDDING_DIM, 0.0f);
return {}; // could not embed → empty; caller skips it

int n_embd = llama_model_n_embd(model);
std::vector<float> result(emb, emb + std::min(n_embd, EMBEDDING_DIM));
Expand Down
64 changes: 59 additions & 5 deletions src/ingestion.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,34 @@ int main() {
}, nullptr);

// ── Embedding model ──────────────────────────────────────────────
// The model is host-provisioned and may not be present yet (fresh install,
// or files dropped in later). Instead of crash-looping under the restart
// policy, wait for it: retry until it loads or we're asked to stop. The
// index then starts building the moment the model appears — no restart.
EmbeddingGenerator embeddings;
if (!embeddings.init()) {
std::cerr << "Failed to initialise embedding model" << std::endl;
return 1;
bool warned = false;
while (g_running.load()) {
// Only attempt to load once the file exists and has stopped changing
// (file_is_settled → false for a missing file, and for one still being
// copied in): don't mmap a half-written model, which could SIGBUS.
if (file_is_settled(get_embedding_model_path()) && embeddings.init())
break;
if (!warned) {
std::cerr << "Embedding model not available yet — "
<< describe_model_path(get_embedding_model_path()) << std::endl;
std::cerr << "Ingestion will wait and retry every "
<< get_scan_interval_sec() << "s." << std::endl;
warned = true;
}
interruptible_sleep(get_scan_interval_sec());
}
if (!g_running.load()) {
std::cout << "Stopped before the embedding model became available."
<< std::endl;
llama_backend_free();
return 0;
}
std::cout << "Embedding model loaded." << std::endl;

// ── SQLite index ─────────────────────────────────────────────────
const std::string db_path = get_db_path();
Expand Down Expand Up @@ -162,12 +185,20 @@ int main() {
const int BATCH = 50;
std::vector<Document> batch_docs;
std::vector<std::vector<float>> batch_embs;
int total_stored = 0;
int total_stored = 0;
int failed_chunks = 0;

for (int ci = 0; ci < static_cast<int>(chunks.size()); ci++) {
if (!g_running.load()) break;
auto emb = embeddings.get_embedding(chunks[ci]);
if (static_cast<int>(emb.size()) != EMBEDDING_DIM) continue;
// Empty = the model failed to embed this chunk. Skip it
// (never store a zero vector — that poisons search) and
// remember the failure so we don't prematurely mark the
// whole file done.
if (static_cast<int>(emb.size()) != EMBEDDING_DIM) {
failed_chunks++;
continue;
}

batch_docs.push_back({rel_path, chunks[ci], -1, ci});
batch_embs.push_back(std::move(emb));
Expand Down Expand Up @@ -201,6 +232,29 @@ int main() {
break;
}

// Every chunk failed to embed → the model is unhealthy, not
// the document. Do NOT mark it processed (that would be
// permanent); leave it for the next scan once the model
// recovers. A partial success still commits what stored.
if (total_stored == 0 && failed_chunks > 0) {
std::cerr << " ⚠ " << rel_path << ": all "
<< failed_chunks << " chunk(s) failed to embed"
<< " — leaving unmarked to retry next scan"
<< std::endl;
continue;
}

// Partial failure: commit what stored (re-ingesting would
// duplicate rows — add_batch is INSERT, not upsert), but
// surface it so a half-indexed document is observable
// instead of silently missing content.
if (failed_chunks > 0) {
std::cerr << " ⚠ " << rel_path << ": " << failed_chunks
<< " of " << chunks.size()
<< " chunk(s) failed to embed and were dropped"
<< std::endl;
}

index.mark_file_processed(rel_path, total_stored);
std::cout << " ✓ " << rel_path << ": " << total_stored
<< " chunks indexed" << std::endl;
Expand Down
6 changes: 6 additions & 0 deletions src/llm.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ class LLMGenerator {
}

bool init() {
// Idempotent: the background loader retries this until it succeeds,
// so free any partially-initialised state from a prior failed attempt
// before reloading — otherwise a reload would leak a model/context.
if (ctx) { llama_free(ctx); ctx = nullptr; }
if (model) { llama_model_free(model); model = nullptr; }

llama_model_params model_params = llama_model_default_params();
model = llama_model_load_from_file(get_llm_model_path().c_str(), model_params);
if (!model) {
Expand Down
Loading
Loading