edgarodriguez commited on
Commit
2d90c38
·
verified ·
1 Parent(s): dfc66e1

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +10 -2
  2. assistant.R +32 -42
  3. depth_interactive_map_alpha.R +26 -23
  4. 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
- ARG CHAT_MODEL=qwen2.5:3b
 
 
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
- rag_search <- function(con, query, cfg = assistant_config(), k = 5) {
 
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 (label + tooltip + source) into a compact data dictionary
113
- # the model can read. Stays in lockstep with the UI -- no separate codebook.
 
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
- if (length(layer_items) == 0) next
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 built by the University of Nottingham Rights Lab. The map ",
135
- "overlays humanitarian, violence and infrastructure indicators on a ",
136
- "hierarchical grid of Mexico.\n\n",
137
- "How to answer:\n",
138
- "- For ANY number, ranking, count or aggregation you MUST call a tool. ",
139
- "Never invent, estimate or recall figures from memory.\n",
140
- "- When the user refers to 'the selection', 'this area', 'the area I drew', ",
141
- "'here', or 'visible', call describe_selection first to learn the scope.\n",
142
- "- Use the <context> document excerpts supplied with the user's message for ",
143
- "background, definitions and qualitative explanation. Cite the document ",
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
- # Prepend retrieved corpus context to the raw question (the UI still shows the
155
- # raw question; only the model sees the augmented version).
156
- assistant_user_turn <- function(question, context_txt) {
157
- if (is.null(context_txt) || !nzchar(context_txt)) return(question)
158
- paste0("<context>\n", context_txt, "\n</context>\n\n", question)
 
 
 
 
 
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
- list_indicators <- function() assistant_indicator_catalog_txt()
312
- describe_selection <- function() assistant_selection_summary(state)
 
 
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" = "#66c2a5",
177
- "Carencia o deficiencia en servicios básicos" = "#fc8d62",
178
- "Caminos, transportes y telecomunicaciones" = "#8da0cb",
179
- "Inseguridad, delincuencia y adicciones" = "#e78ac3",
180
- "Carencia o deficiencia en la infraestructura educativa y de salud"= "#a6d854",
181
- "Municipios sin localidades objetivo" = "#ffd92f",
182
- "Sin problema" = "#e5c494",
183
  "Falta de información / Otro problema" = "#b3b3b3",
184
- "Riesgos ambientales" = "#1f78b4",
185
- "Problema no definido" = "#cab2d6"
 
186
  )
187
  PATP_DELITO_PAL <- c(
188
- "El patrimonio" = "#e41a1c",
189
- "otros bienes" = "#377eb8",
190
- "La vida y la Integridad corporal" = "#4daf4a",
191
- "múltiples bienes" = "#984ea3",
192
- "La familia" = "#ff7f00",
193
- "Sin delito" = "#a6cee3",
194
- "La libertad y la seguridad sexual" = "#b15928",
195
- "Libertad personal" = "#6a3d9a"
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)" = "#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,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 = 5),
3925
  error = function(e) "")
3926
- prompt <- assistant_user_turn(q, ctx)
 
 
 
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