ai_helper1.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. """
  2. AI 助手 — 调用 DeepSeek 分析 OCR 文本结果
  3. 只负责:接收文本 → 调 AI → 返回结构化数据
  4. 不负责 OCR 识别
  5. """
  6. import json
  7. import os
  8. import re
  9. import time
  10. from pathlib import Path
  11. from typing import Optional
  12. import requests
  13. # ── 配置 ────────────────────────────────────────────────
  14. DEEPSEEK_API_URL = "https://ai-api.dfwy.tech/v1/messages"
  15. DEEPSEEK_MODEL = "glm-5.2"
  16. # 项目根目录(用于读取 config.json)
  17. PROJECT_ROOT = Path(__file__).parent
  18. class AIParser:
  19. """调用 DeepSeek 分析 OCR 文本,返回结构化店铺列表"""
  20. # 默认 API Key(写死,也可通过环境变量或 config.json 覆盖)
  21. _DEFAULT_KEY = ""
  22. def __init__(self, api_key: Optional[str] = None):
  23. """
  24. Args:
  25. api_key: DeepSeek API Key,不传则从环境变量或 config.json 读取
  26. """
  27. self.api_key = api_key or self._load_api_key()
  28. if not self.api_key:
  29. raise RuntimeError(
  30. "未设置 DeepSeek API Key。请:\n"
  31. "1. 设环境变量 DEEPSEEK_API_KEY\n"
  32. "2. 或在 config.json 中添加 deepseek_api_key 字段\n"
  33. "3. 或在初始化时传入 api_key"
  34. )
  35. @staticmethod
  36. def _load_api_key() -> str:
  37. """从环境变量、config.json 或默认值读取 API Key"""
  38. key = os.environ.get("DEEPSEEK_API_KEY", "")
  39. if key:
  40. return key
  41. cfg_path = PROJECT_ROOT / "config.json"
  42. if cfg_path.exists():
  43. try:
  44. with open(cfg_path) as f:
  45. cfg = json.load(f)
  46. key = cfg.get("deepseek_api_key", "")
  47. if key:
  48. return key
  49. except Exception:
  50. pass
  51. return AIParser._DEFAULT_KEY
  52. # ── 核心:分析 OCR 结果 → 店铺列表 ──────────────────
  53. def parse_shops(
  54. self,
  55. ocr_results: list,
  56. screen_size: tuple = (1220, 2712),
  57. keyword: str = "",
  58. ) -> list:
  59. """
  60. 将 OCR 识别结果传给 DeepSeek,返回结构化店铺列表
  61. Args:
  62. ocr_results: OCR.recognize(detail="all") 返回值
  63. [{"text": str, "confidence": float, "bbox": [[x1,y1],...]}, ...]
  64. screen_size: (宽, 高),默认 1220x2712
  65. keyword: 当前搜索的商品关键词,用于帮助AI区分标题和功能描述
  66. Returns:
  67. [[店铺名, 商品标题, 价格, [点击x, 点击y], 配送距离y], ...]
  68. 点击坐标是店铺卡片中心点
  69. 配送距离y = 配送时间/距离文字的y坐标,用于滑动到其上方
  70. """
  71. # 1. 格式化 OCR 数据 — 按 y 坐标排序(从上到下)
  72. sorted_results = sorted(ocr_results, key=lambda r: r["bbox"][0][1])
  73. ocr_lines = []
  74. for r in sorted_results:
  75. box = r["bbox"]
  76. cy = (box[0][1] + box[2][1]) // 2
  77. cx = (box[0][0] + box[2][0]) // 2
  78. ocr_lines.append(
  79. f"[x={cx:4d}, y={cy:4d}, conf={r['confidence']:.2f}] {r['text']}"
  80. )
  81. ocr_text = "\n".join(ocr_lines)
  82. screen_w, screen_h = screen_size
  83. # 2. system prompt
  84. system_prompt = f"""你是OCR文本结构化助手。从OCR文字列表中找出每个"店铺卡片"的字段,必须严格使用OCR原文。
  85. 【重要:当前搜索的商品】
  86. 用户正在搜索「{keyword}」。请严格遵守以下规则:
  87. - 商品标题优先取卡片中与「{keyword}」匹配的文字行(含品牌名和规格)
  88. - 功能描述(如"养阴敛肺""解热镇痛""清热解毒""止咳化痰"等)通常字数少(2-8字)、无品牌名、在多个卡片中重复出现 → 绝对不能作为商品标题
  89. - 如果找不到与「{keyword}」匹配的文字,商品标题取卡片中最像完整商品名的文字(含品牌+药名+规格结构)
  90. - 但是如果整个页面没有任何文字与「{keyword}」相关(例如搜索药品却显示快餐/外卖商家),说明页面不对,返回空数组 []
  91. - 店铺名和商品标题不能是同一个文字(如果是同一个,说明该卡片没有独立的商品标题,应跳过)
  92. 【OCR数据格式】
  93. 每行: [x=坐标x, y=坐标y, conf=置信度] OCR识别出的文字
  94. y越大越靠下,文字按从上到下排列。
  95. 【判断店铺卡片方法】
  96. - 卡片判定(严格):同一卡片的文字块y坐标连续;任意两个文字块的y间隔≥80px则必属不同卡片。
  97. 商品标题的所有文字必须来自同一张卡片,绝不允许跨卡片拼接其他卡片的文字。
  98. - 先分析布局:同一卡片内,x坐标接近的行组成一列。卡片通常有两列:
  99. 左列是商品图片(图片上印刷的文字,可能含英文、乱码、与标题重复的药名、孤立数字);
  100. 右列是商品信息(标题、价格、店铺名)。
  101. 如果某行无法确定属于哪列,以它和标题/价格/店铺名的x坐标接近程度判断。
  102. - 提取字段时只使用右列(商品信息列)的文字,忽略左列(图片列)的文字。
  103. - 卡片从上到下:商品标题(多行) → 价格(¥/¥开头) → 店铺名(以店/超市/旗舰店等结尾)
  104. - 如果某文字块与当前卡片的y间隔≥80px,它属于下一张卡片,不得用于当前卡片的任何字段。
  105. 【字段规则 — 严格使用OCR原文,不得修改】
  106. 1. 店铺名 = 卡片最下方像店名的文字。
  107. 如果文字含"(",只取"("之前的部分(括号内是分店名,不重要)。
  108. 店铺名通常以"店/超市/旗舰店"结尾。
  109. 2. 商品标题 = 右列(商品信息列)中描述商品名称和规格的行。
  110. 多行时要完整拼接在一起,不要截断;
  111. 拼接时遇到以下任一行立即停止:
  112. - 价格行(¥/¥开头)
  113. - 店铺名行
  114. - 营销/标签行(如"原研药"、"处方药"、"热销"、"满减"等,不含商品名称和规格结构)
  115. 3. 价格 = 卡片中间带¥或¥符号的文字。
  116. 只取**数值+货币符号**部分(如"¥12.8"、"¥67"),不要含"预估价"等额外文字。
  117. 如果找不到价格,留空字符串""。
  118. 4. 点击坐标[x,y] = OCR数据中该卡片区域中间位置的实际坐标,用整数。
  119. 5. 月售量 = 卡片中含"月售"或"已售"的文字里的数字部分。
  120. 如"月售1000+" → "1000";含"万"时保留,如"月售1万+" → "1万"。
  121. 找不到留空字符串""。
  122. 【约束】
  123. - 所有字段值必须严格等于或取自OCR原文,不得修改、拼凑、推断
  124. - 店铺名如果找不到,留空字符串""
  125. - 找不到价格的卡片也要列出,价格留空
  126. - 找不到月售量的卡片也要列出,月售量留空
  127. - 有多少个卡片就输出多少个,不要遗漏
  128. - 输出纯JSON数组,不要任何多余文字
  129. 返回格式:
  130. [[店铺名(去括号), 商品标题(完整), 价格(纯数字), [点击x, 点击y], 店铺名y, 月售量], ...]
  131. 店铺名y = 店铺名文字在OCR数据中的y坐标,必须从OCR数据中找
  132. 月售量 = 第5条规则提取的数字,找不到就是"""""
  133. # 3. user prompt
  134. user_prompt = (
  135. f"屏幕OCR识别结果(屏幕尺寸 {screen_w}x{screen_h}):\n\n"
  136. f"{ocr_text}\n\n请按格式返回店铺列表。"
  137. )
  138. # 4. 调 AI
  139. print(f"[ai] 分析 {len(ocr_results)} 个文本块...")
  140. response = self._call(system_prompt, user_prompt)
  141. # 5. 解析返回
  142. return self._parse_response(response, screen_w, screen_h)
  143. # ── 页面类型检测 ──────────────────────────────────
  144. def check_page(self, ocr_results: list) -> dict:
  145. """
  146. 分析进入商品页后的页面类型
  147. Returns:
  148. {"type": "normal"|"risk"|"qrcode"|"unknown",
  149. "shop": "店铺名(如有)",
  150. "detail": "额外描述"}
  151. """
  152. # 1. 格式化
  153. sorted_r = sorted(ocr_results, key=lambda r: r["bbox"][0][1])
  154. lines = []
  155. for r in sorted_r:
  156. box = r["bbox"]
  157. cy = (box[0][1] + box[2][1]) // 2
  158. cx = (box[0][0] + box[2][0]) // 2
  159. lines.append(f"[x={cx:4d}, y={cy:4d}] {r['text']}")
  160. ocr_text = "\n".join(lines)
  161. system_prompt = """你是手机屏幕分析助手。收到OCR文字列表,判断当前屏幕是什么类型的页面。
  162. 【页面类型】
  163. 1. normal = 正常商品/店铺详情页(可以看到商品信息、价格、评价、店铺名等)
  164. 2. risk = 风控/验证码/拦截页(包含"拖动滑块"、"请按住滑块"、"滑块验证"、"拼图"、"安全验证"、"图形验证"、"点击完成验证"、"没有新图片"、"操作频繁"等强特征)
  165. 注意:单独的"验证"、"安全"、"风险"字样不算risk(商品说明/证书里常见,会误判)
  166. 3. qrcode = 二维码/扫码页(包含"二维码"、"扫码"、"请用微信"等)
  167. 4. home = App首页(底部导航必须有"AI点外卖";或有多个频道入口如"看病买药/盒马鲜生/超市便利/美食外卖/到店团购"。
  168. 注意:店铺页顶部的"首页/商品/评价"页签只是页内切换按钮,含"首页"二字不代表当前是App首页;
  169. 真首页没有"刚刚搜过"和"去结算")
  170. 5. list = 搜索结果列表页(包含"筛选"、"综合"、"销量"等列表页特征)
  171. 6. login = 登录页(包含"一键登录"、"更多登录方式"、"用户服务协议"等——账号被踢/封号时会跳转登录页)
  172. 7. unknown = 其他无法识别的页面
  173. **优先检测 risk 类型**:只要有类似验证码、风控拦截的文字,就返回risk
  174. 【输出格式】
  175. 只返回一个JSON对象,不要任何多余文字:
  176. {"type": "normal|risk|qrcode|home|list|login|unknown", "shop": "店铺名或空字符串", "detail": "简要说明"}
  177. """
  178. user_prompt = f"屏幕OCR文字:\n{ocr_text}\n\n请判断页面类型。"
  179. print(f"[ai] 检测页面类型...")
  180. response = self._call(system_prompt, user_prompt)
  181. import json
  182. cleaned = response.strip()
  183. if cleaned.startswith("```"):
  184. lines = cleaned.split("\n")
  185. if lines[0].startswith("```"):
  186. lines = lines[1:]
  187. if lines and lines[-1].strip() == "```":
  188. lines = lines[:-1]
  189. cleaned = "\n".join(lines).strip()
  190. try:
  191. data = json.loads(cleaned)
  192. return {
  193. "type": data.get("type", "unknown"),
  194. "shop": data.get("shop", ""),
  195. "detail": data.get("detail", ""),
  196. }
  197. except json.JSONDecodeError:
  198. if not response.strip():
  199. return {"type": "normal", "shop": "", "detail": "API调用失败,默认继续"}
  200. return {"type": "unknown", "shop": "", "detail": response[:100]}
  201. # ── API 调用 ────────────────────────────────────────
  202. def _call(self, system_prompt: str, user_content: str, timeout: int = 120, retries: int = 3) -> str:
  203. """调用 API(Anthropic 格式),失败自动重试"""
  204. headers = {
  205. "x-api-key": self.api_key,
  206. "anthropic-version": "2023-06-01",
  207. "Content-Type": "application/json",
  208. }
  209. payload = {
  210. "model": DEEPSEEK_MODEL,
  211. "max_tokens": 8192,
  212. "thinking": {"type": "disabled"},
  213. "system": system_prompt,
  214. "messages": [
  215. {"role": "user", "content": user_content},
  216. ],
  217. }
  218. last_err = None
  219. for attempt in range(retries):
  220. try:
  221. resp = requests.post(DEEPSEEK_API_URL, json=payload, headers=headers, timeout=timeout)
  222. resp.raise_for_status()
  223. data = resp.json()
  224. # 从 content 中提取所有 text 类型块
  225. texts = [c["text"] for c in data.get("content", []) if c.get("type") == "text"]
  226. return "".join(texts)
  227. except Exception as e:
  228. last_err = e
  229. if attempt < retries - 1:
  230. print(f" [ai] API调用失败(第{attempt+1}次),重试... ({e})")
  231. time.sleep(2)
  232. print(f" [ai] API调用{retries}次均失败,跳过: {last_err}")
  233. return ""
  234. # ── 解析 ────────────────────────────────────────────
  235. @staticmethod
  236. def _parse_response(response: str, screen_w: int, screen_h: int) -> list:
  237. """解析 AI 返回的 JSON"""
  238. cleaned = response.strip()
  239. # 去掉 markdown 代码块包裹
  240. if cleaned.startswith("```"):
  241. lines = cleaned.split("\n")
  242. if lines[0].startswith("```"):
  243. lines = lines[1:]
  244. if lines and lines[-1].strip() == "```":
  245. lines = lines[:-1]
  246. cleaned = "\n".join(lines).strip()
  247. # 解析 JSON
  248. try:
  249. data = json.loads(cleaned)
  250. except json.JSONDecodeError:
  251. print(f"[ai] WARNING: JSON 解析失败,尝试提取数组片段")
  252. match = re.search(r'\[[\s\S]*\]', cleaned)
  253. if match:
  254. try:
  255. data = json.loads(match.group(0))
  256. except json.JSONDecodeError:
  257. print(f"[ai] ERROR: JSON 解析失败")
  258. return []
  259. else:
  260. print(f"[ai] ERROR: 未找到 JSON 数组")
  261. return []
  262. if not isinstance(data, list):
  263. print(f"[ai] ERROR: 返回不是数组")
  264. return []
  265. validated = []
  266. for item in data:
  267. # 兼容 dict 格式(模型偶发不按 list 模板输出时自动转换)
  268. if isinstance(item, dict):
  269. shop = str(item.get("name") or item.get("shop") or item.get("shop_name") or "")
  270. title = str(item.get("title") or item.get("description") or item.get("product_name") or "")
  271. price = str(item.get("price") or item.get("min_price") or "")
  272. coords = item.get("coords") or item.get("click_pos") or item.get("click")
  273. if not isinstance(coords, list) and item.get("x") is not None and item.get("y") is not None:
  274. coords = [item.get("x"), item.get("y")]
  275. shop_name_y = item.get("shop_name_y") or item.get("y")
  276. sales = str(item.get("sales") or item.get("month_sales") or "")
  277. item = [shop, title, price, coords, shop_name_y, sales]
  278. if not isinstance(item, list) or len(item) < 4:
  279. continue
  280. shop = str(item[0]) if item[0] else ""
  281. title = str(item[1]) if item[1] else ""
  282. price = str(item[2]) if item[2] else ""
  283. coords = item[3]
  284. if isinstance(coords, list) and len(coords) == 2:
  285. cx, cy = int(coords[0]), int(coords[1])
  286. if 0 <= cx <= screen_w and 0 <= cy <= screen_h:
  287. # 第5个字段=店铺名文字的y坐标
  288. shop_name_y = int(item[4]) if len(item) >= 5 and isinstance(item[4], (int, float)) else min(cy + 200, screen_h)
  289. # 第6个字段=月售量(找不到留空)
  290. sales = str(item[5]) if len(item) >= 6 and item[5] not in (None, "") else ""
  291. validated.append([shop, title, price, [cx, cy], shop_name_y, sales])
  292. print(f"[ai] 解析到 {validated} 个店铺")
  293. return validated
  294. def detect_popup(ocr_results: list) -> dict:
  295. """
  296. AI判断当前屏幕是否有广告弹窗(红包活动弹窗/营销弹窗)。
  297. ocr_results: OCR.recognize(detail="all") 返回值(云端OCR结果,含坐标)
  298. 返回 {"has_popup": bool, "close_xy": [x,y]或None, "reason": "..."}
  299. """
  300. lines = []
  301. for r in ocr_results:
  302. box = r["bbox"]
  303. cx = (box[0][0] + box[2][0]) // 2
  304. cy = (box[0][1] + box[2][1]) // 2
  305. lines.append(f"[x={cx}, y={cy}] {r['text']}")
  306. ocr_text = "\n".join(lines)
  307. system_prompt = """你是手机屏幕分析助手。根据OCR文字判断屏幕上是否有广告弹窗。
  308. 【弹窗特征】
  309. - 红包活动弹窗/营销弹窗:覆盖页面主体,带关闭按钮(关闭/跳过/我知道了/×)
  310. - OCR中出现"红包具有随机性"、"祛暑品类券"、"去使用"、"立即领取"、"优惠券"等弹窗文案 = 有弹窗
  311. - 出现"关闭"、"跳过"、"我知道了"等关闭按钮文字 = 有弹窗
  312. - 普通页面元素(分类入口、商品卡片、底部导航、搜索结果)不是弹窗
  313. 只返回JSON,不要多余文字:
  314. {"has_popup": true/false, "close_xy": [x,y]或null, "reason": "一句话说明"}
  315. close_xy = 关闭按钮文字的坐标(必须从OCR数据中选取);纯图标打叉找不到文字时返回null"""
  316. user_prompt = f"屏幕OCR文字(带坐标):\n{ocr_text}\n\n请判断是否有广告弹窗。"
  317. parser = AIParser() # 从 config.json 读 key
  318. resp = parser._call(system_prompt, user_prompt, timeout=60)
  319. cleaned = resp.strip()
  320. if cleaned.startswith("```"):
  321. cl = cleaned.split("\n")
  322. if cl[0].startswith("```"):
  323. cl = cl[1:]
  324. if cl and cl[-1].strip() == "```":
  325. cl = cl[:-1]
  326. cleaned = "\n".join(cl).strip()
  327. try:
  328. data = json.loads(cleaned)
  329. return {
  330. "has_popup": bool(data.get("has_popup")),
  331. "close_xy": data.get("close_xy"),
  332. "reason": data.get("reason", ""),
  333. }
  334. except json.JSONDecodeError:
  335. return {"has_popup": False, "close_xy": None, "reason": f"AI返回解析失败: {resp[:50]}"}
  336. def extract_value_after(ocr_results: list, label: str) -> str:
  337. """
  338. 从 OCR 结果中提取指定标签后面的值(美团 get_next_data 逻辑)。
  339. 兼容:标签值同块("有效期 36个月")、标签带冒号("批准文号:")、标签值分离两块。
  340. """
  341. rows = sorted(ocr_results, key=lambda r: (r["bbox"][0][1], r["bbox"][0][0]))
  342. # 场景1: 标签和值在同一块
  343. for r in rows:
  344. t = r["text"].strip()
  345. if t.startswith(label):
  346. rest = t[len(label):].lstrip(":: \t")
  347. if rest:
  348. return rest
  349. # 场景2: 标签单独一块(允许尾部带冒号),值在下一块
  350. for i, r in enumerate(rows):
  351. t = r["text"].strip().rstrip(":: \t")
  352. if t == label and i + 1 < len(rows):
  353. return rows[i + 1]["text"].strip()
  354. return ""
  355. def parse_instructions(ocr_results: list) -> dict:
  356. """
  357. 从药品说明书页 OCR 结果提取批准文号/有效期(模仿美团 get_next_data 逻辑)。
  358. Args:
  359. ocr_results: OCR.recognize(detail="all") 返回值
  360. [{"text": str, "bbox": [[x1,y1],...], ...}, ...]
  361. Returns:
  362. {"approval_no": "批准文号值或''", "validity": "有效期值或''"}
  363. """
  364. return {
  365. "approval_no": extract_value_after(ocr_results, "批准文号"),
  366. "validity": extract_value_after(ocr_results, "有效期"),
  367. }
  368. # ── 单元测试(不调真实 API)─────────────────────────────
  369. def _selftest() -> None:
  370. """mock AI 响应,验证月售字段解析、提示词规则、请求格式"""
  371. import sys
  372. import unittest.mock as _mock
  373. import requests as _requests
  374. passed = 0
  375. total = 0
  376. def check(name: str, cond: bool, detail: str = ""):
  377. nonlocal passed, total
  378. total += 1
  379. if cond:
  380. passed += 1
  381. print(f" [OK] {name}")
  382. else:
  383. print(f" [FAIL] {name} {detail}")
  384. # 1) _parse_response:月售字段解析
  385. cases = [
  386. ("正常6字段", '[[\"xx大药房\",\"商品标题\",\"12.8\",[100,200],150,\"1000\"]]', ["1000"]),
  387. ("带万字", '[[\"xx大药房\",\"商品标题\",\"12.8\",[100,200],150,\"1万\"]]', ["1万"]),
  388. ("老格式5字段", '[[\"xx大药房\",\"商品标题\",\"12.8\",[100,200],150]]', [""]),
  389. ("月售留空", '[[\"xx大药房\",\"商品标题\",\"12.8\",[100,200],150,\"\"]]', [""]),
  390. ("markdown包裹", '```json\n[[\"xx大药房\",\"商品标题\",\"12.8\",[100,200],150,\"500\"]]\n```', ["500"]),
  391. ("坐标越界过滤", '[[\"xx大药房\",\"商品标题\",\"12.8\",[99999,99999],150,\"500\"]]', []),
  392. ("非JSON返回空", "这不是json", []),
  393. ]
  394. for name, resp, want in cases:
  395. shops = AIParser._parse_response(resp, 1220, 2712)
  396. got = [s[5] for s in shops]
  397. check(f"_parse_response: {name}", got == want, f"期望{want} 实际{got}")
  398. # 2) parse_shops 全流程(mock _call,验证提示词含月售规则)
  399. fake_ocr = [
  400. {"text": "xx大药房", "confidence": 0.9, "bbox": [[100, 300], [100, 330], [300, 330], [300, 300]]},
  401. {"text": "商品标题", "confidence": 0.9, "bbox": [[100, 100], [100, 130], [300, 130], [300, 100]]},
  402. {"text": "¥12.8", "confidence": 0.9, "bbox": [[100, 200], [100, 230], [300, 230], [300, 200]]},
  403. {"text": "月售1000+", "confidence": 0.9, "bbox": [[100, 260], [100, 290], [300, 290], [300, 260]]},
  404. ]
  405. fake_resp = '[[\"xx大药房\",\"商品标题\",\"12.8\",[200,215],285,\"1000\"]]'
  406. with _mock.patch.object(AIParser, "_call", return_value=fake_resp) as mc:
  407. shops = AIParser(api_key="test-key").parse_shops(fake_ocr)
  408. check("parse_shops返回1个店铺", len(shops) == 1, f"实际{len(shops)}")
  409. check("parse_shops带出月售", len(shops) == 1 and shops[0][5] == "1000", f"实际{shops}")
  410. sp = mc.call_args[0][0] # system prompt(patch后无self,第0个参数即system)
  411. check("提示词含月售规则", "月售" in sp and "已售" in sp)
  412. check("提示词含第6字段", "月售量" in sp and "第5条规则" in sp)
  413. # 3) _call 请求格式(mock requests.post,验证 payload)
  414. class _FakeResp:
  415. def raise_for_status(self):
  416. pass
  417. def json(self):
  418. return {"content": [{"type": "text", "text": "ok"}]}
  419. with _mock.patch.object(_requests, "post", return_value=_FakeResp()) as mp:
  420. r = AIParser(api_key="test-key")._call("sys", "user")
  421. kw = mp.call_args.kwargs.get("json", {})
  422. check("payload含thinking关闭", kw.get("thinking") == {"type": "disabled"})
  423. check("模型为deepseek-v4-flash", kw.get("model") == "deepseek-v4-flash")
  424. check("_call提取text返回", r == "ok", f"实际{r!r}")
  425. # 4) parse_instructions:说明书批准文号/有效期解析
  426. def _blk(text, y, x=50):
  427. return {"text": text, "confidence": 0.9,
  428. "bbox": [[x, y], [x, y + 20], [x + 300, y + 20], [x + 300, y]]}
  429. inst_cases = [
  430. ("标签值同一块", [
  431. _blk("批准文号:国药准字Z20240001", 100),
  432. _blk("有效期 36个月", 140),
  433. ], {"approval_no": "国药准字Z20240001", "validity": "36个月"}),
  434. ("标签值分离两块", [
  435. _blk("批准文号", 100),
  436. _blk("国药准字Z20240001", 100, 160),
  437. _blk("有效期", 140),
  438. _blk("36个月", 140, 160),
  439. ], {"approval_no": "国药准字Z20240001", "validity": "36个月"}),
  440. ("乱序输入也能解析", [
  441. _blk("国药准字Z20240001", 100, 160),
  442. _blk("有效期", 140),
  443. _blk("批准文号", 100),
  444. ], {"approval_no": "国药准字Z20240001", "validity": ""}),
  445. ("标签带冒号值在下一块", [
  446. _blk("批准文号:", 100),
  447. _blk("国药准字Z20063286", 100, 160),
  448. _blk("有效期:", 140),
  449. _blk("48个月", 140, 160),
  450. ], {"approval_no": "国药准字Z20063286", "validity": "48个月"}),
  451. ("缺有效期", [
  452. _blk("批准文号:国药准字Z20240001", 100),
  453. ], {"approval_no": "国药准字Z20240001", "validity": ""}),
  454. ("空OCR", [], {"approval_no": "", "validity": ""}),
  455. ("页面无说明书字段", [
  456. _blk("商品名称:感冒灵颗粒", 100),
  457. ], {"approval_no": "", "validity": ""}),
  458. ]
  459. for name, ocr, want in inst_cases:
  460. got = parse_instructions(ocr)
  461. check(f"parse_instructions: {name}", got == want, f"期望{want} 实际{got}")
  462. # 5) extract_value_after:资质编号等通用标签取值
  463. lic_ocr = [
  464. _blk("资质编号:JY1234567890", 100),
  465. _blk("营业执照信息", 200),
  466. ]
  467. check("extract_value_after: 资质编号同块",
  468. extract_value_after(lic_ocr, "资质编号") == "JY1234567890",
  469. f"实际{extract_value_after(lic_ocr, '资质编号')!r}")
  470. lic_ocr2 = [
  471. _blk("资质编号", 100),
  472. _blk("JY1234567890", 100, 160),
  473. ]
  474. check("extract_value_after: 资质编号分离",
  475. extract_value_after(lic_ocr2, "资质编号") == "JY1234567890",
  476. f"实际{extract_value_after(lic_ocr2, '资质编号')!r}")
  477. check("extract_value_after: 找不到返回空",
  478. extract_value_after([_blk("随便", 100)], "资质编号") == "")
  479. print(f"\n测试结果: {passed}/{total} 通过")
  480. sys.exit(0 if passed == total else 1)
  481. # ── 独立测试 ────────────────────────────────────────────
  482. if __name__ == "__main__":
  483. import sys
  484. if "--selftest" in sys.argv:
  485. _selftest()
  486. sys.exit(0)
  487. from steps.ocr import OCR
  488. # 读取已有截图
  489. test_img = str(PROJECT_ROOT / "screenshots" / "step2_result.png")
  490. if not os.path.exists(test_img):
  491. print(f"测试图片不存在: {test_img}")
  492. print("请先跑一次 main.py 生成截图")
  493. sys.exit(1)
  494. # OCR 识别
  495. ocr = OCR()
  496. raw = ocr.recognize(test_img, detail="all")
  497. print(f"OCR 识别到 {len(raw)} 个文本块\n")
  498. # AI 分析
  499. parser = AIParser()
  500. shops = parser.parse_shops(raw)
  501. print("\n" + "=" * 60)
  502. print(f" 店铺列表 ({len(shops)} 个)")
  503. print("=" * 60)
  504. for s in shops:
  505. print(f" 店铺: {s[0]}")
  506. print(f" 商品: {s[1]}")
  507. print(f" 价格: {s[2]}")
  508. print(f" 点击: ({s[3][0]}, {s[3][1]})")
  509. print(f" 配送y: {s[4]}")
  510. print()