""" 开版订单 AI 解析服务 Flask + 豆包 API(/api/v3/responses 格式) 功能: 1. 代理企微消息接口(解决 CORS) 2. AI 解析消息内容 → 开版订单字段 3. 字典枚举值缓存(启动时拉取,每日刷新) 4. 布料/客户智能匹配:搜索 → 不存在则自动新增 启动: python app.py """ import os import json import logging import threading import time import requests from datetime import datetime, timedelta from flask import Flask, request, jsonify from flask_cors import CORS from dotenv import load_dotenv load_dotenv() # ─── 日志 ───────────────────────────────────────────────────────────────────── logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", ) logger = logging.getLogger(__name__) # ─── Flask ──────────────────────────────────────────────────────────────────── app = Flask(__name__) CORS(app, origins="*") # ─── 豆包 AI 配置 ───────────────────────────────────────────────────────────── AI_API_KEY = os.getenv("AI_API_KEY", "") AI_BASE_URL = os.getenv("AI_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3") AI_MODEL = os.getenv("AI_MODEL", "doubao-seed-2-0-mini-260428") DOUBAO_URL = f"{AI_BASE_URL}/responses" DOUBAO_HEADERS = { "Authorization": f"Bearer {AI_API_KEY}", "Content-Type": "application/json", } # ─── 企微消息服务配置 ───────────────────────────────────────────────────────── WECHAT_API_BASE = "https://test.ruicaiyinhua.online" WECHAT_AUTH = "hophopkk" WORK_ORDER_ROOM = "wrW_mXZwAAaGwXBWvzbiAfEjy3ZM7ong" # ─── ERP 后端配置 ───────────────────────────────────────────────────────────── ERP_BASE_URL = os.getenv("ERP_BASE_URL", "https://yuwenerp.yuwen.cloud") ERP_USERNAME = os.getenv("ERP_USERNAME", "jimi") ERP_PASSWORD = os.getenv("ERP_PASSWORD", "zuowei1216") # 需要全量缓存的小字典分组(用于 AI prompt 约束) SMALL_DICT_GROUPS = [ "起版情况", "紧急程度", "做货方式", "开版方式", "地区", "画图评级", "调色评级", "套样评级", "难度评级", "布源", "幅宽", ] # ─── ERP Token 管理 ────────────────────────────────────────────────────────── class ErpAuth: def __init__(self): self.access_token = None self.refresh_token = None self.expires_at = 0 self._lock = threading.Lock() def login(self): with self._lock: try: r = requests.post( f"{ERP_BASE_URL}/api/auth/login/", json={"username": ERP_USERNAME, "password": ERP_PASSWORD}, timeout=15, ) r.raise_for_status() data = r.json() self.access_token = data["access"] self.refresh_token = data.get("refresh") # JWT 默认 7 天,提前 1 小时刷新 self.expires_at = time.time() + 6 * 24 * 3600 logger.info("ERP 登录成功") return self.access_token except Exception as e: logger.error(f"ERP 登录失败: {e}") raise def get_token(self): if not self.access_token or time.time() >= self.expires_at: return self.login() return self.access_token def headers(self): return {"Authorization": f"Bearer {self.get_token()}"} erp_auth = ErpAuth() def erp_get(path: str, params: dict = None): """GET ERP 接口,自动处理 401""" url = f"{ERP_BASE_URL}{path}" r = requests.get(url, headers=erp_auth.headers(), params=params or {}, timeout=30) if r.status_code == 401: erp_auth.login() r = requests.get(url, headers=erp_auth.headers(), params=params or {}, timeout=30) r.raise_for_status() return r.json() def erp_post(path: str, data: dict): """POST ERP 接口""" url = f"{ERP_BASE_URL}{path}" r = requests.post(url, headers={**erp_auth.headers(), "Content-Type": "application/json"}, json=data, timeout=30) if r.status_code == 401: erp_auth.login() r = requests.post(url, headers={**erp_auth.headers(), "Content-Type": "application/json"}, json=data, timeout=30) r.raise_for_status() return r.json() # ─── 字典缓存 ───────────────────────────────────────────────────────────────── class DictCache: """小字典分组的内存缓存,启动时加载,每日刷新""" def __init__(self): self.cache = {} # {group: [name1, name2, ...]} self.processes = [] # [{id, name, description}] 关联流程 self.last_loaded = 0 self.ttl = 24 * 3600 # 24 小时 self._lock = threading.Lock() def load(self): with self._lock: logger.info("加载字典缓存...") new_cache = {} for group in SMALL_DICT_GROUPS: try: data = erp_get("/api/backend/quick-inputs/", {"group": group, "limit": 200}) items = data.get("results", []) new_cache[group] = [it["name"] for it in items if it.get("name")] logger.info(f" [{group}] {len(new_cache[group])} 项") except Exception as e: logger.warning(f" [{group}] 加载失败: {e}") new_cache[group] = self.cache.get(group, []) self.cache = new_cache # 加载关联流程 try: data = erp_get("/api/v1/stateflow/processes/", {"limit": 100}) self.processes = [ {"id": p["id"], "name": p["name"], "description": p.get("description", "")} for p in data.get("results", []) ] logger.info(f" [关联流程] {len(self.processes)} 项") except Exception as e: logger.warning(f" [关联流程] 加载失败: {e}") self.last_loaded = time.time() logger.info("字典缓存加载完成") def get(self, group: str): if time.time() - self.last_loaded > self.ttl: try: self.load() except Exception: pass return self.cache.get(group, []) def get_processes(self): return self.processes def find_process_id(self, name: str): """根据名称查找流程 ID(精确 + 模糊)""" if not name: return None for p in self.processes: if p["name"] == name: return p["id"] # 模糊匹配 for p in self.processes: if name in p["name"] or p["name"] in name: return p["id"] return None def all(self): return dict(self.cache) dict_cache = DictCache() def background_init(): """后台线程:登录 ERP + 加载字典缓存(不阻塞 Flask 启动)""" try: erp_auth.login() dict_cache.load() except Exception as e: logger.error(f"初始化失败: {e}") # ─── 布料/客户 智能匹配 ─────────────────────────────────────────────────────── def find_or_create_fabric(name: str) -> dict: """ 精确匹配优先;找不到则新增(不做模糊匹配,避免错配) 返回 {id, name, exists, created} """ if not name: return None name = name.strip() try: # 精确搜索(按 name 参数过滤) data = erp_get("/api/backend/quick-inputs/", {"group": "布料", "name": name, "limit": 20}) items = data.get("results", []) # 只接受精确匹配 exact = next((it for it in items if it.get("name") == name), None) if exact: return {"id": exact["id"], "name": exact["name"], "exists": True, "created": False} # 找不到精确 → 新增 new_item = erp_post("/api/backend/quick-inputs/", { "group": "布料", "name": name, "value": name, }) logger.info(f"新增布料: {name} (id={new_item.get('id')})") return {"id": new_item.get("id"), "name": name, "exists": False, "created": True} except Exception as e: logger.error(f"匹配/新增布料失败 [{name}]: {e}") return None def find_customer(name: str) -> dict: """ 搜索客户:精确匹配优先;模糊匹配只在精确未命中时使用,并标记 fuzzy 返回 {id, name, exists, ...} """ if not name: return None name = name.strip() try: data = erp_get("/api/backend/customers/", {"name": name, "limit": 10}) items = data.get("results", []) if isinstance(data, dict) else data if not items: return {"id": None, "name": name, "exists": False} # 精确匹配 exact = next((it for it in items if it.get("name") == name), None) if exact: return { "id": exact["id"], "name": exact["name"], "exists": True, "area": exact.get("area"), "visible_employees": exact.get("visible_employees"), } # 没有精确匹配 → 返回未找到(让前端决定是否模糊建议) return { "id": None, "name": name, "exists": False, "suggestions": [{"id": it["id"], "name": it["name"]} for it in items[:5]], } except Exception as e: logger.error(f"搜索客户失败 [{name}]: {e}") return None # ─── AI 解析(带枚举约束)──────────────────────────────────────────────────── def build_system_prompt() -> str: """根据缓存的字典动态构建 prompt,让 AI 严格选枚举""" def opts(group): vals = dict_cache.get(group) return "/".join(vals) if vals else "(待加载)" # 流程选项(带描述) process_lines = [] for p in dict_cache.get_processes(): desc = f"({p['description']})" if p.get("description") else "" process_lines.append(f" - {p['name']}{desc}") process_text = "\n".join(process_lines) if process_lines else "(待加载)" return f"""你是一个印染厂开版订单信息提取助手。 用户会发给你一条企业微信群聊消息,消息内容是跟单员发给设计师的工单指令。 你需要从消息中提取以下字段,返回 JSON 格式。 【字段说明(**必须严格使用以下枚举值**,不在列表中的返回 null)】 - customer_name: 客户名称(自由文本,消息开头通常是客户名,如"華鑫"、"林庆福"、"糖糖") - plate_type: 起版情况,**只能选**:{opts('起版情况')} 映射规则:图片起版/样衣起版/图片开版/样衣开版/找图开发/起版 → 首版 修改/加色/调色/调整/改版 → 修改单 套大货/套纸样/套样/重新套 → 复版/开版 - urgency_level: 紧急程度,**只能选**:{opts('紧急程度')} 映射规则:消息有"加急"→ 加急;有"特急"→ 特急;"加急已下单"等 → 紧急已下单;否则 → 正常 - production_method: 做货方式,**只能选**:{opts('做货方式')} - plate_method: 开版方式,**只能选**:{opts('开版方式')} 映射规则:消息有"图片起版/图片开版"→ 图片开版 消息有"样衣起版/样板起版/样衣开版"→ 样衣开版 消息有"文件开版"→ 文件开版 其他原始词如"开发"、"开板"→ 开发 - area: 客户区域,**只能选**:{opts('地区')}(消息里很少提及,多数返回 null) - fabric: 面料名称(自由文本,完整保留克重和布料类型,如"140g四面弹"、"120克本白四面弹"、"180克牛奶丝"、"泡泡皱") - width: 面料幅宽,**只能选**:{opts('幅宽')}(如"幅宽151"匹配"1.51","120"匹配"120",找不到则 null) - fabric_source: 面料来源,**只能选**:{opts('布源')} - drawing_rating: 画图评级,**只能选**:{opts('画图评级')}(消息里几乎不会有,多返 null) - color_matching_rating: 调色评级,**只能选**:{opts('调色评级')}(多返 null) - sample_rating: 套样评级,**只能选**:{opts('套样评级')}(多返 null) - difficulty_rating: 难度评级,**只能选**:{opts('难度评级')}(多返 null) - process_name: 关联流程,**只能选**以下名称之一(直接返回流程名字符串,不要带括号注释): {process_text} 选择规则:根据 plate_type 和操作内容综合判断 - 套大货/套纸样 → "开版(套米样)" 或 "开版(套大货)" - 加色/调色 → "开版(配色)" 或 "开版(绘图调色)" - P图/抠图 → "开版(P图)" - 描图/找图开发 → "开版(描图开版)" 或 "开版(绘图调色套样)" - 不确定时返回 null(让用户手动选) - plate_notes: 打版备注(自由文本,把消息中的具体要求、注意事项原文保留:克重、颜色要求、特殊工艺等) - style_name: 款号名称(如"NSY1090"、"G67单600-20款"、"6576改"等款号,没有则 null) - original_design_code: 原订单ID(如果消息提到要"套大货/套纸样/修改"等,且消息中有 5-7 位的纯数字编号,那就是原订单号;否则 null) - merchandiser_name: 跟单员(直接返回 sender 字段的原值) 【重要规则】 1. 枚举字段(plate_type、urgency_level、production_method、plate_method、area、width、fabric_source、各评级、process_name)**必须**从给定选项里选,不能自创;判断不出就返回 null 2. 自由文本字段(customer_name、fabric、plate_notes、style_name、original_design_code)保持原文 3. 一律返回 JSON 对象,不要任何其它文字""" def call_doubao(user_text: str) -> dict: """调用豆包 /v3/responses,返回解析后的 dict""" payload = { "model": AI_MODEL, "input": [ {"role": "system", "content": [{"type": "input_text", "text": build_system_prompt()}]}, {"role": "user", "content": [{"type": "input_text", "text": user_text}]}, ], "temperature": 0.1, "max_output_tokens": 4096, } r = requests.post(DOUBAO_URL, headers=DOUBAO_HEADERS, json=payload, timeout=60) r.raise_for_status() data = r.json() raw_text = "" for item in data.get("output", []): if item.get("type") == "message": for c in item.get("content", []): if c.get("type") == "output_text": raw_text = c.get("text", "").strip() break break if not raw_text: raise ValueError("豆包返回内容为空") start = raw_text.find("{") end = raw_text.rfind("}") + 1 if start == -1 or end == 0: raise ValueError(f"响应中未找到 JSON:{raw_text}") return json.loads(raw_text[start:end]) # ─── 路由 ───────────────────────────────────────────────────────────────────── @app.route("/health", methods=["GET"]) def health(): return jsonify({ "status": "ok", "model": AI_MODEL, "dict_cache_loaded": bool(dict_cache.cache), "dict_groups": list(dict_cache.cache.keys()), "erp_logged_in": bool(erp_auth.access_token), }) @app.route("/api/v1/dict-cache/", methods=["GET"]) def get_dict_cache(): """查看当前缓存(调试用)""" return jsonify(dict_cache.all()) @app.route("/api/v1/dict-cache/refresh/", methods=["POST"]) def refresh_dict_cache(): """手动刷新字典缓存""" try: dict_cache.load() return jsonify({"status": "ok", "groups": list(dict_cache.cache.keys())}) except Exception as e: return jsonify({"error": str(e)}), 500 # ─── 企微消息代理 ───────────────────────────────────────────────────────────── @app.route("/api/v1/wechat/messages/", methods=["GET"]) def proxy_wechat_messages(): limit = request.args.get("limit", "30") offset = request.args.get("offset", "0") room_id = request.args.get("room_id", WORK_ORDER_ROOM) with_image = request.args.get("with_image", "false") try: r = requests.get( f"{WECHAT_API_BASE}/messages", headers={"Authorization": WECHAT_AUTH}, params={"room_id": room_id, "limit": limit, "offset": offset, "with_image": with_image}, timeout=60, ) r.raise_for_status() return jsonify(r.json()) except requests.Timeout: return jsonify({"error": "企微接口超时"}), 504 except Exception as e: logger.error(f"代理请求失败: {e}") return jsonify({"error": str(e)}), 500 @app.route("/api/v1/wechat/messages//with-image", methods=["GET"]) def proxy_get_message_with_image(msg_id): """ 代理 GET /messages/:msg_id?with_image=true 企微服务直接支持按 msg_id 查单条消息,速度极快(< 0.1s) """ try: r = requests.get( f"{WECHAT_API_BASE}/messages/{msg_id}", headers={"Authorization": WECHAT_AUTH}, params={"with_image": "true"}, timeout=30, ) r.raise_for_status() return jsonify(r.json()) except requests.Timeout: return jsonify({"error": "企微接口超时"}), 504 except requests.HTTPError as e: return jsonify({"error": f"企微接口错误 {e.response.status_code}"}), e.response.status_code except Exception as e: logger.error(f"获取消息图片失败 msg_id={msg_id}: {e}") return jsonify({"error": str(e)}), 500 @app.route("/api/v1/wechat/messages//tags", methods=["PUT"]) def proxy_update_tags(msg_id): try: body = request.get_data() r = requests.put( f"{WECHAT_API_BASE}/messages/{msg_id}/tags", headers={"Authorization": WECHAT_AUTH, "Content-Type": "application/json"}, data=body, timeout=10, ) return jsonify(r.json()), r.status_code except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/v1/wechat/messages//parse-record/", methods=["POST"]) def record_parse(msg_id): try: body = request.get_json(silent=True) or {} old_tags = body.get("old_tags") or {} new_count = int(old_tags.get("parse_count", 0)) + 1 new_tags = { **old_tags, "parse_count": new_count, "last_parsed_at": datetime.utcnow().isoformat() + "Z", "last_parsed_by": body.get("user", "anonymous"), } r = requests.put( f"{WECHAT_API_BASE}/messages/{msg_id}/tags", headers={"Authorization": WECHAT_AUTH, "Content-Type": "application/json"}, json=new_tags, timeout=10, ) r.raise_for_status() return jsonify(r.json()) except Exception as e: logger.error(f"记录解析失败 msg_id={msg_id}: {e}") return jsonify({"error": str(e)}), 500 # ─── 主接口:解析开版订单 ──────────────────────────────────────────────────── @app.route("/api/v1/ai/parse-plate-order/", methods=["POST"]) def parse_plate_order(): """ 解析企业微信消息 → 开版订单字段 自动匹配/新增布料、搜索客户 请求体: { "text": "消息文字", "sender": "发送者", "has_images": true, "image_count": 2 } """ data = request.get_json(silent=True) or {} text = data.get("text", "").strip() sender = data.get("sender", "") has_images = data.get("has_images", False) image_count = data.get("image_count", 0) if not text and not has_images: return jsonify({"error": "消息内容为空"}), 400 user_content = f"发送者:{sender}\n" if has_images: user_content += f"(消息含 {image_count} 张图片)\n" user_content += f"消息内容:{text or '(纯图片消息,无文字)'}" logger.info(f"解析请求 | sender={sender} | text={text[:80]}") try: result = call_doubao(user_content) except Exception as e: logger.error(f"AI 解析失败: {e}") return jsonify({"error": f"AI 解析失败: {e}"}), 500 # 清理空值 cleaned = {k: v for k, v in result.items() if v not in (None, "", "null")} # 跟单员兜底 if "merchandiser_name" not in cleaned and sender: cleaned["merchandiser_name"] = sender # 校验枚举字段(不在缓存里就剔除) enum_fields = { "plate_type": "起版情况", "urgency_level": "紧急程度", "production_method": "做货方式", "plate_method": "开版方式", "area": "地区", "width": "幅宽", "fabric_source": "布源", "drawing_rating": "画图评级", "color_matching_rating": "调色评级", "sample_rating": "套样评级", "difficulty_rating": "难度评级", } for field, group in enum_fields.items(): if field in cleaned: valid = dict_cache.get(group) if valid and cleaned[field] not in valid: logger.warning(f"AI 返回 {field}={cleaned[field]} 不在枚举中,剔除") cleaned.pop(field, None) # 处理布料:搜索 → 不存在则新增 if cleaned.get("fabric"): match = find_or_create_fabric(cleaned["fabric"]) if match: cleaned["fabric"] = match["name"] # 用规范化的名称 cleaned["fabric_id"] = match["id"] cleaned["fabric_created"] = match.get("created", False) # 处理客户:精确匹配优先;找不到时附带模糊建议供前端选择 if cleaned.get("customer_name"): match = find_customer(cleaned["customer_name"]) if match and match.get("id"): cleaned["customer_id"] = match["id"] cleaned["customer_name"] = match["name"] if match.get("area"): cleaned["customer_area"] = match["area"] if match.get("visible_employees"): cleaned["customer_visible_employees"] = match["visible_employees"] else: cleaned["customer_not_found"] = True if match and match.get("suggestions"): cleaned["customer_suggestions"] = match["suggestions"] # 处理关联流程:根据 process_name 查找 ID if cleaned.get("process_name"): pid = dict_cache.find_process_id(cleaned["process_name"]) if pid: cleaned["process_id"] = pid else: logger.warning(f"未找到流程:{cleaned['process_name']}") cleaned.pop("process_name", None) logger.info(f"解析结果:{cleaned}") return jsonify(cleaned) # ─── 入口 ───────────────────────────────────────────────────────────────────── if __name__ == "__main__": port = int(os.getenv("PORT", 5050)) debug = os.getenv("DEBUG", "false").lower() == "true" # 后台线程初始化(不阻塞 Flask 启动) threading.Thread(target=background_init, daemon=True).start() logger.info(f"启动 plate-ai-service | port={port} | model={AI_MODEL}") app.run(host="0.0.0.0", port=port, debug=debug, use_reloader=False)