Upload 4 files
Browse files- Dockerfile +10 -2
- assistant.R +32 -42
- depth_interactive_map_alpha.R +26 -23
- entrypoint.sh +11 -0
Dockerfile
CHANGED
|
@@ -18,14 +18,22 @@ ARG DDB_URL=https://huggingface.co/datasets/edgarodriguez/depth_alpha/resolve/ma
|
|
| 18 |
ARG RAG_URL=https://huggingface.co/datasets/edgarodriguez/depth_rag/resolve/main/rag_chunks.parquet
|
| 19 |
|
| 20 |
# Small local models served in-container by Ollama (no external inference).
|
| 21 |
-
|
|
|
|
|
|
|
| 22 |
ARG EMBED_MODEL=nomic-embed-text
|
| 23 |
|
|
|
|
|
|
|
|
|
|
| 24 |
ENV OLLAMA_MODELS=/app/.ollama \
|
| 25 |
DEPTH_OLLAMA_URL=http://127.0.0.1:11434 \
|
| 26 |
DEPTH_LLM_MODEL=${CHAT_MODEL} \
|
| 27 |
DEPTH_EMBED_MODEL=${EMBED_MODEL} \
|
| 28 |
-
DEPTH_RAG_PARQUET=/app/rag_chunks.parquet
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
# R packages. The base image pins a dated CRAN snapshot whose ellmer/shinychat
|
| 31 |
# are too old -- that ellmer lacked the `params` argument, so the assistant failed
|
|
|
|
| 18 |
ARG RAG_URL=https://huggingface.co/datasets/edgarodriguez/depth_rag/resolve/main/rag_chunks.parquet
|
| 19 |
|
| 20 |
# Small local models served in-container by Ollama (no external inference).
|
| 21 |
+
# 1.5b (not 3b) for ~2x faster CPU inference on the free Space; flip to qwen2.5:3b
|
| 22 |
+
# via the DEPTH_LLM_MODEL env var (no rebuild) if you later add a GPU/CPU upgrade.
|
| 23 |
+
ARG CHAT_MODEL=qwen2.5:1.5b
|
| 24 |
ARG EMBED_MODEL=nomic-embed-text
|
| 25 |
|
| 26 |
+
# OLLAMA_KEEP_ALIVE=-1 keeps the model resident (never unload) so the 2nd+ replies
|
| 27 |
+
# skip the reload that caused the ~420s timeout; flash-attention + q8 KV cache trim
|
| 28 |
+
# CPU work per token.
|
| 29 |
ENV OLLAMA_MODELS=/app/.ollama \
|
| 30 |
DEPTH_OLLAMA_URL=http://127.0.0.1:11434 \
|
| 31 |
DEPTH_LLM_MODEL=${CHAT_MODEL} \
|
| 32 |
DEPTH_EMBED_MODEL=${EMBED_MODEL} \
|
| 33 |
+
DEPTH_RAG_PARQUET=/app/rag_chunks.parquet \
|
| 34 |
+
OLLAMA_KEEP_ALIVE=-1 \
|
| 35 |
+
OLLAMA_FLASH_ATTENTION=1 \
|
| 36 |
+
OLLAMA_KV_CACHE_TYPE=q8_0
|
| 37 |
|
| 38 |
# R packages. The base image pins a dated CRAN snapshot whose ellmer/shinychat
|
| 39 |
# are too old -- that ellmer lacked the `params` argument, so the assistant failed
|
assistant.R
CHANGED
|
@@ -86,8 +86,9 @@ ollama_embed <- function(text, cfg = assistant_config()) {
|
|
| 86 |
}
|
| 87 |
|
| 88 |
# Embed the question (with the nomic query prefix) and return the top-k corpus
|
| 89 |
-
# passages as a single context string with [source] tags for citation.
|
| 90 |
-
|
|
|
|
| 91 |
if (is.null(con) || !file.exists(cfg$rag_path)) return("")
|
| 92 |
qvec <- ollama_embed(paste0("search_query: ", query), cfg)
|
| 93 |
if (length(qvec) == 0) return("")
|
|
@@ -109,53 +110,46 @@ rag_search <- function(con, query, cfg = assistant_config(), k = 5) {
|
|
| 109 |
}
|
| 110 |
|
| 111 |
# --- Grounding text from WEB_SPEC --------------------------------------------
|
| 112 |
-
# Walk the accordion
|
| 113 |
-
#
|
|
|
|
| 114 |
assistant_indicator_catalog_txt <- function() {
|
| 115 |
if (!exists("WEB_SPEC")) return("")
|
| 116 |
out <- character(0)
|
| 117 |
for (grp in WEB_SPEC$accordion) {
|
| 118 |
layer_items <- Filter(function(it) identical(it$kind, "layer"), grp$items)
|
| 119 |
-
|
| 120 |
-
out <- c(out, paste0("## ", grp$label))
|
| 121 |
-
for (it in layer_items) {
|
| 122 |
-
tip <- it$tooltip
|
| 123 |
-
body <- if (is.list(tip)) paste(c(tip$body, tip$meta), collapse = " ")
|
| 124 |
-
else tip %||% ""
|
| 125 |
-
out <- c(out, sprintf("- %s: %s", it$label, body))
|
| 126 |
-
}
|
| 127 |
}
|
| 128 |
-
paste(out, collapse = "\n")
|
| 129 |
}
|
| 130 |
|
| 131 |
assistant_system_prompt <- function(catalog_txt) {
|
| 132 |
paste0(
|
| 133 |
"You are the research assistant for DEPTH, an interactive map of migration ",
|
| 134 |
-
"risk in Mexico
|
| 135 |
-
"
|
| 136 |
-
"
|
| 137 |
-
"
|
| 138 |
-
"
|
| 139 |
-
"
|
| 140 |
-
"
|
| 141 |
-
"
|
| 142 |
-
"
|
| 143 |
-
"
|
| 144 |
-
"title in square brackets when you rely on it.\n",
|
| 145 |
-
"- If the data and documents do not cover the question, say so plainly. ",
|
| 146 |
-
"Do not speculate.\n",
|
| 147 |
-
"- Reply in the user's language (English or Spanish). Be concise: a few ",
|
| 148 |
-
"sentences or a short list.\n\n",
|
| 149 |
-
"Indicators available on the map (label: meaning and source):\n",
|
| 150 |
catalog_txt
|
| 151 |
)
|
| 152 |
}
|
| 153 |
|
| 154 |
-
#
|
| 155 |
-
# raw question; only the model sees the augmented version).
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
}
|
| 160 |
|
| 161 |
# --- Tool back-ends (operate on a plain snapshot env + read-only con) ---------
|
|
@@ -308,20 +302,16 @@ make_assistant <- function(con, state, cfg = assistant_config()) {
|
|
| 308 |
|
| 309 |
ind_enum <- ellmer::type_enum(names(.IND_LABELS), "Indicator column.")
|
| 310 |
|
| 311 |
-
|
| 312 |
-
|
|
|
|
|
|
|
| 313 |
aggregate_indicator <- function(indicator, statistic = "mean")
|
| 314 |
assistant_aggregate(con, state, indicator, statistic)
|
| 315 |
top_municipalities <- function(indicator, n = 5)
|
| 316 |
assistant_top_municipalities(con, state, indicator, n)
|
| 317 |
count_points <- function(layer) assistant_count_points(state, layer)
|
| 318 |
|
| 319 |
-
chat$register_tool(ellmer::tool(list_indicators,
|
| 320 |
-
"List the indicators / data layers available in the DEPTH map, with their meaning and source. Call this when the user asks what data exists."))
|
| 321 |
-
|
| 322 |
-
chat$register_tool(ellmer::tool(describe_selection,
|
| 323 |
-
"Summarise the user's CURRENT map selection (drawn area, clicked municipality or state): how many municipalities and points of each type fall inside, plus key totals. Returns national totals if nothing is selected."))
|
| 324 |
-
|
| 325 |
chat$register_tool(ellmer::tool(aggregate_indicator,
|
| 326 |
"Aggregate one municipality indicator over the current selection (or nationally if nothing is selected). Use 'mean' for rate indicators and 'sum' for count indicators.",
|
| 327 |
arguments = list(
|
|
|
|
| 86 |
}
|
| 87 |
|
| 88 |
# Embed the question (with the nomic query prefix) and return the top-k corpus
|
| 89 |
+
# passages as a single context string with [source] tags for citation. k kept
|
| 90 |
+
# small (2) so the injected context stays short -> cheaper CPU prefill.
|
| 91 |
+
rag_search <- function(con, query, cfg = assistant_config(), k = 2) {
|
| 92 |
if (is.null(con) || !file.exists(cfg$rag_path)) return("")
|
| 93 |
qvec <- ollama_embed(paste0("search_query: ", query), cfg)
|
| 94 |
if (length(qvec) == 0) return("")
|
|
|
|
| 110 |
}
|
| 111 |
|
| 112 |
# --- Grounding text from WEB_SPEC --------------------------------------------
|
| 113 |
+
# Walk the accordion into a COMPACT data dictionary the model can read: just the
|
| 114 |
+
# layer labels (one per line), no verbose tooltip bodies. Keeps the system prompt
|
| 115 |
+
# small so CPU prefill stays cheap. Stays in lockstep with the UI -- no codebook.
|
| 116 |
assistant_indicator_catalog_txt <- function() {
|
| 117 |
if (!exists("WEB_SPEC")) return("")
|
| 118 |
out <- character(0)
|
| 119 |
for (grp in WEB_SPEC$accordion) {
|
| 120 |
layer_items <- Filter(function(it) identical(it$kind, "layer"), grp$items)
|
| 121 |
+
for (it in layer_items) out <- c(out, paste0("- ", it$label))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
}
|
| 123 |
+
paste(unique(out), collapse = "\n")
|
| 124 |
}
|
| 125 |
|
| 126 |
assistant_system_prompt <- function(catalog_txt) {
|
| 127 |
paste0(
|
| 128 |
"You are the research assistant for DEPTH, an interactive map of migration ",
|
| 129 |
+
"risk in Mexico (University of Nottingham Rights Lab).\n",
|
| 130 |
+
"Rules:\n",
|
| 131 |
+
"- For any number, count, ranking or average, call a tool. Never invent figures.\n",
|
| 132 |
+
"- The user's current map scope and its totals are in <scope>; use them directly. ",
|
| 133 |
+
"Call a tool only for a different cut (a ranking, or another indicator).\n",
|
| 134 |
+
"- Use <context> document excerpts for definitions/background; cite the title in ",
|
| 135 |
+
"[brackets].\n",
|
| 136 |
+
"- If the data/documents don't cover it, say so. Reply in the user's language ",
|
| 137 |
+
"(English/Spanish), concisely.\n",
|
| 138 |
+
"Indicators on the map:\n",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
catalog_txt
|
| 140 |
)
|
| 141 |
}
|
| 142 |
|
| 143 |
+
# Wrap the raw question with the current map scope and retrieved corpus context
|
| 144 |
+
# (the UI still shows the raw question; only the model sees the augmented version).
|
| 145 |
+
# Injecting <scope> here saves a whole tool round-trip on CPU.
|
| 146 |
+
assistant_user_turn <- function(question, context_txt = "", scope_txt = "") {
|
| 147 |
+
parts <- character(0)
|
| 148 |
+
if (!is.null(scope_txt) && nzchar(scope_txt))
|
| 149 |
+
parts <- c(parts, paste0("<scope>\n", scope_txt, "\n</scope>"))
|
| 150 |
+
if (!is.null(context_txt) && nzchar(context_txt))
|
| 151 |
+
parts <- c(parts, paste0("<context>\n", context_txt, "\n</context>"))
|
| 152 |
+
paste(c(parts, question), collapse = "\n\n")
|
| 153 |
}
|
| 154 |
|
| 155 |
# --- Tool back-ends (operate on a plain snapshot env + read-only con) ---------
|
|
|
|
| 302 |
|
| 303 |
ind_enum <- ellmer::type_enum(names(.IND_LABELS), "Indicator column.")
|
| 304 |
|
| 305 |
+
# Only the 3 data tools are registered. The indicator catalog lives in the
|
| 306 |
+
# system prompt and the current scope/totals are injected as <scope> each turn,
|
| 307 |
+
# so list_indicators / describe_selection are unnecessary -- fewer tool schemas
|
| 308 |
+
# mean less CPU prefill and easier tool choice for the 1.5B model.
|
| 309 |
aggregate_indicator <- function(indicator, statistic = "mean")
|
| 310 |
assistant_aggregate(con, state, indicator, statistic)
|
| 311 |
top_municipalities <- function(indicator, n = 5)
|
| 312 |
assistant_top_municipalities(con, state, indicator, n)
|
| 313 |
count_points <- function(layer) assistant_count_points(state, layer)
|
| 314 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
chat$register_tool(ellmer::tool(aggregate_indicator,
|
| 316 |
"Aggregate one municipality indicator over the current selection (or nationally if nothing is selected). Use 'mean' for rate indicators and 'sum' for count indicators.",
|
| 317 |
arguments = list(
|
depth_interactive_map_alpha.R
CHANGED
|
@@ -173,26 +173,26 @@ POBREZA_PAL <- c("#fff7f3", "#feebe2", "#fbb4b9", "#f768a1",
|
|
| 173 |
# Categorical palettes for the two non-numeric patp sub-types. Stable colour-blind
|
| 174 |
# safe Set2-style hues; keys must match the truncated label list in setPATPType().
|
| 175 |
PATP_RURAL_PROBLEM_PAL <- c(
|
| 176 |
-
"Sociales y económicos" = "#
|
| 177 |
-
"Carencia o deficiencia en servicios básicos" = "#
|
| 178 |
-
"
|
| 179 |
-
"
|
| 180 |
-
"Carencia o deficiencia en la infraestructura educativa y de salud"= "#
|
| 181 |
-
"
|
| 182 |
-
"Sin problema" = "#e5c494",
|
| 183 |
"Falta de información / Otro problema" = "#b3b3b3",
|
| 184 |
-
"
|
| 185 |
-
"
|
|
|
|
| 186 |
)
|
| 187 |
PATP_DELITO_PAL <- c(
|
| 188 |
-
"El patrimonio" = "#
|
| 189 |
-
"
|
| 190 |
-
"
|
| 191 |
-
"
|
| 192 |
-
"La familia" = "#
|
| 193 |
-
"
|
| 194 |
-
"
|
| 195 |
-
"
|
| 196 |
)
|
| 197 |
|
| 198 |
# Spanish-key -> English-display maps used by acc_legend_cats() so that the
|
|
@@ -231,10 +231,10 @@ PATP_DELITO_LABELS_EN <- c(
|
|
| 231 |
# WEB_SQL_TEMPLATES$load_infra_pts maps the raw values to these buckets.
|
| 232 |
INFRA_CATS <- c(
|
| 233 |
"Shelter" = "#3C6E71", # forest green
|
| 234 |
-
"Support (NGO)"
|
| 235 |
-
"Support (GOV)"
|
| 236 |
-
"Embassy / Consulate"
|
| 237 |
-
"Migration office"
|
| 238 |
"Other" = "#94a3b8" # neutral slate (fallback)
|
| 239 |
)
|
| 240 |
|
|
@@ -3921,9 +3921,12 @@ server <- function(input, output, session) {
|
|
| 3921 |
"Sorry -- the local model is unavailable right now.")
|
| 3922 |
return()
|
| 3923 |
}
|
| 3924 |
-
ctx <- tryCatch(rag_search(con, q, .ASSIST_CFG, k =
|
| 3925 |
error = function(e) "")
|
| 3926 |
-
|
|
|
|
|
|
|
|
|
|
| 3927 |
tryCatch(
|
| 3928 |
shinychat::chat_append("assist_chat", chat$stream_async(prompt)),
|
| 3929 |
error = function(e) {
|
|
|
|
| 173 |
# Categorical palettes for the two non-numeric patp sub-types. Stable colour-blind
|
| 174 |
# safe Set2-style hues; keys must match the truncated label list in setPATPType().
|
| 175 |
PATP_RURAL_PROBLEM_PAL <- c(
|
| 176 |
+
"Sociales y económicos" = "#4269D0",
|
| 177 |
+
"Carencia o deficiencia en servicios básicos" = "#EFB118",
|
| 178 |
+
"Inseguridad, delincuencia y adicciones" = "#FF725C",
|
| 179 |
+
"Caminos, transportes y telecomunicaciones" = "#FF8AB7",
|
| 180 |
+
"Carencia o deficiencia en la infraestructura educativa y de salud" = "#6CC5B0",
|
| 181 |
+
"Riesgos ambientales" = "#3CA951",
|
|
|
|
| 182 |
"Falta de información / Otro problema" = "#b3b3b3",
|
| 183 |
+
"Problema no definido" = "#cab2d6",
|
| 184 |
+
"Sin problema" = "#FBF9F1",
|
| 185 |
+
"Municipios sin localidades objetivo" = "#9C6B4E"
|
| 186 |
)
|
| 187 |
PATP_DELITO_PAL <- c(
|
| 188 |
+
"El patrimonio" = "#4269D0",
|
| 189 |
+
"múltiples bienes" = "#EFB118",
|
| 190 |
+
"otros bienes" = "#FF725C",
|
| 191 |
+
"La vida y la Integridad corporal" = "#FF8AB7",
|
| 192 |
+
"La familia" = "#6CC5B0",
|
| 193 |
+
"La libertad y la seguridad sexual" = "#3CA951",
|
| 194 |
+
"Libertad personal" = "#A463F2",
|
| 195 |
+
"Sin delito" = "#FBF9F1"
|
| 196 |
)
|
| 197 |
|
| 198 |
# Spanish-key -> English-display maps used by acc_legend_cats() so that the
|
|
|
|
| 231 |
# WEB_SQL_TEMPLATES$load_infra_pts maps the raw values to these buckets.
|
| 232 |
INFRA_CATS <- c(
|
| 233 |
"Shelter" = "#3C6E71", # forest green
|
| 234 |
+
"Support (NGO)" = "#F5CB5C", # ochre
|
| 235 |
+
"Support (GOV)" = "#3A4B5C", # slate
|
| 236 |
+
"Embassy / Consulate" = "#4D194D", # deep navy (international protection)
|
| 237 |
+
"Migration office" = "#9E2A3A", # brick red
|
| 238 |
"Other" = "#94a3b8" # neutral slate (fallback)
|
| 239 |
)
|
| 240 |
|
|
|
|
| 3921 |
"Sorry -- the local model is unavailable right now.")
|
| 3922 |
return()
|
| 3923 |
}
|
| 3924 |
+
ctx <- tryCatch(rag_search(con, q, .ASSIST_CFG, k = 2),
|
| 3925 |
error = function(e) "")
|
| 3926 |
+
# Inject the current map scope so the model skips a describe_selection round-trip.
|
| 3927 |
+
scope <- tryCatch(assistant_selection_summary(assist_state),
|
| 3928 |
+
error = function(e) "")
|
| 3929 |
+
prompt <- assistant_user_turn(q, ctx, scope)
|
| 3930 |
tryCatch(
|
| 3931 |
shinychat::chat_append("assist_chat", chat$stream_async(prompt)),
|
| 3932 |
error = function(e) {
|
entrypoint.sh
CHANGED
|
@@ -42,5 +42,16 @@ ensure_model "${EMBED_MODEL}"
|
|
| 42 |
echo "[entrypoint] ollama models available at runtime:"
|
| 43 |
ollama list || true
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
echo "[entrypoint] launching Shiny app..."
|
| 46 |
exec Rscript depth_interactive_map_alpha.R
|
|
|
|
| 42 |
echo "[entrypoint] ollama models available at runtime:"
|
| 43 |
ollama list || true
|
| 44 |
|
| 45 |
+
# Pre-warm both models so the FIRST user question hits a hot model -- the CPU load
|
| 46 |
+
# of the weights into RAM happens now (at startup) instead of on that first reply.
|
| 47 |
+
# Combined with OLLAMA_KEEP_ALIVE=-1 this removes the cold-load and reload latency.
|
| 48 |
+
echo "[entrypoint] pre-warming ${CHAT_MODEL} (loads weights into RAM)..."
|
| 49 |
+
curl -sf "http://${OLLAMA_HOST}/api/generate" \
|
| 50 |
+
-d "{\"model\":\"${CHAT_MODEL}\",\"prompt\":\"hi\",\"stream\":false,\"keep_alive\":-1}" \
|
| 51 |
+
>/dev/null 2>&1 && echo "[entrypoint] chat model warm." || echo "[entrypoint] chat warm skipped."
|
| 52 |
+
curl -sf "http://${OLLAMA_HOST}/api/embed" \
|
| 53 |
+
-d "{\"model\":\"${EMBED_MODEL}\",\"input\":\"warm\"}" \
|
| 54 |
+
>/dev/null 2>&1 && echo "[entrypoint] embed model warm." || echo "[entrypoint] embed warm skipped."
|
| 55 |
+
|
| 56 |
echo "[entrypoint] launching Shiny app..."
|
| 57 |
exec Rscript depth_interactive_map_alpha.R
|