airayven7 commited on
Commit
838a298
·
verified ·
1 Parent(s): d8f1e4d

Sync from GitHub f6782fa

Browse files
README.md CHANGED
@@ -105,3 +105,34 @@ is running show up without a restart. See `pipelines/mock_ask.py`.
105
  `flash_attention_2` if flash-attn is installed).
106
 
107
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  `flash_attention_2` if flash-attn is installed).
106
 
107
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
108
+
109
+ ## Tracing (Langfuse)
110
+
111
+ Each find turn is traced to [Langfuse](https://langfuse.com) when keys are
112
+ present, otherwise tracing is a complete no-op and the Space runs unchanged.
113
+ Set these as **Space secrets** (or shell env / a `.env` for local runs):
114
+
115
+ ```bash
116
+ LANGFUSE_PUBLIC_KEY=pk-lf-...
117
+ LANGFUSE_SECRET_KEY=sk-lf-...
118
+ LANGFUSE_HOST=https://cloud.langfuse.com # or https://us.cloud.langfuse.com, or self-hosted
119
+ ```
120
+
121
+ (`LANGFUSE_BASE_URL` is accepted as an alias for `LANGFUSE_HOST`.) Keys come from
122
+ the Langfuse project's **Settings → API Keys**.
123
+
124
+ One turn is one trace — an `agent` observation named `agent-find` (input = the
125
+ mechanic's request, output = the terminal action), with a child per step:
126
+
127
+ - `agent-decide` (`generation`) — the 1B brain picking a tool, with the resident
128
+ model id and input/output token counts.
129
+ - `search` / `find_answer` (`retriever`) — the ColEmbed / dense lookups and their
130
+ page hits.
131
+ - `ground-circle` (`generation`) — MiniCPM-V placing the circle box.
132
+
133
+ The frontend sends a per-page-load `session_id`, so a visit's turns group into
134
+ one **session** in the Langfuse UI. The wiring lives in `core/tracing.py` (a
135
+ no-op-safe wrapper that builds and flushes the trace inside the ZeroGPU worker,
136
+ attaching children to the root explicitly rather than via context — the turn is
137
+ a streamed generator). The Langfuse working skill is vendored under
138
+ `.claude/skills/langfuse/`.
app.py CHANGED
@@ -300,6 +300,7 @@ def api_find(
300
  think: bool = GROUND_ENABLE_THINKING,
301
  agent_model: str = DEFAULT_AGENT_MODEL,
302
  vram_log: bool = False,
 
303
  ) -> dict: # the per-yield type: Server.api infers outputs from this annotation
304
  """One agent turn (one ZeroGPU call), streamed as events (see
305
  pipelines/agent_ask.py for the protocol). page/section are what the viewer
@@ -345,6 +346,7 @@ def api_find(
345
  events = PIPELINE.run_find(
346
  VISUAL_STORE, PARSED_STORE, request, [manual], int(k), options,
347
  viewer, history, bool(think), agent_model, bool(vram_log),
 
348
  )
349
  for ev in events:
350
  if ev.get("type") == "tool_result" and "gallery" in ev:
 
300
  think: bool = GROUND_ENABLE_THINKING,
301
  agent_model: str = DEFAULT_AGENT_MODEL,
302
  vram_log: bool = False,
303
+ session_id: str = "",
304
  ) -> dict: # the per-yield type: Server.api infers outputs from this annotation
305
  """One agent turn (one ZeroGPU call), streamed as events (see
306
  pipelines/agent_ask.py for the protocol). page/section are what the viewer
 
346
  events = PIPELINE.run_find(
347
  VISUAL_STORE, PARSED_STORE, request, [manual], int(k), options,
348
  viewer, history, bool(think), agent_model, bool(vram_log),
349
+ str(session_id or ""),
350
  )
351
  for ev in events:
352
  if ev.get("type") == "tool_result" and "gallery" in ev:
core/tracing.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Langfuse tracing for the find-and-point agent turn — optional, no-op safe.
2
+
3
+ One agent turn (pipelines/agent_ask.agent_events) is the unit of observability:
4
+ the root is an `agent` observation whose children are the brain's decisions
5
+ (`generation`), the searches (`retriever`) and the circle grounding
6
+ (`generation`). The mapping to Langfuse:
7
+
8
+ agent-find (agent) input=the request, output=the terminal action
9
+ ├─ agent-decide (generation) the 1B "brain" picks a tool [models/minicpm_agent]
10
+ ├─ search / find_answer (retriever) ColEmbed / dense lookup [pipelines/agent_ask]
11
+ └─ ground-circle (generation) MiniCPM-V places the box [models/minicpm]
12
+
13
+ Two constraints shape the design, and are why this does NOT use the SDK's
14
+ context-manager "current span" nesting:
15
+
16
+ 1. The turn runs inside a @spaces.GPU worker (ZeroGPU forks a worker that
17
+ shares the resident CUDA models). The whole trace must therefore be built
18
+ and flushed *inside* that worker — so the root span is opened in
19
+ agent_events, not in the FastAPI endpoint that streams it.
20
+ 2. agent_events is a generator. OTel's contextvar-based "current span" is
21
+ fragile across generator yields, so instead we open a root span object,
22
+ stash it on a threadlocal (one worker thread per turn), and attach every
23
+ child to it EXPLICITLY via parent.start_observation(...). No reliance on
24
+ the active context survives a yield.
25
+
26
+ Tracing is fully disabled — every call a cheap no-op — unless
27
+ LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY are set, so the Space (and local
28
+ MOCK_MODELS runs) work unchanged without any Langfuse config.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import contextlib
34
+ import logging
35
+ import os
36
+ import threading
37
+
38
+ log = logging.getLogger("repairguy.tracing")
39
+
40
+ # Keys present → tracing on. Read once at import; the Space sets these as Space
41
+ # secrets, local dev via the shell / .env.
42
+ _ENABLED = bool(
43
+ os.environ.get("LANGFUSE_PUBLIC_KEY") and os.environ.get("LANGFUSE_SECRET_KEY")
44
+ )
45
+ # The skill/CLI use LANGFUSE_BASE_URL; the SDK reads LANGFUSE_HOST. Bridge them
46
+ # so a single var configures both.
47
+ if os.environ.get("LANGFUSE_BASE_URL") and not os.environ.get("LANGFUSE_HOST"):
48
+ os.environ["LANGFUSE_HOST"] = os.environ["LANGFUSE_BASE_URL"]
49
+
50
+ _client = None
51
+ _client_failed = False
52
+ # The root span of the turn currently running on this (worker) thread. Children
53
+ # attach to it explicitly; cleared by finish_turn so a later untraced turn on
54
+ # the same worker thread can't graft onto a stale parent.
55
+ _state = threading.local()
56
+
57
+
58
+ def _get_client():
59
+ """The shared Langfuse client, or None when disabled / unconfigurable.
60
+ Lazy so importing this module never touches the network or hard-requires
61
+ the SDK; cached, including the failure case."""
62
+ global _client, _client_failed
63
+ if not _ENABLED or _client_failed:
64
+ return None
65
+ if _client is None:
66
+ try:
67
+ from langfuse import get_client
68
+
69
+ _client = get_client()
70
+ except Exception as e: # SDK missing or misconfigured — degrade to no-op
71
+ log.warning("Langfuse tracing disabled: %s", e)
72
+ _client_failed = True
73
+ return None
74
+ return _client
75
+
76
+
77
+ def enabled() -> bool:
78
+ return _get_client() is not None
79
+
80
+
81
+ def _parent():
82
+ return getattr(_state, "parent", None)
83
+
84
+
85
+ def start_turn(
86
+ *,
87
+ name: str,
88
+ input,
89
+ session_id: str | None = None,
90
+ user_id: str | None = None,
91
+ tags: list[str] | None = None,
92
+ metadata: dict | None = None,
93
+ ):
94
+ """Open the root `agent` span for one turn and register it as the active
95
+ parent for this thread. Returns the span (or None when tracing is off).
96
+ Always pair with finish_turn in a finally."""
97
+ client = _get_client()
98
+ if client is None:
99
+ _state.parent = None
100
+ return None
101
+ try:
102
+ from langfuse import propagate_attributes
103
+
104
+ # session_id/user_id/tags are trace-level; propagate_attributes stamps
105
+ # them onto the span we create inside the context (and thus its trace).
106
+ # Coerce empty strings to None so we never set blank attributes.
107
+ with propagate_attributes(
108
+ session_id=session_id or None,
109
+ user_id=user_id or None,
110
+ tags=tags or None,
111
+ trace_name=name,
112
+ ):
113
+ span = client.start_observation(
114
+ name=name, as_type="agent", input=input, metadata=metadata
115
+ )
116
+ _state.parent = span
117
+ return span
118
+ except Exception as e:
119
+ log.warning("tracing start_turn failed: %s", e)
120
+ _state.parent = None
121
+ return None
122
+
123
+
124
+ def finish_turn(span, *, output=None, level: str | None = None, status_message: str | None = None):
125
+ """Close the turn: record its output, end the root span, clear the parent,
126
+ and flush (the worker is short-lived, so unflushed events would be lost)."""
127
+ _state.parent = None
128
+ if span is None:
129
+ return
130
+ try:
131
+ if output is not None or level is not None or status_message is not None:
132
+ span.update(output=output, level=level, status_message=status_message)
133
+ span.end()
134
+ except Exception as e:
135
+ log.warning("tracing finish_turn failed: %s", e)
136
+ finally:
137
+ client = _get_client()
138
+ if client is not None:
139
+ with contextlib.suppress(Exception):
140
+ client.flush()
141
+
142
+
143
+ @contextlib.contextmanager
144
+ def generation(name: str, *, model: str | None = None, input=None, metadata: dict | None = None):
145
+ """A child `generation` under the active turn — one model decode. Yields the
146
+ span (or None when off / no active turn) so the caller can .update(output=,
147
+ usage_details=). Synchronous body only (no yields inside), so the parent
148
+ link is explicit and contextvar-safe."""
149
+ parent = _parent()
150
+ if parent is None:
151
+ yield None
152
+ return
153
+ gen = None
154
+ try:
155
+ gen = parent.start_observation(
156
+ name=name, as_type="generation", model=model, input=input, metadata=metadata
157
+ )
158
+ except Exception as e:
159
+ log.warning("tracing generation(%s) failed: %s", name, e)
160
+ try:
161
+ yield gen
162
+ finally:
163
+ if gen is not None:
164
+ with contextlib.suppress(Exception):
165
+ gen.end()
166
+
167
+
168
+ @contextlib.contextmanager
169
+ def retriever(name: str, *, input=None, metadata: dict | None = None):
170
+ """A child `retriever` under the active turn — one search/lookup. Yields the
171
+ span (or None) for the caller to .update(output=the hits)."""
172
+ parent = _parent()
173
+ if parent is None:
174
+ yield None
175
+ return
176
+ span = None
177
+ try:
178
+ span = parent.start_observation(
179
+ name=name, as_type="retriever", input=input, metadata=metadata
180
+ )
181
+ except Exception as e:
182
+ log.warning("tracing retriever(%s) failed: %s", name, e)
183
+ try:
184
+ yield span
185
+ finally:
186
+ if span is not None:
187
+ with contextlib.suppress(Exception):
188
+ span.end()
frontend/index.html CHANGED
@@ -433,6 +433,9 @@ const ICONS = {
433
  function repairGuy() {
434
  return {
435
  client:null, ready:false,
 
 
 
436
  manuals:[], manual:'',
437
  k:__DEFAULT_K__, maxK:__MAX_K__,
438
  // When on, MiniCPM-V reasons before committing to a circle box (slower,
@@ -731,6 +734,7 @@ function repairGuy() {
731
  try{
732
  const job = this.client.submit('/find', {
733
  request:q, manual:this.manual, k:this.k, think:this.think, agent_model:this.agentModel,
 
734
  page: this.viewDoc ? this.viewPage : 0, section: this.sectionTitle(this.viewPage),
735
  pages: this.spreadPages, // every page currently on screen — the agent may circle on any
736
  history: this.history,
 
433
  function repairGuy() {
434
  return {
435
  client:null, ready:false,
436
+ // One id per page load — groups this visit's find turns into a single
437
+ // Langfuse session so a whole conversation can be replayed together.
438
+ sessionId:(self.crypto&&crypto.randomUUID)?crypto.randomUUID():'sess-'+Date.now()+'-'+Math.random().toString(36).slice(2),
439
  manuals:[], manual:'',
440
  k:__DEFAULT_K__, maxK:__MAX_K__,
441
  // When on, MiniCPM-V reasons before committing to a circle box (slower,
 
734
  try{
735
  const job = this.client.submit('/find', {
736
  request:q, manual:this.manual, k:this.k, think:this.think, agent_model:this.agentModel,
737
+ session_id:this.sessionId,
738
  page: this.viewDoc ? this.viewPage : 0, section: this.sectionTitle(this.viewPage),
739
  pages: this.spreadPages, // every page currently on screen — the agent may circle on any
740
  history: this.history,
models/minicpm.py CHANGED
@@ -25,6 +25,7 @@ import torch
25
  from PIL import Image
26
  from transformers import AutoModel, AutoProcessor, AutoTokenizer
27
 
 
28
  from core.constants import (
29
  ANSWER_MAX_NEW_TOKENS,
30
  DESCRIBE_MAX_NEW_TOKENS,
@@ -171,24 +172,33 @@ def ground_box(
171
  GROUND_ENABLE_THINKING default; the UI settings panel passes an explicit
172
  bool per request."""
173
  think = GROUND_ENABLE_THINKING if enable_thinking is None else enable_thinking
174
- with torch.no_grad():
175
- out = _MODEL.chat(
176
- msgs=[
177
- {
178
- "role": "user",
179
- "content": [image.convert("RGB"), GROUND_PROMPT.format(query=query)],
180
- }
181
- ],
182
- tokenizer=_TOKENIZER,
183
- # The think trace needs room (a bare box fits in 64 tokens; a think
184
- # trace does not) or it gets cut off before emitting the box — so
185
- # the token budget tracks the flag.
186
- enable_thinking=think,
187
- max_new_tokens=(
188
- GROUND_THINK_MAX_NEW_TOKENS if think else GROUND_BOX_MAX_NEW_TOKENS
189
- ),
190
- )
191
- raw = str(out).strip()
 
 
 
 
 
 
 
 
 
192
  # The thinking trace can mention "not found" mid-reasoning ("at first this
193
  # looked not found, but…"), so test only the FINAL answer after </think>,
194
  # not the whole reply.
 
25
  from PIL import Image
26
  from transformers import AutoModel, AutoProcessor, AutoTokenizer
27
 
28
+ from core import tracing
29
  from core.constants import (
30
  ANSWER_MAX_NEW_TOKENS,
31
  DESCRIBE_MAX_NEW_TOKENS,
 
172
  GROUND_ENABLE_THINKING default; the UI settings panel passes an explicit
173
  bool per request."""
174
  think = GROUND_ENABLE_THINKING if enable_thinking is None else enable_thinking
175
+ # One `generation` (the VLM "eyes" placing the box): query in, raw reply out.
176
+ with tracing.generation(
177
+ "ground-circle",
178
+ model=MINICPM_MODEL_ID,
179
+ input=query,
180
+ metadata={"thinking": bool(think)},
181
+ ) as gen:
182
+ with torch.no_grad():
183
+ out = _MODEL.chat(
184
+ msgs=[
185
+ {
186
+ "role": "user",
187
+ "content": [image.convert("RGB"), GROUND_PROMPT.format(query=query)],
188
+ }
189
+ ],
190
+ tokenizer=_TOKENIZER,
191
+ # The think trace needs room (a bare box fits in 64 tokens; a think
192
+ # trace does not) or it gets cut off before emitting the box — so
193
+ # the token budget tracks the flag.
194
+ enable_thinking=think,
195
+ max_new_tokens=(
196
+ GROUND_THINK_MAX_NEW_TOKENS if think else GROUND_BOX_MAX_NEW_TOKENS
197
+ ),
198
+ )
199
+ raw = str(out).strip()
200
+ if gen is not None:
201
+ gen.update(output=raw)
202
  # The thinking trace can mention "not found" mid-reasoning ("at first this
203
  # looked not found, but…"), so test only the FINAL answer after </think>,
204
  # not the whole reply.
models/minicpm_agent.py CHANGED
@@ -42,6 +42,7 @@ import re
42
  import torch
43
  from transformers import AutoModelForCausalLM, AutoTokenizer
44
 
 
45
  from core.constants import (
46
  AGENT_MAX_NEW_TOKENS,
47
  AGENT_MODELS,
@@ -254,6 +255,11 @@ def _spec(key: str | None) -> dict:
254
  return _REGISTRY.get(key or "", _REGISTRY[DEFAULT_AGENT_MODEL])
255
 
256
 
 
 
 
 
 
257
  def use_model(key: str | None = None) -> str:
258
  """Make `key` the resident agent brain, loading it (and evicting the
259
  previous one) when it isn't already active — one model in VRAM at a time.
@@ -319,25 +325,40 @@ def _template_kwargs() -> dict:
319
  return {"enable_thinking": False} if _THINKING else {}
320
 
321
 
322
- def _generate(messages: list[dict], max_new_tokens: int) -> str:
323
- """Greedy decode the assistant's next message."""
324
- inputs = _TOKENIZER.apply_chat_template(
325
- messages,
326
- tokenize=True,
327
- add_generation_prompt=True,
328
- return_dict=True,
329
- return_tensors="pt",
330
- **_template_kwargs(),
331
- ).to(_MODEL.device)
332
- # apply_chat_template emits token_type_ids, which this LlamaForCausalLM's
333
- # generate() rejects as an unused kwarg.
334
- inputs.pop("token_type_ids", None)
335
- with torch.no_grad():
336
- out = _MODEL.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
337
- text = _TOKENIZER.decode(
338
- out[0, inputs["input_ids"].shape[1] :], skip_special_tokens=True
339
- )
340
- return text.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
 
342
 
343
  def render_prompt(messages: list[dict]) -> str:
@@ -419,7 +440,7 @@ def decide(messages: list[dict]) -> tuple[dict | None, str]:
419
  maintains (system + past turns + this turn's state and any tool results).
420
  Returns (parsed tool call, raw reply); the tool is None when the reply isn't
421
  a usable JSON tool call. Must run on GPU."""
422
- raw = _generate(messages, AGENT_MAX_NEW_TOKENS)
423
  return _parse_tool(raw), raw
424
 
425
 
@@ -443,7 +464,9 @@ def rerank(query: str, candidates: list[tuple[int, str]]) -> tuple[int, str]:
443
  f"PAGE {page}:\n{text or '(no text)'}" for page, text in candidates
444
  )
445
  prompt = RERANK_PROMPT.format(query=query, n=len(candidates), candidates=listing)
446
- raw = _generate([{"role": "user", "content": prompt}], max_new_tokens=8)
 
 
447
  m = re.search(r"\d+", raw)
448
  if m:
449
  picked = int(m.group())
 
42
  import torch
43
  from transformers import AutoModelForCausalLM, AutoTokenizer
44
 
45
+ from core import tracing
46
  from core.constants import (
47
  AGENT_MAX_NEW_TOKENS,
48
  AGENT_MODELS,
 
255
  return _REGISTRY.get(key or "", _REGISTRY[DEFAULT_AGENT_MODEL])
256
 
257
 
258
+ def _active_model_id() -> str:
259
+ """The HF id of the resident brain — the `model` for its generation spans."""
260
+ return _spec(_active_key).get("model_id", _active_key or "unknown")
261
+
262
+
263
  def use_model(key: str | None = None) -> str:
264
  """Make `key` the resident agent brain, loading it (and evicting the
265
  previous one) when it isn't already active — one model in VRAM at a time.
 
325
  return {"enable_thinking": False} if _THINKING else {}
326
 
327
 
328
+ def _generate(
329
+ messages: list[dict], max_new_tokens: int, trace_name: str = "agent-generate"
330
+ ) -> str:
331
+ """Greedy decode the assistant's next message. Traced as one `generation`
332
+ (the resident brain as the model, the messages as input, the reply and the
333
+ in/out token counts attached) when Langfuse is configured."""
334
+ with tracing.generation(
335
+ trace_name, model=_active_model_id(), input=messages
336
+ ) as gen:
337
+ inputs = _TOKENIZER.apply_chat_template(
338
+ messages,
339
+ tokenize=True,
340
+ add_generation_prompt=True,
341
+ return_dict=True,
342
+ return_tensors="pt",
343
+ **_template_kwargs(),
344
+ ).to(_MODEL.device)
345
+ # apply_chat_template emits token_type_ids, which this LlamaForCausalLM's
346
+ # generate() rejects as an unused kwarg.
347
+ inputs.pop("token_type_ids", None)
348
+ n_in = int(inputs["input_ids"].shape[1])
349
+ with torch.no_grad():
350
+ out = _MODEL.generate(
351
+ **inputs, max_new_tokens=max_new_tokens, do_sample=False
352
+ )
353
+ new_ids = out[0, n_in:]
354
+ text = _TOKENIZER.decode(new_ids, skip_special_tokens=True)
355
+ if gen is not None:
356
+ n_out = int(new_ids.shape[0])
357
+ gen.update(
358
+ output=text.strip(),
359
+ usage_details={"input": n_in, "output": n_out, "total": n_in + n_out},
360
+ )
361
+ return text.strip()
362
 
363
 
364
  def render_prompt(messages: list[dict]) -> str:
 
440
  maintains (system + past turns + this turn's state and any tool results).
441
  Returns (parsed tool call, raw reply); the tool is None when the reply isn't
442
  a usable JSON tool call. Must run on GPU."""
443
+ raw = _generate(messages, AGENT_MAX_NEW_TOKENS, trace_name="agent-decide")
444
  return _parse_tool(raw), raw
445
 
446
 
 
464
  f"PAGE {page}:\n{text or '(no text)'}" for page, text in candidates
465
  )
466
  prompt = RERANK_PROMPT.format(query=query, n=len(candidates), candidates=listing)
467
+ raw = _generate(
468
+ [{"role": "user", "content": prompt}], max_new_tokens=8, trace_name="agent-rerank"
469
+ )
470
  m = re.search(r"\d+", raw)
471
  if m:
472
  picked = int(m.group())
pipelines/agent_ask.py CHANGED
@@ -47,6 +47,7 @@ import logging
47
 
48
  import spaces
49
 
 
50
  from core.constants import (
51
  AGENT_HISTORY_TURNS,
52
  AGENT_MAX_STEPS,
@@ -91,6 +92,7 @@ def agent_events(
91
  ground_thinking: bool | None = None,
92
  agent_model: str | None = None,
93
  vram_log: bool = False,
 
94
  ):
95
  """Yield the events of one agent turn (see module docstring). sections is the
96
  numbered table of contents shown to the agent ([{title, page}]); the agent's
@@ -182,167 +184,205 @@ def agent_events(
182
  )
183
  )
184
 
185
- for step in range(AGENT_MAX_STEPS):
186
- # Render the exact prompt BEFORE deciding so the trace can show what the
187
- # brain was asked, not just what it answered.
188
- prompt = minicpm_agent.render_prompt(messages)
189
- tool, raw = minicpm_agent.decide(messages)
190
- log.info("step %d: tool=%s | raw=%r", step, tool, raw[:200])
191
- # Diagnostic event: the prompt fed in, the raw 1B reply, and the parsed
192
- # tool for this step, so the UI's trace view shows exactly what the brain
193
- # was asked and decided (and why a reply was rejected). Not used by the
194
- # normal chip flow.
195
- yield {"type": "trace", "step": step, "tool": tool, "raw": raw,
196
- "prompt": prompt}
197
- if tool is None:
198
- # Unusable reply (bad JSON, or an echoed placeholder target). Correct
199
- # it and let the agent try again rather than abandon the turn.
200
- messages.append(
201
- minicpm_agent.tool_result_message(
202
- "Your last reply was not one complete JSON object. Reply with "
203
- "ONE complete JSON object and nothing else, e.g. "
204
- '{"tool": "search", "query": "fuel filter"}. If you circle, the '
205
- "target MUST be copied from the page text above — never invent "
206
- "a part that is not printed there."
207
- )
208
- )
209
- continue
210
- messages.append(minicpm_agent.assistant_action_message(tool))
211
-
212
- if tool["tool"] == "go_to_section":
213
- idx = tool["section"] - 1
214
- if not 0 <= idx < len(sections):
 
 
 
 
 
 
215
  messages.append(
216
  minicpm_agent.tool_result_message(
217
- f"There is no section {tool['section']}. Pick a number from "
218
- "the table of contents, or use search."
 
 
 
219
  )
220
  )
221
  continue
222
- opt = sections[idx]
223
- yield {"type": "step", "tool": "go_to_section",
224
- "title": opt["title"], "page": int(opt["page"])}
225
- yield {"type": "done", "kind": "navigate", "nav": "section",
226
- "page": int(opt["page"]), "title": opt["title"]}
227
- return
228
 
229
- if tool["tool"] == "go_to_page":
230
- page = tool["page"]
231
- n = page_count(visual_store.pdf_path(doc_id))
232
- if not 1 <= page <= n:
233
- messages.append(
234
- minicpm_agent.tool_result_message(
235
- f"There is no page {page}; this manual has pages 1–{n}. "
236
- "Pick a page in range, search, or go to a section."
237
  )
238
- )
239
- continue
240
- yield {"type": "step", "tool": "go_to_page", "page": page}
241
- yield {"type": "done", "kind": "navigate", "nav": "page",
242
- "page": page, "title": f"Page {page}"}
243
- return
 
 
244
 
245
- if tool["tool"] == "search":
246
- query = tool["query"]
247
- yield {"type": "step", "tool": "search", "query": query}
248
- yield {"type": "status", "text": f"Searching for “{query}”…"}
249
- # k (the viewer's slider) is the shortlist size; ColEmbed's top page
250
- # is the one shown. A 1B text rerank measured WORSE than raw ColEmbed
251
- # top-1 (0.68 vs 0.84 hit@1) visual late interaction already ranks
252
- # these (figure-heavy) pages better than re-judging from page text.
253
- hits = maxsim_search(query, visual_store, doc_ids, top_k)
254
- log.info("search(%r) → %s", query, [(p, round(s, 3)) for _, p, s in hits])
255
- if not hits:
256
- messages.append(
257
- minicpm_agent.tool_result_message(f"Search for {query!r} found nothing.")
258
- )
259
- continue
260
- yield from present_hits(hits, "search:" + " ".join(query.lower().split()))
261
- continue
262
 
263
- if tool["tool"] == "find_answer":
264
- query = tool["query"]
265
- yield {"type": "step", "tool": "find_answer", "query": query}
266
- yield {"type": "status", "text": f"Looking up “{query}”…"}
267
- # Dense retrieval over the PARSED chunks (text/semantic) the index
268
- # the parsed store was built for. A fact lookup ("what fuel does it
269
- # take") is a TEXT match: ColEmbed ranks pages by VISUAL similarity
270
- # and misses the plain specs page, so fact questions route here. Same
271
- # (doc_id, page, score) shape as maxsim_search; the agent then circles
272
- # the answering line on the page shown.
273
- hits = retrieve_pages(query, parsed_store, doc_ids, top_k)
274
- log.info("find_answer(%r) %s", query, [(p, round(s, 3)) for _, p, s in hits])
275
- if not hits:
276
- messages.append(
277
- minicpm_agent.tool_result_message(f"Looking up {query!r} found nothing.")
278
- )
 
 
 
 
 
279
  continue
280
- yield from present_hits(hits, "answer:" + " ".join(query.lower().split()))
281
- continue
282
 
283
- if tool["tool"] == "circle":
284
- target = tool["target"]
285
- # The agent says which shown page the target is on (it has both pages'
286
- # text). Default to the active page when it's unspecified or not one of
287
- # the pages on screen — so the box is grounded on, and drawn over, the
288
- # RIGHT page.
289
- page = tool.get("page")
290
- if page not in circleable:
291
- page = current_page
292
- yield {"type": "step", "tool": "circle", "target": target, "page": page}
293
- yield {"type": "status", "text": "Pinning it down…"}
294
- img = render_page(visual_store.pdf_path(doc_id), page)
295
- box, braw = minicpm.ground_box(img, target, enable_thinking=ground_thinking)
296
- # Heaviest op of the turn — the VLM vision encoder runs on a full-res
297
- # page. peak here is the turn's activation high-water (since
298
- # turn-start), the number that decides whether a big brain still fits.
299
- log_vram("after-ground")
300
- log.info("ground_box(%r) on p.%d → %s | raw=%r",
301
- target, page, box, braw[:200])
302
- # The VLM couldn't find the target on this page — almost always
303
- # because it's on a DIFFERENT page (the agent circled too early).
304
- # Don't end the turn with an empty pin: push it to relocate and try
305
- # again. Only fall through to showing the page un-pinned once we've
306
- # already missed this exact (page, target) — a repeat means retrying
307
- # here won't help, same guard as the no-op search.
308
- tkey = (page, " ".join(target.lower().split()))
309
- if box is None and tkey not in ground_failed:
310
- ground_failed.add(tkey)
311
- messages.append(
312
- minicpm_agent.tool_result_message(
313
- minicpm_agent.ground_failed_message(request, target, page)
314
  )
315
- )
 
316
  continue
317
- yield {
318
- "type": "done",
319
- "kind": "point",
320
- "found": True,
321
- "target": target,
322
- "page": page,
323
- "bbox": [round(v) for v in box] if box is not None else None,
324
- # The pixel size of the image the box was GROUNDED on — the bbox
325
- # is in this coordinate space. The frontend sizes its SVG viewBox
326
- # from this (not the browser-loaded <img>), so the circle lands
327
- # correctly even if the displayed page PNG is served at a
328
- # different/stale resolution than this grounding render.
329
- "dims": [img.width, img.height],
330
- # the VLM's raw grounding reply — diagnostic only (helps explain
331
- # where/why a box landed); shown in the trace view.
332
- "ground_raw": braw[:300],
333
- }
334
- return
335
 
336
- if tool["tool"] == "done":
337
- yield {"type": "done", "kind": "reply",
338
- "message": tool.get("message") or "Done."}
339
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
 
341
- yield {
342
- "type": "done",
343
- "kind": "reply",
344
- "message": "I went in circles on that one — try rephrasing?",
345
- }
 
 
 
 
 
 
 
 
 
346
 
347
 
348
  class AgentPipeline:
@@ -362,9 +402,11 @@ class AgentPipeline:
362
  ground_thinking: bool | None = None,
363
  agent_model: str | None = None,
364
  vram_log: bool = False,
 
365
  ):
366
  """One streamed agent turn (the event generator of agent_events).
367
- vram_log forwards the UI's VRAM-logging toggle to the probe."""
 
368
  request = (request or "").strip()
369
  if not request:
370
  raise ValueError("Tell me what to find.")
@@ -375,5 +417,5 @@ class AgentPipeline:
375
  return agent_events(
376
  request, visual_store, parsed_store, doc_ids or list(names),
377
  int(top_k), names, sections, viewer, history, ground_thinking,
378
- agent_model, vram_log,
379
  )
 
47
 
48
  import spaces
49
 
50
+ from core import tracing
51
  from core.constants import (
52
  AGENT_HISTORY_TURNS,
53
  AGENT_MAX_STEPS,
 
92
  ground_thinking: bool | None = None,
93
  agent_model: str | None = None,
94
  vram_log: bool = False,
95
+ session_id: str | None = None,
96
  ):
97
  """Yield the events of one agent turn (see module docstring). sections is the
98
  numbered table of contents shown to the agent ([{title, page}]); the agent's
 
184
  )
185
  )
186
 
187
+ # Open the trace for this turn (no-op when Langfuse is unconfigured). The
188
+ # whole loop runs under try/finally so the root span is always ended and
189
+ # flushed on a terminal return, a mid-turn error, OR an early client
190
+ # disconnect (GeneratorExit raised at a yield). `turn_output` is the terminal
191
+ # `done` event, recorded as the trace's output. Children (the brain's
192
+ # decisions, searches, the grounding) attach to this span explicitly.
193
+ span = tracing.start_turn(
194
+ name="agent-find",
195
+ input=request,
196
+ session_id=session_id,
197
+ tags=[t for t in (manual, active) if t],
198
+ metadata={
199
+ "manual": manual,
200
+ "agent_model": active,
201
+ "k": int(top_k),
202
+ "thinking": bool(ground_thinking),
203
+ "viewer_pages": shown_pages,
204
+ },
205
+ )
206
+ turn_output = None
207
+ try:
208
+ for step in range(AGENT_MAX_STEPS):
209
+ # Render the exact prompt BEFORE deciding so the trace can show what the
210
+ # brain was asked, not just what it answered.
211
+ prompt = minicpm_agent.render_prompt(messages)
212
+ tool, raw = minicpm_agent.decide(messages)
213
+ log.info("step %d: tool=%s | raw=%r", step, tool, raw[:200])
214
+ # Diagnostic event: the prompt fed in, the raw 1B reply, and the parsed
215
+ # tool for this step, so the UI's trace view shows exactly what the brain
216
+ # was asked and decided (and why a reply was rejected). Not used by the
217
+ # normal chip flow.
218
+ yield {"type": "trace", "step": step, "tool": tool, "raw": raw,
219
+ "prompt": prompt}
220
+ if tool is None:
221
+ # Unusable reply (bad JSON, or an echoed placeholder target). Correct
222
+ # it and let the agent try again rather than abandon the turn.
223
  messages.append(
224
  minicpm_agent.tool_result_message(
225
+ "Your last reply was not one complete JSON object. Reply with "
226
+ "ONE complete JSON object and nothing else, e.g. "
227
+ '{"tool": "search", "query": "fuel filter"}. If you circle, the '
228
+ "target MUST be copied from the page text above — never invent "
229
+ "a part that is not printed there."
230
  )
231
  )
232
  continue
233
+ messages.append(minicpm_agent.assistant_action_message(tool))
 
 
 
 
 
234
 
235
+ if tool["tool"] == "go_to_section":
236
+ idx = tool["section"] - 1
237
+ if not 0 <= idx < len(sections):
238
+ messages.append(
239
+ minicpm_agent.tool_result_message(
240
+ f"There is no section {tool['section']}. Pick a number from "
241
+ "the table of contents, or use search."
242
+ )
243
  )
244
+ continue
245
+ opt = sections[idx]
246
+ yield {"type": "step", "tool": "go_to_section",
247
+ "title": opt["title"], "page": int(opt["page"])}
248
+ turn_output = {"type": "done", "kind": "navigate", "nav": "section",
249
+ "page": int(opt["page"]), "title": opt["title"]}
250
+ yield turn_output
251
+ return
252
 
253
+ if tool["tool"] == "go_to_page":
254
+ page = tool["page"]
255
+ n = page_count(visual_store.pdf_path(doc_id))
256
+ if not 1 <= page <= n:
257
+ messages.append(
258
+ minicpm_agent.tool_result_message(
259
+ f"There is no page {page}; this manual has pages 1–{n}. "
260
+ "Pick a page in range, search, or go to a section."
261
+ )
262
+ )
263
+ continue
264
+ yield {"type": "step", "tool": "go_to_page", "page": page}
265
+ turn_output = {"type": "done", "kind": "navigate", "nav": "page",
266
+ "page": page, "title": f"Page {page}"}
267
+ yield turn_output
268
+ return
 
269
 
270
+ if tool["tool"] == "search":
271
+ query = tool["query"]
272
+ yield {"type": "step", "tool": "search", "query": query}
273
+ yield {"type": "status", "text": f"Searching for “{query}”…"}
274
+ # k (the viewer's slider) is the shortlist size; ColEmbed's top page
275
+ # is the one shown. A 1B text rerank measured WORSE than raw ColEmbed
276
+ # top-1 (0.68 vs 0.84 hit@1) visual late interaction already ranks
277
+ # these (figure-heavy) pages better than re-judging from page text.
278
+ with tracing.retriever("search", input=query,
279
+ metadata={"retriever": "colembed", "k": int(top_k)}) as rsp:
280
+ hits = maxsim_search(query, visual_store, doc_ids, top_k)
281
+ if rsp is not None:
282
+ rsp.update(output=[{"page": p, "score": round(float(s), 4)}
283
+ for _, p, s in hits])
284
+ log.info("search(%r) %s", query, [(p, round(s, 3)) for _, p, s in hits])
285
+ if not hits:
286
+ messages.append(
287
+ minicpm_agent.tool_result_message(f"Search for {query!r} found nothing.")
288
+ )
289
+ continue
290
+ yield from present_hits(hits, "search:" + " ".join(query.lower().split()))
291
  continue
 
 
292
 
293
+ if tool["tool"] == "find_answer":
294
+ query = tool["query"]
295
+ yield {"type": "step", "tool": "find_answer", "query": query}
296
+ yield {"type": "status", "text": f"Looking up “{query}”…"}
297
+ # Dense retrieval over the PARSED chunks (text/semantic) the index
298
+ # the parsed store was built for. A fact lookup ("what fuel does it
299
+ # take") is a TEXT match: ColEmbed ranks pages by VISUAL similarity
300
+ # and misses the plain specs page, so fact questions route here. Same
301
+ # (doc_id, page, score) shape as maxsim_search; the agent then circles
302
+ # the answering line on the page shown.
303
+ with tracing.retriever("find_answer", input=query,
304
+ metadata={"retriever": "parsed-dense", "k": int(top_k)}) as rsp:
305
+ hits = retrieve_pages(query, parsed_store, doc_ids, top_k)
306
+ if rsp is not None:
307
+ rsp.update(output=[{"page": p, "score": round(float(s), 4)}
308
+ for _, p, s in hits])
309
+ log.info("find_answer(%r) → %s", query, [(p, round(s, 3)) for _, p, s in hits])
310
+ if not hits:
311
+ messages.append(
312
+ minicpm_agent.tool_result_message(f"Looking up {query!r} found nothing.")
 
 
 
 
 
 
 
 
 
 
 
313
  )
314
+ continue
315
+ yield from present_hits(hits, "answer:" + " ".join(query.lower().split()))
316
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
 
318
+ if tool["tool"] == "circle":
319
+ target = tool["target"]
320
+ # The agent says which shown page the target is on (it has both pages'
321
+ # text). Default to the active page when it's unspecified or not one of
322
+ # the pages on screen — so the box is grounded on, and drawn over, the
323
+ # RIGHT page.
324
+ page = tool.get("page")
325
+ if page not in circleable:
326
+ page = current_page
327
+ yield {"type": "step", "tool": "circle", "target": target, "page": page}
328
+ yield {"type": "status", "text": "Pinning it down…"}
329
+ img = render_page(visual_store.pdf_path(doc_id), page)
330
+ box, braw = minicpm.ground_box(img, target, enable_thinking=ground_thinking)
331
+ # Heaviest op of the turn — the VLM vision encoder runs on a full-res
332
+ # page. peak here is the turn's activation high-water (since
333
+ # turn-start), the number that decides whether a big brain still fits.
334
+ log_vram("after-ground")
335
+ log.info("ground_box(%r) on p.%d → %s | raw=%r",
336
+ target, page, box, braw[:200])
337
+ # The VLM couldn't find the target on this page — almost always
338
+ # because it's on a DIFFERENT page (the agent circled too early).
339
+ # Don't end the turn with an empty pin: push it to relocate and try
340
+ # again. Only fall through to showing the page un-pinned once we've
341
+ # already missed this exact (page, target) — a repeat means retrying
342
+ # here won't help, same guard as the no-op search.
343
+ tkey = (page, " ".join(target.lower().split()))
344
+ if box is None and tkey not in ground_failed:
345
+ ground_failed.add(tkey)
346
+ messages.append(
347
+ minicpm_agent.tool_result_message(
348
+ minicpm_agent.ground_failed_message(request, target, page)
349
+ )
350
+ )
351
+ continue
352
+ turn_output = {
353
+ "type": "done",
354
+ "kind": "point",
355
+ "found": True,
356
+ "target": target,
357
+ "page": page,
358
+ "bbox": [round(v) for v in box] if box is not None else None,
359
+ # The pixel size of the image the box was GROUNDED on — the bbox
360
+ # is in this coordinate space. The frontend sizes its SVG viewBox
361
+ # from this (not the browser-loaded <img>), so the circle lands
362
+ # correctly even if the displayed page PNG is served at a
363
+ # different/stale resolution than this grounding render.
364
+ "dims": [img.width, img.height],
365
+ # the VLM's raw grounding reply — diagnostic only (helps explain
366
+ # where/why a box landed); shown in the trace view.
367
+ "ground_raw": braw[:300],
368
+ }
369
+ yield turn_output
370
+ return
371
 
372
+ if tool["tool"] == "done":
373
+ turn_output = {"type": "done", "kind": "reply",
374
+ "message": tool.get("message") or "Done."}
375
+ yield turn_output
376
+ return
377
+
378
+ turn_output = {
379
+ "type": "done",
380
+ "kind": "reply",
381
+ "message": "I went in circles on that one — try rephrasing?",
382
+ }
383
+ yield turn_output
384
+ finally:
385
+ tracing.finish_turn(span, output=turn_output)
386
 
387
 
388
  class AgentPipeline:
 
402
  ground_thinking: bool | None = None,
403
  agent_model: str | None = None,
404
  vram_log: bool = False,
405
+ session_id: str | None = None,
406
  ):
407
  """One streamed agent turn (the event generator of agent_events).
408
+ vram_log forwards the UI's VRAM-logging toggle to the probe; session_id
409
+ groups a page session's turns together in Langfuse."""
410
  request = (request or "").strip()
411
  if not request:
412
  raise ValueError("Tell me what to find.")
 
417
  return agent_events(
418
  request, visual_store, parsed_store, doc_ids or list(names),
419
  int(top_k), names, sections, viewer, history, ground_thinking,
420
+ agent_model, vram_log, session_id,
421
  )
pipelines/mock_ask.py CHANGED
@@ -145,11 +145,13 @@ class MockAskPipeline:
145
  history: list | None = None,
146
  ground_thinking: bool | None = None,
147
  agent_model: str | None = None,
 
 
148
  ):
149
  """Yield the same event sequence as pipelines/agent_ask.py, with a
150
  keyword-driven stand-in for the agent's tool choice. Both stores are the
151
- one MockStore; history, ground_thinking and agent_model are ignored.
152
- MOCK_DELAY (seconds) paces events."""
153
  request = (request or "").strip()
154
  if not request:
155
  raise ValueError("Tell me what to find.")
 
145
  history: list | None = None,
146
  ground_thinking: bool | None = None,
147
  agent_model: str | None = None,
148
+ vram_log: bool = False,
149
+ session_id: str | None = None,
150
  ):
151
  """Yield the same event sequence as pipelines/agent_ask.py, with a
152
  keyword-driven stand-in for the agent's tool choice. Both stores are the
153
+ one MockStore; history, ground_thinking, agent_model, vram_log and
154
+ session_id are ignored. MOCK_DELAY (seconds) paces events."""
155
  request = (request or "").strip()
156
  if not request:
157
  raise ValueError("Tell me what to find.")
requirements.txt CHANGED
@@ -6,3 +6,4 @@ torchvision
6
  pymupdf
7
  pillow
8
  numpy
 
 
6
  pymupdf
7
  pillow
8
  numpy
9
+ langfuse>=3,<5