File size: 12,893 Bytes
14a9b91
2ad630b
14a9b91
 
89111aa
 
51720d8
 
 
436dbdd
3f08eff
 
 
293ed8c
2ad630b
 
 
3f08eff
436dbdd
3f08eff
436dbdd
c982278
9c0bd3b
3f08eff
436dbdd
293ed8c
436dbdd
838a298
2ad630b
 
 
3f92f69
 
 
2ad630b
 
 
d8f1e4d
436dbdd
 
 
 
 
3f08eff
 
 
 
 
 
 
 
 
 
 
 
 
 
436dbdd
 
89111aa
 
c982278
 
89111aa
1a277d6
 
 
 
 
 
 
3dfd032
 
 
 
1a277d6
 
 
c982278
9c0bd3b
14a9b91
 
 
 
 
 
 
 
 
 
 
 
2ad630b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f08eff
 
 
293ed8c
3f08eff
 
 
 
 
 
 
293ed8c
 
 
 
 
 
 
 
d8f1e4d
436dbdd
 
293ed8c
 
 
3f08eff
 
 
 
 
 
 
 
 
 
 
 
 
2ad630b
 
c982278
3f92f69
c982278
 
 
3f92f69
 
 
 
 
 
 
838a298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f92f69
 
 
 
 
1a277d6
 
 
 
3f92f69
 
 
 
c982278
 
 
 
 
 
 
9c0bd3b
 
14a9b91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51720d8
 
 
 
 
 
 
 
 
 
 
 
 
 
2ad630b
 
51720d8
2ad630b
 
 
 
51720d8
2ad630b
 
 
51720d8
 
2ad630b
 
 
51720d8
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
"""MiniCPM-V: the local VLM — the find-and-point "eyes".

- ground_box: return the bounding box of a described object on a page image —
  the circle the agent (models/minicpm_agent, the text "brain") asks for.
- generate_answer: answers a question grounded in retrieved page images
  (one-shot; used by scripts/eval_answers_modal.py and the pipelines' .run()).
- describe_batch (+ describe_figure / describe_table wrappers): used only by
  the parsed ingest pipeline (on Modal) to turn Picture crops and Table
  markdown into searchable text, batched through chat()'s batched mode.

The model and tokenizer are module-level globals: ZeroGPU packs module-level
CUDA tensors at startup and shares them with the GPU worker, whereas function
arguments are pickled — and trust_remote_code model classes are not picklable.

All functions are plain (not @spaces.GPU): callers run them inside their own
GPU context — the ask pipelines' single @spaces.GPU call on the Space, or a
real CUDA process on Modal.
"""

from __future__ import annotations

import re

import torch
from PIL import Image
from transformers import AutoModel, AutoProcessor, AutoTokenizer

from core import tracing
from core.constants import (
    ANSWER_MAX_NEW_TOKENS,
    DESCRIBE_MAX_NEW_TOKENS,
    GROUND_BOX_MAX_NEW_TOKENS,
    GROUND_ENABLE_THINKING,
    GROUND_THINK_MAX_NEW_TOKENS,
    MINICPM_MODEL_ID,
    MINICPM_REVISION,
)
from core.vram import log_vram

PROMPT = (
    "You are a repair-manual assistant. The images are the manual pages most "
    "relevant to the user's question, each preceded by its label (manual name "
    "and page number).\n\n"
    "Answer the question using ONLY what is printed on these pages, following "
    "these rules:\n"
    "1. If the answer is a procedure, reproduce EVERY step in order, numbered "
    "exactly as in the manual. Never skip, merge, or summarize steps. Keep each "
    "step's notes, model exceptions, and specifications (e.g. torque values) "
    "with that step, exactly as printed.\n"
    "2. Quote exact values (torques, clearances, part numbers, capacities) as "
    "printed, including units.\n"
    "3. End with the page label(s) you used, e.g. (Manual — p.238). If a "
    "procedure clearly continues on a page you were not given, say so.\n"
    "4. If the pages do not contain the answer, say so instead of guessing.\n"
    "5. Start directly with the answer — no preamble like 'Based on the "
    "provided pages'.\n\n"
    "Question: {question}"
)

# Visual grounding for "circle the <thing>": the mechanic asks to circle
# something on the page they are viewing. MiniCPM-V grounding replies in
# <box>x1 y1 x2 y2</box> form with coordinates normalized to 0-1000.
GROUND_PROMPT = (
    "The image is one page of a repair manual. A mechanic asked to circle "
    "{query!r} on this page. Locate it precisely:\n"
    "- In an exploded or assembly diagram, parts carry callout numbers/letters "
    "on leader lines, and a legend lists what each number is. Find {query!r} in "
    "the legend to get its number, then follow that number's leader line to the "
    "part in the drawing and box THAT part (not the legend text).\n"
    "- Otherwise it may be a row in a table, a specification value, or a "
    "heading — box that.\n"
    "Box ONLY that one part, as TIGHTLY as possible — just the part itself. Do "
    "NOT box the whole figure, the whole diagram, a group of parts, or the page; "
    "if the part is small, the box must be small. A box wider than about half "
    "the page is almost always wrong.\n"
    "Reply with ONLY the box, as <box>x1 y1 x2 y2</box>: four integers "
    "normalized to 0-1000 (x left→right, y top→bottom) over the whole page, "
    "and nothing else. If it is not on this page, reply exactly: NOT FOUND"
)

# Visual page rerank: score the search shortlist by LOOKING at the page images,
# instead of re-judging from page text (a 1B text rerank measured worse than raw
# ColEmbed top-1, because repair pages are figure-heavy and text retrieval
# surfaces index/spec pages that merely name-drop the part).
PAGE_RERANK_PROMPT = (
    "The image is one page of a repair manual. A mechanic wants: {query!r}\n"
    "How directly does THIS page give them what they need — the procedure, "
    "component, diagram, table, or value involved (judge by topic, not exact "
    "wording)? Reply with ONLY one digit 0-5: 5 = this is exactly the page, "
    "0 = unrelated."
)

FIGURE_PROMPT = (
    "The image is a figure cropped from a page of a repair manual. Context "
    "from the page it appears on:\n{context}\n\n"
    "Using the context's terminology, write a search-index description of the "
    "figure in 1-3 sentences: name the specific component or assembly shown "
    "(and which models it applies to, if stated), the kind of view or diagram, "
    "and what the labeled parts or callouts are. Be definite — never write "
    "'likely', 'possibly' or 'appears to'. Example of the expected style:\n"
    "'Dimensional reference drawing of the cross-type universal joint used on "
    "the Pn35-50 and Cu35-55 propeller shafts: two yokes joined by a spider "
    "and four bearing cups, shown in side and end views with the overall "
    "length, width, and height dimensions.'\n"
    "Start directly with the description — no preamble."
)

TABLE_PROMPT = (
    "Below is a table extracted from a repair manual as markdown, followed by "
    "context from the page it appears on.\n\nTable:\n{markdown}\n\n"
    "Context:\n{context}\n\n"
    "Using the context's terminology, write a search-index description of the "
    "table in 1-3 sentences: what the table is for, which components, models "
    "or operations it covers, and what values it lists (with units). Example "
    "of the expected style:\n"
    "'Propeller shaft specifications by forklift model (Pn35-50, Pn60-80, "
    "Cu35-55, Cu60/70): the universal joint type, the three shaft length "
    "dimensions in millimeters and inches, and whether the upper and lower "
    "propeller shaft covers are fitted.'\n"
    "Start directly with the description — no preamble."
)

_MODEL = (
    AutoModel.from_pretrained(
        MINICPM_MODEL_ID,
        revision=MINICPM_REVISION,
        trust_remote_code=True,
        dtype=torch.bfloat16,
        attn_implementation="sdpa",
    )
    .to("cuda")
    .eval()
)
_TOKENIZER = AutoTokenizer.from_pretrained(
    MINICPM_MODEL_ID, revision=MINICPM_REVISION, trust_remote_code=True
)
# Pre-build the processor chat() would otherwise lazily create per GPU worker
# (it caches on this exact attribute, see modeling_minicpmv.chat).
_MODEL.processor = AutoProcessor.from_pretrained(
    MINICPM_MODEL_ID, revision=MINICPM_REVISION, trust_remote_code=True
)
log_vram("load-minicpm-v")


def generate_answer(question: str, pages: list[tuple[str, Image.Image]]) -> str:
    """pages: [(label, page image)] in retrieval order. Must run on GPU
    (called from within a @spaces.GPU context)."""
    content = []
    for label, img in pages:  # chat() accepts interleaved strings and PIL images
        content.append(f"[{label}]")
        content.append(img.convert("RGB"))
    content.append(PROMPT.format(question=question))
    with torch.no_grad():
        answer = _MODEL.chat(
            msgs=[{"role": "user", "content": content}],
            tokenizer=_TOKENIZER,
            enable_thinking=False,
            max_new_tokens=ANSWER_MAX_NEW_TOKENS,
        )
    return str(answer).strip()


def ground_box(
    image: Image.Image, query: str, enable_thinking: bool | None = None
) -> tuple[tuple[float, float, float, float] | None, str]:
    """(bbox, raw reply): the bounding box of the described object on a page
    image, in that image's pixel coordinates — or None when the model can't
    place it (no box in the reply, or a degenerate one). Must run on GPU.

    enable_thinking lets the model reason (legend → callout-number →
    leader-line → part) before committing to a box. None defers to the
    GROUND_ENABLE_THINKING default; the UI settings panel passes an explicit
    bool per request."""
    think = GROUND_ENABLE_THINKING if enable_thinking is None else enable_thinking
    # One `generation` (the VLM "eyes" placing the box): query in, raw reply out.
    with tracing.generation(
        "ground-circle",
        model=MINICPM_MODEL_ID,
        input=query,
        metadata={"thinking": bool(think)},
    ) as gen:
        with torch.no_grad():
            out = _MODEL.chat(
                msgs=[
                    {
                        "role": "user",
                        "content": [image.convert("RGB"), GROUND_PROMPT.format(query=query)],
                    }
                ],
                tokenizer=_TOKENIZER,
                # The think trace needs room (a bare box fits in 64 tokens; a think
                # trace does not) or it gets cut off before emitting the box — so
                # the token budget tracks the flag.
                enable_thinking=think,
                max_new_tokens=(
                    GROUND_THINK_MAX_NEW_TOKENS if think else GROUND_BOX_MAX_NEW_TOKENS
                ),
            )
        raw = str(out).strip()
        if gen is not None:
            gen.update(output=raw)
    # The thinking trace can mention "not found" mid-reasoning ("at first this
    # looked not found, but…"), so test only the FINAL answer after </think>,
    # not the whole reply.
    final = raw.rsplit("</think>", 1)[-1]
    if "NOT FOUND" in final.upper():
        return None, raw
    # Read the coordinates from the <box>…</box> tag specifically. MiniCPM-V
    # may prefix a <ref>…</ref> (e.g. echoing "5. Rod"); that digit would
    # otherwise be grabbed as the first coordinate and shift the whole box.
    # Search `final` so digits inside the reasoning can't be mistaken for
    # coordinates.
    m = re.search(r"<box>(.*?)</box>", final, re.IGNORECASE | re.DOTALL)
    nums = re.findall(r"\d+(?:\.\d+)?", m.group(1) if m else final)
    if len(nums) < 4:
        return None, raw
    x1, y1, x2, y2 = (min(1000.0, max(0.0, float(n))) for n in nums[:4])
    if x2 - x1 < 1 or y2 - y1 < 1:
        return None, raw
    w, h = image.size
    return (x1 * w / 1000, y1 * h / 1000, x2 * w / 1000, y2 * h / 1000), raw


def rerank_pages(images: list[Image.Image], query: str) -> tuple[int, list[int]]:
    """Pick the best of the search shortlist by LOOKING at the page images.
    Each candidate is scored 0-5 for how directly it answers the query, in ONE
    batched chat() call; the highest score wins, ties broken by retrieval order
    (so on a tie it never does worse than ColEmbed). Returns (index into images,
    the per-page scores; -1 where the reply had no digit). Must run on GPU."""
    prompt = PAGE_RERANK_PROMPT.format(query=query)
    msgs = [
        [{"role": "user", "content": [img.convert("RGB"), prompt]}] for img in images
    ]
    with torch.no_grad():
        out = _MODEL.chat(
            msgs=msgs,
            tokenizer=_TOKENIZER,
            enable_thinking=False,
            max_new_tokens=4,
        )
    scores = []
    for a in out:
        m = re.search(r"\d", str(a))
        scores.append(int(m.group()) if m else -1)
    # max() returns the FIRST argmax, so equal scores keep ColEmbed's order.
    best = max(range(len(scores)), key=lambda i: scores[i]) if scores else 0
    return best, scores


def describe_batch(jobs: list[tuple[str, object, str]]) -> list[str]:
    """Searchable descriptions for a batch of parsed elements in ONE chat()
    call (its batched mode: msgs = list of conversations). Each job is
    ("figure", crop image, context) or ("table", markdown, context); the
    context (manual name, section heading, caption, page text) is what lets
    MiniCPM use the manual's own terminology. Returns descriptions in job
    order. Parsed ingest only; must run on GPU."""
    msgs = []
    for kind, payload, context in jobs:
        if kind == "figure":
            content = [payload.convert("RGB"), FIGURE_PROMPT.format(context=context)]
        else:
            content = [TABLE_PROMPT.format(markdown=payload, context=context)]
        msgs.append([{"role": "user", "content": content}])
    with torch.no_grad():
        out = _MODEL.chat(
            msgs=msgs,
            tokenizer=_TOKENIZER,
            enable_thinking=False,
            max_new_tokens=DESCRIBE_MAX_NEW_TOKENS,
        )
    return [str(answer).strip() for answer in out]


def describe_figure(image: Image.Image, context: str) -> str:
    """Single-element convenience wrapper around describe_batch."""
    return describe_batch([("figure", image, context)])[0]


def describe_table(markdown: str, context: str) -> str:
    """Single-element convenience wrapper around describe_batch."""
    return describe_batch([("table", markdown, context)])[0]