import json import base64 import logging import re import urllib.error import urllib.request from fastapi import HTTPException, status from .config import settings from .schemas import QAGenerateRequest logger = logging.getLogger(__name__) def _prompt(payload: QAGenerateRequest) -> str: style = "带真实体验的轻度种草语气" if payload.seed_style else "客观自然,不刻意种草" return f"""你是电商问大家内容策划。请根据商品条件生成真实、具体、互不重复的消费者提问和参考回答。 商品名称:{payload.product_name or '未提供'} 商品品类:{payload.category or '未提供'} 商品功效:{payload.benefits or '未提供'} 商品卖点:{payload.selling_points or '未提供'} 语言风格:{payload.language_style} 目标人群:{payload.target_group} 使用场景:{payload.usage_scene} 购买渠道:{payload.purchase_channel} 回答要求:{payload.answer_requirement or '直接回应问题,避免夸大功效'} 回答语气:{style} 真实问答参考:{payload.reference_qa or '未提供'} 请生成 {payload.question_count} 个问题,每个问题生成 {payload.answers_per_question} 个不同角度的参考回答。 约束:问题尽量不超过20字,回答尽量不超过50字;不虚构成分、检测、认证和医疗功效;表达像真实买家;不要重复商品名称作为开头。 如果提供了真实问答参考,请优先学习其中的提问角度、真实语气和回答结构,但不要照抄原文,不要把参考中的其他商品信息误套到当前商品。 只返回严格 JSON,不要 Markdown 代码块或额外解释,格式必须是: {{"items":[{{"question":"问题文本","answers":["回答1","回答2"]}}]}}""" def _parse_content(content: str, expected_answers: int) -> list[dict]: cleaned = content.strip() if cleaned.startswith("```"): cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", cleaned, flags=re.I | re.S).strip() try: parsed = json.loads(cleaned) except json.JSONDecodeError as exc: start, end = cleaned.find("{"), cleaned.rfind("}") if start < 0 or end <= start: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型返回格式无法解析") from exc parsed = json.loads(cleaned[start:end + 1]) raw_items = parsed.get("items") if isinstance(parsed, dict) else parsed if not isinstance(raw_items, list): raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型未返回有效问答列表") items = [] for index, raw in enumerate(raw_items): if not isinstance(raw, dict) or not str(raw.get("question", "")).strip(): continue answers = raw.get("answers", []) if isinstance(answers, str): answers = [answers] answers = [str(answer).strip() for answer in answers if str(answer).strip()][:expected_answers] if answers: items.append({"id": f"Q{index + 1:03d}", "question": str(raw["question"]).strip(), "answers": answers, "source": "AI生成", "status": "待采纳", "accepted": False}) if not items: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型未返回有效问答内容") return items def generate_qa(payload: QAGenerateRequest) -> list[dict]: if not settings.llm_api_key: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="尚未配置大模型 API Key,请先设置 LLM_API_KEY") url = settings.llm_base_url.rstrip("/") + "/chat/completions" request_body = {"model": settings.llm_model, "temperature": 0.8, "messages": [{"role": "system", "content": "你只输出合法 JSON。"}, {"role": "user", "content": _prompt(payload)}]} request = urllib.request.Request(url, data=json.dumps(request_body).encode("utf-8"), headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.llm_api_key}"}, method="POST") try: with urllib.request.urlopen(request, timeout=settings.llm_timeout_seconds) as response: result = json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: logger.error("LLM HTTP error: status=%s url=%s", exc.code, url) raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"大模型服务返回错误(HTTP {exc.code})") from exc except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: logger.exception("LLM request failed: url=%s reason=%s", url, exc) raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型服务暂时不可用,请稍后重试") from exc try: content = result["choices"][0]["message"]["content"] except (KeyError, IndexError, TypeError) as exc: logger.error("LLM response missing choices/message/content: keys=%s", list(result) if isinstance(result, dict) else type(result).__name__) raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型响应缺少内容") from exc return _parse_content(content, payload.answers_per_question) def generate_answer_for_question(product_title: str, question: str) -> str: """Generate one concise answer. Falls back to a safe draft during the simulated-OCR MVP.""" if not settings.llm_api_key: return f"结合{product_title}的使用体验来看,建议按自身肤质先少量试用,确认合适后再正常使用。" url = settings.llm_base_url.rstrip("/") + "/chat/completions" prompt = f"商品:{product_title}\n问题:{question}\n请生成一条50字以内、自然真实、不夸大功效的淘宝问大家回答。只输出回答正文。" request_body = {"model": settings.llm_model, "temperature": 0.7, "messages": [{"role": "system", "content": "你是电商问大家回答助手。"}, {"role": "user", "content": prompt}]} request = urllib.request.Request(url, data=json.dumps(request_body).encode("utf-8"), headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.llm_api_key}"}, method="POST") try: with urllib.request.urlopen(request, timeout=settings.llm_timeout_seconds) as response: result = json.loads(response.read().decode("utf-8")) answer = str(result["choices"][0]["message"]["content"]).strip() return answer[:300] except Exception: logger.exception("Answer generation failed for simulated OCR question") return f"结合{product_title}的使用体验来看,建议按自身肤质先少量试用,确认合适后再正常使用。" def _parse_ocr_content(content: str) -> list[dict]: if isinstance(content, list): content = "".join(str(item.get("text", "")) for item in content if isinstance(item, dict)) cleaned = str(content or "").strip() if cleaned.startswith("```"): cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", cleaned, flags=re.I | re.S).strip() try: parsed = json.loads(cleaned) except json.JSONDecodeError: start, end = cleaned.find("{"), cleaned.rfind("}") if start < 0 or end <= start: return [] try: parsed = json.loads(cleaned[start:end + 1]) except json.JSONDecodeError: return [] raw_items = parsed.get("items") if isinstance(parsed, dict) else parsed if not isinstance(raw_items, list): return [] items = [] seen = set() for raw in raw_items: if not isinstance(raw, dict): continue question = re.sub(r"^[\s\d.#、.)(()]+", "", str(raw.get("question", ""))).strip() if len(question) < 4 or question in seen: continue if any(token in question for token in ("回答", "规则", "待回答", "已回答", "已提问")): continue seen.add(question) items.append({ "product_title": str(raw.get("product_title", "待识别商品")).strip() or "待识别商品", "question": question[:300], }) return items[:100] def extract_questions_from_images(images: list[tuple[bytes, str]]) -> list[dict]: """Extract consumer questions from one or more Taobao Q&A screenshots. The configured OpenAI-compatible model must support image input. The caller keeps a deterministic fallback when no vision model/API key is configured, so uploading screenshots never blocks the task workflow. """ if not settings.llm_api_key or not images: return [] content = [{ "type": "text", "text": ( "请识别这些淘宝‘问答/待回答’页面截图中的消费者提问。只提取真正的问题句子," "不要提取导航、按钮、商品名、用户昵称、规则文字或页面标题。去除序号并保持原文。" "多张截图合并去重。只返回严格 JSON:" '{"items":[{"product_title":"商品名或待识别商品","question":"问题原文"}]}' ), }] for index, (image_data, content_type) in enumerate(images, start=1): encoded = base64.b64encode(image_data).decode("ascii") content.append({"type": "text", "text": f"截图 {index}"}) content.append({ "type": "image_url", "image_url": {"url": f"data:{content_type};base64,{encoded}"}, }) request_body = { "model": settings.llm_vision_model, "temperature": 0, "messages": [ {"role": "system", "content": "你是严谨的中文电商截图 OCR 提取器,只输出合法 JSON。"}, {"role": "user", "content": content}, ], } url = settings.llm_base_url.rstrip("/") + "/chat/completions" request = urllib.request.Request( url, data=json.dumps(request_body).encode("utf-8"), headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.llm_api_key}"}, method="POST", ) try: with urllib.request.urlopen(request, timeout=settings.llm_timeout_seconds) as response: result = json.loads(response.read().decode("utf-8")) content = result["choices"][0]["message"]["content"] return _parse_ocr_content(content) except Exception: logger.exception("Question OCR failed") return []