${label}
${escapeHtml(String(value))}
diff --git a/.env.example b/.env.example
index 360e066..46c7ecd 100644
--- a/.env.example
+++ b/.env.example
@@ -81,11 +81,11 @@ RATE_LIMIT_ENABLED=true
RATE_LIMIT_WINDOW_SECONDS=60
RATE_LIMIT_MAX_REQUESTS=600
RATE_LIMIT_KEY_PREFIX=jr:rate_limit
-OFFSITE_MAILBOX=15273589815@163.com
-OFFSITE_ALLOWED_SENDERS=["15008108550@163.com"]
+OFFSITE_MAILBOX=yuan80818843@163.com
+OFFSITE_ALLOWED_SENDERS=["15273589815@163.com"]
OFFSITE_RISK_RECEIVER_ID=
OFFSITE_SETTLEMENT_RECEIVER_ID=
-OFFSITE_MAIL_RETURN_RECEIVER=15008108550@163.com
+OFFSITE_MAIL_RETURN_RECEIVER=yuan80818843@163.com
OFFSITE_MAX_RETRY_COUNT=3
OFFSITE_IMAP_ENABLED=false
OFFSITE_IMAP_HOST=
@@ -127,3 +127,8 @@ PROMOTION_PDF_ENABLED=false
PROMOTION_PDF_CONVERTER_PATH=
PROMOTION_MAX_PHOTO_SIZE_BYTES=10485760
PROMOTION_MAX_PERFORMANCE_FILE_SIZE_BYTES=20971520
+PROMOTION_IMAGE_ENABLED=false
+PROMOTION_IMAGE_BASE_URL=https://dashscope.aliyuncs.com
+PROMOTION_IMAGE_MODEL=wan2.2-t2i-flash
+PROMOTION_IMAGE_TIMEOUT_SECONDS=90
+PROMOTION_IMAGE_POLL_INTERVAL_SECONDS=2
diff --git a/.gitignore b/.gitignore
index a2eb366..1a6dac7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,6 @@ data/
# AI 编码助手配置,与本项目无关(2026-09-13 清理误提交)
.agents/
skills-lock.json
+
+# 本机实际功能测试数据,不提交远程仓库
+测试/
diff --git a/app/api/controllers/offsite_fund.py b/app/api/controllers/offsite_fund.py
index fb9d1ae..43eb905 100644
--- a/app/api/controllers/offsite_fund.py
+++ b/app/api/controllers/offsite_fund.py
@@ -58,7 +58,7 @@ async def delete_mail(
context: RequestContext = Depends(build_request_context), # noqa: B008
session: AsyncSession = Depends(get_session), # noqa: B008
) -> dict[str, Any]:
- """软删除邮件:运营列表隐藏,但保留原始文件、识别结果和审计链路。"""
+ """物理删除邮件及其关联业务数据,保留删除审计。"""
return await OffsiteFundService(session).delete_mail(
mail_id, payload.operator_id, context
)
diff --git a/app/api/schemas/offsite_fund.py b/app/api/schemas/offsite_fund.py
index 2a027da..0e610f2 100644
--- a/app/api/schemas/offsite_fund.py
+++ b/app/api/schemas/offsite_fund.py
@@ -58,7 +58,7 @@ class OffsiteMailboxRecoveryRequest(BaseModel):
class OffsiteMailDeletionRequest(BaseModel):
- """邮件删除请求:只隐藏运营列表,不删除原始邮件和识别链路。"""
+ """邮件删除请求:物理删除邮件及其场外业务链路,审计记录除外。"""
model_config = ConfigDict(extra="forbid")
diff --git a/app/core/config.py b/app/core/config.py
index f80514f..bb47db4 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -66,11 +66,11 @@ class Settings(BaseSettings):
worker_lease_seconds: int = Field(default=60, gt=0)
worker_retry_limit: int = Field(default=3, ge=0)
worker_poll_seconds: float = Field(default=1, gt=0)
- offsite_mailbox: str = "15273589815@163.com"
- offsite_allowed_senders: tuple[str, ...] = ("15008108550@163.com",)
+ offsite_mailbox: str = "yuan80818843@163.com"
+ offsite_allowed_senders: tuple[str, ...] = ("15273589815@163.com",)
offsite_risk_receiver_id: str = ""
offsite_settlement_receiver_id: str = ""
- offsite_mail_return_receiver: str = "15008108550@163.com"
+ offsite_mail_return_receiver: str = "yuan80818843@163.com"
offsite_max_retry_count: int = Field(default=3, ge=0)
offsite_imap_enabled: bool = False
offsite_imap_host: str = ""
@@ -111,6 +111,11 @@ class Settings(BaseSettings):
promotion_pdf_converter_path: str = ""
promotion_max_photo_size_bytes: int = Field(default=10 * 1024 * 1024, gt=0)
promotion_max_performance_file_size_bytes: int = Field(default=20 * 1024 * 1024, gt=0)
+ promotion_image_enabled: bool = False
+ promotion_image_base_url: str = "https://dashscope.aliyuncs.com"
+ promotion_image_model: str = "wan2.2-t2i-flash"
+ promotion_image_timeout_seconds: float = Field(default=90, gt=0)
+ promotion_image_poll_interval_seconds: float = Field(default=2, gt=0)
risk_scan_schedule_enabled: bool = False
risk_scan_interval_minutes: int = Field(default=5, ge=1, le=1440)
diff --git a/app/core/contracts.py b/app/core/contracts.py
index 4db0e36..fa22808 100644
--- a/app/core/contracts.py
+++ b/app/core/contracts.py
@@ -138,6 +138,9 @@ class CoreResult(BaseModel):
model_config = ConfigDict(frozen=True)
text: str
+ # 业务 Agent 可返回结构化结果;默认为空,兼容只返回文本的其他 Agent。
+ data: dict[str, Any] = Field(default_factory=dict)
+ sql: str | None = None
intent: IntentResult | None = None
source_references: tuple[SourceReference, ...] = ()
tool_calls: tuple[ToolCallRecord, ...] = ()
diff --git a/app/core/promotion_material_contracts.py b/app/core/promotion_material_contracts.py
index e3cce45..6429b1b 100644
--- a/app/core/promotion_material_contracts.py
+++ b/app/core/promotion_material_contracts.py
@@ -5,8 +5,6 @@ from typing import Literal
AttachmentType = Literal[
"manager_photo",
"performance_data",
- "source_evidence",
- "template_file",
]
ComplianceScope = Literal["input", "generated_text", "ppt_text", "pdf_text"]
ComplianceSeverity = Literal["pass", "warning", "block"]
diff --git a/app/service/agent/bootstrap.py b/app/service/agent/bootstrap.py
index 98da2e6..9041f51 100644
--- a/app/service/agent/bootstrap.py
+++ b/app/service/agent/bootstrap.py
@@ -25,6 +25,7 @@ from app.service.agent.factory import AgentFactory
from app.service.agent.governance import PlatformGovernance
from app.service.agent.implementations.advisor import AdvisorAgent
from app.service.agent.implementations.customer_service import CustomerServiceAgent
+from app.service.agent.implementations.financial_nl2sql import FinancialNL2SQLAgent
from app.service.agent.implementations.fund_query_demo import FundQueryDemoAgent
from app.service.agent.implementations.platform_probe import (
PROBE_ALT_TOOL,
@@ -445,3 +446,7 @@ def register_business_agents(factory: AgentFactory) -> None:
AdvisorAgent.definition,
lambda _context: AdvisorAgent(AdvisorAgent.definition),
)
+ factory.register(
+ FinancialNL2SQLAgent.definition,
+ lambda _context: FinancialNL2SQLAgent(FinancialNL2SQLAgent.definition),
+ )
diff --git a/app/service/agent/implementations/financial_nl2sql.py b/app/service/agent/implementations/financial_nl2sql.py
new file mode 100644
index 0000000..f1f8d40
--- /dev/null
+++ b/app/service/agent/implementations/financial_nl2sql.py
@@ -0,0 +1,38 @@
+"""金融 NL2SQL 业务 Agent。"""
+
+from typing import Any, cast
+
+from app.core.contracts import AgentDefinition, AgentRequest, CoreResult, RequestContext
+from app.service.agent.base import BaseAgent
+
+TOOL_NAME = "query_financial_data"
+INTENTS = ("financial_query", "general")
+
+
+class FinancialNL2SQLAgent(BaseAgent):
+ """把通用金融问题交给公共金融只读查询工具处理。"""
+
+ definition = AgentDefinition(
+ agent_type="financial_nl2sql",
+ version="1.0.0",
+ allowed_roles=("advisor", "operator", "admin", "super_admin"),
+ allowed_portals=("api",),
+ allowed_tools=(TOOL_NAME,),
+ supported_intents=INTENTS,
+ # 查询工具自身带有确定性规划与澄清机制,不需要依赖模型意图分类端点。
+ requires_model_intent_classification=False,
+ )
+
+ async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
+ result = cast(dict[str, Any], await self.call_tool(
+ TOOL_NAME,
+ {"question": request.message, "dry_run": False, "limit": 50},
+ intent="financial_query",
+ context=context,
+ ))
+ data = result.get("data")
+ return CoreResult(
+ text=str(result.get("message", "金融查询未返回结果")),
+ data=data if isinstance(data, dict) else {},
+ sql=result.get("sql") if isinstance(result.get("sql"), str) else None,
+ )
diff --git a/app/service/agent_persistence_service.py b/app/service/agent_persistence_service.py
index 39debef..1f78072 100644
--- a/app/service/agent_persistence_service.py
+++ b/app/service/agent_persistence_service.py
@@ -79,6 +79,16 @@ class AgentPersistenceService:
)
else:
session_row.clarification_round = 0
+ stored_tool_calls: dict[str, Any] = {
+ "calls": [call.model_dump(mode="json")
+ for call in result.result.tool_calls],
+ "transfer_required": bool(result.result.transfer_required),
+ "transfer_reason": result.result.transfer_reason,
+ }
+ if result.result.data:
+ stored_tool_calls["data"] = result.result.data
+ if result.result.sql:
+ stored_tool_calls["sql"] = result.result.sql
message = ConversationMessage(
session_id=run.session_id, customer_id=run.user_id, portal="agent",
role="assistant", content=result.result.text,
@@ -97,12 +107,7 @@ class AgentPersistenceService:
# **既没落库也没出参** —— 前端只能靠"回答里是否含兜底话术开头"来猜要不要转人工
# (`docs/24` 自己把这称为权宜之计)。落库后读写两侧才有同一份真相。
# 读侧允许 `calls` 是裸列表(历史行),见 `RunQueryService.get`。
- tool_calls={
- "calls": [call.model_dump(mode="json")
- for call in result.result.tool_calls],
- "transfer_required": bool(result.result.transfer_required),
- "transfer_reason": result.result.transfer_reason,
- },
+ tool_calls=stored_tool_calls,
)
self.session.add(message)
await self.session.flush()
diff --git a/app/service/financial_nl2sql_service.py b/app/service/financial_nl2sql_service.py
index 446801e..9c20e12 100644
--- a/app/service/financial_nl2sql_service.py
+++ b/app/service/financial_nl2sql_service.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import re
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
-from datetime import UTC, datetime, timedelta
+from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from typing import Any
@@ -454,6 +454,8 @@ class FinancialNL2SQLService:
converted[key] = str(value)
elif isinstance(value, datetime):
converted[key] = value.isoformat(sep=" ", timespec="seconds")
+ elif isinstance(value, date):
+ converted[key] = value.isoformat()
else:
converted[key] = value
return converted
diff --git a/app/service/offsite_fund_service.py b/app/service/offsite_fund_service.py
index 7304e7d..ea82d5c 100644
--- a/app/service/offsite_fund_service.py
+++ b/app/service/offsite_fund_service.py
@@ -1,6 +1,8 @@
"""场外基金申购赎回业务编排服务。"""
import asyncio
+import os
+import stat
from collections import defaultdict
from collections.abc import Mapping, Sequence
from datetime import UTC, date, datetime
@@ -14,8 +16,9 @@ from pathlib import Path
from typing import Any, Literal, cast
from zoneinfo import ZoneInfo
-from sqlalchemy import func, select, update
+from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.sql.elements import ColumnElement
from app.core.config import get_settings
from app.core.contracts import RequestContext
@@ -210,7 +213,7 @@ class OffsiteFundService:
async def delete_mail(
self, mail_id: str, operator_id: str, context: RequestContext
) -> dict[str, object]:
- """软删除一封邮件,保留所有原始数据供审计和补偿使用。"""
+ """物理删除邮件及其全部场外业务数据,同时保留删除审计。"""
operator_error = self._operator_error(operator_id, context)
if operator_error is not None:
return operator_error
@@ -218,7 +221,6 @@ class OffsiteFundService:
if denied is not None:
return denied
- now = datetime.now(UTC).replace(tzinfo=None)
async with self.session.begin():
mail = await self.session.scalar(
select(OffsiteFundMail)
@@ -227,29 +229,160 @@ class OffsiteFundService:
)
if mail is None:
return {"code": 404, "message": "邮件不存在", "data": {}}
- if mail.status == "deleted":
- return {
- "code": 0,
- "message": "邮件已删除",
- "data": {"mail_id": mail_id, "status": "deleted"},
- }
+
+ attachments = (
+ await self.session.execute(
+ select(OffsiteFundAttachment).where(
+ OffsiteFundAttachment.mail_id == mail_id
+ )
+ )
+ ).scalars().all()
+ documents = (
+ await self.session.execute(
+ select(OffsiteFundDocument).where(
+ OffsiteFundDocument.mail_id == mail_id
+ )
+ )
+ ).scalars().all()
+ attachment_ids = tuple(item.attachment_id for item in attachments)
+ task_ids = tuple(item.task_id for item in documents)
+ file_hashes = tuple(item.file_hash for item in attachments)
+ storage_paths = {
+ path
+ for path in (
+ [mail.original_eml_path]
+ + [item.original_file_path for item in attachments]
+ )
+ if path
+ }
+
+ # Worker 在邮件入库前可能先写入没有 mail_id 的自动识别尝试,
+ # 因此按邮件、附件、任务和“消息号+文件哈希”四组键一起清理。
+ recognition_filters = [OffsiteRecognitionAttempt.mail_id == mail_id]
+ if attachment_ids:
+ recognition_filters.append(
+ OffsiteRecognitionAttempt.attachment_id.in_(attachment_ids)
+ )
+ if task_ids:
+ recognition_filters.append(
+ OffsiteRecognitionAttempt.task_id.in_(task_ids)
+ )
+ if file_hashes:
+ recognition_filters.append(
+ (OffsiteRecognitionAttempt.message_id == mail.message_id)
+ & OffsiteRecognitionAttempt.file_hash.in_(file_hashes)
+ )
+ recognition_attempts = (
+ await self.session.execute(
+ select(OffsiteRecognitionAttempt).where(
+ or_(*recognition_filters)
+ )
+ )
+ ).scalars().all()
+ storage_paths.update(
+ item.original_file_path
+ for item in recognition_attempts
+ if item.original_file_path
+ )
+
+ correction_filters = [OffsiteFieldCorrection.mail_id == mail_id]
+ if attachment_ids:
+ correction_filters.append(
+ OffsiteFieldCorrection.attachment_id.in_(attachment_ids)
+ )
+ if task_ids:
+ correction_filters.append(
+ OffsiteFieldCorrection.task_id.in_(task_ids)
+ )
+
+ # 文件系统不参加数据库事务;在事务提交前删除,删除失败会抛错并
+ # 回滚数据库事务,避免返回“成功”却留下可继续使用的旧文件。
+ self._delete_storage_files(storage_paths)
+
previous_status = mail.status
- mail.status = "deleted"
- mail.updated_at = now
+ deleted_counts: dict[str, int] = {}
+ if task_ids:
+ deleted_counts["通知"] = await self._delete_rows(
+ OffsiteNotification,
+ OffsiteNotification.business_key.in_(task_ids),
+ )
+ deleted_counts["NL2SQL查询"] = await self._delete_rows(
+ OffsiteQueryRecord,
+ OffsiteQueryRecord.task_id.in_(task_ids),
+ )
+ deleted_counts["规则结果"] = await self._delete_rows(
+ OffsiteRuleResult,
+ OffsiteRuleResult.task_id.in_(task_ids),
+ )
+ deleted_counts["执行计划"] = await self._delete_rows(
+ OffsiteExecutionPlanTask,
+ OffsiteExecutionPlanTask.task_id.in_(task_ids),
+ )
+ else:
+ deleted_counts["通知"] = 0
+ deleted_counts["NL2SQL查询"] = 0
+ deleted_counts["规则结果"] = 0
+ deleted_counts["执行计划"] = 0
+ deleted_counts["字段修正"] = await self._delete_rows(
+ OffsiteFieldCorrection,
+ or_(*correction_filters),
+ )
+ deleted_counts["识别尝试"] = await self._delete_rows(
+ OffsiteRecognitionAttempt,
+ or_(*recognition_filters),
+ )
+ deleted_counts["业务单据"] = await self._delete_rows(
+ OffsiteFundDocument,
+ OffsiteFundDocument.mail_id == mail_id,
+ )
+ deleted_counts["附件"] = await self._delete_rows(
+ OffsiteFundAttachment,
+ OffsiteFundAttachment.mail_id == mail_id,
+ )
+ deleted_counts["邮件"] = await self._delete_rows(
+ OffsiteFundMail,
+ OffsiteFundMail.mail_id == mail_id,
+ )
self._add_audit(
context,
"offsite.mail_deleted",
{
"mail_id": mail_id,
"previous_status": previous_status,
+ "deleted_counts": deleted_counts,
+ "deleted_file_count": len(storage_paths),
},
)
return {
"code": 0,
"message": "邮件已删除",
- "data": {"mail_id": mail_id, "status": "deleted"},
+ "data": {
+ "mail_id": mail_id,
+ "status": "deleted",
+ "deleted_counts": deleted_counts,
+ "deleted_file_count": len(storage_paths),
+ },
}
+ async def _delete_rows(
+ self, model: type, criterion: ColumnElement[bool]
+ ) -> int:
+ result = await self.session.execute(delete(model).where(criterion))
+ return int(getattr(result, "rowcount", 0) or 0)
+
+ @classmethod
+ def _delete_storage_files(cls, paths: set[str]) -> None:
+ for original_path in paths:
+ path = cls._resolve_storage_path(original_path)
+ if path is None:
+ continue
+ try:
+ # Worker 保存文件时会设为只读;Windows 删除只读文件前需清除属性。
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
+ path.unlink(missing_ok=True)
+ except OSError as exc:
+ raise RuntimeError(f"删除场外邮件文件失败:{path.name}") from exc
+
async def mail_recognition_fields(
self, mail_id: str, context: RequestContext
) -> dict[str, object]:
@@ -1912,14 +2045,9 @@ class OffsiteFundService:
if document.task_id in successful_normal_returns
]
grouped: dict[str, list[OffsiteFundDocument]] = defaultdict(list)
- for document in documents:
- if document.fund_code:
- grouped.setdefault(document.fund_code, [])
for row in rows:
if row.fund_code:
grouped[row.fund_code].append(row)
- if requested_fund_code and requested_fund_code not in grouped:
- grouped[requested_fund_code] = []
statistics = [
await self._build_settlement_statistic(
code, target_date, grouped[code], context
@@ -1927,6 +2055,17 @@ class OffsiteFundService:
for code in sorted(grouped)
]
if requested_fund_code:
+ if not statistics:
+ return {
+ "code": 0,
+ "message": "ok",
+ "data": {
+ "application_date": target_date.isoformat(),
+ "fund_code": None,
+ "fund_count": 0,
+ "items": [],
+ },
+ }
data = {**statistics[0], "items": statistics}
return {"code": 0, "message": "ok", "data": data}
return {
diff --git a/app/service/promotion_image_service.py b/app/service/promotion_image_service.py
new file mode 100644
index 0000000..1858cbb
--- /dev/null
+++ b/app/service/promotion_image_service.py
@@ -0,0 +1,173 @@
+"""阿里云百炼背景图生成服务。"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Any
+
+import httpx
+
+from app.core.config import get_settings
+from app.core.errors import DependencyUnavailableError, UpstreamTimeoutError
+from app.service.model_gateway import EnvironmentSecretResolver
+
+logger = logging.getLogger(__name__)
+
+
+class PromotionImageService:
+ """生成无文字底图,供渲染器美化整页与内容面板。"""
+
+ def __init__(self) -> None:
+ settings = get_settings()
+ self.enabled = settings.promotion_image_enabled
+ self.api_key = ""
+ if self.enabled:
+ try:
+ self.api_key = EnvironmentSecretResolver().resolve(
+ "env:DASHSCOPE_API_KEY"
+ )
+ except Exception:
+ logger.warning("DASHSCOPE_API_KEY 未配置,背景图生成已降级", exc_info=True)
+ self.enabled = False
+ self.base_url = settings.promotion_image_base_url.rstrip("/")
+ self.model = settings.promotion_image_model
+ self.timeout_seconds = settings.promotion_image_timeout_seconds
+ self.poll_interval_seconds = settings.promotion_image_poll_interval_seconds
+
+ async def generate_background(
+ self,
+ *,
+ output_path: str | Path,
+ style_code: str,
+ background_theme: str,
+ fund_type: str,
+ ) -> str | None:
+ if not self.enabled or not self.api_key:
+ return None
+ prompt = self._build_prompt(
+ style_code=style_code,
+ background_theme=background_theme,
+ fund_type=fund_type,
+ )
+ try:
+ async with httpx.AsyncClient() as client:
+ task_id = await self._submit(client, prompt)
+ image_url = await self._poll(client, task_id)
+ response = await client.get(
+ image_url,
+ timeout=httpx.Timeout(self.timeout_seconds),
+ )
+ response.raise_for_status()
+ destination = Path(output_path)
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_bytes(self._tone_down_background(response.content))
+ return str(destination)
+ except httpx.TimeoutException:
+ logger.warning("阿里云背景图生成超时,使用程序化背景", exc_info=True)
+ return None
+ except (
+ httpx.HTTPError,
+ DependencyUnavailableError,
+ UpstreamTimeoutError,
+ KeyError,
+ TypeError,
+ ValueError,
+ ):
+ logger.warning("阿里云背景图生成失败,使用程序化背景", exc_info=True)
+ return None
+
+ async def _submit(self, client: httpx.AsyncClient, prompt: str) -> str:
+ response = await client.post(
+ f"{self.base_url}/api/v1/services/aigc/text2image/image-synthesis",
+ headers={
+ "Authorization": f"Bearer {self.api_key}",
+ "Content-Type": "application/json",
+ "X-DashScope-Async": "enable",
+ },
+ json={
+ "model": self.model,
+ "input": {"prompt": prompt},
+ "parameters": {"size": "1024*1024", "n": 1},
+ },
+ timeout=httpx.Timeout(self.timeout_seconds),
+ )
+ response.raise_for_status()
+ body: Any = response.json()
+ task_id = body.get("output", {}).get("task_id") if isinstance(body, dict) else None
+ if not isinstance(task_id, str) or not task_id:
+ raise DependencyUnavailableError("阿里云背景图任务响应缺少 task_id")
+ return task_id
+
+ async def _poll(self, client: httpx.AsyncClient, task_id: str) -> str:
+ url = f"{self.base_url}/api/v1/tasks/{task_id}"
+ max_polls = max(1, int(self.timeout_seconds / self.poll_interval_seconds))
+ for _ in range(max_polls):
+ response = await client.get(
+ url,
+ headers={"Authorization": f"Bearer {self.api_key}"},
+ timeout=httpx.Timeout(self.timeout_seconds),
+ )
+ response.raise_for_status()
+ body: Any = response.json()
+ output = body.get("output", {}) if isinstance(body, dict) else {}
+ status = output.get("task_status")
+ if status == "SUCCEEDED":
+ results = output.get("results") or []
+ image_url = results[0].get("url") if results else None
+ if isinstance(image_url, str) and image_url:
+ return image_url
+ raise DependencyUnavailableError("阿里云背景图任务缺少图片地址")
+ if status in {"FAILED", "CANCELED", "UNKNOWN"}:
+ raise DependencyUnavailableError("阿里云背景图任务未成功")
+ await self._sleep()
+ raise UpstreamTimeoutError("阿里云背景图任务轮询超时")
+
+ async def _sleep(self) -> None:
+ import asyncio
+
+ await asyncio.sleep(self.poll_interval_seconds)
+
+ @staticmethod
+ def _tone_down_background(payload: bytes) -> bytes:
+ """将模型背景处理成低干扰底图,避免纹理压过材料正文。"""
+ import io
+
+ from PIL import Image, ImageEnhance, ImageFilter
+
+ with Image.open(io.BytesIO(payload)) as source:
+ image = source.convert("RGB")
+ image = ImageEnhance.Color(image).enhance(0.22)
+ image = ImageEnhance.Contrast(image).enhance(0.30)
+ image = ImageEnhance.Brightness(image).enhance(1.22)
+ image = image.filter(ImageFilter.GaussianBlur(radius=1.0))
+ output = io.BytesIO()
+ image.save(output, format="PNG", optimize=True)
+ return output.getvalue()
+
+ @staticmethod
+ def _build_prompt(
+ *,
+ style_code: str,
+ background_theme: str,
+ fund_type: str,
+ ) -> str:
+ style = {
+ "steady_professional": "稳健、克制、专业的暖色金融视觉",
+ "growth_research": "理性、现代、研究感的蓝色金融视觉",
+ "balanced_allocation": "平衡、清晰、专业的蓝金金融视觉",
+ }.get(style_code, "专业、克制、清晰的金融视觉")
+ theme = {
+ "data_lines": "抽象数据线、轻量节点和流动曲线",
+ "geometric_grid": "低对比几何网格、细线和少量节点",
+ "soft_wave": "柔和抽象波形和层次光影",
+ }.get(background_theme, "低对比抽象几何纹理")
+ return (
+ "Use case: productivity-visual. "
+ "为基金产品推介材料生成可作为 PPT 和宣传长图底图的抽象背景。"
+ f"基金类型语境:{fund_type}。视觉风格:{style}。主要元素:{theme}。"
+ "宽松留白,主体内容区域保持低对比,适合叠加中文文字和数据图表;"
+ "画面主色应可用于提取同色系的半透明文字板块背景。"
+ "只生成背景和装饰纹理,不生成任何文字、数字、字母、Logo、人物、头像、"
+ "基金图表、表格、产品名称、收益率、金融承诺、印章或水印。"
+ )
diff --git a/app/service/promotion_layout_planner.py b/app/service/promotion_layout_planner.py
new file mode 100644
index 0000000..b869f7a
--- /dev/null
+++ b/app/service/promotion_layout_planner.py
@@ -0,0 +1,284 @@
+"""产品推介材料的受限版式规划。"""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+from app.service.agent.bootstrap import get_model_service
+from app.service.model_gateway import DatabaseModelEndpointResolver
+
+logger = logging.getLogger(__name__)
+
+ALLOWED_COVER_LAYOUTS = {
+ "cover_left_photo_right",
+ "cover_visual_right",
+ "cover_full_bleed",
+}
+ALLOWED_CONTENT_LAYOUTS = {
+ "single_column",
+ "two_columns",
+ "chart_focus",
+ "metrics_focus",
+ "manager_profile",
+ "full_width_disclosure",
+ "visual_focus",
+}
+ALLOWED_POSTER_LAYOUTS = {
+ "poster_balanced",
+ "poster_chart_focus",
+ "poster_manager_focus",
+}
+ALLOWED_BACKGROUND_THEMES = {
+ "data_lines",
+ "geometric_grid",
+ "soft_wave",
+ "none",
+}
+ALLOWED_DESIGN_TONES = {"institutional_tech", "warm_editorial", "quiet_data"}
+ALLOWED_CONTENT_STRUCTURES = {
+ "balanced_split",
+ "strategy_right_emphasis",
+ "strategy_left_emphasis",
+}
+ALLOWED_COLUMN_RATIOS = {"40_60", "45_55", "50_50", "55_45", "60_40"}
+
+
+class PromotionLayoutPlanner:
+ """调用文本模型规划有限枚举,模型不可输出坐标或改写材料内容。"""
+
+ async def plan(
+ self,
+ draft: dict[str, Any],
+ *,
+ has_photo: bool,
+ has_chart: bool,
+ ) -> dict[str, Any]:
+ fallback = self.default_plan(draft, has_photo=has_photo, has_chart=has_chart)
+ prompt = self._prompt(draft, has_photo=has_photo, has_chart=has_chart)
+ try:
+ endpoints = await DatabaseModelEndpointResolver().resolve(
+ agent_type="promotion_material",
+ task_type="text_generation",
+ )
+ result = await get_model_service().generate(endpoints, prompt, max_attempts=2)
+ parsed = self._parse_json(result.text)
+ return self._validate(parsed, draft, fallback)
+ except Exception:
+ logger.warning("推介材料版式规划失败,退回确定性版式", exc_info=True)
+ return fallback
+
+ @staticmethod
+ def default_plan(
+ draft: dict[str, Any],
+ *,
+ has_photo: bool,
+ has_chart: bool,
+ ) -> dict[str, Any]:
+ chapters = draft.get("chapters", [])
+ page_plans = []
+ for chapter in chapters:
+ title = str(chapter.get("title") or "")
+ density = PromotionLayoutPlanner.content_density(chapter)
+ if chapter.get("chart_index") is not None and has_chart:
+ layout = "chart_focus"
+ elif title == "管理人及投研团队" and has_photo:
+ layout = "manager_profile"
+ elif title == "风险揭示":
+ layout = "full_width_disclosure"
+ elif density == "low":
+ layout = "visual_focus"
+ elif title in {"产品基本信息", "投资范围、策略与限制", "费用结构"}:
+ layout = "two_columns"
+ else:
+ layout = "single_column"
+ page_plans.append({
+ "chapter_title": title,
+ "layout": layout,
+ "content_density": density,
+ })
+ return {
+ "cover_layout": "cover_left_photo_right" if has_photo else "cover_visual_right",
+ "poster_layout": "poster_chart_focus" if has_chart else "poster_balanced",
+ "background_theme": "data_lines",
+ "design_profile": PromotionLayoutPlanner._default_design_profile(
+ chapters, has_photo=has_photo, has_chart=has_chart
+ ),
+ "page_plans": page_plans,
+ }
+
+ @staticmethod
+ def _default_design_profile(
+ chapters: list[dict[str, Any]], *, has_photo: bool, has_chart: bool
+ ) -> dict[str, str]:
+ """从参考样张提炼可组合的设计基因,而不是复制固定模板。"""
+ strategy = next(
+ (item for item in chapters if item.get("title") == "投资范围、策略与限制"),
+ {},
+ )
+ strategy_is_primary = len(str(strategy.get("body") or "")) >= 90
+ if has_chart and has_photo:
+ tone = "institutional_tech"
+ elif has_photo:
+ tone = "warm_editorial"
+ else:
+ tone = "quiet_data"
+ return {
+ "design_tone": tone,
+ "content_structure": (
+ "strategy_right_emphasis" if strategy_is_primary else "balanced_split"
+ ),
+ "column_ratio": "40_60" if strategy_is_primary else "50_50",
+ "manager_layout": "profile_feature" if has_photo else "profile_compact",
+ "performance_layout": "chart_dominant" if has_chart else "metrics_first",
+ "background_role": "edge_texture" if has_chart else "soft_paper",
+ }
+
+ @staticmethod
+ def content_density(chapter: dict[str, Any]) -> str:
+ """按原始内容和已有素材判断信息密度,不生成或修改业务内容。"""
+ body = str(chapter.get("body") or "").strip()
+ length = len(body.replace("\\n", ""))
+ signals = sum(
+ chapter.get("chart_index") is not None
+ if name == "chart_index"
+ else bool(chapter.get(name))
+ for name in ("chart_index", "metrics", "table", "photo")
+ )
+ if length < 90 and signals == 0:
+ return "low"
+ if length < 260 and signals <= 1:
+ return "medium"
+ return "high"
+
+ @staticmethod
+ def _prompt(
+ draft: dict[str, Any],
+ *,
+ has_photo: bool,
+ has_chart: bool,
+ ) -> str:
+ chapters = [
+ {
+ "title": str(item.get("title") or ""),
+ "body_length": len(str(item.get("body") or "")),
+ "has_chart": item.get("chart_index") is not None,
+ "content_density": PromotionLayoutPlanner.content_density(item),
+ }
+ for item in draft.get("chapters", [])
+ ]
+ request = {
+ "style_code": draft.get("style_code"),
+ "title": draft.get("title"),
+ "chapters": chapters,
+ "has_manager_photo": has_photo,
+ "has_performance_chart": has_chart,
+ }
+ return (
+ "你是金融产品推介材料的版式规划器。只根据以下结构化摘要选择版式,"
+ "不得改写、删减、补充任何材料文字、数字、表格、头像或业绩数据。"
+ "不得返回坐标、颜色值、HTML、SVG、图片提示词或新事实。"
+ "必须只返回一个 JSON 对象,字段为 cover_layout、poster_layout、"
+ "background_theme、design_profile、page_plans。"
+ "design_profile 含 design_tone、content_structure、column_ratio、manager_layout、"
+ "performance_layout、background_role;page_plans 的每项只含 chapter_title 和 layout。"
+ "可选 cover_layout:cover_left_photo_right、cover_visual_right、cover_full_bleed。"
+ "可选 poster_layout:poster_balanced、poster_chart_focus、poster_manager_focus。"
+ "可选 background_theme:data_lines、geometric_grid、soft_wave、none。"
+ "可选 layout:single_column、two_columns、chart_focus、metrics_focus、"
+ "manager_profile、full_width_disclosure、visual_focus。"
+ "低密度页面优先选择 visual_focus,"
+ "但不得删除或改写原始文字。content_density 只用于版式选择。"
+ "可选 design_tone:institutional_tech、warm_editorial、quiet_data。"
+ "可选 content_structure:balanced_split、strategy_right_emphasis、"
+ "strategy_left_emphasis。可选 column_ratio:40_60、45_55、50_50、55_45、60_40。"
+ "manager_layout 只能是 profile_feature 或 profile_compact;"
+ "performance_layout 只能是 chart_dominant 或 metrics_first;"
+ "background_role 只能是 edge_texture 或 soft_paper。"
+ "页面标题必须逐字复制输入中的 title。"
+ f"结构化摘要:{json.dumps(request, ensure_ascii=False)}"
+ )
+
+ @staticmethod
+ def _parse_json(text: str) -> dict[str, Any]:
+ value = text.strip()
+ if value.startswith("```"):
+ value = value.split("\n", 1)[1].rsplit("```", 1)[0].strip()
+ parsed = json.loads(value)
+ if not isinstance(parsed, dict):
+ raise ValueError("版式规划不是 JSON 对象")
+ return parsed
+
+ @classmethod
+ def _validate(
+ cls,
+ value: dict[str, Any],
+ draft: dict[str, Any],
+ fallback: dict[str, Any],
+ ) -> dict[str, Any]:
+ if value.get("cover_layout") not in ALLOWED_COVER_LAYOUTS:
+ return fallback
+ if value.get("poster_layout") not in ALLOWED_POSTER_LAYOUTS:
+ return fallback
+ if value.get("background_theme") not in ALLOWED_BACKGROUND_THEMES:
+ return fallback
+ profile = value.get("design_profile")
+ if not isinstance(profile, dict):
+ return fallback
+ if profile.get("design_tone") not in ALLOWED_DESIGN_TONES:
+ return fallback
+ if profile.get("content_structure") not in ALLOWED_CONTENT_STRUCTURES:
+ return fallback
+ if profile.get("column_ratio") not in ALLOWED_COLUMN_RATIOS:
+ return fallback
+ if profile.get("manager_layout") not in {"profile_feature", "profile_compact"}:
+ return fallback
+ if profile.get("performance_layout") not in {"chart_dominant", "metrics_first"}:
+ return fallback
+ if profile.get("background_role") not in {"edge_texture", "soft_paper"}:
+ return fallback
+ expected_titles = [
+ str(item.get("title") or "") for item in draft.get("chapters", [])
+ ]
+ raw_pages = value.get("page_plans")
+ if not isinstance(raw_pages, list):
+ return fallback
+ by_title: dict[str, str] = {}
+ for item in raw_pages:
+ if not isinstance(item, dict):
+ return fallback
+ title = item.get("chapter_title")
+ layout = item.get("layout")
+ if title not in expected_titles or layout not in ALLOWED_CONTENT_LAYOUTS:
+ return fallback
+ by_title[str(title)] = str(layout)
+ if set(by_title) != set(expected_titles):
+ return fallback
+ return {
+ "cover_layout": value["cover_layout"],
+ "poster_layout": value["poster_layout"],
+ "background_theme": value["background_theme"],
+ "design_profile": {name: str(profile[name]) for name in (
+ "design_tone", "content_structure", "column_ratio", "manager_layout",
+ "performance_layout", "background_role",
+ )},
+ "page_plans": [
+ {
+ "chapter_title": title,
+ "layout": by_title[title],
+ "content_density": next(
+ (
+ str(item.get("content_density"))
+ for item in raw_pages
+ if isinstance(item, dict) and item.get("chapter_title") == title
+ ),
+ cls.content_density(next(
+ item for item in draft.get("chapters", [])
+ if str(item.get("title") or "") == title
+ )),
+ ),
+ }
+ for title in expected_titles
+ ],
+ }
diff --git a/app/service/promotion_material_service.py b/app/service/promotion_material_service.py
index bb9d22d..6868b74 100644
--- a/app/service/promotion_material_service.py
+++ b/app/service/promotion_material_service.py
@@ -37,6 +37,8 @@ from app.service.promotion_compliance import (
ComplianceFinding,
PromotionComplianceChecker,
)
+from app.service.promotion_image_service import PromotionImageService
+from app.service.promotion_layout_planner import PromotionLayoutPlanner
from app.service.promotion_pdf_adapter import PromotionPdfAdapter
from app.service.promotion_performance import (
create_performance_chart,
@@ -44,7 +46,7 @@ from app.service.promotion_performance import (
parse_performance_file,
)
from app.service.promotion_poster_renderer import PromotionPosterRenderer
-from app.service.promotion_renderer import PromotionPptxRenderer
+from app.service.promotion_renderer import PromotionPptxRenderer, resolve_visual_palette
class PromotionMaterialService:
@@ -57,6 +59,8 @@ class PromotionMaterialService:
self.compliance = PromotionComplianceChecker()
self.renderer = PromotionPptxRenderer()
self.poster_renderer = PromotionPosterRenderer()
+ self.layout_planner = PromotionLayoutPlanner()
+ self.image_service = PromotionImageService()
self.pdf_adapter = PromotionPdfAdapter(
enabled=settings.promotion_pdf_enabled,
converter_path=settings.promotion_pdf_converter_path,
@@ -327,6 +331,11 @@ class PromotionMaterialService:
if duplicate is not None:
return {"code": 0, "message": "ok", "data": {
"attachment_id": duplicate.id, "duplicate": True,
+ "performance_summary": (
+ duplicate.parsed_metadata.get("summary")
+ if attachment_type == "performance_data"
+ else None
+ ),
}}
extension = Path(filename).suffix.lower()
destination = self.storage_root / task_no / attachment_type / f"{digest}{extension}"
@@ -368,6 +377,11 @@ class PromotionMaterialService:
return {"code": 0, "message": "ok", "data": {
"attachment_id": attachment.id, "attachment_type": attachment_type,
"sha256": digest, "size_bytes": len(payload),
+ "performance_summary": (
+ parsed_metadata.get("summary")
+ if attachment_type == "performance_data"
+ else None
+ ),
}}
async def _generate_tx(
@@ -397,6 +411,12 @@ class PromotionMaterialService:
if performance_attachment is not None
else None,
}
+ if performance_attachment is not None:
+ performance_data = parse_performance_file(performance_attachment.file_path)
+ inputs["performance_info"] = {
+ **inputs["performance_info"],
+ **performance_data["summary"],
+ }
input_findings = self.compliance.check_inputs(inputs)
if input_findings:
self._save_findings(session, task_no, None, input_findings)
@@ -424,8 +444,31 @@ class PromotionMaterialService:
)
try:
chart_paths = self._build_charts(task_no, version_no, list(attachments), inputs)
- self.renderer.render(draft, pptx_path, photo_path=photo_path,
- chart_paths=chart_paths)
+ draft["layout_plan"] = await self.layout_planner.plan(
+ draft,
+ has_photo=bool(photo_path and Path(photo_path).exists()),
+ has_chart=bool(chart_paths),
+ )
+ background_path = await self.image_service.generate_background(
+ output_path=self.storage_root / task_no / "materials"
+ / f"v{version_no}-background.png",
+ style_code=task.style_code,
+ background_theme=draft["layout_plan"]["background_theme"],
+ fund_type=str(inputs["product_info"].get("fund_type") or ""),
+ )
+ if background_path:
+ draft["background_image_path"] = background_path
+ draft["visual_palette"] = resolve_visual_palette(
+ task.style_code,
+ background_path,
+ )
+ self.renderer.render(
+ draft,
+ pptx_path,
+ photo_path=photo_path,
+ chart_paths=chart_paths,
+ background_path=background_path,
+ )
requested_formats = payload.output_formats or task.output_formats
if "poster" in requested_formats:
self.poster_renderer.render(
@@ -433,6 +476,7 @@ class PromotionMaterialService:
poster_path,
photo_path=photo_path,
chart_paths=chart_paths,
+ background_path=background_path,
)
pdf_path: str | None = None
if "pdf" in requested_formats:
@@ -450,7 +494,7 @@ class PromotionMaterialService:
version_no=version_no,
style_code=task.style_code,
template_version=task.template_version,
- prompt_version="deterministic-v1",
+ prompt_version="layout-ai-v1",
draft_json=draft,
pptx_path=str(pptx_path),
pdf_path=pdf_path,
@@ -712,8 +756,6 @@ class PromotionMaterialService:
limits = {
"manager_photo": (max_photo_size, {".jpg", ".jpeg", ".png", ".webp"}),
"performance_data": (max_performance_size, {".csv", ".xlsx", ".xlsm"}),
- "source_evidence": (30 * 1024 * 1024, {".pdf", ".docx", ".xlsx", ".csv"}),
- "template_file": (50 * 1024 * 1024, {".pptx"}),
}
if attachment_type not in limits:
raise ValidationAgentError("不支持的附件类型")
diff --git a/app/service/promotion_performance.py b/app/service/promotion_performance.py
index 73fcaa1..0a21c85 100644
--- a/app/service/promotion_performance.py
+++ b/app/service/promotion_performance.py
@@ -38,9 +38,65 @@ def parse_performance_file(path: str | Path) -> dict[str, Any]:
columns = list(normalized[0]) if normalized else []
result = {"columns": columns, "rows": normalized, "row_count": len(normalized)}
_validate_performance_data(result)
+ result["summary"] = summarize_performance_data(result)
return result
+def summarize_performance_data(data: dict[str, Any]) -> dict[str, Any]:
+ """从已校验的业绩数据中提取材料需要的业绩摘要。"""
+ date_key = _find_key(data, ("date", "日期", "净值日期", "统计日期"))
+ if date_key is None:
+ raise ValueError("业绩数据必须包含日期列")
+ dates = [_parse_date(row.get(date_key)) for row in data.get("rows", [])]
+ valid_dates = [item for item in dates if item is not None]
+ if not valid_dates:
+ raise ValueError("业绩数据没有有效日期")
+ first_date = valid_dates[0]
+ latest_date = valid_dates[-1]
+ product_key = _find_key(data, ("product_return", "产品收益率"))
+ product_values = (
+ [_number(row.get(product_key)) for row in data.get("rows", [])]
+ if product_key is not None
+ else []
+ )
+ valid_product_values = [value for value in product_values if value is not None]
+ return {
+ "initial_date": first_date.isoformat(),
+ "as_of_date": latest_date.isoformat(),
+ "history_months": (
+ (latest_date.year - first_date.year) * 12
+ + latest_date.month
+ - first_date.month
+ ),
+ "product_return": (
+ _format_percentage(valid_product_values[-1])
+ if valid_product_values
+ else None
+ ),
+ "max_drawdown": _format_percentage(_max_drawdown(valid_product_values)),
+ }
+
+
+def _max_drawdown(values: list[float]) -> float | None:
+ if not values:
+ return None
+ # 业绩文件中的产品收益率是累计收益百分比,先还原为指数化净值再计算回撤。
+ levels = [100 + value for value in values]
+ peak = levels[0]
+ drawdown = 0.0
+ for level in levels:
+ peak = max(peak, level)
+ if peak:
+ drawdown = min(drawdown, (level / peak - 1) * 100)
+ return drawdown
+
+
+def _format_percentage(value: float | None) -> str | None:
+ if value is None:
+ return None
+ return f"{value:.2f}".rstrip("0").rstrip(".") + "%"
+
+
def create_performance_chart(
data: dict[str, Any], output_path: str | Path, *, title: str
) -> str:
diff --git a/app/service/promotion_poster_renderer.py b/app/service/promotion_poster_renderer.py
index 109ea50..b379d8d 100644
--- a/app/service/promotion_poster_renderer.py
+++ b/app/service/promotion_poster_renderer.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from pathlib import Path
from typing import Any
-from app.service.promotion_renderer import STYLE_CONFIG
+from app.service.promotion_renderer import STYLE_CONFIG, resolve_visual_palette
class PromotionPosterRenderer:
@@ -21,6 +21,7 @@ class PromotionPosterRenderer:
*,
photo_path: str | None = None,
chart_paths: list[str] | None = None,
+ background_path: str | None = None,
) -> str:
try:
from PIL import Image, ImageDraw, ImageFont
@@ -29,27 +30,75 @@ class PromotionPosterRenderer:
style_code = str(draft.get("style_code") or "balanced_allocation")
style = STYLE_CONFIG.get(style_code, STYLE_CONFIG["balanced_allocation"])
+ palette = resolve_visual_palette(
+ style_code,
+ background_path,
+ draft.get("visual_palette"),
+ )
colors = {
- "primary": f"#{style['primary']}",
- "secondary": f"#{style['secondary']}",
+ "primary": f"#{palette['primary']}",
+ "secondary": f"#{palette['secondary']}",
"accent": f"#{style['accent']}",
- "ink": f"#{style['ink']}",
- "muted": f"#{style['muted']}",
- "paper": f"#{style['background']}",
- "white": f"#{style['surface']}",
- "line": f"#{style['secondary']}",
- "headline": f"#{style['headline']}",
+ "ink": f"#{palette['ink']}",
+ "muted": f"#{palette['muted']}",
+ "paper": f"#{palette['background']}",
+ "panel": f"#{palette['panel']}",
+ "panel_alt": f"#{palette['panel_alt']}",
+ "white": "#FFFFFF",
+ "line": f"#{palette['line']}",
+ "headline": f"#{palette['headline']}",
}
fonts = self._fonts(ImageFont)
+ layout_plan = draft.get("layout_plan") or {}
+ profile = layout_plan.get("design_profile") or {}
+ tone = str(profile.get("design_tone") or "institutional_tech")
+ section_color = colors["primary"] if tone == "institutional_tech" else colors["accent"]
canvas = Image.new("RGB", (self.WIDTH, self.HEIGHT), colors["paper"])
+ if background_path and Path(background_path).exists():
+ with Image.open(background_path) as background:
+ canvas.paste(self._cover(background.convert("RGB"), self.WIDTH, self.HEIGHT))
+ # 正文区使用主题浅色保护层,让 AI 纹理可见但不压过中文内容。
+ body = canvas.crop((0, 462, self.WIDTH, self.HEIGHT - 115))
+ protection = 0.42 if profile.get("background_role") == "soft_paper" else 0.34
+ body = Image.blend(
+ body,
+ Image.new("RGB", body.size, colors["paper"]),
+ protection,
+ )
+ canvas.paste(body, (0, 462))
draw = ImageDraw.Draw(canvas)
chapters = {
str(item.get("title", "")): item
for item in draft.get("chapters", [])
if item.get("title")
}
+ poster_layout = str(layout_plan.get("poster_layout") or "poster_balanced")
+ chart_focus = (
+ poster_layout == "poster_chart_focus"
+ or profile.get("performance_layout") == "chart_dominant"
+ )
+ column_ratio = self._column_ratio(str(profile.get("column_ratio") or "50_50"))
+ product_y = 590
+ product_height = 320 if chart_focus else 365
+ manager_header_y = 1030
+ manager_y = 1115
+ manager_height = 355
+ history_header_y = 1505 if chart_focus else 1550
+ chart_y = 1585 if chart_focus else 1635
+ chart_height = 560 if chart_focus else 520
+ metrics_y = 2225
+ self._draw_visual_background(
+ draw, layout_plan.get("background_theme"), colors, fonts
+ )
- self._draw_cover(draw, canvas, draft, colors, fonts)
+ self._draw_cover(
+ draw,
+ canvas,
+ draft,
+ colors,
+ fonts,
+ has_ai_background=bool(background_path and Path(background_path).exists()),
+ )
self._draw_section_header(
draw,
"产品定位与投资策略",
@@ -57,23 +106,27 @@ class PromotionPosterRenderer:
500,
colors,
fonts,
+ fill=section_color,
)
self._draw_two_columns(
draw,
chapters.get("产品基本信息", {}).get("body", ""),
chapters.get("投资范围、策略与限制", {}).get("body", ""),
90,
- 590,
+ product_y,
colors,
fonts,
+ height=product_height,
+ left_ratio=column_ratio,
)
self._draw_section_header(
draw,
"基金经理与投研团队",
90,
- 1030,
+ manager_header_y,
colors,
fonts,
+ fill=section_color,
)
self._draw_manager(
draw,
@@ -81,17 +134,20 @@ class PromotionPosterRenderer:
chapters.get("管理人及投研团队", {}).get("body", ""),
photo_path,
90,
- 1115,
+ manager_y,
colors,
fonts,
+ panel_height=manager_height,
+ photo_width=350 if profile.get("manager_layout") == "profile_feature" else 300,
)
self._draw_section_header(
draw,
"历史业绩与风险指标",
90,
- 1550,
+ history_header_y,
colors,
fonts,
+ fill=section_color,
)
chart_path = next(
(
@@ -102,15 +158,23 @@ class PromotionPosterRenderer:
None,
)
if chart_path:
- self._draw_chart(canvas, chart_path, 90, 1635, 1620, 520)
+ self._draw_chart(
+ canvas,
+ chart_path,
+ 90,
+ chart_y,
+ 1620,
+ chart_height,
+ colors,
+ )
else:
self._draw_note(
draw,
"当前未提供可展示的业绩曲线附件",
90,
- 1735,
+ chart_y + 100,
1620,
- 260,
+ chart_height - 100,
colors,
fonts,
)
@@ -118,7 +182,7 @@ class PromotionPosterRenderer:
draw,
chapters.get("收益与风险指标", {}).get("body", ""),
90,
- 2185,
+ metrics_y,
colors,
fonts,
)
@@ -129,6 +193,40 @@ class PromotionPosterRenderer:
canvas.save(output, format="PNG", optimize=True)
return str(output)
+ def _draw_visual_background(
+ self,
+ draw: Any,
+ theme: Any,
+ colors: dict[str, str],
+ fonts: dict[str, Any],
+ ) -> None:
+ del fonts
+ if theme in (None, "none"):
+ return
+ for index in range(7):
+ x = 1120 + index * 90
+ y = 90 + index * 42
+ draw.line((x, y, x + 460, y + 160), fill=colors["secondary"], width=4)
+ draw.ellipse(
+ (x + 450, y + 150, x + 466, y + 166),
+ fill=colors["accent"],
+ )
+ if theme == "geometric_grid":
+ for index in range(8):
+ x = 1260 + (index % 2) * 160
+ y = 620 + index * 105
+ draw.ellipse((x, y, x + 14, y + 14), fill=colors["accent"])
+
+ @staticmethod
+ def _column_ratio(value: str) -> float:
+ return {
+ "40_60": 0.40,
+ "45_55": 0.45,
+ "50_50": 0.50,
+ "55_45": 0.55,
+ "60_40": 0.60,
+ }.get(value, 0.50)
+
@staticmethod
def _fonts(image_font: Any) -> dict[str, Any]:
candidates = (
@@ -160,8 +258,21 @@ class PromotionPosterRenderer:
draft: dict[str, Any],
colors: dict[str, str],
fonts: dict[str, Any],
+ *,
+ has_ai_background: bool = False,
) -> None:
- draw.rectangle((0, 0, self.WIDTH, 430), fill=colors["primary"])
+ if has_ai_background:
+ from PIL import Image
+
+ cover = canvas.crop((0, 0, self.WIDTH, 430))
+ cover = Image.blend(
+ cover,
+ Image.new("RGB", cover.size, colors["primary"]),
+ 0.58,
+ )
+ canvas.paste(cover, (0, 0))
+ else:
+ draw.rectangle((0, 0, self.WIDTH, 430), fill=colors["primary"])
draw.rectangle((0, 430, self.WIDTH, 462), fill=colors["accent"])
title = str(draft.get("title") or draft.get("subtitle") or "基金产品推介材料")
self._draw_wrapped(
@@ -202,11 +313,14 @@ class PromotionPosterRenderer:
y: int,
colors: dict[str, str],
fonts: dict[str, Any],
+ *,
+ fill: str | None = None,
) -> None:
- draw.rectangle((x, y, x + 720, y + 62), fill=colors["accent"])
+ color = fill or colors["accent"]
+ draw.rectangle((x, y, x + 720, y + 62), fill=color)
draw.polygon(
((x + 720, y), (x + 760, y + 31), (x + 720, y + 62)),
- fill=colors["accent"],
+ fill=color,
)
draw.text((x + 24, y + 14), title, font=fonts["section"], fill=colors["white"])
@@ -219,28 +333,124 @@ class PromotionPosterRenderer:
y: int,
colors: dict[str, str],
fonts: dict[str, Any],
- ) -> None:
- column_width = 775
+ *,
+ height: int = 365,
+ left_ratio: float = 0.5,
+ ) -> dict[str, Any]:
+ total_width = 1620
+ gap = 54
+ resolved_ratio, body_font = self._resolve_two_column_layout(
+ draw,
+ left_body,
+ right_body,
+ total_width=total_width,
+ height=height,
+ requested_ratio=left_ratio,
+ font=fonts["body"],
+ )
+ left_width = int((total_width - gap) * resolved_ratio)
+ right_width = total_width - gap - left_width
self._draw_text_panel(
draw,
left_body,
x,
y,
- column_width,
- 365,
+ left_width,
+ height,
colors,
- fonts,
+ {**fonts, "body": body_font},
)
self._draw_text_panel(
draw,
right_body,
- x + 850,
+ x + left_width + gap,
y,
- column_width,
- 365,
+ right_width,
+ height,
colors,
- fonts,
+ {**fonts, "body": body_font},
)
+ return {
+ "column_ratio": resolved_ratio,
+ "font_size": getattr(body_font, "size", None),
+ }
+
+ def _resolve_two_column_layout(
+ self,
+ draw: Any,
+ left_body: str,
+ right_body: str,
+ *,
+ total_width: int,
+ height: int,
+ requested_ratio: float,
+ font: Any,
+ ) -> tuple[float, Any]:
+ """在模型版式落地前检查文字容量,避免栏位比例造成溢出。"""
+ gap = 54
+ ratios = [requested_ratio]
+ if requested_ratio == 0.5:
+ ratios.extend((0.4, 0.6))
+ elif requested_ratio < 0.5:
+ ratios.append(0.45)
+ else:
+ ratios.append(0.55)
+
+ for ratio in ratios:
+ left_width = int((total_width - gap) * ratio) - 68
+ right_width = total_width - gap - int((total_width - gap) * ratio) - 68
+ if self._text_fits(draw, left_body, left_width, height - 60, font) and (
+ self._text_fits(draw, right_body, right_width, height - 60, font)
+ ):
+ return ratio, font
+ fitted_font = self._fit_font(
+ draw,
+ (left_body, left_width),
+ (right_body, right_width),
+ height=height - 60,
+ font=font,
+ )
+ if self._text_fits(draw, left_body, left_width, height - 60, fitted_font) and (
+ self._text_fits(draw, right_body, right_width, height - 60, fitted_font)
+ ):
+ return ratio, fitted_font
+ smallest = self._font_variant(font, 15)
+ return ratios[-1], smallest
+
+ def _fit_font(
+ self,
+ draw: Any,
+ *text_blocks: tuple[str, int],
+ height: int,
+ font: Any,
+ ) -> Any:
+ base_size = int(getattr(font, "size", 24))
+ for size in range(base_size, 14, -1):
+ candidate = self._font_variant(font, size)
+ if all(
+ self._text_fits(draw, text, width, height, candidate)
+ for text, width in text_blocks
+ ):
+ return candidate
+ return self._font_variant(font, 15)
+
+ @staticmethod
+ def _font_variant(font: Any, size: int) -> Any:
+ variant = getattr(font, "font_variant", None)
+ return variant(size=size) if variant is not None else font
+
+ @classmethod
+ def _text_fits(
+ cls,
+ draw: Any,
+ text: str,
+ width: int,
+ height: int,
+ font: Any,
+ ) -> bool:
+ line_height = int(getattr(font, "size", 24) * 1.35)
+ lines = cls._wrapped_lines(draw, text or "资料待补充", width, font)
+ return len(lines) * line_height + max(0, len(lines) - 1) * 8 <= height
def _draw_manager(
self,
@@ -252,40 +462,48 @@ class PromotionPosterRenderer:
y: int,
colors: dict[str, str],
fonts: dict[str, Any],
+ *,
+ panel_height: int = 355,
+ photo_width: int = 300,
) -> None:
panel_width = 1620
- panel_height = 355
draw.rectangle(
(x, y, x + panel_width, y + panel_height),
- fill=colors["white"],
+ fill=colors["panel"],
outline=colors["line"],
width=2,
)
- photo_box = (x + 30, y + 28, x + 330, y + 325)
+ photo_height = min(297, panel_height - 56)
+ photo_box = (x + 30, y + 28, x + 30 + photo_width, y + 28 + photo_height)
if photo_path and Path(photo_path).exists():
from PIL import Image
with Image.open(photo_path) as source:
- portrait = self._contain(source.convert("RGB"), 300, 297)
+ portrait = self._contain(source.convert("RGB"), photo_width, photo_height)
canvas.paste(portrait, (photo_box[0], photo_box[1]))
else:
draw.rectangle(photo_box, fill=colors["secondary"])
draw.text(
- (photo_box[0] + 70, photo_box[1] + 125),
+ (photo_box[0] + 70, photo_box[1] + max(20, photo_height // 2 - 10)),
"未上传头像",
font=fonts["small"],
fill=colors["muted"],
)
- draw.line((x + 365, y + 28, x + 365, y + 325), fill=colors["line"], width=2)
+ text_x = x + photo_width + 95
+ draw.line(
+ (text_x - 45, y + 28, text_x - 45, y + panel_height - 30),
+ fill=colors["line"],
+ width=2,
+ )
self._draw_wrapped(
draw,
body,
- x + 410,
+ text_x,
y + 36,
- 1120,
+ panel_width - photo_width - 135,
fonts["body"],
colors["ink"],
- max_lines=9,
+ max_lines=7 if panel_height < 355 else 9,
line_gap=8,
)
@@ -297,11 +515,18 @@ class PromotionPosterRenderer:
y: int,
width: int,
height: int,
+ colors: dict[str, str],
) -> None:
from PIL import Image
with Image.open(chart_path) as source:
- chart = self._contain(source.convert("RGB"), width, height)
+ chart = self._contain(
+ source.convert("RGB"),
+ width,
+ height,
+ fill=colors["panel_alt"],
+ )
+ chart = self._recolor_light_background(chart, colors["panel_alt"])
canvas.paste(chart, (x, y))
def _draw_metrics(
@@ -313,6 +538,13 @@ class PromotionPosterRenderer:
colors: dict[str, str],
fonts: dict[str, Any],
) -> None:
+ backdrop_top = y - 34
+ draw.rectangle(
+ (x, backdrop_top, x + 1620, self.HEIGHT - 115),
+ fill=colors["panel_alt"],
+ outline=colors["line"],
+ width=2,
+ )
metrics: list[str] = []
for line in body.splitlines():
if any(keyword in line for keyword in ("产品收益", "最大回撤", "波动率", "夏普比率")):
@@ -361,7 +593,7 @@ class PromotionPosterRenderer:
) -> None:
draw.rectangle(
(x, y, x + width, y + height),
- fill=colors["white"],
+ fill=colors["panel" if x < self.WIDTH // 2 else "panel_alt"],
outline=colors["line"],
width=2,
)
@@ -374,7 +606,7 @@ class PromotionPosterRenderer:
width - 68,
fonts["body"],
colors["ink"],
- max_lines=11,
+ max_lines=1000,
line_gap=8,
)
@@ -411,6 +643,10 @@ class PromotionPosterRenderer:
fonts: dict[str, Any],
) -> None:
y = self.HEIGHT - 115
+ draw.rectangle(
+ (0, y, self.WIDTH, self.HEIGHT),
+ fill=colors["panel_alt"],
+ )
draw.rectangle((0, y, self.WIDTH, y + 8), fill=colors["primary"])
draw.text(
(90, y + 32),
@@ -420,16 +656,41 @@ class PromotionPosterRenderer:
)
@staticmethod
- def _contain(image: Any, width: int, height: int) -> Any:
+ def _contain(image: Any, width: int, height: int, *, fill: str = "#FFFFFF") -> Any:
from PIL import Image
ratio = min(width / image.width, height / image.height)
size = (max(1, int(image.width * ratio)), max(1, int(image.height * ratio)))
resized = image.resize(size, Image.Resampling.LANCZOS)
- canvas = Image.new("RGB", (width, height), "#FFFFFF")
+ canvas = Image.new("RGB", (width, height), fill)
canvas.paste(resized, ((width - size[0]) // 2, (height - size[1]) // 2))
return canvas
+ @staticmethod
+ def _cover(image: Any, width: int, height: int) -> Any:
+ from PIL import Image
+
+ ratio = max(width / image.width, height / image.height)
+ size = (
+ max(width, int(round(image.width * ratio))),
+ max(height, int(round(image.height * ratio))),
+ )
+ resized = image.resize(size, Image.Resampling.LANCZOS)
+ left = max(0, (resized.width - width) // 2)
+ top = max(0, (resized.height - height) // 2)
+ return resized.crop((left, top, left + width, top + height))
+
+ @staticmethod
+ def _recolor_light_background(image: Any, color: str) -> Any:
+ pixels = image.load()
+ replacement = tuple(int(color[index:index + 2], 16) for index in (1, 3, 5))
+ for y in range(image.height):
+ for x in range(image.width):
+ red, green, blue = pixels[x, y]
+ if red >= 242 and green >= 242 and blue >= 242:
+ pixels[x, y] = replacement
+ return image
+
@staticmethod
def _draw_wrapped(
draw: Any,
@@ -443,6 +704,17 @@ class PromotionPosterRenderer:
max_lines: int,
line_gap: int = 6,
) -> int:
+ lines = PromotionPosterRenderer._wrapped_lines(draw, text, width, font)
+ lines = lines[:max_lines]
+ cursor = y
+ line_height = int(font.size * 1.35)
+ for line in lines:
+ draw.text((x, cursor), line, font=font, fill=fill)
+ cursor += line_height + line_gap
+ return cursor
+
+ @staticmethod
+ def _wrapped_lines(draw: Any, text: str, width: int, font: Any) -> list[str]:
lines: list[str] = []
for paragraph in str(text).splitlines() or [""]:
current = ""
@@ -454,10 +726,4 @@ class PromotionPosterRenderer:
else:
current = candidate
lines.append(current)
- lines = lines[:max_lines]
- cursor = y
- line_height = int(font.size * 1.35)
- for line in lines:
- draw.text((x, cursor), line, font=font, fill=fill)
- cursor += line_height + line_gap
- return cursor
+ return lines
diff --git a/app/service/promotion_renderer.py b/app/service/promotion_renderer.py
index 33af8a7..cf40136 100644
--- a/app/service/promotion_renderer.py
+++ b/app/service/promotion_renderer.py
@@ -1,4 +1,4 @@
-"""产品推介材料固定模板渲染器。"""
+"""产品推介材料安全渲染器。"""
from __future__ import annotations
@@ -12,6 +12,8 @@ STYLE_CONFIG: dict[str, dict[str, str]] = {
"accent": "E16A36",
"background": "FFF9F2",
"surface": "FFFFFF",
+ "panel": "F3E5D9",
+ "panel_alt": "EBD8C9",
"ink": "56372D",
"muted": "9A7564",
"headline": "FFF2DF",
@@ -23,6 +25,8 @@ STYLE_CONFIG: dict[str, dict[str, str]] = {
"accent": "D9A441",
"background": "F6F9FC",
"surface": "FFFFFF",
+ "panel": "DCEAF3",
+ "panel_alt": "D1E2ED",
"ink": "19324A",
"muted": "65798B",
"headline": "F7D98A",
@@ -34,6 +38,8 @@ STYLE_CONFIG: dict[str, dict[str, str]] = {
"accent": "54A7D7",
"background": "F4FAFE",
"surface": "FFFFFF",
+ "panel": "DCEEF7",
+ "panel_alt": "D1E5F0",
"ink": "18344F",
"muted": "66849A",
"headline": "F4D889",
@@ -42,8 +48,83 @@ STYLE_CONFIG: dict[str, dict[str, str]] = {
}
+def resolve_visual_palette(
+ style_code: str,
+ background_path: str | Path | None = None,
+ stored_palette: Any = None,
+) -> dict[str, str]:
+ """从 AI 底图提取协调色,供 PPTX 与长图共同使用。
+
+ 模型只负责底图氛围,正文可读性仍由渲染器控制。面板色保持明亮但不取纯白,
+ 让底图的主色能穿透到内容区,同时保证中文和图表仍有足够对比度。
+ """
+ style = STYLE_CONFIG.get(style_code, STYLE_CONFIG["balanced_allocation"])
+ palette = dict(style)
+ if isinstance(stored_palette, dict):
+ for key in ("panel", "panel_alt", "line"):
+ value = stored_palette.get(key)
+ if isinstance(value, str) and _is_hex_color(value):
+ palette[key] = value.upper()
+ if not all(key in palette for key in ("panel", "panel_alt")):
+ palette.setdefault("panel", style["secondary"])
+ palette.setdefault("panel_alt", style["background"])
+
+ source_path = Path(background_path) if background_path else None
+ if source_path and source_path.exists() and not isinstance(stored_palette, dict):
+ try:
+ from PIL import Image, ImageStat
+
+ with Image.open(source_path) as source:
+ sample = source.convert("RGB")
+ sample.thumbnail((64, 64))
+ average = tuple(int(value) for value in ImageStat.Stat(sample).mean)
+ base_panel = _hex_to_rgb(palette["panel"])
+ secondary = _hex_to_rgb(style["secondary"])
+ primary = _hex_to_rgb(style["primary"])
+ tinted = _mix_rgb(average, base_panel, 0.55)
+ panel = _mix_rgb(tinted, (248, 250, 250), 0.58)
+ if _luminance(panel) > 244:
+ panel = _mix_rgb(panel, secondary, 0.45)
+ palette["panel"] = _rgb_to_hex(panel)
+ palette["panel_alt"] = _rgb_to_hex(_mix_rgb(panel, secondary, 0.42))
+ palette["line"] = _rgb_to_hex(_mix_rgb(panel, primary, 0.48))
+ except (OSError, ValueError, TypeError):
+ # 底图只影响美化,不应阻断材料生成。
+ pass
+ palette.setdefault("line", palette["panel_alt"])
+ return palette
+
+
+def _hex_to_rgb(value: str) -> tuple[int, int, int]:
+ return tuple(int(value[index:index + 2], 16) for index in (0, 2, 4)) # type: ignore[return-value]
+
+
+def _is_hex_color(value: str) -> bool:
+ return len(value) == 6 and all(char in "0123456789abcdefABCDEF" for char in value)
+
+
+def _rgb_to_hex(value: tuple[int, int, int]) -> str:
+ return "".join(f"{max(0, min(255, channel)):02X}" for channel in value)
+
+
+def _mix_rgb(
+ first: tuple[int, int, int],
+ second: tuple[int, int, int],
+ second_weight: float,
+) -> tuple[int, int, int]:
+ weight = max(0.0, min(1.0, second_weight))
+ return tuple(
+ int(round(left * (1 - weight) + right * weight))
+ for left, right in zip(first, second)
+ ) # type: ignore[return-value]
+
+
+def _luminance(value: tuple[int, int, int]) -> float:
+ return 0.2126 * value[0] + 0.7152 * value[1] + 0.0722 * value[2]
+
+
class PromotionPptxRenderer:
- """只允许结构化草稿填充固定版面,禁止模型自行决定布局。"""
+ """只允许内部白名单版式影响坐标,禁止模型自行决定自由坐标。"""
def render(
self,
@@ -52,6 +133,7 @@ class PromotionPptxRenderer:
*,
photo_path: str | None = None,
chart_paths: list[str] | None = None,
+ background_path: str | None = None,
) -> str:
try:
from pptx import Presentation
@@ -63,11 +145,17 @@ class PromotionPptxRenderer:
style_code = str(draft.get("style_code") or "balanced_allocation")
style = STYLE_CONFIG.get(style_code, STYLE_CONFIG["balanced_allocation"])
- primary = RGBColor.from_string(style["primary"]) # type: ignore[no-untyped-call]
- secondary = RGBColor.from_string(style["secondary"]) # type: ignore[no-untyped-call]
+ palette = resolve_visual_palette(
+ style_code,
+ background_path,
+ draft.get("visual_palette"),
+ )
+ primary = RGBColor.from_string(palette["primary"]) # type: ignore[no-untyped-call]
+ secondary = RGBColor.from_string(palette["secondary"]) # type: ignore[no-untyped-call]
accent = RGBColor.from_string(style["accent"]) # type: ignore[no-untyped-call]
- background = RGBColor.from_string(style["background"]) # type: ignore[no-untyped-call]
- surface = RGBColor.from_string(style["surface"]) # type: ignore[no-untyped-call]
+ background = RGBColor.from_string(palette["background"]) # type: ignore[no-untyped-call]
+ surface = RGBColor.from_string(palette["panel"]) # type: ignore[no-untyped-call]
+ surface_alt = RGBColor.from_string(palette["panel_alt"]) # type: ignore[no-untyped-call]
ink = RGBColor.from_string(style["ink"]) # type: ignore[no-untyped-call]
muted = RGBColor.from_string(style["muted"]) # type: ignore[no-untyped-call]
font_name = style["font"]
@@ -78,10 +166,25 @@ class PromotionPptxRenderer:
blank = presentation.slide_layouts[6]
chapters = draft.get("chapters", [])
chart_paths = chart_paths or []
+ layout_plan = draft.get("layout_plan") or {}
+ page_plans = {
+ str(item.get("chapter_title")): str(item.get("layout"))
+ for item in layout_plan.get("page_plans", [])
+ if isinstance(item, dict)
+ }
+ background_theme = str(layout_plan.get("background_theme") or "none")
for index, chapter in enumerate(chapters):
slide = presentation.slides.add_slide(blank)
self._paint_background(slide, background if index else primary)
+ if background_path and Path(background_path).exists():
+ self._add_background_picture(slide, background_path)
+ self._add_color_overlay(
+ slide,
+ primary if index == 0 else background,
+ transparency=46 if index == 0 else 70,
+ )
+ self._paint_visual_background(slide, background_theme, primary, secondary, accent)
self._add_text(
slide,
chapter.get("title", ""),
@@ -127,8 +230,13 @@ class PromotionPptxRenderer:
RGBColor.from_string(style["headline"]), # type: ignore[no-untyped-call]
font_name,
)
+ cover_layout = layout_plan.get("cover_layout")
+ if cover_layout == "cover_visual_right" and not (
+ photo_path and Path(photo_path).exists()
+ ):
+ self._add_visual_panel(slide, 8.55, 1.0, 3.7, 4.7, accent, secondary)
if photo_path and Path(photo_path).exists():
- self._add_panel(slide, 8.7, 1.15, 3.35, 4.35, surface)
+ self._add_panel(slide, 8.7, 1.15, 3.35, 4.35, surface, transparency=18 if background_path else 0)
self._add_picture_contain(
slide,
photo_path,
@@ -152,7 +260,7 @@ class PromotionPptxRenderer:
if chapter.get("chart_index") is not None:
chart_index = int(chapter["chart_index"])
if chart_index < len(chart_paths) and Path(chart_paths[chart_index]).exists():
- self._add_panel(slide, 0.72, 1.2, 11.9, 5.15, surface)
+ self._add_panel(slide, 0.72, 1.2, 11.9, 5.15, surface, transparency=22 if background_path else 0)
self._add_picture_contain(
slide,
chart_paths[chart_index],
@@ -175,18 +283,31 @@ class PromotionPptxRenderer:
)
else:
body = chapter.get("body", "")
- self._add_panel(slide, 0.72, 1.2, 11.9, 5.15, surface)
- self._add_text(
- slide,
- body,
- 1.0,
- 1.48,
- 11.3,
- 4.55,
- 18,
- ink,
- font_name,
- )
+ page_layout = page_plans.get(str(chapter.get("title")), "single_column")
+ if page_layout == "two_columns":
+ self._add_panel(slide, 0.72, 1.2, 5.72, 5.15, surface, transparency=22 if background_path else 0)
+ self._add_panel(slide, 6.89, 1.2, 5.72, 5.15, surface_alt, transparency=22 if background_path else 0)
+ left_body, right_body = self._split_body(str(body or ""))
+ self._add_text(slide, left_body, 1.0, 1.48, 5.15, 4.55, 16, ink, font_name)
+ self._add_text(slide, right_body, 7.17, 1.48, 5.15, 4.55, 16, ink, font_name)
+ elif page_layout == "manager_profile" and photo_path and Path(photo_path).exists():
+ self._add_panel(slide, 0.72, 1.2, 3.1, 5.15, surface_alt, transparency=18 if background_path else 0)
+ self._add_picture_contain(
+ slide, photo_path, left=0.98, top=1.55, width=2.58, height=4.45
+ )
+ self._add_panel(slide, 4.05, 1.2, 8.57, 5.15, surface, transparency=22 if background_path else 0)
+ self._add_text(slide, body, 4.35, 1.48, 8.0, 4.55, 17, ink, font_name)
+ elif page_layout == "visual_focus":
+ # 低密度页面保留全部原文,并把背景视觉集中到右侧,避免空白堆积。
+ self._add_panel(slide, 0.72, 1.2, 7.15, 5.15, surface, transparency=22 if background_path else 0)
+ self._add_text(slide, body, 1.0, 1.48, 6.55, 4.55, 18, ink, font_name)
+ self._add_visual_panel(slide, 8.18, 1.2, 4.44, 5.15, accent, secondary)
+ else:
+ self._add_panel(slide, 0.72, 1.2, 11.9, 5.15, surface, transparency=22 if background_path else 0)
+ text_size = 16 if page_layout == "full_width_disclosure" else 18
+ self._add_text(
+ slide, body, 1.0, 1.48, 11.3, 4.55, text_size, ink, font_name
+ )
self._add_text(
slide,
f"{index + 1:02d}",
@@ -208,6 +329,13 @@ class PromotionPptxRenderer:
presentation.save(str(output))
return str(output)
+ @staticmethod
+ def _split_body(body: str) -> tuple[str, str]:
+ """按原始换行切分文字,不改写任何字符。"""
+ lines = body.splitlines()
+ midpoint = (len(lines) + 1) // 2
+ return "\n".join(lines[:midpoint]), "\n".join(lines[midpoint:])
+
@staticmethod
def _paint_background(slide: Any, color: Any) -> None:
from pptx.enum.shapes import MSO_SHAPE
@@ -222,6 +350,93 @@ class PromotionPptxRenderer:
slide.shapes._spTree.remove(shape._element)
slide.shapes._spTree.insert(2, shape._element)
+ @staticmethod
+ def _paint_visual_background(
+ slide: Any,
+ theme: str,
+ primary: Any,
+ secondary: Any,
+ accent: Any,
+ ) -> None:
+ from pptx.enum.shapes import MSO_SHAPE
+ from pptx.util import Inches
+
+ if theme == "none":
+ return
+ for index in range(5):
+ line = slide.shapes.add_shape(
+ MSO_SHAPE.RECTANGLE,
+ Inches(8.8 + index * 0.55),
+ Inches(0.2 + index * 0.18),
+ Inches(3.0),
+ Inches(0.015),
+ )
+ line.fill.solid()
+ line.fill.fore_color.rgb = accent if index % 2 else secondary
+ line.fill.transparency = 35
+ line.line.fill.background()
+ if theme == "geometric_grid":
+ for index in range(1, 7):
+ marker = slide.shapes.add_shape(
+ MSO_SHAPE.OVAL,
+ Inches(11.2 + (index % 2) * 0.55),
+ Inches(1.1 + index * 0.45),
+ Inches(0.08),
+ Inches(0.08),
+ )
+ marker.fill.solid()
+ marker.fill.fore_color.rgb = primary
+ marker.line.fill.background()
+
+ @staticmethod
+ def _add_background_picture(slide: Any, background_path: str) -> None:
+ from pptx.util import Inches
+
+ picture = slide.shapes.add_picture(
+ background_path, Inches(0), Inches(0), width=Inches(13.333), height=Inches(7.5)
+ )
+ slide.shapes._spTree.remove(picture._element)
+ slide.shapes._spTree.insert(2, picture._element)
+
+ @staticmethod
+ def _add_color_overlay(slide: Any, color: Any, *, transparency: int) -> None:
+ from pptx.enum.shapes import MSO_SHAPE
+ from pptx.util import Inches
+
+ overlay = slide.shapes.add_shape(
+ MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), Inches(13.333), Inches(7.5)
+ )
+ overlay.fill.solid()
+ overlay.fill.fore_color.rgb = color
+ overlay.fill.transparency = transparency
+ overlay.line.fill.background()
+
+ @staticmethod
+ def _add_visual_panel(
+ slide: Any,
+ left: float,
+ top: float,
+ width: float,
+ height: float,
+ accent: Any,
+ secondary: Any,
+ ) -> None:
+ from pptx.enum.shapes import MSO_SHAPE
+ from pptx.util import Inches
+
+ panel = slide.shapes.add_shape(
+ MSO_SHAPE.ROUNDED_RECTANGLE,
+ Inches(left),
+ Inches(top),
+ Inches(width),
+ Inches(height),
+ )
+ panel.fill.solid()
+ panel.fill.fore_color.rgb = secondary
+ panel.fill.transparency = 18
+ panel.line.color.rgb = accent
+ panel.line.transparency = 35
+
@staticmethod
def _decorate_slide(
slide: Any,
@@ -284,6 +499,8 @@ class PromotionPptxRenderer:
width: float,
height: float,
color: Any,
+ *,
+ transparency: int = 0,
) -> None:
from pptx.enum.shapes import MSO_SHAPE
from pptx.util import Inches
@@ -297,6 +514,7 @@ class PromotionPptxRenderer:
)
panel.fill.solid()
panel.fill.fore_color.rgb = color
+ panel.fill.transparency = transparency
panel.line.fill.background()
@staticmethod
diff --git a/app/service/run_query_service.py b/app/service/run_query_service.py
index 4ba8f1b..99740ff 100644
--- a/app/service/run_query_service.py
+++ b/app/service/run_query_service.py
@@ -1,4 +1,5 @@
import asyncio
+import logging
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any
@@ -7,7 +8,12 @@ from app.core.config import get_settings
from app.core.contracts import RequestContext
from app.core.errors import RunNotFoundError
from app.infrastructure.db import SessionFactory
+from app.model.conversation import ConversationMessage
from app.repository.conversation_repository import ConversationRepository
+from app.service.financial_nl2sql_service import FinancialNL2SQLService
+from app.core.nl2sql_contracts import FinancialNL2SQLInput
+
+logger = logging.getLogger(__name__)
@dataclass(frozen=True)
@@ -25,6 +31,7 @@ class RunSnapshot:
class RunQueryService:
async def get(self, run_id: str, context: RequestContext) -> RunSnapshot:
+ recovery_question: str | None = None
async with SessionFactory() as session:
rows = await ConversationRepository(session).run_result(run_id, int(context.user_id))
if rows is None:
@@ -37,11 +44,19 @@ class RunQueryService:
# 旧行取不到就按 False 处理,不猜、也不因为缺字段让整个响应失败。
transfer_required = False
transfer_reason = None
+ result_data: dict[str, Any] = {}
+ generated_sql: str | None = None
stored_calls = message.tool_calls
if isinstance(stored_calls, dict):
transfer_required = bool(stored_calls.get("transfer_required", False))
reason = stored_calls.get("transfer_reason")
transfer_reason = str(reason) if reason else None
+ stored_data = stored_calls.get("data")
+ if isinstance(stored_data, dict):
+ result_data = stored_data
+ stored_sql = stored_calls.get("sql")
+ if isinstance(stored_sql, str):
+ generated_sql = stored_sql
result = {"content": message.content, "tool_calls": stored_calls,
"intent": message.intent,
"confidence": str(message.confidence) if message.confidence else None,
@@ -50,11 +65,49 @@ class RunQueryService:
# 前端据此判断"这轮要不要转人工",不必再去猜兜底话术的开头。
"transfer_required": transfer_required,
"transfer_reason": transfer_reason}
- return RunSnapshot(
- run.run_id, run.trace_id, run.status, run.agent_type, run.session_id, result,
- run.error_code, run.created_at.isoformat() + "Z",
- run.completed_at.isoformat() + "Z" if run.completed_at else None,
+ if run.agent_type == "financial_nl2sql":
+ result["data"] = result_data
+ result["sql"] = generated_sql
+ if not result_data or not generated_sql:
+ request_message = await session.get(
+ ConversationMessage, run.request_message_id
+ )
+ if request_message is not None:
+ recovery_question = request_message.content
+ if recovery_question:
+ result = await self._recover_financial_result(
+ result, recovery_question, context
)
+ return RunSnapshot(
+ run.run_id, run.trace_id, run.status, run.agent_type, run.session_id, result,
+ run.error_code, run.created_at.isoformat() + "Z",
+ run.completed_at.isoformat() + "Z" if run.completed_at else None,
+ )
+
+ async def _recover_financial_result(
+ self, result: dict[str, Any], question: str, context: RequestContext
+ ) -> dict[str, Any]:
+ """兼容旧 Worker 只保存工具成功摘要、未保存查询载荷的历史运行。"""
+ try:
+ recovered = await FinancialNL2SQLService().query(
+ FinancialNL2SQLInput(question=question), context
+ )
+ except Exception:
+ logger.warning(
+ "历史 NL2SQL 结果补取失败,保留原运行摘要 trace_id=%s",
+ context.trace_id,
+ exc_info=True,
+ )
+ return result
+ if recovered.get("status") != "success":
+ return result
+ recovered_data = recovered.get("data")
+ recovered_sql = recovered.get("sql")
+ if isinstance(recovered_data, dict):
+ result["data"] = recovered_data
+ if isinstance(recovered_sql, str):
+ result["sql"] = recovered_sql
+ return result
async def watch(
self, initial: RunSnapshot, context: RequestContext
diff --git a/app/static/portal/README.md b/app/static/portal/README.md
index 5bab63c..6764d3f 100644
--- a/app/static/portal/README.md
+++ b/app/static/portal/README.md
@@ -19,7 +19,7 @@
| `/portal/employee-console/workspace/` | admin / super_admin | A002-A006、A012、A033、A035-A040、客服转人工管理接口 |
| `/portal/employee-risk/dashboard/` | risk_operator / admin / super_admin | `/api/v1/risk/**`、R001-R003 |
| `/portal/employee-advisor/dashboard/` | advisor / admin / super_admin | `/api/v1/advisor/recommendations/published`(本人 + 名下归属客户的**已发布**交付物) |
-| `/portal/employee-operations/dashboard/` | operator / admin / super_admin | `/api/v1/offsite-fund/mails`、`/api/v1/offsite-fund/mailbox-status` |
+| 运营账号默认进入 `/portal/employee-operations/offsite/` | operator / admin / super_admin | `/api/v1/offsite-fund/mails`、`/api/v1/offsite-fund/mailbox-status` |
> 投顾页的数据口径:接口按「本人 + `sys_customer_assignment` 里名下归属客户」过滤,且同时覆盖
> `investment_goal_book`(方案书,发布后 `review_status='published'`)与
diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js
index d6c8497..75457c7 100644
--- a/app/static/portal/common/api-client.js
+++ b/app/static/portal/common/api-client.js
@@ -116,7 +116,8 @@ const ENDPOINTS = Object.freeze({
PROMOTION_TASK: { method: 'GET', path: '/api/v1/fund-promotion-materials/{taskNo}' },
PROMOTION_INPUTS: { method: 'PUT', path: '/api/v1/fund-promotion-materials/{taskNo}/inputs', idempotent: true },
PROMOTION_ATTACHMENT: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/attachments', formData: true, idempotent: true },
- PROMOTION_GENERATE: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/generations', idempotent: true },
+ // 阿里云背景图生成采用异步任务,前端等待时间必须覆盖后端的 90 秒上游超时。
+ PROMOTION_GENERATE: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/generations', idempotent: true, timeout: 120000 },
PROMOTION_CHECKS: { method: 'GET', path: '/api/v1/fund-promotion-materials/{taskNo}/compliance-checks' },
PROMOTION_REVIEW: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/reviews', idempotent: true },
PROMOTION_DELIVERY: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/deliveries', idempotent: true },
diff --git a/app/static/portal/common/auth.js b/app/static/portal/common/auth.js
index 9ac1935..07482c1 100644
--- a/app/static/portal/common/auth.js
+++ b/app/static/portal/common/auth.js
@@ -259,7 +259,7 @@ export function staffHomeForRoles(roles = []) {
if (roles.includes('admin') || roles.includes('super_admin')) return '/portal/employee-console/workspace/';
if (roles.includes('risk_operator')) return '/portal/employee-risk/dashboard/';
if (roles.includes('advisor')) return '/portal/employee-advisor/dashboard/';
- if (roles.includes('operator')) return '/portal/employee-operations/dashboard/';
+ if (roles.includes('operator')) return '/portal/employee-operations/offsite/';
return '/portal/employee-console/workspace/';
}
diff --git a/app/static/portal/common/layout/app-shell.js b/app/static/portal/common/layout/app-shell.js
index 1e4b31d..2b4a2c8 100644
--- a/app/static/portal/common/layout/app-shell.js
+++ b/app/static/portal/common/layout/app-shell.js
@@ -3,7 +3,7 @@ import {
getAuthContext,
startAuthSync,
switchAccount,
-} from '/static/portal/common/auth.js?v=20260913';
+} from '/static/portal/common/auth.js?v=20260916';
import { escapeHtml } from '/static/portal/common/formatters.js';
import { mountCustomerServiceWidget } from '/static/portal/common/customer-service-widget/widget.js';
@@ -32,7 +32,6 @@ const ADVISOR_LINKS = [
];
const OPERATOR_LINKS = [
- ['operator-dashboard', '运营工作台', '/portal/employee-operations/dashboard/'],
['operator-offsite', '场外申赎', '/portal/employee-operations/offsite/'],
['operator-promotion', '推介材料', '/portal/employee-operations/promotion/'],
['operator-nl2sql', 'NL2SQL', '/portal/employee-operations/nl2sql/'],
diff --git a/app/static/portal/employee-operations/dashboard/dashboard.css b/app/static/portal/employee-operations/dashboard/dashboard.css
deleted file mode 100644
index 2b1495e..0000000
--- a/app/static/portal/employee-operations/dashboard/dashboard.css
+++ /dev/null
@@ -1,20 +0,0 @@
-.operator-shell { --brand: #9a6d32; --brand-dark: #765122; --brand-soft: #f5ead9; --canvas: #f7f5f1; }
-.operator-hero { background-image: linear-gradient(118deg, rgba(52, 38, 24, .96), rgba(107, 76, 39, .84) 64%, rgba(145, 108, 64, .7)), url('/static/portal/guest/home/assets/wealth_architecture_hero.jpg'); }
-.operator-grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(280px, .85fr); gap: var(--space-4); }
-.operator-mail { padding: var(--space-4); display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-2); align-items: start; border-bottom: 1px solid var(--line); }
-.operator-mail:last-child { border-bottom: 0; }
-.operator-mail__title { margin: 0; font-size: 15px; font-weight: 680; overflow-wrap: anywhere; }
-.operator-mail__meta { margin: 4px 0 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.55; }
-.operator-mail__status { align-self: center; color: var(--brand-dark); background: var(--brand-soft); }
-.operator-status__card { padding: var(--space-4); background: var(--surface-soft); border-radius: var(--radius-sm); }
-.operator-status__card strong { display: block; margin-bottom: 5px; font-size: 18px; }
-.operator-status__card p { margin: 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.65; }
-.operator-module-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-4); }
-.operator-module-card { min-height: 148px; padding: var(--space-5); display: grid; align-content: start; gap: var(--space-2); color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: var(--shadow-card); transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease; }
-.operator-module-card:hover, .operator-module-card:focus-visible { color: var(--ink); border-color: var(--brand); outline: 0; transform: translateY(-2px); box-shadow: var(--shadow-elevated); }
-.operator-module-card__index { color: var(--brand-dark); font: 700 var(--fs-small)/1 Consolas, monospace; letter-spacing: .08em; }
-.operator-module-card strong { font-size: 18px; }
-.operator-module-card > span:last-child { color: var(--muted); font-size: var(--fs-small); line-height: 1.6; }
-.heading-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
-@media (max-width: 760px) { .operator-grid { grid-template-columns: 1fr; } }
-@media (max-width: 860px) { .operator-module-grid { grid-template-columns: 1fr; } }
diff --git a/app/static/portal/employee-operations/dashboard/dashboard.js b/app/static/portal/employee-operations/dashboard/dashboard.js
deleted file mode 100644
index c239965..0000000
--- a/app/static/portal/employee-operations/dashboard/dashboard.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import { apiClient } from '/static/portal/common/api-client.js?v=20260913';
-import { getAuthContext, requireOperator } from '/static/portal/common/auth.js?v=20260913';
-import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js';
-import { mountShell } from '/static/portal/common/layout/app-shell.js';
-import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js';
-
-if (requireOperator()) {
- mountShell({ active: 'operator-dashboard', mode: 'operator' });
- const context = getAuthContext();
- const mails = document.querySelector('[data-mails]');
- const mailbox = document.querySelector('[data-mailbox]');
- document.querySelector('[data-operator-name]').textContent = context?.username || '运营人员';
- document.querySelector('[data-operator-scope]').textContent = `数据范围:${context?.dataScope || 'assigned'}`;
- async function load() {
- renderLoading(mails, 4); renderLoading(mailbox, 1);
- try {
- const [mailResponse, boxResponse] = await Promise.all([
- apiClient.get('OFFSITE_MAILS', { query: { page: 1, page_size: 8 } }),
- apiClient.get('OFFSITE_MAILBOX'),
- ]);
- const payload = mailResponse.data || {};
- const rows = Array.isArray(payload.items) ? payload.items : [];
- const box = boxResponse.data || {};
- document.querySelector('[data-operator-metrics]').innerHTML = [['待处理邮件', rows.length, `本页 ${payload.total ?? rows.length} 封`], ['收件监控', box.monitoring ? '运行中' : '未启用', '服务端状态'], ['游标状态', box.status || '--', box.blocked ? '需要恢复' : '正常']].map(([label, value, meta]) => ` ${label} ${escapeHtml(String(value))}${escapeHtml(row.subject || row.mail_id || '未命名邮件')}
邮箱:${escapeHtml(box.mailbox || '--')}
最后 UID:${escapeHtml(String(box.last_uid ?? '--'))}
${box.alert_message ? escapeHtml(box.alert_message) : '收件游标运行正常,失败邮件会保留待人工处理。'}
场外运营与资料处理
集中查看收件箱、识别任务与规则状态,所有场外运营流程与场内模拟交易数据严格隔离。
数据运营 · 只读查询链路
支持场外单据核对和通用金融自然语言查询,查询、权限、审计和最终状态均由后端 Agent 链路决定。
调用 POST /api/v1/agent-runs 并轮询 GET /api/v1/agent-runs/{run_id},前端不会使用无授权头的原生 EventSource。
状态完成后展示结果和工具调用摘要。
状态完成后展示结果和工具调用摘要。