194 lines
6.9 KiB
Python
194 lines
6.9 KiB
Python
"""
|
||
通义千问文生图(DashScope 多模态生成 API)。
|
||
默认模型:qwen-image-2.0-pro(与阿里云 Model Studio 文档一致)。
|
||
文档:https://www.alibabacloud.com/help/en/model-studio/qwen-image-api
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
from typing import Any, Optional
|
||
|
||
import requests
|
||
|
||
import config
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 北京地域同步接口(与 QWEN_BASE_URL 常用配置同账号)
|
||
DEFAULT_MULTIMODAL_BASE = 'https://dashscope.aliyuncs.com/api/v1'
|
||
MULTIMODAL_GENERATION_PATH = '/services/aigc/multimodal-generation/generation'
|
||
|
||
_NEG_DEFAULT = (
|
||
'低分辨率, 模糊, 畸形手指, 过度饱和, 蜡像感, 杂乱构图, 扭曲文字, '
|
||
'公司商标, 企业LOGO, 投标人名称, 水印, 证件照'
|
||
)
|
||
|
||
_PLACEHOLDER_KEY_RE = re.compile(r'^sk-your|^sk-xxxx', re.I)
|
||
|
||
|
||
def _effective_multimodal_base() -> str:
|
||
raw = (getattr(config, 'QWEN_MULTIMODAL_BASE', '') or '').strip().rstrip('/')
|
||
if raw:
|
||
return raw
|
||
return DEFAULT_MULTIMODAL_BASE
|
||
|
||
|
||
def _generation_url() -> str:
|
||
return f'{_effective_multimodal_base()}{MULTIMODAL_GENERATION_PATH}'
|
||
|
||
|
||
def _qwen_api_key() -> str:
|
||
key = (getattr(config, 'QWEN_API_KEY', '') or '').strip()
|
||
if not key or _PLACEHOLDER_KEY_RE.match(key):
|
||
return ''
|
||
return key
|
||
|
||
|
||
def _extract_image_url(payload: dict[str, Any]) -> Optional[str]:
|
||
try:
|
||
out = payload.get('output') or {}
|
||
choices = out.get('choices') or []
|
||
if not choices:
|
||
return None
|
||
msg = (choices[0] or {}).get('message') or {}
|
||
content = msg.get('content')
|
||
if isinstance(content, list):
|
||
for part in content:
|
||
if isinstance(part, dict):
|
||
url = part.get('image')
|
||
if isinstance(url, str) and url.startswith('http'):
|
||
return url
|
||
if isinstance(content, dict):
|
||
url = content.get('image')
|
||
if isinstance(url, str) and url.startswith('http'):
|
||
return url
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _truncate_prompt(text: str, max_chars: int = 780) -> str:
|
||
t = (text or '').strip().replace('\r\n', '\n').replace('\r', '\n')
|
||
if len(t) <= max_chars:
|
||
return t
|
||
return t[: max_chars - 3] + '...'
|
||
|
||
|
||
def build_attachment_figure_prompt(title: str, body: str) -> str:
|
||
"""投标附件用图:专业工程示意风格,避免标识与暗标敏感信息。"""
|
||
core = _truncate_prompt(f'{title}\n{body}', 720)
|
||
head = (
|
||
'建筑施工投标技术标附件插图:清晰线稿或浅色块示意,横版;可含简短中文标注。'
|
||
'禁止公司名、商标、LOGO、投标人信息。'
|
||
)
|
||
sched_keys = ('进度', '网络图', '横道', '开工', '完工', '工期', '里程碑')
|
||
site_keys = ('总平面', '平面布置', '布置图')
|
||
t = f'{title}\n{body}'
|
||
extra = ''
|
||
if any(k in t for k in sched_keys):
|
||
extra += (
|
||
'【进度图】仅表现文字中已给出的开工完工逻辑、清单节点与工期关系;'
|
||
'不得添加正文中未出现的日历日、工序名称或里程碑;无具体日期时不写具体年月日数字。'
|
||
)
|
||
if any(k in t for k in site_keys):
|
||
extra += (
|
||
'【总平面图】临时设施分区与面积须与文字中的临时用地表一致;'
|
||
'施工机械仅绘制文字明确列出的机型,禁止无关挖掘机、塔吊等;无机械列表时只画分区块不写机械剪影。'
|
||
)
|
||
extra = _truncate_prompt(extra, 200) if extra else ''
|
||
parts = [head]
|
||
if extra:
|
||
parts.append(extra)
|
||
parts.append('内容要点:\n' + core)
|
||
return '\n'.join(parts)
|
||
|
||
|
||
def generate_qwen_image_png(
|
||
prompt: str,
|
||
*,
|
||
size: Optional[str] = None,
|
||
timeout: Optional[int] = None,
|
||
) -> tuple[Optional[bytes], Optional[str]]:
|
||
"""
|
||
调用 qwen-image-2.0-pro 同步文生图,返回 PNG 字节。
|
||
失败时 (None, error_message)。
|
||
"""
|
||
api_key = _qwen_api_key()
|
||
if not api_key:
|
||
return None, '未配置有效的通义千问 API Key(文生图与文本模型共用 DashScope Key)'
|
||
|
||
model = getattr(config, 'QWEN_IMAGE_MODEL', 'qwen-image-2.0-pro') or 'qwen-image-2.0-pro'
|
||
sz = (size or getattr(config, 'QWEN_IMAGE_SIZE', '1536*1024') or '1536*1024').strip()
|
||
prompt_clean = _truncate_prompt(prompt, 800)
|
||
url = _generation_url()
|
||
|
||
body = {
|
||
'model': model,
|
||
'input': {
|
||
'messages': [
|
||
{
|
||
'role': 'user',
|
||
'content': [{'text': prompt_clean}],
|
||
}
|
||
]
|
||
},
|
||
'parameters': {
|
||
'negative_prompt': getattr(config, 'QWEN_IMAGE_NEGATIVE_PROMPT', _NEG_DEFAULT),
|
||
'prompt_extend': bool(getattr(config, 'QWEN_IMAGE_PROMPT_EXTEND', True)),
|
||
'watermark': bool(getattr(config, 'QWEN_IMAGE_WATERMARK', False)),
|
||
'size': sz,
|
||
},
|
||
}
|
||
|
||
to = timeout if timeout is not None else max(120, int(getattr(config, 'REQUEST_TIMEOUT', 180) or 180))
|
||
try:
|
||
resp = requests.post(
|
||
url,
|
||
headers={
|
||
'Authorization': f'Bearer {api_key}',
|
||
'Content-Type': 'application/json',
|
||
},
|
||
data=json.dumps(body, ensure_ascii=False).encode('utf-8'),
|
||
timeout=to,
|
||
)
|
||
except requests.RequestException as e:
|
||
logger.warning('qwen-image 请求异常: %s', e)
|
||
return None, str(e)
|
||
|
||
try:
|
||
data = resp.json()
|
||
except Exception:
|
||
return None, f'文生图接口返回非 JSON,HTTP {resp.status_code}'
|
||
|
||
if resp.status_code != 200:
|
||
msg = data.get('message') or data.get('code') or resp.text[:500]
|
||
logger.warning('qwen-image HTTP %s: %s', resp.status_code, msg)
|
||
return None, f'文生图失败 HTTP {resp.status_code}: {msg}'
|
||
|
||
code = data.get('code')
|
||
if code and str(code).upper() not in ('OK', 'SUCCESS', '200', ''):
|
||
msg = data.get('message') or str(code)
|
||
logger.warning('qwen-image 业务错误: %s', msg)
|
||
return None, f'文生图接口错误: {msg}'
|
||
|
||
img_url = _extract_image_url(data)
|
||
if not img_url:
|
||
logger.warning('qwen-image 响应无图片 URL: %s', str(data)[:800])
|
||
return None, '文生图响应中未找到图片 URL'
|
||
|
||
try:
|
||
ir = requests.get(img_url, timeout=min(to, 120))
|
||
ir.raise_for_status()
|
||
return ir.content, None
|
||
except requests.RequestException as e:
|
||
logger.warning('下载生成图片失败: %s', e)
|
||
return None, f'下载图片失败: {e}'
|
||
|
||
|
||
def generate_attachment_figure_png(title: str, content: str) -> tuple[Optional[bytes], Optional[str]]:
|
||
"""附件 [FIGURE] 专用:拼装提示词并生成 PNG。"""
|
||
prompt = build_attachment_figure_prompt(title or '附图', content or '')
|
||
return generate_qwen_image_png(prompt)
|