跳转至

视觉服务

src.retrieval.cg_rag_vision.CgVisionService 实现两种图片隐患识别模式:自由生成(直接 VQA)与智能生成(agent 自行检索并引用)。它由 CgRagService 持有,HTTP 与 MCP 共用同一实例。

主要职责

  • 校验并解码 base64 图片,施加张数与体积上限,全程不落盘。
  • 为直接 VQA 组装多模态消息,注入内置的重大事故隐患判定标准条款清单。
  • 为智能生成装配 VisionAgenticSearchRuntime:提供调用 VLM 的 agent_call,以及直接调用本实例 retrieve_rerank 的进程内检索回调。
  • 把检索失败转成 agent 可读的工具错误,而不是中断整轮生成。
  • 将阻塞式生成过程中的进度回调缓冲为有序 SSE 事件。

错误契约

CgVisionError 携带稳定错误码,由 HTTP 层映射为状态码、由 MCP 层映射为失败 envelope:

错误码 HTTP 触发条件
vision_not_configured 503 未配置 vision_endpoint / vision_model
invalid_image 400 张数超限、体积超限、base64 非法、媒体类型不受支持
vision_upstream_error 502 调用 VLM 的传输层失败
vision_response_invalid 502 VLM 未返回可用内容

cg_rag_vision

Hazard identification from site photos, served by CG_RAG itself.

Three shapes, covering every way the product looks at an image:

  • 自由生成 (vision_answer): the VLM answers straight from the images. No retrieval, so the report carries no citations.
  • 智能生成 (vision_agentic): the VLM observes, decides its own retrieval queries, and cites the clauses it got back.
  • 观察 (vision_observe): one structured pass that yields a validated hazard label plus display text, for callers that want to turn a photo into a text query and then run their own retrieval.

The agentic path drives the same VisionAgenticSearchRuntime the Question API uses, but hands it a retrieval callback that calls this service's own retrieve_rerank in-process, removing a network round trip per search round.

Every VLM call in the repository lives here. Callers that need vision reach it through the HTTP or MCP surface rather than building their own multimodal payloads -- see src.retrieval.cg_rag_client for the in-repo client.

CgVisionService

源代码位于: src/retrieval/cg_rag_vision.py
 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
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
class CgVisionService:
    def __init__(self, owner: Any) -> None:
        self.owner = owner

    # ---- configuration / transport -------------------------------------

    @property
    def config(self) -> Any:
        return self.owner.config

    def _require_configured(self) -> None:
        if not self.config.vision_configured:
            raise CgVisionError(
                VISION_NOT_CONFIGURED_CODE,
                "视觉识别未配置:请设置 CG_RAG_VISION_ENDPOINT 与 CG_RAG_VISION_MODEL。",
            )

    def _headers(self) -> dict[str, str]:
        headers = {"Content-Type": "application/json"}
        key = self.config.effective_vision_api_key
        if key:
            headers["Authorization"] = f"Bearer {key}"
        return headers

    def _decode_images(self, request: CgVisionAnswerRequest) -> tuple[VisionImagePart, ...]:
        max_images = int(self.config.vision_max_images)
        if len(request.images) > max_images:
            raise CgVisionError(
                VISION_INVALID_IMAGE_CODE,
                f"最多支持 {max_images} 张图片,本次收到 {len(request.images)} 张。",
            )
        max_bytes = int(self.config.vision_max_image_bytes)
        parts: list[VisionImagePart] = []
        for ordinal, image in enumerate(request.images, start=1):
            try:
                mime_type, payload = image.resolved()
            except ValueError as exc:
                raise CgVisionError(VISION_INVALID_IMAGE_CODE, str(exc)) from None
            try:
                data = base64.b64decode(payload, validate=True)
            except (binascii.Error, ValueError):
                raise CgVisionError(
                    VISION_INVALID_IMAGE_CODE,
                    f"第 {ordinal} 张图片不是合法的 base64 数据。",
                ) from None
            if not data:
                raise CgVisionError(
                    VISION_INVALID_IMAGE_CODE, f"第 {ordinal} 张图片为空。"
                )
            if len(data) > max_bytes:
                raise CgVisionError(
                    VISION_INVALID_IMAGE_CODE,
                    f"第 {ordinal} 张图片为 {len(data)} 字节,超过上限 {max_bytes} 字节。",
                )
            parts.append(
                VisionImagePart(
                    attachment_id=f"inline-{ordinal}",
                    ordinal=ordinal,
                    mime_type=mime_type,
                    data=data,
                )
            )
        return tuple(parts)

    def _vision_request(self, request: CgVisionAnswerRequest) -> VisionModelRequest:
        return VisionModelRequest(
            profile=load_construction_hazard_profile(),
            original_text=request.question,
            images=self._decode_images(request),
        )

    def _thinking_enabled(self, request: CgVisionAnswerRequest) -> bool:
        if request.enable_thinking is None:
            return bool(self.config.vision_enable_thinking)
        return bool(request.enable_thinking)

    def _post(
        self,
        payload: dict[str, Any],
        *,
        streaming: bool,
        on_token: Callable[[str, str], None] | None = None,
        cancel_token: Any = None,
    ) -> dict[str, Any]:
        _raise_if_cancelled(cancel_token)
        with self.owner.capacity_limiters.generation.acquire(cancel_token=cancel_token):
            try:
                if streaming:
                    return self._post_streaming(payload, on_token=on_token, cancel_token=cancel_token)
                response = self.owner.generation_session.post(
                    self.config.vision_endpoint,
                    headers=self._headers(),
                    json=payload,
                    timeout=self.config.timeout_seconds,
                )
                response.raise_for_status()
                body = response.json()
            except requests.RequestException as exc:
                raise CgVisionError(
                    VISION_UPSTREAM_ERROR_CODE, "视觉模型调用失败。"
                ) from exc
        if not isinstance(body, dict):
            raise CgVisionError(VISION_RESPONSE_INVALID_CODE, "视觉模型返回的响应体不是对象。")
        return body

    def _post_streaming(
        self,
        payload: dict[str, Any],
        *,
        on_token: Callable[[str, str], None] | None,
        cancel_token: Any = None,
    ) -> dict[str, Any]:
        streaming_payload = {**payload, "stream": True}
        content_parts: list[str] = []
        reasoning_parts: list[str] = []
        # The agent decides to search by emitting tool calls, which arrive as
        # deltas. Dropping them makes every round look like the model declined
        # to retrieve, so the answer silently loses its citations.
        tool_calls = StreamingToolCallAccumulator()
        usage: dict[str, Any] = {}
        response = self.owner.generation_session.post(
            self.config.vision_endpoint,
            headers=self._headers(),
            json=streaming_payload,
            timeout=self.config.timeout_seconds,
            stream=True,
        )
        try:
            response.raise_for_status()
            # parse_chat_completion_sse already flattens each chunk into
            # {"content"|"reasoning_content"|"usage"|"done"}; it does not hand
            # back raw OpenAI chunks, so there is no choices/delta to walk.
            for event in self.owner._parse_chat_completion_sse(
                response.iter_lines(decode_unicode=True)
            ):
                _raise_if_cancelled(cancel_token)
                if not isinstance(event, dict):
                    continue
                if isinstance(event.get("usage"), dict):
                    usage = event["usage"]
                tool_calls.push(event)
                text = event.get("content")
                if isinstance(text, str) and text:
                    content_parts.append(text)
                    if on_token is not None:
                        on_token("answer", text)
                reasoning = event.get("reasoning_content")
                if isinstance(reasoning, str) and reasoning:
                    reasoning_parts.append(reasoning)
                    if on_token is not None:
                        on_token("reasoning", reasoning)
        finally:
            response.close()
        message: dict[str, Any] = {
            "role": "assistant",
            "content": "".join(content_parts),
            "reasoning_content": "".join(reasoning_parts),
        }
        tool_calls.apply_to(message)
        return {"choices": [{"message": message}], "usage": usage}

    # ---- 自由生成 -------------------------------------------------------

    def vision_answer(
        self,
        request: CgVisionAnswerRequest,
        *,
        on_token: Callable[[str, str], None] | None = None,
        cancel_token: Any = None,
    ) -> dict[str, Any]:
        self._require_configured()
        started_at = time.perf_counter()
        vision_request = self._vision_request(request)
        thinking_enabled = self._thinking_enabled(request)
        adapter = get_provider_adapter("qwen_openai_compatible")
        payload: dict[str, Any] = {
            "model": self.config.vision_model,
            "messages": build_qwen_direct_vqa_messages(vision_request),
            "temperature": VISION_AGENT_TEMPERATURE,
            "max_tokens": int(self.config.vision_max_tokens),
        }
        adapter.apply_payload_options(
            payload, response_format_json=False, enable_thinking=thinking_enabled
        )
        body = self._post(
            payload,
            streaming=bool(self.config.vision_streaming),
            on_token=on_token,
            cancel_token=cancel_token,
        )
        message = self.owner._extract_response_message(body)
        answer = strip_think_blocks(
            self.owner._extract_message_content(message), final=True
        ).strip()
        if not answer:
            raise CgVisionError(
                VISION_RESPONSE_INVALID_CODE, "视觉模型没有返回可用的回答内容。"
            )
        elapsed_ms = (time.perf_counter() - started_at) * 1000.0
        return _with_request_id(
            {
                "ok": True,
                "mode": "free_form",
                "question": request.question,
                "answer": answer,
                "reasoning": (
                    self.owner._extract_reasoning_content(body) if thinking_enabled else ""
                ),
                "image_count": len(vision_request.images),
                "model": self.config.vision_model,
                "thinking_enabled": thinking_enabled,
                "citations": [],
                "retrieval_docs": [],
                "usage": self.owner._extract_usage(body),
                "runtime_stats": {"total_time_ms": elapsed_ms},
            }
        )

    # ---- 观察 -----------------------------------------------------------

    def vision_observe(
        self,
        request: CgVisionAnswerRequest,
        *,
        cancel_token: Any = None,
    ) -> dict[str, Any]:
        """One structured pass over the images.

        Unlike the two report modes this validates the model's output against
        the hazard-label vocabulary and reports the parse outcome, so a caller
        can turn the photo into a retrieval query and fall back cleanly when
        the model does not produce a usable observation.
        """

        self._require_configured()
        started_at = time.perf_counter()
        vision_request = self._vision_request(request)
        adapter = get_provider_adapter("qwen_openai_compatible")
        payload: dict[str, Any] = {
            "model": self.config.vision_model,
            "messages": build_qwen_vision_messages(vision_request),
            "temperature": VISION_AGENT_TEMPERATURE,
            "max_tokens": int(self.config.vision_max_tokens),
        }
        adapter.apply_payload_options(
            payload, response_format_json=False, enable_thinking=False
        )
        body = self._post(
            payload,
            streaming=bool(self.config.vision_streaming),
            cancel_token=cancel_token,
        )
        message = self.owner._extract_response_message(body)
        raw_content = self.owner._extract_message_content(message)
        safe_content = extract_safe_fallback_text(raw_content)
        if not safe_content:
            raise CgVisionError(
                VISION_RESPONSE_INVALID_CODE, "视觉观察没有返回可安全展示的内容。"
            )
        profile = vision_request.profile
        base = {
            "ok": True,
            "mode": "observe",
            "text": safe_content,
            "hazard_label": "",
            "is_non_scene": False,
            "parse_status": "failed",
            "parse_error": "",
            "profile_id": profile.id,
            "system_prompt_sha256": profile.system_prompt_sha256,
            "image_count": len(vision_request.images),
            "model": self.config.vision_model,
            "usage": self.owner._extract_usage(body),
        }
        try:
            report = parse_hazard_report(safe_content, allowed_labels=profile.labels)
        except VlmOutputInvalidError as exc:
            # A schema miss is a reportable outcome, not a failed call: the
            # caller decides whether to fall back or abort.
            base["parse_error"] = str(exc)
            base["elapsed_ms"] = (time.perf_counter() - started_at) * 1000.0
            return _with_request_id(base)
        base.update(
            {
                "text": report.text,
                "hazard_label": report.label,
                "is_non_scene": report.is_non_scene,
                "parse_status": "succeeded",
                "elapsed_ms": (time.perf_counter() - started_at) * 1000.0,
            }
        )
        return _with_request_id(base)

    # ---- 智能生成 -------------------------------------------------------

    def _build_agent_call(
        self,
        *,
        thinking_enabled_default: bool,
        progress_callback: Callable[[dict[str, Any]], None] | None,
    ) -> Callable[..., ModelPhaseResponse]:
        adapter = get_provider_adapter("qwen_openai_compatible")

        def call_agent(
            *,
            messages: list[dict[str, Any]],
            phase: str,
            include_tools: bool,
            thinking_enabled: bool,
            streaming: bool,
            emit_answer_tokens: bool,
            emit_reasoning_tokens: bool,
            final_answer_guard: Any = None,
            cancel_token: Any = None,
        ) -> ModelPhaseResponse:
            _raise_if_cancelled(cancel_token)
            phase_started_at = time.perf_counter()
            payload: dict[str, Any] = {
                "model": self.config.vision_model,
                "messages": list(messages),
                "temperature": VISION_AGENT_TEMPERATURE,
                "max_tokens": int(self.config.vision_max_tokens),
            }
            if include_tools:
                payload["tools"] = [build_vision_retrieval_tool_schema()]
                payload["tool_choice"] = "auto"
            # The final phase's content must be a strict JSON envelope. A
            # guided json_schema catches most drift at decode time instead of
            # burning protocol-correction retries. Only safe with thinking
            # off: with it on this deployment routes the whole JSON into
            # reasoning_content and leaves content null.
            enforce_final_schema = phase == "final" and not thinking_enabled
            adapter.apply_payload_options(
                payload,
                response_format_json=enforce_final_schema,
                enable_thinking=thinking_enabled,
                response_json_schema=(
                    build_public_envelope_json_schema() if enforce_final_schema else None
                ),
            )

            def forward(channel: str, text: str) -> None:
                if progress_callback is None:
                    return
                if channel == "answer" and not emit_answer_tokens:
                    return
                if channel == "reasoning" and not emit_reasoning_tokens:
                    return
                progress_callback({"_event": "token", "channel": channel, "text": text})

            body = self._post(
                payload,
                streaming=streaming and bool(self.config.vision_streaming),
                on_token=forward,
                cancel_token=cancel_token,
            )
            _raise_if_cancelled(cancel_token)
            message = self.owner._extract_response_message(body)
            content = strip_think_blocks(
                self.owner._extract_message_content(message), final=phase == "final"
            )
            if phase == "final":
                content = strip_answer_tags(content).strip()
            return ModelPhaseResponse(
                content=content,
                reasoning=(
                    self.owner._extract_reasoning_content(body) if thinking_enabled else ""
                ),
                usage=self.owner._extract_usage(body),
                duration_ms=(time.perf_counter() - phase_started_at) * 1000.0,
                message=dict(message),
            )

        return call_agent

    def _build_retrieval_batch_call(
        self, *, scope: str, topk: int
    ) -> Callable[..., list[dict[str, Any]]]:
        """In-process retrieval, shaped exactly like the MCP tool result the
        runtime already knows how to read."""

        def call_retrieval_batch(
            *,
            calls: Sequence[tuple[str, dict[str, Any]]],
            cancel_token: Any = None,
        ) -> list[dict[str, Any]]:
            results: list[dict[str, Any]] = []
            for name, arguments in calls:
                _raise_if_cancelled(cancel_token)
                started_at = time.perf_counter()
                if name != CG_RETRIEVE_RERANK_TOOL_NAME:
                    results.append(
                        compact_retrieve_rerank_tool_result(
                            {"ok": False, "error": {"code": "unsupported_tool", "message": name}}
                        )
                    )
                    continue
                try:
                    raw = self.owner.retrieve_rerank(
                        CgRetrieveRerankRequest(
                            query=str(arguments.get("query") or ""),
                            scope=scope,
                            topk=topk,
                        ),
                        cancel_token=cancel_token,
                    )
                    compact = compact_retrieve_rerank_tool_result(raw)
                except Exception as exc:  # surfaced to the agent as a tool error
                    if cancel_token is not None and getattr(cancel_token, "cancelled", False):
                        raise
                    compact = compact_retrieve_rerank_tool_result(
                        {
                            "ok": False,
                            "error": {"code": "service_error", "message": str(exc)[:200]},
                        }
                    )
                compact["tool_duration_ms"] = (time.perf_counter() - started_at) * 1000.0
                results.append(compact)
            return results

        return call_retrieval_batch

    def vision_agentic(
        self,
        request: CgVisionAgenticRequest,
        *,
        progress_callback: Callable[[dict[str, Any]], None] | None = None,
        cancel_token: Any = None,
    ) -> dict[str, Any]:
        self._require_configured()
        started_at = time.perf_counter()
        vision_request = self._vision_request(request)
        thinking_enabled = self._thinking_enabled(request)
        topk = int(request.topk or self.config.vision_agentic_retrieval_topk)
        rounds = int(
            request.max_search_rounds or self.config.vision_agentic_max_search_rounds
        )
        runtime = VisionAgenticSearchRuntime(
            agent_call=self._build_agent_call(
                thinking_enabled_default=thinking_enabled,
                progress_callback=progress_callback,
            ),
            retrieval_batch_call=self._build_retrieval_batch_call(
                scope=request.scope, topk=topk
            ),
            max_search_rounds=rounds,
            retrieval_topk=topk,
        )
        result = runtime.run(
            request=vision_request,
            scope=request.scope,
            final_thinking_enabled=thinking_enabled,
            final_streaming=bool(self.config.vision_streaming),
            progress_callback=progress_callback,
            cancel_token=cancel_token,
        )
        elapsed_ms = (time.perf_counter() - started_at) * 1000.0
        runtime_stats = dict(result.runtime_stats)
        runtime_stats.setdefault("total_time_ms", elapsed_ms)
        return _with_request_id(
            {
                "ok": True,
                "mode": "agentic",
                "question": request.question,
                "answer": result.assistant_text,
                "reasoning": result.reasoning if thinking_enabled else "",
                "reasoning_stages": [dict(stage) for stage in result.reasoning_stages],
                # Raw [cite^n] markers, deliberately: an API consumer needs the
                # machine-readable form, and rendering them as 〔n〕 is the
                # caller's presentation choice.
                "citations": list(result.citations),
                "retrieval_docs": [dict(doc) for doc in result.retrieval_docs],
                "pipeline_trace": [dict(item) for item in result.pipeline_trace],
                "agent_events": [dict(event) for event in result.agent_events],
                "metadata": dict(result.metadata),
                "protocol_mode": result.protocol_mode,
                "scene_status": result.scene_status,
                "image_count": len(vision_request.images),
                "model": self.config.vision_model,
                "scope": request.scope,
                "thinking_enabled": thinking_enabled,
                "warnings": list(result.warnings),
                "usage": runtime_stats.get("tokens", {}).get("total", {}),
                "runtime_stats": runtime_stats,
            }
        )

    # ---- streaming ------------------------------------------------------

    def vision_answer_stream_events(
        self,
        request: CgVisionAnswerRequest,
        *,
        cancel_token: Any = None,
    ) -> Iterable[tuple[str, dict[str, Any]]]:
        yield from self._stream_events(
            lambda emit: self.vision_answer(
                request,
                on_token=lambda channel, text: emit(
                    {"_event": "token", "channel": channel, "text": text}
                ),
                cancel_token=cancel_token,
            ),
            stage_label="正在识别图片隐患",
        )

    def vision_agentic_stream_events(
        self,
        request: CgVisionAgenticRequest,
        *,
        cancel_token: Any = None,
    ) -> Iterable[tuple[str, dict[str, Any]]]:
        yield from self._stream_events(
            lambda emit: self.vision_agentic(
                request, progress_callback=emit, cancel_token=cancel_token
            ),
            stage_label="正在结合法规识别图片隐患",
        )

    def _stream_events(
        self,
        run: Callable[[Callable[[dict[str, Any]], None]], dict[str, Any]],
        *,
        stage_label: str,
    ) -> Iterable[tuple[str, dict[str, Any]]]:
        """Streams progress live while the (blocking) generation runs.

        The runtime and the direct path both push progress through a callback
        rather than yielding, so the generation runs on a worker thread and its
        callbacks land on a queue this generator drains. Buffering them until
        the run finished would technically deliver every event, but a hazard
        turn takes 60-90s and the caller would sit in silence for all of it --
        which is the whole reason these endpoints stream.
        """

        events: queue.Queue[dict[str, Any] | None] = queue.Queue()
        outcome: dict[str, Any] = {}

        def worker() -> None:
            try:
                outcome["data"] = run(lambda payload: events.put(dict(payload)))
            except BaseException as exc:  # re-raised on the consumer side
                outcome["error"] = exc
            finally:
                events.put(None)

        yield ("progress", {"stage": "vision", "label": stage_label, "status": "running"})
        thread = threading.Thread(target=worker, name="cg-rag-vision", daemon=True)
        thread.start()
        while True:
            payload = events.get()
            if payload is None:
                break
            yield (str(payload.pop("_event", "progress") or "progress"), payload)
        thread.join()

        error = outcome.get("error")
        if isinstance(error, CgVisionError):
            yield (
                "error",
                _with_request_id(
                    {"code": error.code, "message": error.message, "details": error.details}
                ),
            )
            return
        if error is not None:
            raise error
        yield ("final", outcome.get("data") or {})

vision_observe(request: CgVisionAnswerRequest, *, cancel_token: Any = None) -> dict[str, Any]

One structured pass over the images.

Unlike the two report modes this validates the model's output against the hazard-label vocabulary and reports the parse outcome, so a caller can turn the photo into a retrieval query and fall back cleanly when the model does not produce a usable observation.

源代码位于: src/retrieval/cg_rag_vision.py
def vision_observe(
    self,
    request: CgVisionAnswerRequest,
    *,
    cancel_token: Any = None,
) -> dict[str, Any]:
    """One structured pass over the images.

    Unlike the two report modes this validates the model's output against
    the hazard-label vocabulary and reports the parse outcome, so a caller
    can turn the photo into a retrieval query and fall back cleanly when
    the model does not produce a usable observation.
    """

    self._require_configured()
    started_at = time.perf_counter()
    vision_request = self._vision_request(request)
    adapter = get_provider_adapter("qwen_openai_compatible")
    payload: dict[str, Any] = {
        "model": self.config.vision_model,
        "messages": build_qwen_vision_messages(vision_request),
        "temperature": VISION_AGENT_TEMPERATURE,
        "max_tokens": int(self.config.vision_max_tokens),
    }
    adapter.apply_payload_options(
        payload, response_format_json=False, enable_thinking=False
    )
    body = self._post(
        payload,
        streaming=bool(self.config.vision_streaming),
        cancel_token=cancel_token,
    )
    message = self.owner._extract_response_message(body)
    raw_content = self.owner._extract_message_content(message)
    safe_content = extract_safe_fallback_text(raw_content)
    if not safe_content:
        raise CgVisionError(
            VISION_RESPONSE_INVALID_CODE, "视觉观察没有返回可安全展示的内容。"
        )
    profile = vision_request.profile
    base = {
        "ok": True,
        "mode": "observe",
        "text": safe_content,
        "hazard_label": "",
        "is_non_scene": False,
        "parse_status": "failed",
        "parse_error": "",
        "profile_id": profile.id,
        "system_prompt_sha256": profile.system_prompt_sha256,
        "image_count": len(vision_request.images),
        "model": self.config.vision_model,
        "usage": self.owner._extract_usage(body),
    }
    try:
        report = parse_hazard_report(safe_content, allowed_labels=profile.labels)
    except VlmOutputInvalidError as exc:
        # A schema miss is a reportable outcome, not a failed call: the
        # caller decides whether to fall back or abort.
        base["parse_error"] = str(exc)
        base["elapsed_ms"] = (time.perf_counter() - started_at) * 1000.0
        return _with_request_id(base)
    base.update(
        {
            "text": report.text,
            "hazard_label": report.label,
            "is_non_scene": report.is_non_scene,
            "parse_status": "succeeded",
            "elapsed_ms": (time.perf_counter() - started_at) * 1000.0,
        }
    )
    return _with_request_id(base)

CgVisionError

Bases: Exception

源代码位于: src/retrieval/cg_rag_vision.py
class CgVisionError(Exception):
    def __init__(self, code: str, message: str, *, details: dict[str, Any] | None = None) -> None:
        super().__init__(message)
        self.code = code
        self.message = message
        self.details = details or {}