Files
beige/backend/app/services/ai_engine/gemini_factory.py
yangqianqian df1856d793 上线版: 产品表单统一+form嵌套修复+用户管理+部署+三套叙事
- 产品编辑入口统一走 ProductFormFull(卖点/风格/人群/品牌词全字段);
  修复开任务页 <form> 套 <form> 致"编辑产品"报错、改不了、跳回首个产品
- dashboard 入口卡片对齐实际路由: 系统管理(/config) 与 工作配置(/settings) 分开;
  settings ?tab=products 直达改用挂载后读 URL, 消除 hydration mismatch
- 新增用户管理(users API/admin service/改密页) + alembic 022/023/024
- 上线部署: Dockerfile / docker-compose.prod+https / nginx https / .env.example
- A8 三套正交叙事(痛点/场景/成分背书) + beige 调色去AI化 + 飞轮 text_import 高权重信号

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:08:13 +08:00

401 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
gemini_factory.py — 每任务构建独立的 AI client 实例
解决全局单例问题(扒 banana gemini_service.py __init__改造为每任务局部实例
铁律基石B
- 调用方只传 task_id不传 key
- 本模块在 worker 内部查库 → Fernet 解密 → 构建 client
- 解密结果只活在局部变量,函数返回后即销毁
- 绝不打印 / 记录 / 传递明文 key
"""
from __future__ import annotations
import asyncio
import logging
import os
from dataclasses import dataclass, field
from typing import Any
import httpx
logger = logging.getLogger(__name__)
@dataclass
class AIClients:
"""
一个任务专用的 AI client 集合。
worker 在任务开始时构建,任务结束后释放(局部变量,不存 Redis/DB
"""
# httpx AsyncClient 懒加载 + 按事件循环缓存Celery 每任务多次 asyncio.run
# 持久 client 会绑死到首个已关闭的 loop → "Event loop is closed"。
# 故只存 token/base按当前运行 loop 缓存 clientloop 变了就重建。
_gpt_token: str | None = field(default=None, repr=False)
_gpt_base: str | None = field(default=None, repr=False)
_gpt_client: httpx.AsyncClient | None = field(default=None, repr=False)
_gpt_client_loop_id: int | None = field(default=None, repr=False)
# 备用 OpenAI 兼容中转站codeproxyapiports 503 时真正切过去(独立 base+key
_alt_token: str | None = field(default=None, repr=False)
_alt_base: str | None = field(default=None, repr=False)
# 多 base/token 的 client 池key=(base,token的id),按 loop 失效重建
_client_pool: dict = field(default_factory=dict, repr=False)
_pool_loop_id: int | None = field(default=None, repr=False)
_gemini_key: str | None = field(default=None, repr=False) # 局部变量不打印
_model_image: str = "gpt-image-2"
_model_text: str = "claude-opus-4-8" # 最强档(倩倩姐红线):Claude系一律4.8,绝不降级
def has_alt_channel(self) -> bool:
"""是否真正配置了 codeproxy 备用通道(有独立 key 才算)。
🔴 没配 codeproxy key 时(用户未录 + env 未设),编排层据此跳过 codeproxy
避免每张图都白白尝试 codeproxy → 撞"绝不静默降级"红线 → 空转重试 3 次。
录了 key 就自动启用真双通道互备,逻辑不变。
"""
return bool(self._alt_token)
def _client(self) -> httpx.AsyncClient:
"""主通道(apiports) client按当前事件循环缓存"""
return self._client_for(self._gpt_base, self._gpt_token)
def _client_for(self, base: str | None, token: str | None) -> httpx.AsyncClient:
"""按 (base, token) 返回可用 clientloop 变化则整池重建(避免跨 loop 复用)"""
if not token:
raise RuntimeError("GPT client 未初始化(缺 token")
loop_id = id(asyncio.get_running_loop())
if self._pool_loop_id != loop_id:
self._client_pool = {}
self._pool_loop_id = loop_id
ck = (base or "", token)
if ck not in self._client_pool:
self._client_pool[ck] = httpx.AsyncClient(
headers={"Authorization": f"Bearer {token}"},
base_url=base or None,
timeout=120.0,
)
return self._client_pool[ck]
# ── ImageClient 协议实现(供 image_gen.py 使用)────────
def _gpt_target(self, provider: str | None) -> tuple[str, str | None, httpx.AsyncClient]:
"""按 provider 选 (base, token, client)codeproxy 走备用站独立 base+key。
🔴 绝不静默降级(红线):显式点名 codeproxy 却没有 codeproxy 独立 key 时,
必须抛错让该通道明确失败(由上层 generate_one_image 记录并切下一通道),
绝不静默用 apiports 主 key 冒充 codeproxy——那等于"假双通道"且无声退化,
既骗了主备设计也违反红线。用户要真备份就得录入第二把 key。
"""
if provider == "codeproxy":
if not self._alt_token:
raise RuntimeError(
"codeproxy 通道未配置独立 key,拒绝用 apiports 主 key 冒充"
"(红线:绝不静默降级)。请为该用户录入 codeproxy key 以启用真双通道互备。"
)
base = (self._alt_base or os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
return base, self._alt_token, self._client_for(base, self._alt_token)
base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
return base, self._gpt_token, self._client_for(self._gpt_base, self._gpt_token)
async def gpt_edits(self, prompt: str, reference_images: list[bytes], size: str, provider: str | None = None) -> bytes:
"""GPT edits endpoint带产品参考图禁纯文生图
codeproxy 走 OpenAI Images `/images/edits`apiports 实测图片编辑注册在
`/chat/completions` 多模态通道,不能复用 `/images/edits`。
"""
if provider == "apiports":
return await self._apiports_chat_image_edit(prompt, reference_images)
import io
files: list[tuple] = [("prompt", (None, prompt))]
for i, img in enumerate(reference_images):
files.append(("image[]", (f"ref_{i}.png", io.BytesIO(img), "image/png")))
files.append(("size", (None, size)))
files.append(("model", (None, self._model_image)))
base, _, client = self._gpt_target(provider)
resp = await client.post(f"{base}/images/edits", files=files, timeout=120.0)
resp.raise_for_status()
return _extract_image_bytes(resp.json())
async def _apiports_chat_image_edit(self, prompt: str, reference_images: list[bytes]) -> bytes:
"""apiports 图片编辑:走 /chat/completions 多模态,参考图用 data URL。"""
if not reference_images:
raise ValueError("apiports 图生图缺少参考图,拒绝纯文生图")
import base64
base, _, client = self._gpt_target("apiports")
content: list[dict] = [{"type": "text", "text": prompt}]
for img in reference_images:
b64 = base64.b64encode(img).decode()
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
})
payload = {
"model": self._model_image,
"messages": [{"role": "user", "content": content}],
}
resp = await client.post(f"{base}/chat/completions", json=payload, timeout=300.0)
resp.raise_for_status()
return _extract_chat_image_bytes(resp.json())
async def gpt_generate(self, prompt: str, size: str, provider: str | None = None) -> bytes:
"""GPT 纯文生图(仅 ALLOW_TEXT_ONLY_IMAGE=true 时用)"""
base, _, client = self._gpt_target(provider)
payload = {"model": self._model_image, "prompt": prompt, "n": 1, "size": size}
resp = await client.post(f"{base}/images/generations", json=payload, timeout=120.0)
resp.raise_for_status()
return _extract_image_bytes(resp.json())
async def gemini_generate(self, prompt: str, reference_images: list[bytes], model: str) -> bytes:
"""Gemini 生图(备用通道)"""
if not self._gemini_key:
raise RuntimeError("Gemini key 未初始化")
gemini_base = os.environ.get("GEMINI_API_URL", "https://generativelanguage.googleapis.com/v1beta")
# 安全红线key 走 header(x-goog-api-key)不进 URL query
# 否则 httpx 异常的 __str__ 含完整 URL会把明文 key 带进 WARNING 日志。
url = f"{gemini_base}/models/{model}:generateContent"
parts: list[dict] = [{"text": prompt}]
for img in reference_images:
import base64
parts.append({"inline_data": {"mime_type": "image/png", "data": base64.b64encode(img).decode()}})
payload = {"contents": [{"role": "user", "parts": parts}], "generationConfig": {"responseModalities": ["IMAGE", "TEXT"]}}
async with httpx.AsyncClient() as client:
resp = await client.post(url, json=payload,
headers={"x-goog-api-key": self._gemini_key}, timeout=120.0)
resp.raise_for_status()
return _extract_gemini_image(resp.json())
async def _chat_post_failover(self, payload: dict, timeout: float) -> dict:
"""
chat/completions 发送器,带 codeproxy 回落。
主通道(apiports, claude-opus-4-8)若 5xx/超时/连接错,自动切 codeproxy 重试一次。
⚠ codeproxy 账号池只支持 gpt 系(gpt-5.5),无 claude故回落时模型换成 gpt-5.5。
这是强档↔强档切换:红线"Claude系4.8/GPT系5.5"本就是两个平级最强档、互为兜底,
非降级(降级=落 sonnet/弱档)。codeproxy 回落档由 CODEPROXY_CHAT_MODEL 配置(默认 gpt-5.5)。
codeproxy 未配置(_alt_token 为空)时不回落,原样抛错。
"""
main_base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
try:
resp = await self._client().post(f"{main_base}/chat/completions", json=payload, timeout=timeout)
resp.raise_for_status()
return resp.json()
except (httpx.HTTPStatusError, httpx.TransportError, httpx.TimeoutException) as exc:
# 回落判定网络层错误、5xx(过载)、以及 402(欠费)/429(限流) 都该切通道——
# 这几种都是"本通道暂时不行、换通道有救"。401/403(鉴权)/400(参数) 回落也没用,直接抛。
status = getattr(getattr(exc, "response", None), "status_code", None)
retryable = status is None or status >= 500 or status in (402, 429)
if not (retryable and self._alt_token):
raise
alt_base = (self._alt_base or os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
alt_payload = dict(payload)
alt_payload["model"] = os.environ.get("CODEPROXY_CHAT_MODEL", "gpt-5.5")
logger.warning("apiports chat 失败(%s),回落 codeproxy 用 %s 重试(强档兜底,非降级)",
status or type(exc).__name__, alt_payload["model"])
client = self._client_for(alt_base, self._alt_token)
resp = await client.post(f"{alt_base}/chat/completions", json=alt_payload, timeout=timeout)
resp.raise_for_status()
return resp.json()
async def chat_complete(self, messages: list[dict], model: str | None = None, max_tokens: int = 4096, temperature: float = 0.75) -> str:
"""文字生成文案生成用。apiports 503 时自动回落 codeproxy。"""
payload = {"model": model or self._model_text, "messages": messages, "max_tokens": max_tokens, "temperature": temperature}
# 单批≤4条文案正常 40-55s 返回apiports 网关 ~60s 上限。客户端超时设 75s
# 略高于网关上限即可过长如180s会在 apiports 卡顿时干等,拖慢整体。
timeout = float(os.environ.get("TEXT_LLM_TIMEOUT", "75"))
data = await self._chat_post_failover(payload, timeout)
return data["choices"][0]["message"]["content"] or ""
async def gpt_vision_analyze(self, prompt: str, images: list[bytes], model: str | None = None) -> str:
"""
GPT/Claude vision 读产品图,返回 JSON 字符串。
messages content 混合 text + image_url(base64)OpenAI vision 格式。
model 默认最强档claude-opus-4-8绝不偷降级。
最多传 4 张图,避免超 token。
"""
import base64
content: list[dict] = [{"type": "text", "text": prompt}]
for img in images[:4]:
b64 = base64.b64encode(img).decode()
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
})
used_model = model or os.environ.get("MODEL_TEXT", "claude-opus-4-8")
messages = [{"role": "user", "content": content}]
payload = {
"model": used_model,
"messages": messages,
"max_tokens": 2048,
"temperature": 0.2,
}
# 评图也走 codeproxy 回落apiports 503 时切备用站,模型档不变(守红线)。
data = await self._chat_post_failover(payload, 90.0)
return data["choices"][0]["message"]["content"] or ""
# duck-type: text_variants._call_llm 用的属性
@property
def _model(self) -> str:
return self._model_text
async def aclose(self) -> None:
# client 可能绑在已关闭的 loopCelery 多次 asyncio.runaclose 也可能报
# "Event loop is closed",吞掉即可——进程级连接随 loop 关闭自然释放。
for c in list(self._client_pool.values()):
try:
await c.aclose()
except Exception:
pass
self._client_pool = {}
self._pool_loop_id = None
self._gpt_client = None
self._gpt_client_loop_id = None
def build_ai_clients(plain_key: str, gemini_key: str | None = None, alt_key: str | None = None) -> AIClients:
"""
用解密后的明文 key 构建 AIClients。
只在 Celery worker 函数体内调用plain_key 是局部变量。
httpx client 不在此预创建(避免绑死到调用方 loop首次 await 时按 loop 懒建。
调用完成后 caller 负责 await clients.aclose()。
alt_key用户录入的 codeproxy 备用站 key基石Bworker 内查库解密后传入)。
倩倩姐红线「全做成自己的不要埋进系统」——codeproxy key 优先用用户录入的,
未录入才回落 env CODEPROXY_KEY向后兼容避免没录时备用通道直接失效
base 地址(中转站 URL非秘密仍走 env 配置。
"""
gpt_base = (
os.environ.get("IMAGE_API_BASE") # 旧变量名
or os.environ.get("APIPORTS_BASE_URL") # .env 实际变量名
or ""
).rstrip("/")
# 备用站 codeproxy优先用户录入 key回落 envapiports 503 时切过去保生图成功
alt_base = (os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
alt_token = alt_key or os.environ.get("CODEPROXY_KEY") or None
return AIClients(
_gpt_token=plain_key,
_gpt_base=gpt_base or None,
_alt_base=alt_base or None,
_alt_token=alt_token,
_gemini_key=gemini_key,
_model_image=os.environ.get("IMAGE_MODEL") or os.environ.get("MODEL_IMAGE", "gpt-image-2"),
_model_text=os.environ.get("MODEL_TEXT", "claude-opus-4-8"),
)
# ── 图片响应解析工具 ─────────────────────────────────────
def _extract_image_bytes(resp_json: dict) -> bytes:
"""从 OpenAI images API 响应提取图片 bytesb64 或 url"""
import base64
data = resp_json.get("data", [{}])
if not data:
raise ValueError("图片 API 返回空 data")
item = data[0]
if "b64_json" in item:
return base64.b64decode(item["b64_json"])
if "url" in item:
resp = httpx.get(item["url"], timeout=30.0)
resp.raise_for_status()
return resp.content
raise ValueError(f"无法解析图片响应:{list(item.keys())}")
def _decode_b64_image(value: str) -> bytes:
"""解 data URL 或裸 base64 图片。"""
import base64
if value.startswith("data:"):
value = value.split(",", 1)[1]
return base64.b64decode(value)
def _bytes_from_image_item(item: dict) -> bytes | None:
"""兼容 chat 图片 item 里的常见 url/base64 字段。"""
if not isinstance(item, dict):
return None
for key in ("b64_json", "base64", "data"):
val = item.get(key)
if isinstance(val, str) and val:
return _decode_b64_image(val)
url_val = None
if isinstance(item.get("image_url"), dict):
url_val = item["image_url"].get("url")
elif isinstance(item.get("image_url"), str):
url_val = item.get("image_url")
elif isinstance(item.get("url"), str):
url_val = item.get("url")
if isinstance(url_val, str) and url_val:
if url_val.startswith("data:"):
return _decode_b64_image(url_val)
resp = httpx.get(url_val, timeout=30.0)
resp.raise_for_status()
return resp.content
return None
def _bytes_from_content_string(text: str) -> bytes | None:
"""从 chat 返回的正文字符串里抠图data URL / markdown 图链 / 裸图片URL。
很多中转站把 gpt-image 结果以 ![](url) 或 data URL 嵌在 content 文本里,
不走 images[]/parts 结构化字段,这里兜住这几种常见形态(前面带说明文字也能抠出)。
"""
import re
m = re.search(r"data:image/[\w.+-]+;base64,[A-Za-z0-9+/=]+", text)
if m:
return _decode_b64_image(m.group(0))
m = re.search(r"!\[[^\]]*\]\((https?://[^\s)]+)\)", text) # markdown 图链
if m:
resp = httpx.get(m.group(1), timeout=30.0)
resp.raise_for_status()
return resp.content
m = re.search( # 裸图片URL限图片后缀避免误抓非图链接
r"https?://[^\s)\]\"']+\.(?:png|jpe?g|webp|gif)(?:\?[^\s)\]\"']*)?",
text, re.IGNORECASE,
)
if m:
resp = httpx.get(m.group(0), timeout=30.0)
resp.raise_for_status()
return resp.content
return None
def _extract_chat_image_bytes(resp_json: dict) -> bytes:
"""从 /chat/completions 图片响应中提取图片 bytes。"""
choices = resp_json.get("choices") or []
if not choices:
raise ValueError("chat 图片响应缺少 choices")
message = choices[0].get("message") or {}
images = message.get("images")
if isinstance(images, list):
for item in images:
found = _bytes_from_image_item(item)
if found:
return found
content = message.get("content")
if isinstance(content, list):
for part in content:
found = _bytes_from_image_item(part)
if found:
return found
elif isinstance(content, str) and content:
found = _bytes_from_content_string(content)
if found:
return found
raise ValueError(
"无法解析 chat 图片响应:"
f"top={list(resp_json.keys())}, message={list(message.keys())}"
)
def _extract_gemini_image(resp_json: dict) -> bytes:
"""从 Gemini generateContent 响应提取图片 bytes"""
import base64
candidates = resp_json.get("candidates", [])
for cand in candidates:
parts = cand.get("content", {}).get("parts", [])
for part in parts:
if "inlineData" in part:
return base64.b64decode(part["inlineData"]["data"])
raise ValueError("Gemini 响应中未找到图片数据")