From b663c7acbf3b53661b956c76626a45d0529f1270 Mon Sep 17 00:00:00 2001 From: jimi <1847930177@qq.com> Date: Tue, 3 Mar 2026 12:41:28 +0800 Subject: [PATCH] feat: add simulator page and image quote analyzer --- qingjian_cs/app/agents.py | 4 +- qingjian_cs/app/client.py | 122 ++++++++++- qingjian_cs/app/http_api.py | 181 ++++++++++++++++ qingjian_cs/app/image_quote_analyzer.py | 265 ++++++++++++++++++++++++ qingjian_cs/app/orchestrator.py | 9 + qingjian_cs/services/service_gemini.py | 31 ++- 6 files changed, 605 insertions(+), 7 deletions(-) create mode 100644 qingjian_cs/app/image_quote_analyzer.py diff --git a/qingjian_cs/app/agents.py b/qingjian_cs/app/agents.py index bbbee3f..999995c 100644 --- a/qingjian_cs/app/agents.py +++ b/qingjian_cs/app/agents.py @@ -134,7 +134,9 @@ class QuoteAgent(_AgentRuntime): def __init__(self) -> None: super().__init__( "QuoteAgent", - rules_prompt() + "\n你是报价Agent。负责收图、报价触发、报价回复和报价阶段状态更新。", + rules_prompt() + + "\n你是报价Agent。负责收图、报价触发、报价回复和报价阶段状态更新。" + + "\n若上下文里有 image_quote_analysis,优先参考其 诉求类型/可做性/复杂度/建议报价 来决定回复语气与报价动作。", ) async def decide(self, context: dict[str, Any]) -> Decision: diff --git a/qingjian_cs/app/client.py b/qingjian_cs/app/client.py index 3337693..0541942 100644 --- a/qingjian_cs/app/client.py +++ b/qingjian_cs/app/client.py @@ -4,6 +4,7 @@ import re import time from collections import defaultdict from datetime import datetime +from contextlib import suppress import websockets @@ -12,6 +13,8 @@ from .auto_draw import auto_draw_preview from .config import ( AUTO_DRAW_ENABLED, AUTO_QUOTE_WAIT_SECONDS, + DECISION_TIMEOUT_SECONDS, + MAX_CONCURRENT_TURNS, MESSAGE_DEBOUNCE_SECONDS, QINGJIAN_WS_URI, ) @@ -20,6 +23,7 @@ from .observability import activity_event, build_trace_id from .orchestrator import Orchestrator from .rules import extract_image_urls, prefilter_message from .runtime_switch import is_listen_only +from .store import ConversationStore class QingjianClient: @@ -30,9 +34,13 @@ class QingjianClient: self.websocket = None self.running = True self.orchestrator = Orchestrator() + self.store = ConversationStore() self.pending_msgs: dict[str, list[dict]] = defaultdict(list) self.debounce_tasks: dict[str, asyncio.Task] = {} + self.processing_tasks: dict[str, asyncio.Task] = {} + self.customer_locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) + self.turn_semaphore = asyncio.Semaphore(max(1, int(MAX_CONCURRENT_TURNS))) self.pending_images: dict[str, list[str]] = defaultdict(list) self.auto_quote_tasks: dict[str, asyncio.Task] = {} self.last_reply_key: dict[str, str] = {} @@ -153,6 +161,20 @@ class QingjianClient: activity_event(self.logger, "send_reply_attempt", trace_id=trace_id, customer_id=data.get("from_id", "-"), msg=text) await self.send_message(msg) self._append_dialogue(key, "assistant", text) + try: + self.store.append_event( + key, + "assistant_message", + { + "acc_id": data.get("acc_id", ""), + "customer_id": data.get("from_id", ""), + "msg_type": 0, + "msg": text, + "trace_id": trace_id, + }, + ) + except Exception as e: + self.logger.error("[入库] 客服消息写入失败: %s", e) self.recent_outbound.append((str(data.get("acc_id", "")), str(data.get("from_id", "")), text, time.monotonic())) if len(self.recent_outbound) > 200: self.recent_outbound = self.recent_outbound[-200:] @@ -177,6 +199,20 @@ class QingjianClient: activity_event(self.logger, "send_image_attempt", trace_id=trace_id, customer_id=data.get("from_id", "-"), msg=image_url) await self.send_message(msg) self._append_dialogue(key, "assistant", f"[image]{image_url}") + try: + self.store.append_event( + key, + "assistant_message", + { + "acc_id": data.get("acc_id", ""), + "customer_id": data.get("from_id", ""), + "msg_type": 1, + "msg": image_url, + "trace_id": trace_id, + }, + ) + except Exception as e: + self.logger.error("[入库] 客服图片消息写入失败: %s", e) self.recent_outbound.append((str(data.get("acc_id", "")), str(data.get("from_id", "")), image_url, time.monotonic())) if len(self.recent_outbound) > 200: self.recent_outbound = self.recent_outbound[-200:] @@ -269,6 +305,13 @@ class QingjianClient: if u not in self.pending_images[key]: self.pending_images[key].append(u) + # 上下文优先从数据库回读,保证重启后也能恢复最近对话 + try: + recent_dialogue = self.store.get_recent_dialogue(key, limit=24) + except Exception as e: + self.logger.error("[入库] 读取最近对话失败: %s", e) + recent_dialogue = self.recent_dialogue.get(key, []) + context = { "customer_key": key, "acc_id": data.get("acc_id", ""), @@ -278,9 +321,12 @@ class QingjianClient: "msg": merged_msg, "intent": "unknown", "pending_images": len(self.pending_images[key]), + "pending_image_urls": self.pending_images[key][-5:], + "current_image_urls": urls[-3:], + "latest_image_url": (urls[-1] if urls else (self.pending_images[key][-1] if self.pending_images[key] else "")), "auto_quote_trigger": auto_quote, "last_reply": self.last_reply_key.get(key, ""), - "recent_dialogue": self.recent_dialogue.get(key, [])[-12:], + "recent_dialogue": recent_dialogue[-12:], } activity_event(self.logger, "agent_process_start", trace_id=trace_id, customer_id=context["customer_id"], acc_id=context["acc_id"], intent=context["intent"]) @@ -390,7 +436,13 @@ class QingjianClient: try: await asyncio.sleep(AUTO_QUOTE_WAIT_SECONDS) if self.pending_images.get(key): - await self._handle_decision(data, "", auto_quote=True) + # 自动报价也走并发控制与客户互斥 + async with self.turn_semaphore: + async with self.customer_locks[key]: + await asyncio.wait_for( + self._handle_decision(data, "", auto_quote=True), + timeout=max(10, int(DECISION_TIMEOUT_SECONDS)), + ) finally: self.auto_quote_tasks.pop(key, None) @@ -398,13 +450,56 @@ class QingjianClient: queue = self.pending_msgs.get(key, []) if not queue: return - indexed = list(enumerate(queue)) + # 只处理当前快照,避免把处理中新增消息一并清掉 + snapshot = list(queue) + if not snapshot: + return + indexed = list(enumerate(snapshot)) indexed.sort(key=lambda it: (self._parse_msg_ts(it[1]), it[0])) ordered = [x for _, x in indexed] merged = "、".join([self._msg_text(x) for x in ordered if self._msg_text(x)]) data = ordered[-1] - self.pending_msgs[key].clear() await self._handle_decision(data, merged) + # 仅弹出已处理快照,保留处理中到达的新消息 + cur = self.pending_msgs.get(key, []) + n = min(len(snapshot), len(cur)) + if n > 0: + del cur[:n] + if not cur: + self.pending_msgs.pop(key, None) + + async def _run_customer_turn(self, key: str) -> None: + async with self.turn_semaphore: + async with self.customer_locks[key]: + await asyncio.wait_for( + self._flush_customer(key), + timeout=max(10, int(DECISION_TIMEOUT_SECONDS)), + ) + + def _schedule_customer_turn(self, key: str) -> None: + # 新消息来了就取消同客户旧任务,重新按最新消息计算 + old = self.processing_tasks.get(key) + if old and not old.done(): + old.cancel() + + async def runner() -> None: + try: + await self._run_customer_turn(key) + except asyncio.CancelledError: + activity_event(self.logger, "customer_turn_cancelled", customer_id=key.split(":")[-1], reason="newer_message") + return + except asyncio.TimeoutError: + activity_event(self.logger, "customer_turn_timeout", customer_id=key.split(":")[-1], reason="decision_timeout") + return + except Exception as e: + activity_event(self.logger, "customer_turn_error", customer_id=key.split(":")[-1], reason=str(e)) + return + finally: + cur = self.processing_tasks.get(key) + if cur is asyncio.current_task(): + self.processing_tasks.pop(key, None) + + self.processing_tasks[key] = asyncio.create_task(runner()) async def _debounce_enqueue(self, data: dict) -> None: key = self._customer_key(data) @@ -420,7 +515,7 @@ class QingjianClient: async def later() -> None: try: await asyncio.sleep(wait_s) - await self._flush_customer(key) + self._schedule_customer_turn(key) except asyncio.CancelledError: return finally: @@ -441,6 +536,23 @@ class QingjianClient: self.logger.info("[收消息] acc=%s from=%s type=%s msg=%s", data.get("acc_id", ""), data.get("from_id", ""), msg_type, msg) await post_tianwang_callback("message_received", data, extra={"msg_type": msg_type}) + # 客户消息全量入库(监听模式也落库) + try: + customer_key = self._customer_key(data) + self.store.append_event( + customer_key, + "customer_message", + { + "acc_id": data.get("acc_id", ""), + "customer_id": data.get("from_id", ""), + "msg_type": msg_type, + "msg": msg, + "raw_msg": data.get("msg", ""), + "timestamp": data.get("timestamp", ""), + }, + ) + except Exception as e: + self.logger.error("[入库] 客户消息写入失败: %s", e) if self._is_outbound_echo(data, msg): activity_event( diff --git a/qingjian_cs/app/http_api.py b/qingjian_cs/app/http_api.py index ea58cb9..4649698 100644 --- a/qingjian_cs/app/http_api.py +++ b/qingjian_cs/app/http_api.py @@ -1,8 +1,14 @@ from __future__ import annotations +import asyncio +from collections import defaultdict + from flask import Flask, jsonify, request +from .auto_draw import auto_draw_preview from .logger import setup_logger +from .orchestrator import Orchestrator +from .rules import extract_image_urls from .runtime_switch import is_listen_only, set_listen_only from .task_manager import TaskManager @@ -11,6 +17,17 @@ def create_http_app(task_manager: TaskManager | None = None) -> Flask: app = Flask(__name__) logger = setup_logger() tm = task_manager or TaskManager() + sim_orch = Orchestrator() + sim_pending_images: dict[str, list[str]] = defaultdict(list) + sim_recent_dialogue: dict[str, list[dict]] = defaultdict(list) + + def _sim_append_dialogue(key: str, role: str, text: str) -> None: + t = str(text or "").strip() + if not t: + return + sim_recent_dialogue[key].append({"role": role, "text": t}) + if len(sim_recent_dialogue[key]) > 24: + sim_recent_dialogue[key] = sim_recent_dialogue[key][-24:] @app.get('/api/health') def health(): @@ -58,6 +75,170 @@ def create_http_app(task_manager: TaskManager | None = None) -> Flask: limit = int(request.args.get('limit', 100)) return jsonify({'ok': True, 'tasks': tm.list_tasks(limit=limit)}) + @app.get('/debug/simulator') + def debug_simulator(): + return """ + + + + + + 链路测试页 + + + +
+
+

AI链路模拟测试

+
店铺acc_id
+
客户customer_id
+
商品标题
+
订单信息
+
客户消息
+
选项
+
+ + +
+
等待发送...
+
+
+ + + +""" + + @app.post('/api/simulate/reset') + def sim_reset(): + body = request.get_json(silent=True) or {} + acc_id = str(body.get("acc_id", "")).strip() + customer_id = str(body.get("customer_id", "")).strip() + if not acc_id or not customer_id: + return jsonify({'ok': False, 'error': 'acc_id and customer_id required'}), 400 + key = f"{acc_id}:{customer_id}" + sim_pending_images.pop(key, None) + sim_recent_dialogue.pop(key, None) + try: + sim_orch.store.upsert_session(key, acc_id, customer_id, "pre_sales", {"after_sales_stage": "new"}) + except Exception: + pass + return jsonify({'ok': True, 'customer_key': key, 'message': '上下文已清空'}) + + @app.post('/api/simulate/message') + def sim_message(): + body = request.get_json(silent=True) or {} + acc_id = str(body.get("acc_id", "")).strip() + customer_id = str(body.get("customer_id", "")).strip() + goods_name = str(body.get("goods_name", "")).strip() + goods_order = str(body.get("goods_order", "")).strip() + msg = str(body.get("msg", "")).strip() + simulate_draw = bool(body.get("simulate_draw")) + if not acc_id or not customer_id or not msg: + return jsonify({'ok': False, 'error': 'acc_id, customer_id, msg required'}), 400 + + key = f"{acc_id}:{customer_id}" + urls = extract_image_urls(msg) + for u in urls: + if u not in sim_pending_images[key]: + sim_pending_images[key].append(u) + _sim_append_dialogue(key, "user", msg) + latest_image_url = urls[-1] if urls else (sim_pending_images[key][-1] if sim_pending_images[key] else "") + + context = { + "customer_key": key, + "acc_id": acc_id, + "customer_id": customer_id, + "goods_name": goods_name, + "goods_order": goods_order, + "msg": msg, + "intent": "unknown", + "pending_images": len(sim_pending_images[key]), + "pending_image_urls": sim_pending_images[key][-5:], + "current_image_urls": urls[-3:], + "latest_image_url": latest_image_url, + "auto_quote_trigger": False, + "last_reply": "", + "recent_dialogue": sim_recent_dialogue[key][-12:], + } + + route, decision, state = asyncio.run(sim_orch.decide(context)) + if decision.reply: + _sim_append_dialogue(key, "assistant", decision.reply) + + draw_result: dict = {} + if simulate_draw and decision.action == "quote" and latest_image_url: + draw_result = asyncio.run( + auto_draw_preview( + image_url=latest_image_url, + customer_id=customer_id, + requirement=msg, + ) + ) + + logger.info("[模拟] key=%s route=%s action=%s", key, route, decision.action) + return jsonify( + { + "ok": True, + "customer_key": key, + "route": route, + "decision": { + "action": decision.action, + "reply": decision.reply, + "transfer_msg": decision.transfer_msg, + "quote_mode": decision.quote_mode, + "reason": decision.reason, + }, + "state": state, + "pending_images": sim_pending_images[key], + "draw_result": draw_result, + } + ) + return app diff --git a/qingjian_cs/app/image_quote_analyzer.py b/qingjian_cs/app/image_quote_analyzer.py new file mode 100644 index 0000000..3031cd0 --- /dev/null +++ b/qingjian_cs/app/image_quote_analyzer.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import asyncio +import logging +import os +import re +from typing import Any + +from openai import AsyncOpenAI + +from .config import OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL_NAME + +logger = logging.getLogger(__name__) + +VISION_MODEL = OPENAI_MODEL_NAME + +ANALYZE_PROMPT = """ +你是一个电商图片处理评估专家,同时也是 Gemini 图像生成提示词专家。 +请仔细分析这张图片,输出以下字段,每行一个,不要多余内容: + +敏感内容: +平整度: +含文字: +含人脸: +阴影: +复杂度: +原因: <15字以内,说明复杂度判断依据> +主体: <图片核心内容,如:印花图案/logo/人物/产品/老照片/风景/文字/其他> +类型: <处理类型,如:印花提取/高清修复/去背景/老照片修复/logo提取/人像修复/其他> +质量: <原图质量,如:清晰/轻微模糊/严重模糊/低分辨率/截图/扫描件> +可做: +风险: +业务相关: +透视: +比例: <从以下选一个最合适的:1:1 / 9:16 / 16:9 / 3:4 / 4:3 / 3:2 / 2:3 / 5:4 / 4:5> +提示词: <为 Gemini 写处理指令,中文,60字以内,说明要做什么、保留什么、去掉什么> +备注: <给客服AI的特别提示,没有则填无> + +判断规则: + +【报价核心:越平整越便宜】 +- 平整度 flat:画面平整、无褶皱、无透视 → 便宜 +- 平整度 mild:轻微褶皱/透视 → 中等 +- 平整度 rough:有褶皱/透视/曲面 → 贵 +- 含文字:大字没关系不加价;小字需精细保留/清晰化 → 加价(含文字填 yes 仅指有小字的情况) +- 含人脸 yes:有人脸 → 加价 +- 阴影 yes:有明显阴影需处理 → 加价 +综合以上因素,越平整、无小字、无人脸、无阴影 → 越便宜(simple) + +【含文字】 +- yes:含小字需精细保留/清晰化(小字难处理 → 加价) +- no:无文字,或仅有大字(大字没关系 → 不加价) + +【文字数量加价规则】 +- none:无文字,不加价 +- 少量 (1-10 字):+5 元 +- 中量 (11-50 字):+10-15 元 +- 大量 (51-200 字):+20-30 元 +- 极多 (200 字以上):+30-50 元 + +【文字分层需求】 +- yes:客户要求可编辑分层文件(PSD 等) → 基础价格 x2 或 +50 元起 +- no:普通图片处理 → 正常价格 + +【文字分层 + 大量文字】 +- 如果 文字数量=大量/极多 且 文字分层需求=yes → 总价可达 60-80 元 + +【含人脸】 +- yes:图中有真实人物面孔(人像照/集体照/证件照/老照片等) +- no:无人脸或人脸极小不影响主体 + +【风险评估 - 重要!】 +- none:印花/图案/logo/风景/产品,AI处理效果稳定,可直接报价接单 +- low:有人脸但清晰度尚可,AI修复后人脸相似度70-90%,可以接单但要说明风险 +- high:以下任一情况 → 严重模糊的人脸照片/老照片人像/需要打印/客户问能否找回原图 + high情况下,可做改为partial,备注写明风险话术,谨慎接单 + +【敏感内容检测 - 必须严格判断!】 +- yes:含以下任一内容 → 色情/黄色/擦边/裸露/性暗示/大尺度/涉政/暴力/血腥/违禁品/地图类 + 敏感内容=yes 时,可做必须填 no,直接拒绝不接单 +- no:无上述敏感内容,可以正常接单处理 + +【可做判断 - 决定是否接单】 +- yes:效果有把握,可以接单处理 +- partial:能处理但有明显限制(人脸变形风险/分辨率极低/严重损坏)→ 可以接单但要说明风险 +- no:无法接单(纯黑/纯白/完全损坏/找原始 RAW 文件/敏感内容/违法内容) + +【敏感内容】优先判断,若为 yes 则 可做 必填 no +- yes:图片含色情/黄色/擦边/裸露/性暗示/大尺度等违规内容 +- no:无上述敏感内容 + +【可做判断】 +- yes:效果有把握,可直接处理 +- partial:能处理但有明显限制(人脸变形风险/分辨率极低/严重损坏) +- no:无法处理(纯黑/纯白/完全损坏/找原始RAW文件/敏感内容) + +【业务相关判断 - 必须由你判断】 +- yes:需求与印花/印刷素材处理,或高清修复业务相关 +- no:与以上业务无关(例如闲聊、非图像处理诉求) +- 若业务相关=no,则可做必须填 no + +【风险话术模板(备注字段)】 +- 含人脸+需打印:AI修复后人脸可能有轻微变化,建议先看效果确认再打印 +- 严重模糊人脸:这张模糊程度较高,修复后清晰了但人脸可能跟原来有差异 +- 找原图:找不到原始文件,只能对现有图片做高清修复处理 +- 完全损坏:这张无法处理 + +【透视判断】 +- no:正面拍摄,无明显变形 +- mild:轻微透视(衣服悬挂/桌面小角度斜拍) +- strong:严重透视(俯拍/贴墙/大角度倾斜) + +【比例选择】 +- 印花/图案/logo/正方形 -> 1:1 +- 竖屏壁纸/短视频封面 -> 9:16 +- 宽屏/横版视频 -> 16:9 +- 移动广告/Instagram竖图 -> 4:5 +- 竖向人像/海报/证件照 -> 3:4 +- 竖向相机照片 -> 2:3 +- 接近正方形产品图 -> 5:4 +- 横向标准图/风景 -> 4:3 +- 横向相机照片/产品实拍 -> 3:2 + +示例1(印花,无风险): +敏感内容: no +平整度: mild +含文字: no +含人脸: no +阴影: no +复杂度: complex +原因: 印花细节密集颜色层次多 +主体: 印花图案 +类型: 印花提取 +质量: 轻微模糊 +可做: yes +风险: none +透视: mild +比例: 1:1 +提示词: 提取衣物印花图案,去除褶皱和背景杂色,补全缺失部分,保持颜色细节100%还原,输出干净平面印花图 +备注: 无 + +示例2(人像老照片,要打印): +敏感内容: no +平整度: flat +含文字: no +含人脸: yes +阴影: no +复杂度: hard +原因: 严重模糊人脸细节丢失 +主体: 人物照片 +类型: 人像修复 +质量: 严重模糊 +可做: partial +风险: high +透视: no +比例: 3:4 +提示词: 对模糊人像进行高清修复,增强细节,保持人物特征不变 +备注: AI修复后人脸可能有轻微变化,建议先看效果确认满意再用于打印 + +示例3(平整印花,最便宜): +敏感内容: no +平整度: flat +含文字: no +含人脸: no +阴影: no +复杂度: simple +原因: 画面平整无褶皱无文字无人脸 +主体: 印花图案 +类型: 印花提取 +质量: 清晰 +可做: yes +风险: none +透视: no +比例: 1:1 +提示词: 提取印花图案,去除背景,输出干净平面图 +备注: 无 +""" + + +def _clip(text: str, n: int = 180) -> str: + t = re.sub(r"\s+", " ", str(text or "")).strip() + if len(t) <= n: + return t + return f"{t[:n]}..." + + +def _extract_line(text: str, key: str) -> str: + m = re.search(rf"{re.escape(key)}\s*[::]\s*(.+)", text or "") + return m.group(1).strip() if m else "" + + +def _parse_result(text: str) -> dict[str, Any]: + intent = _extract_line(text, "诉求类型") + can_do = _extract_line(text, "可做").lower() + complexity = _extract_line(text, "复杂度").lower() + price_text = _extract_line(text, "建议报价") + note = _extract_line(text, "说明") + + if intent not in {"找图", "高清修复", "其他"}: + intent = "其他" + if can_do not in {"yes", "partial", "no"}: + can_do = "partial" + if complexity not in {"simple", "normal", "complex", "hard"}: + complexity = "normal" + + price = 0 + m = re.search(r"\d+", price_text or "") + if m: + price = int(m.group(0)) + + return { + "intent_type": intent, + "can_do": can_do, + "complexity": complexity, + "price_suggest": price, + "note": note or "已看图", + } + + +async def analyze_image_for_quote(image_url: str, customer_text: str = "", goods_name: str = "") -> dict[str, Any]: + """ + 识图报价分析(用于 QuoteAgent 上下文增强) + """ + image_url = str(image_url or "").strip() + if not image_url: + return {"ok": False, "error": "empty_image_url"} + if not OPENAI_API_KEY: + return {"ok": False, "error": "OPENAI_API_KEY 未配置"} + + model_name = VISION_MODEL + client = AsyncOpenAI(base_url=OPENAI_BASE_URL, api_key=OPENAI_API_KEY) + user_text = ( + f"客户文本: {customer_text or '无'}\n" + f"商品标题: {goods_name or '无'}\n" + "请按固定字段输出。" + ) + logger.info("[识图报价] 豆包模型=%s 图片=%s 提示词=%s", model_name, image_url, _clip(ANALYZE_PROMPT, 90)) + try: + resp = await asyncio.wait_for( + client.chat.completions.create( + model=model_name, + temperature=0.1, + messages=[ + {"role": "system", "content": "你是电商图片识别报价助手。"}, + { + "role": "user", + "content": [ + {"type": "text", "text": f"{ANALYZE_PROMPT}\n{user_text}"}, + {"type": "image_url", "image_url": {"url": image_url}}, + ], + }, + ], + ), + timeout=30, + ) + text = str((resp.choices[0].message.content if resp and resp.choices else "") or "").strip() + logger.info("[识图报价] 模型输出=%s", _clip(text, 260)) + parsed = _parse_result(text) + parsed["ok"] = True + parsed["raw"] = text + return parsed + except Exception as e: + logger.error("[识图报价] 调用失败: %s", e) + return {"ok": False, "error": str(e)} + diff --git a/qingjian_cs/app/orchestrator.py b/qingjian_cs/app/orchestrator.py index 088676d..043fa34 100644 --- a/qingjian_cs/app/orchestrator.py +++ b/qingjian_cs/app/orchestrator.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any from .agents import AfterSalesAgent, PreSalesAgent, QuoteAgent, RiskAgent, RouterAgent +from .image_quote_analyzer import analyze_image_for_quote from .models import Decision from .state_machine import evolve_after_sales_state, migrate_state_schema from .store import ConversationStore @@ -38,6 +39,14 @@ class Orchestrator: route, route_reason = await self.router.route(merged_ctx) if route == "quote": + latest_image_url = str(context.get("latest_image_url", "") or "").strip() + if latest_image_url: + analysis = await analyze_image_for_quote( + image_url=latest_image_url, + customer_text=str(context.get("msg", "") or ""), + goods_name=str(context.get("goods_name", "") or ""), + ) + merged_ctx["image_quote_analysis"] = analysis decision = await self.quote.decide(merged_ctx) elif route == "after_sales": decision = await self.after_sales.decide(merged_ctx) diff --git a/qingjian_cs/services/service_gemini.py b/qingjian_cs/services/service_gemini.py index 691cd1a..7e4a192 100644 --- a/qingjian_cs/services/service_gemini.py +++ b/qingjian_cs/services/service_gemini.py @@ -67,6 +67,13 @@ class GeminiExtractV2Service: # DEFAULT_PROMPT = "生成图片,把衣服的图案展开起来做成数码印花印刷平面图。去掉皱褶,生成图案增强细节。排除衣服图案以外内容" def __init__(self): self.session = None + + @staticmethod + def _clip_text(text: str, limit: int = 120) -> str: + if not text: + return "" + compact = re.sub(r"\s+", " ", str(text)).strip() + return compact if len(compact) <= limit else f"{compact[:limit]}..." def image_to_base64(self, image_path: str) -> str: """将图片文件转换为base64编码字符串""" @@ -108,6 +115,12 @@ class GeminiExtractV2Service: # 使用自定义提示词或默认提示词 prompt = custom_prompt or self.DEFAULT_PROMPT + logger.info( + "图片识别任务开始: 输入=%s 输出=%s 提示词=%s", + input_path, + output_path, + self._clip_text(prompt, 100), + ) # 按优先级逐个尝试API配置 for config_index, config in enumerate(self.API_CONFIGS): @@ -117,6 +130,12 @@ class GeminiExtractV2Service: for attempt in range(config['max_retries']): try: logger.info(f"开始Gemini V2印花提取 - {config['name']} (第{attempt + 1}/{config['max_retries']}次尝试): {input_path}") + logger.info( + "%s 模型参数: model=%s aspect_ratio=%s", + config["name"], + config["api_model"], + aspect_ratio, + ) # 准备请求数据和URL if config.get('use_gemini_format', False): @@ -271,7 +290,15 @@ class GeminiExtractV2Service: # 根据API格式提取内容 if config.get('use_gemini_format', False): # Gemini原生API格式: candidates[0].content.parts[0] - content_parts = result['candidates'][0]['content']['parts'] + candidates = result.get("candidates", []) + logger.info("%s 返回候选数量: %s", api_name, len(candidates)) + if not candidates: + logger.error("%s 响应缺少candidates", api_name) + return False, "响应缺少candidates", {} + finish_reason = candidates[0].get("finishReason", "") + if finish_reason: + logger.info("%s 返回结束原因: %s", api_name, finish_reason) + content_parts = candidates[0]['content']['parts'] # 查找包含图片数据的part image_data = None @@ -287,6 +314,8 @@ class GeminiExtractV2Service: except Exception as e: logger.error(f"{api_name} Base64解码失败: {e}") return False, f"Base64解码失败: {e}", {} + elif "text" in part: + logger.info("%s 模型文本输出: %s", api_name, self._clip_text(part.get("text", ""), 160)) if not image_data: logger.error(f"{api_name} 在Gemini响应中未找到图片数据")