From 2548c39c6bc9e7f39bf43762c3ceca95d989a393 Mon Sep 17 00:00:00 2001 From: yujiangjiang12 Date: Mon, 14 Sep 2026 18:13:10 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A2=81=E8=81=AA=E7=9A=84=E6=9C=80=E5=90=8E?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E5=AE=8C=E5=96=84=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 11 +- .gitignore | 3 + app/api/controllers/offsite_fund.py | 2 +- app/api/schemas/offsite_fund.py | 2 +- app/core/config.py | 11 +- app/core/contracts.py | 3 + app/core/promotion_material_contracts.py | 2 - app/service/agent/bootstrap.py | 5 + .../agent/implementations/financial_nl2sql.py | 38 ++ app/service/agent_persistence_service.py | 17 +- app/service/financial_nl2sql_service.py | 4 +- app/service/offsite_fund_service.py | 173 +++++++- app/service/promotion_image_service.py | 173 ++++++++ app/service/promotion_layout_planner.py | 284 ++++++++++++++ app/service/promotion_material_service.py | 54 ++- app/service/promotion_performance.py | 56 +++ app/service/promotion_poster_renderer.py | 368 +++++++++++++++--- app/service/promotion_renderer.py | 258 +++++++++++- app/service/run_query_service.py | 61 ++- app/static/portal/README.md | 2 +- app/static/portal/common/api-client.js | 3 +- app/static/portal/common/auth.js | 2 +- app/static/portal/common/layout/app-shell.js | 3 +- .../dashboard/dashboard.css | 20 - .../dashboard/dashboard.js | 32 -- .../employee-operations/dashboard/index.html | 3 - .../employee-operations/nl2sql/index.html | 14 +- .../employee-operations/nl2sql/nl2sql.css | 164 +++++++- .../employee-operations/nl2sql/nl2sql.js | 55 ++- .../employee-operations/offsite/index.html | 14 +- .../employee-operations/offsite/offsite.js | 122 +++++- .../operator-workspace.css | 50 +++ .../employee-operations/promotion/index.html | 14 +- .../promotion/promotion.css | 74 ++++ .../promotion/promotion.js | 56 +-- .../test_financial_nl2sql_agent_contract.py | 84 ++++ tests/integration/conftest.py | 2 +- tests/integration/test_offsite_fund_api.py | 216 +++++++++- .../test_promotion_material_api.py | 7 + tests/unit/api/test_portal_frontend.py | 62 +++ .../service/test_financial_nl2sql_service.py | 8 + .../unit/service/test_offsite_smtp_adapter.py | 4 +- .../service/test_promotion_image_service.py | 41 ++ .../service/test_promotion_layout_planner.py | 119 ++++++ tests/unit/service/test_promotion_material.py | 37 ++ .../service/test_promotion_poster_renderer.py | 56 ++- tests/unit/service/test_promotion_renderer.py | 14 +- tests/unit/service/test_run_query_service.py | 75 +++- tools/grant_operator_role.py | 9 +- tools/publish_financial_nl2sql_config.py | 198 ++++++++++ 50 files changed, 2818 insertions(+), 267 deletions(-) create mode 100644 app/service/agent/implementations/financial_nl2sql.py create mode 100644 app/service/promotion_image_service.py create mode 100644 app/service/promotion_layout_planner.py delete mode 100644 app/static/portal/employee-operations/dashboard/dashboard.css delete mode 100644 app/static/portal/employee-operations/dashboard/dashboard.js delete mode 100644 app/static/portal/employee-operations/dashboard/index.html create mode 100644 tests/contract/test_financial_nl2sql_agent_contract.py create mode 100644 tests/unit/service/test_promotion_image_service.py create mode 100644 tests/unit/service/test_promotion_layout_planner.py create mode 100644 tools/publish_financial_nl2sql_config.py 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(String(meta))}

`).join(''); - if (!rows.length) renderEmpty(mails, '暂无运营邮件', '当前收件箱没有可展示的场外基金邮件。'); - else mails.innerHTML = rows.map((row) => `

${escapeHtml(row.subject || row.mail_id || '未命名邮件')}

${escapeHtml(row.sender || '--')} · ${escapeHtml(formatDateTime(row.received_at || row.created_at))}

${escapeHtml(row.status || '待处理')}
`).join(''); - mailbox.innerHTML = `
${escapeHtml(box.status || '未初始化')}

邮箱:${escapeHtml(box.mailbox || '--')}
最后 UID:${escapeHtml(String(box.last_uid ?? '--'))}
${box.alert_message ? escapeHtml(box.alert_message) : '收件游标运行正常,失败邮件会保留待人工处理。'}

`; - } catch (error) { apiClient.reportError(error); renderError(mails, error, load); renderError(mailbox, error, load); } - } - document.querySelector('[data-refresh]').addEventListener('click', load); - load(); -} diff --git a/app/static/portal/employee-operations/dashboard/index.html b/app/static/portal/employee-operations/dashboard/index.html deleted file mode 100644 index c9e0d04..0000000 --- a/app/static/portal/employee-operations/dashboard/index.html +++ /dev/null @@ -1,3 +0,0 @@ - -运营工作台 · 南方财富 -

场外运营与资料处理

运营工作台

集中查看收件箱、识别任务与规则状态,所有场外运营流程与场内模拟交易数据严格隔离。

邮件收件识别队列规则核对
运营人员权限加载中
01场外申购和赎回邮件、识别字段、规则核对、通知与清算统计02推介材料生成结构化资料、附件、生成、合规、审核与交付03NL2SQL场外单据核对与通用只读自然语言查询

场外基金收件箱

仅展示未删除的运营邮件

运行状态

收件游标与识别监控

diff --git a/app/static/portal/employee-operations/nl2sql/index.html b/app/static/portal/employee-operations/nl2sql/index.html index 70169cb..413fe5a 100644 --- a/app/static/portal/employee-operations/nl2sql/index.html +++ b/app/static/portal/employee-operations/nl2sql/index.html @@ -3,14 +3,14 @@ - NL2SQL · 运营工作台 + NL2SQL · 南方财富 - - + + -
+

数据运营 · 只读查询链路

NL2SQL

支持场外单据核对和通用金融自然语言查询,查询、权限、审计和最终状态均由后端 Agent 链路决定。

只读查询白名单表权限审计
运营人员权限加载中
@@ -25,12 +25,12 @@
- + diff --git a/app/static/portal/employee-operations/nl2sql/nl2sql.css b/app/static/portal/employee-operations/nl2sql/nl2sql.css index a8cc7ec..86541ea 100644 --- a/app/static/portal/employee-operations/nl2sql/nl2sql.css +++ b/app/static/portal/employee-operations/nl2sql/nl2sql.css @@ -1,8 +1,156 @@ -/* NL2SQL 页面的专属样式入口。 - * - * 本页当前复用 `operator-workspace.css`,这里暂时没有需要覆盖的规则。 - * 文件的存在是**约定**:门户每个页面都要有与目录同名的 js/css 入口 - * (`tests/unit/api/test_portal_frontend.py` 的 - * `test_every_portal_page_has_local_js_and_css_entry` 守着这条)—— - * 这样页面将来需要专属样式时有确定的位置,而不是继续往共用文件里堆。 - */ +/* NL2SQL 的工作区需要同时照顾查询输入、结果阅读和双栏基线。 */ +.nl2sql-page > .panel { + overflow: hidden; +} + +.nl2sql-page .operator-tabs { + padding: 0 28px; + gap: var(--space-2); +} + +.nl2sql-page .operator-tab { + padding-right: 20px; + padding-left: 20px; +} + +.nl2sql-page .operator-view > .panel__body { + padding: 28px 32px 32px; +} + +.nl2sql-page .operator-columns { + grid-template-columns: minmax(340px, 0.85fr) minmax(0, 1.55fr); + gap: 32px; +} + +.nl2sql-page .operator-stack { + gap: 20px; +} + +.nl2sql-page .operator-columns > section { + min-width: 0; +} + +.nl2sql-page .operator-columns > section > .operator-section-heading { + min-height: 48px; + margin-bottom: 20px; +} + +.nl2sql-page .operator-columns > section > [data-offsite-result], +.nl2sql-page .operator-columns > section > [data-general-result] { + display: grid; + gap: 16px; +} + +.nl2sql-page .form-field__input { + padding-right: 16px; + padding-left: 16px; +} + +.nl2sql-page [data-offsite-natural-language], +.nl2sql-page [data-query-text] { + height: 216px; + min-height: 176px; + max-height: 440px; + resize: vertical; + overflow-y: auto; + line-height: 1.75; + padding-top: 14px; + padding-bottom: 14px; +} + +.nl2sql-page [data-query-text] { + height: 220px; + min-height: 180px; +} + +.nl2sql-page .operator-actions { + gap: 12px; + padding-top: 4px; +} + +.nl2sql-page .operator-inline-note { + line-height: 1.75; +} + +.nl2sql-page .nl2sql-output { + margin: 0; + padding: 20px; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--surface-soft); +} + +.nl2sql-page .nl2sql-output__title { + margin: 0 0 16px; + color: var(--ink); + font-size: 1rem; + line-height: 1.4; +} + +.nl2sql-page .nl2sql-output .operator-table-wrap { + margin: 0; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--surface); +} + +.nl2sql-page .operator-kv { + gap: 12px; +} + +.nl2sql-page .operator-kv > div { + padding: 14px 16px; +} + +.nl2sql-page .nl2sql-sql { + margin: 0; + padding: 16px; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--ink); + font: 0.9rem/1.75 Consolas, "Courier New", monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +@media (max-width: 900px) { + .nl2sql-page .operator-columns { + grid-template-columns: 1fr; + gap: 28px; + } +} + +@media (max-width: 620px) { + .nl2sql-page .operator-tabs { + padding: 0 16px; + } + + .nl2sql-page .operator-tab { + padding-right: 14px; + padding-left: 14px; + } + + .nl2sql-page .operator-view > .panel__body { + padding: 20px 16px 24px; + } + + .nl2sql-page .operator-columns { + gap: 24px; + } + + .nl2sql-page .operator-columns > section > .operator-section-heading { + margin-bottom: 16px; + } + + .nl2sql-page .nl2sql-output { + padding: 16px; + } + + .nl2sql-page [data-offsite-natural-language], + .nl2sql-page [data-query-text] { + height: 200px; + min-height: 160px; + } +} diff --git a/app/static/portal/employee-operations/nl2sql/nl2sql.js b/app/static/portal/employee-operations/nl2sql/nl2sql.js index 1dcfb1a..a7600eb 100644 --- a/app/static/portal/employee-operations/nl2sql/nl2sql.js +++ b/app/static/portal/employee-operations/nl2sql/nl2sql.js @@ -1,15 +1,20 @@ -import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; +import { apiClient } from '/static/portal/common/api-client.js?v=20260920'; import { getAuthContext, requireOperator } from '/static/portal/common/auth.js'; import { escapeHtml } from '/static/portal/common/formatters.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; import { showToast } from '/static/portal/common/notifications.js'; +const FINANCIAL_NL2SQL_AGENT_TYPE = 'financial_nl2sql'; + if (requireOperator()) { mountShell({ active: 'operator-nl2sql', mode: 'operator' }); const context = getAuthContext(); const operatorId = String(context?.userId || context?.username || ''); + const pageParams = new URLSearchParams(location.search); const state = { - offsiteTask: new URLSearchParams(location.search).get('task_id') || '', + offsiteTask: pageParams.get('task_id') || '', + returnMailId: pageParams.get('mail_id') || '', + returnPage: pageParams.get('page') || '1', offsiteNaturalLanguage: '', offsiteResult: null, generalRun: null, @@ -31,6 +36,11 @@ if (requireOperator()) { error: '查询失败', pending: '待执行', running: '执行中', + queued: '排队中', + processing: '处理中', + succeeded: '查询成功', + failed: '查询失败', + cancelled: '已取消', }; const RULE_LABELS = { @@ -49,6 +59,35 @@ if (requireOperator()) { } function statusTag(status) { return `${escapeHtml(status || '等待执行')}`; } + function generalResultData(run) { + const result = run?.result; + return result?.data || result?.result?.data || null; + } + function generalGeneratedSql(run) { + return run?.result?.sql || run?.result?.generated_sql || ''; + } + function generalResultRows(run) { + const data = generalResultData(run); + return Array.isArray(data?.rows) ? data.rows : []; + } + function renderGeneralResultDetail(run) { + const rows = generalResultRows(run); + if (rows.length) { + const columns = [...new Set(rows.flatMap((row) => Object.keys(row || {})))]; + return `
${columns.map((column) => ``).join('')}${rows.map((row) => `${columns.map((column) => ``).join('')}`).join('')}
${escapeHtml(column)}
${escapeHtml(String(row?.[column] ?? '--'))}
`; + } + const data = generalResultData(run); + const emptyMessage = data?.total !== undefined + ? `查询返回 ${data.total} 条记录` + : '本次查询没有返回可展示的字段值'; + return `
${escapeHtml(emptyMessage)}
`; + } + function renderGeneralSql(run) { + const sql = generalGeneratedSql(run); + return sql + ? `
${escapeHtml(sql)}
` + : '
本次运行未生成可执行 SQL。
'; + } function buildNaturalLanguage(fields) { const fundCode = String(fields?.fund_code || '').trim(); if (!fundCode) throw new Error('当前单据缺少基金代码,无法生成自然语言'); @@ -71,7 +110,7 @@ if (requireOperator()) { offsiteNaturalLanguage.value = state.offsiteNaturalLanguage; if (backLink) { backLink.href = state.offsiteTask - ? `/portal/employee-operations/offsite/?task_id=${encodeURIComponent(state.offsiteTask)}` + ? `/portal/employee-operations/offsite/?${new URLSearchParams({ task_id: state.offsiteTask, mail_id: state.returnMailId, page: state.returnPage }).toString()}` : '/portal/employee-operations/offsite/'; } document.querySelector('[data-offsite-status]').outerHTML = `${escapeHtml(statusLabel(result?.status))}`; @@ -79,8 +118,7 @@ if (requireOperator()) { } function renderGeneral() { const run = state.generalRun; - document.querySelector('[data-general-status]').textContent = run?.status || '等待执行'; - generalResult.innerHTML = run ? `
运行编号
${escapeHtml(run.run_id)}
Agent 类型
${escapeHtml(run.agent_type || '--')}
状态
${escapeHtml(run.status)}
错误码
${escapeHtml(run.error_code || '--')}
${escapeHtml(JSON.stringify(run.result || run, null, 2))}
` : '
提交问题后,前端会自动轮询运行状态。
'; + generalResult.innerHTML = run ? `
状态
${escapeHtml(statusLabel(run.status))}

最终查询结果

${renderGeneralResultDetail(run)}

AI 生成的 SQL

${renderGeneralSql(run)}
` : '
提交问题后,前端会自动轮询运行状态。
'; } async function poll(runId) { if (state.timer) clearTimeout(state.timer); @@ -112,10 +150,9 @@ if (requireOperator()) { if (action === 'run-general') { const message = document.querySelector('[data-query-text]').value.trim(); if (!message) throw new Error('请输入业务问题'); - const sessionId = document.querySelector('[data-session-id]').value.trim() || `portal-${Date.now()}`; - document.querySelector('[data-session-id]').value = sessionId; - const response = await apiClient.post('AGENT_RUN_CREATE', { agent_type: document.querySelector('[data-agent-type]').value, message, session_id: sessionId, idempotency_key: crypto.randomUUID().replaceAll('-', '') }); - state.generalRun = response.data; renderGeneral(); showToast(`查询已提交:${response.data.run_id}`); poll(response.data.run_id); return; + const sessionId = `portal-${crypto.randomUUID()}`; + const response = await apiClient.post('AGENT_RUN_CREATE', { agent_type: FINANCIAL_NL2SQL_AGENT_TYPE, message, session_id: sessionId, idempotency_key: crypto.randomUUID().replaceAll('-', '') }); + state.generalRun = response.data; renderGeneral(); showToast('查询已提交,正在获取结果'); poll(response.data.run_id); return; } } catch (error) { apiClient.reportError(error); showToast(error.message || '操作失败', 'error'); } } diff --git a/app/static/portal/employee-operations/offsite/index.html b/app/static/portal/employee-operations/offsite/index.html index cd72274..6ee3ffb 100644 --- a/app/static/portal/employee-operations/offsite/index.html +++ b/app/static/portal/employee-operations/offsite/index.html @@ -3,14 +3,14 @@ - 场外申购和赎回 · 运营工作台 + 场外申购和赎回 · 南方财富 - - + + -
+

场外运营 · 业务邮件处理

@@ -42,7 +42,7 @@
-

单据核对与运营动作

识别字段、NL2SQL 字段和规则结果分别来自独立后端接口

+

单据核对与运营动作

-

清算统计

只统计已确认正常且正常返回通知发送成功的单据

+

清算统计

- + diff --git a/app/static/portal/employee-operations/offsite/offsite.js b/app/static/portal/employee-operations/offsite/offsite.js index 464b075..22da2c1 100644 --- a/app/static/portal/employee-operations/offsite/offsite.js +++ b/app/static/portal/employee-operations/offsite/offsite.js @@ -1,4 +1,4 @@ -import { apiClient } from '/static/portal/common/api-client.js?v=20260915'; +import { apiClient } from '/static/portal/common/api-client.js?v=20260921'; import { getAuthContext, requireOperator } from '/static/portal/common/auth.js'; import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; @@ -21,13 +21,15 @@ if (requireOperator()) { mountShell({ active: 'operator-offsite', mode: 'operator' }); const context = getAuthContext(); const operatorId = String(context?.userId || context?.username || ''); + const pageParams = new URLSearchParams(location.search); const state = { - page: 1, pageSize: 10, total: 0, mails: [], mailbox: null, activeMailStatus: 'all', + page: Number(pageParams.get('page')) > 0 ? Number(pageParams.get('page')) : 1, pageSize: 10, total: 0, mails: [], mailbox: null, activeMailStatus: 'all', mailDetails: {}, selectedMailId: '', mail: null, recognition: null, documents: [], nl2sql: {}, rules: {}, ocrDrafts: {}, nlDrafts: {}, notice: null, noticeTaskId: '', noticeDraft: '', stats: null, statsForm: { fundCode: '', applicationDate: '' }, recalculatingTasks: new Set(), + requestedMailId: pageParams.get('mail_id') || '', }; const root = document.querySelector('main'); const targets = { @@ -69,6 +71,23 @@ if (requireOperator()) { }[String(status || '').toLowerCase()] || '未查询'; } + function documentStatusLabel(status) { + return { + operator_confirmed: '已处理', + recognition_exception: '识别异常', + recognition_review: '待复核', + recognition_retrying: '识别重试中', + query_failed: '查询失败', + planned: '已入库', + recognized: '已入库', + received: '已入库', + normal_return_sent: '已回执', + completed: '已完成', + deleted: '已删除', + processing: '处理中', + }[String(status || '').toLowerCase()] || status || '已读取'; + } + function documentNotificationType(document) { if (document?.operator_decision === '确认正常') return 'normal_return'; if (document?.operator_decision === '确认异常') return 'exception_return'; @@ -156,6 +175,58 @@ if (requireOperator()) { return { actual: '', rule: '', expression: '' }; } + function numericValue(value) { + if (value === null || value === undefined || value === '') return null; + const normalized = Number(String(value).replace(/,/g, '').replace(/%$/, '')); + return Number.isFinite(normalized) ? normalized : null; + } + + function formatFixed(value, digits = 2) { + const number = numericValue(value); + if (number === null) return value === undefined || value === null || value === '' ? '--' : String(value); + return number.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); + } + + function formatAmount(value) { + return formatFixed(value, 2); + } + + function formatNav(value) { + return formatFixed(value, 4); + } + + function isNumericDisplayField(name) { + return /金额|份额|总份额|持有份额|可用份额|subscription_amount_yuan|redemption_amount_yuan|redemption_shares/.test(String(name || '')); + } + + function formatFieldValue(name, value) { + if (/净值|nav/i.test(String(name || ''))) return formatNav(value); + if (isNumericDisplayField(name)) return formatAmount(value); + return value === undefined || value === null ? '' : String(value); + } + + function formatExpression(value) { + return String(value || '').replace(/-?\d+(?:\.\d+)?(?:e[+-]?\d+)?%?/gi, (match) => { + const isPercent = match.endsWith('%'); + const number = numericValue(match); + if (number === null) return match; + return `${number.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}${isPercent ? '%' : ''}`; + }); + } + + function renderDocumentValue(row) { + const documentValue = row?.document_value || {}; + const subscriptionAmount = documentValue.申购金额 ?? documentValue.申购金额元 ?? documentValue.subscription_amount_yuan; + if (subscriptionAmount !== undefined && subscriptionAmount !== null && subscriptionAmount !== '') { + return `申购金额:${formatAmount(subscriptionAmount)}`; + } + const redemptionAmount = documentValue.赎回金额 ?? documentValue.赎回金额元 ?? documentValue.redemption_amount_yuan ?? documentValue.赎回份额; + if (redemptionAmount !== undefined && redemptionAmount !== null && redemptionAmount !== '') { + return `赎回金额:${formatAmount(redemptionAmount)}`; + } + return '--'; + } + function renderRuleComparison(row) { const comparison = ruleComparison(row); if (!comparison.actual && !comparison.rule) { @@ -164,11 +235,7 @@ if (requireOperator()) { const conclusion = row.result === '正常' ? '满足规则' : row.result === '异常' ? '不满足规则' : '无法判断'; const tone = row.result === '正常' ? 'operator-comparison--normal' : row.result === '异常' ? 'operator-comparison--abnormal' : 'operator-comparison--unknown'; return `
-
-
实际值${escapeHtml(value(comparison.actual))}
-
规则值${escapeHtml(value(comparison.rule))}
-
- ${comparison.expression ? `
${escapeHtml(comparison.expression)} · ${conclusion}
` : `
${conclusion}
`} +
${escapeHtml(comparison.expression ? `${formatExpression(comparison.expression)} · ${conclusion}` : conclusion)}
`; } @@ -229,7 +296,12 @@ if (requireOperator()) { function renderFieldGrid(fields, draft, scope, key, statuses = {}) { const names = fieldNames(fields, draft); if (!names.length) return '
暂无可展示字段。
'; - return `
${names.map((name) => ``).join('')}
`; + return `
${names.map((name) => { + const statusLabel = fieldStatusLabel(statuses[name]); + const shouldShowStatus = statuses[name] !== undefined && statuses[name] !== null && statuses[name] !== '' && statusLabel !== '未查询'; + const fieldValue = draft[name] ?? fields?.[name] ?? ''; + return ``; + }).join('')}
`; } function renderDetail() { @@ -274,9 +346,9 @@ if (requireOperator()) { const notificationAction = notificationType ? `notice:${encodedTaskId}:${notificationType}` : `notice:${encodedTaskId}`; - return `
${escapeHtml(taskId)}

${escapeHtml(document.document_type || '单据')} · ${escapeHtml(document.fund_name || document.fund_code || '--')} · ${escapeHtml(document.application_no || '--')}

${tag(document.operator_decision || displayStatus.label, document.operator_decision === '确认异常' ? 'high' : displayStatus.tone)}
申请日期
${escapeHtml(value(document.application_date))}
申购金额 / 赎回份额
${escapeHtml(value(document.subscription_amount_yuan))} / ${escapeHtml(value(document.redemption_shares))}
机构
${escapeHtml(value(document.agency))}
基金代码
${escapeHtml(value(document.fund_code))}
+ return `
${escapeHtml(taskId)}

${escapeHtml(document.document_type || '单据')} · ${escapeHtml(document.fund_name || document.fund_code || '--')} · ${escapeHtml(document.application_no || '--')}

${tag(document.operator_decision || displayStatus.label, document.operator_decision === '确认异常' ? 'high' : displayStatus.tone)}
申请日期
${escapeHtml(value(document.application_date))}
申购金额 / 赎回份额
${escapeHtml(formatAmount(document.subscription_amount_yuan))} / ${escapeHtml(formatAmount(document.redemption_shares))}
机构
${escapeHtml(value(document.agency))}
基金代码
${escapeHtml(value(document.fund_code))}

NL2SQL 返回字段

${escapeHtml(nl?.updated_at ? formatDateTime(nl.updated_at, true) : '尚未核对')}
${nl?.error ? errorText({ message: nl.error }) : renderFieldGrid(nl?.effective_fields || nl?.fields, draft, 'nl', taskId, nl?.field_status || {})}
${actionButton('保存', `save-nl:${encodeURIComponent(taskId)}`, 'primary')}${actionButton('进入 NL2SQL', `open-nl:${encodeURIComponent(taskId)}`)}
-

规则结果

${rule ? tag(rule.document_status || '已读取') : ''}
${rule?.error ? errorText({ message: rule.error }) : `
${ruleRows.map((row) => ``).join('') || ''}
规则结果单据值实际值 / 规则值
${escapeHtml(row.rule_name || row.rule_code || '--')}${tag(row.result)}${escapeHtml(value(row.document_value))}${renderRuleComparison(row)}
暂无规则结果,请先执行 NL2SQL 核对。
`}
${actionButton(recalculating ? '正在核对并判定...' : '重新核对并判定规则', `recalculate:${encodeURIComponent(taskId)}`, 'primary', recalculating)}${actionButton('确认正常', `confirm:${encodeURIComponent(taskId)}:确认正常`)}${actionButton('确认异常', `confirm:${encodeURIComponent(taskId)}:确认异常`, 'danger')}${actionButton(notificationType ? `创建${NOTIFICATION_LABELS[notificationType]}` : '创建通知', notificationAction)}
`; +

规则结果

${rule ? tag(documentStatusLabel(rule.document_status)) : ''}
${rule?.error ? errorText({ message: rule.error }) : `
${ruleRows.map((row) => ``).join('') || ''}
规则结果单据值规则判断
${escapeHtml(row.rule_name || row.rule_code || '--')}${tag(row.result)}${escapeHtml(renderDocumentValue(row))}${renderRuleComparison(row)}
暂无规则结果,请先执行 NL2SQL 核对。
`}
${actionButton(recalculating ? '正在核对并判定规则' : '重新核对并判定规则', `recalculate:${encodeURIComponent(taskId)}`, 'primary', recalculating)}${actionButton('确认正常', `confirm:${encodeURIComponent(taskId)}:确认正常`)}${actionButton('确认异常', `confirm:${encodeURIComponent(taskId)}:确认异常`, 'danger')}${actionButton(notificationType ? `创建${NOTIFICATION_LABELS[notificationType]}` : '请先确认正常或异常', notificationAction, '', !notificationType)}
`; }).join(''); } @@ -293,8 +365,8 @@ if (requireOperator()) { const items = Array.isArray(state.stats?.items) ? state.stats.items : state.stats ? [state.stats] : []; - const blocks = items.map((item) => `

${escapeHtml(value(item.fund_name, '未识别基金'))}

基金代码:${escapeHtml(value(item.fund_code))}

${escapeHtml(value(item.application_date))}
申购金额 / 笔数
${escapeHtml(value(item.subscription_amount_yuan, '0'))} / ${escapeHtml(value(item.subscription_count, '0'))}
赎回份额 / 金额
${escapeHtml(value(item.redemption_shares, '0'))} / ${escapeHtml(value(item.redemption_amount_yuan))}
净流入 / 流出
${escapeHtml(value(item.net_flow_amount_yuan))}
最新净值
${escapeHtml(value(item.latest_nav))}
`).join(''); - targets.statistics.innerHTML = `
${state.stats ? (items.length ? `

共 ${items.length} 只基金,每只基金单独汇总。

${blocks}
` : '
当天没有符合条件的清算单据。
') : '

基金代码留空时,将统计申请日期当天全部基金。

'}`; + const blocks = items.map((item) => `

${escapeHtml(value(item.fund_name, '未识别基金'))}

基金代码:${escapeHtml(value(item.fund_code))}

${escapeHtml(value(item.application_date))}
申购金额 / 笔数
${escapeHtml(formatAmount(item.subscription_amount_yuan ?? '0'))} / ${escapeHtml(value(item.subscription_count, '0'))}
赎回份额 / 金额
${escapeHtml(formatAmount(item.redemption_shares ?? '0'))} / ${escapeHtml(formatAmount(item.redemption_amount_yuan))}
净流入 / 流出
${escapeHtml(formatAmount(item.net_flow_amount_yuan))}
最新净值
${escapeHtml(formatNav(item.latest_nav))}
`).join(''); + targets.statistics.innerHTML = `
${state.stats ? (items.length ? `

共 ${items.length} 只基金,每只基金单独汇总。

${blocks}
` : '
当天没有符合条件的清算单据。
') : ''}`; } function render() { @@ -377,7 +449,9 @@ if (requireOperator()) { state.total = Number(payload.total || 0); state.mailbox = mailbox.data || {}; await loadMailStatusDetails(state.mails); - if (!state.selectedMailId || !state.mails.some((item) => item.mail_id === state.selectedMailId)) { + if (state.requestedMailId && state.mails.some((item) => item.mail_id === state.requestedMailId)) { + state.selectedMailId = state.requestedMailId; + } else if (!state.selectedMailId || !state.mails.some((item) => item.mail_id === state.selectedMailId)) { state.selectedMailId = state.mails[0]?.mail_id || ''; } if (state.selectedMailId) await loadMail(state.selectedMailId); @@ -408,7 +482,7 @@ if (requireOperator()) { if (action.startsWith('page:')) { const pages = Math.max(1, Math.ceil(state.total / state.pageSize)); state.page = Math.min(pages, Math.max(1, state.page + (action.endsWith('next') ? 1 : -1))); await load(); return; } if (action.startsWith('delete-mail:')) { const mailId = decodeURIComponent(action.slice(12)); - if (!window.confirm('确认删除这封邮件吗?删除后只会从运营列表隐藏,原始邮件和识别记录仍保留。')) return; + if (!window.confirm('确认彻底删除这封邮件吗?邮件、附件、OCR、NL2SQL、规则、确认和通知记录都会删除,删除审计会保留。')) return; await apiClient.post('OFFSITE_MAIL_DELETE', { operator_id: operatorId }, { pathParams: { mailId } }); state.selectedMailId = ''; await load(); showToast('邮件已删除'); return; } @@ -420,14 +494,17 @@ if (requireOperator()) { const saved = state.recognition.attachments?.find((item) => item.attachment_id === attachmentId); state.ocrDrafts[attachmentId] = { ...(saved?.effective_fields || {}) }; syncDocumentsFromRecognition(state.recognition); - showToast('OCR 识别字段修正已保存'); render(); return; + await loadMail(state.selectedMailId); + showToast('OCR 识别字段修正已保存'); return; } if (action.startsWith('save-nl:')) { const taskId = decodeURIComponent(action.slice(8)); const response = await apiClient.post('OFFSITE_NL2SQL_FIELDS_SAVE', { operator_id: operatorId, fields: state.nlDrafts[taskId] || {} }, { pathParams: { taskId } }); state.nl2sql[taskId] = response.data; state.nlDrafts[taskId] = { ...(response.data.effective_fields || response.data.fields || {}) }; - showToast('NL2SQL 字段修正已保存'); render(); return; + await apiClient.post('OFFSITE_RULE_RECALCULATE', { operator_id: operatorId }, { pathParams: { taskId } }); + await loadMail(state.selectedMailId); + showToast('NL2SQL 字段修正已保存'); return; } if (action.startsWith('retry:')) { const taskId = decodeURIComponent(action.slice(6)); @@ -477,9 +554,14 @@ if (requireOperator()) { await apiClient.post('OFFSITE_CONFIRM', { decision, operator_id: operatorId }, { pathParams: { taskId } }); await loadMail(state.selectedMailId); showToast(`单据已${decision}`); return; } - if (action.startsWith('open-nl:')) { window.location.href = `/portal/employee-operations/nl2sql/?task_id=${encodeURIComponent(decodeURIComponent(action.slice(8)))}`; return; } + if (action.startsWith('open-nl:')) { + const taskId = decodeURIComponent(action.slice(8)); + const params = new URLSearchParams({ task_id: taskId, mail_id: state.selectedMailId, page: String(state.page) }); + window.location.href = `/portal/employee-operations/nl2sql/?${params.toString()}`; + return; + } if (action.startsWith('notice:')) { - const actionPayload = action.slice(6); + const actionPayload = action.slice(7); const separator = actionPayload.lastIndexOf(':'); const encodedTaskId = separator >= 0 ? actionPayload.slice(0, separator) : actionPayload; const noticeType = separator >= 0 ? actionPayload.slice(separator + 1) : ''; @@ -517,8 +599,8 @@ if (requireOperator()) { }); root.addEventListener('input', (event) => { const target = event.target; - if (target.matches('[data-ocr-field]')) { state.ocrDrafts[target.dataset.ocrField] ||= {}; state.ocrDrafts[target.dataset.ocrField][target.dataset.fieldName] = target.value; } - if (target.matches('[data-nl-field]')) { state.nlDrafts[target.dataset.nlField] ||= {}; state.nlDrafts[target.dataset.nlField][target.dataset.fieldName] = target.value; } + if (target.matches('[data-ocr-field]')) { state.ocrDrafts[target.dataset.ocrField] ||= {}; state.ocrDrafts[target.dataset.ocrField][target.dataset.fieldName] = isNumericDisplayField(target.dataset.fieldName) || /净值|nav/i.test(target.dataset.fieldName) ? target.value.replace(/,/g, '') : target.value; } + if (target.matches('[data-nl-field]')) { state.nlDrafts[target.dataset.nlField] ||= {}; state.nlDrafts[target.dataset.nlField][target.dataset.fieldName] = isNumericDisplayField(target.dataset.fieldName) || /净值|nav/i.test(target.dataset.fieldName) ? target.value.replace(/,/g, '') : target.value; } if (target.matches('[data-stat-field]')) { state.statsForm[target.dataset.statField] = target.value; } if (target.matches('[data-notice-content]')) state.noticeDraft = target.value; }); diff --git a/app/static/portal/employee-operations/operator-workspace.css b/app/static/portal/employee-operations/operator-workspace.css index 14d3631..90d0006 100644 --- a/app/static/portal/employee-operations/operator-workspace.css +++ b/app/static/portal/employee-operations/operator-workspace.css @@ -81,3 +81,53 @@ .operator-sticky-actions { position: sticky; bottom: var(--space-3); z-index: 2; padding: var(--space-3); display: flex; flex-wrap: wrap; gap: var(--space-2); background: color-mix(in srgb, var(--surface) 92%, transparent); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: var(--shadow-menu); backdrop-filter: blur(8px); } @media (max-width: 900px) { .operator-columns { grid-template-columns: 1fr; } .operator-page-heading { align-items: start; flex-direction: column; } .operator-page-heading__actions { justify-content: flex-start; } .operator-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .operator-mail-panel > .panel__header, .operator-mail-detail > .panel__header { min-height: 0; } } @media (max-width: 620px) { .operator-field-grid, .operator-kv, .operator-form-grid, .operator-form-grid--wide { grid-template-columns: 1fr; } .operator-list__item, .operator-attachment__header, .operator-document__header, .operator-section-heading { grid-template-columns: 1fr; flex-direction: column; } .operator-mail-status-row { flex-wrap: wrap; } .operator-mail-status-actions { width: 100%; justify-content: flex-end; } .operator-file-result { grid-template-columns: 1fr; gap: var(--space-1); } } + +/* 三个运营工作台采用更舒展的内容节奏,避免文字和边框贴得过紧。 */ +.operator-workspace--spacious { gap: var(--space-6); } +.operator-workspace--spacious .panel__header { padding: 20px 28px; } +.operator-workspace--spacious .panel--flush > .panel__body { padding: 28px; } +.operator-workspace--spacious .operator-list { padding: 8px; } +.operator-workspace--spacious .operator-form-grid, +.operator-workspace--spacious .operator-field-grid { gap: 20px; } +.operator-workspace--spacious .operator-stack, +.operator-workspace--spacious .operator-detail__body { gap: var(--space-6); } +.operator-workspace--spacious .operator-field-grid, +.operator-workspace--spacious .operator-kv { margin-top: 20px; } +.operator-workspace--spacious .operator-attachment, +.operator-workspace--spacious .operator-document, +.operator-workspace--spacious .operator-field-card, +.operator-workspace--spacious .operator-result-card { padding: 20px; } +.operator-workspace--spacious .operator-subsection { margin-top: 20px; } +.operator-workspace--spacious .operator-actions, +.operator-workspace--spacious .operator-list__actions { gap: var(--space-3); } +.operator-workspace--spacious .form-field__input { padding-right: 16px; padding-left: 16px; } +.operator-workspace--spacious .operator-form-grid textarea.form-field__input { + min-height: 88px; + padding-top: 14px; + padding-bottom: 14px; + line-height: 1.7; +} +.operator-workspace--spacious .operator-text, +.operator-workspace--spacious .operator-json { padding: 20px; line-height: 1.8; } +.operator-workspace--spacious .operator-table th, +.operator-workspace--spacious .operator-table td { padding: 14px 16px; line-height: 1.6; } +.operator-workspace--spacious .operator-file-result { padding: 14px 16px; } +.operator-workspace--spacious .nl2sql-output { margin-top: 20px; padding: 20px; } + +@media (max-width: 620px) { + .operator-workspace--spacious { gap: var(--space-5); } + .operator-workspace--spacious .panel__header { padding: 18px 20px; } + .operator-workspace--spacious .panel--flush > .panel__body { padding: 20px; } + .operator-workspace--spacious .operator-list { padding: 4px; } + .operator-workspace--spacious .operator-form-grid, + .operator-workspace--spacious .operator-field-grid { gap: 16px; } + .operator-workspace--spacious .operator-stack, + .operator-workspace--spacious .operator-detail__body { gap: 20px; } + .operator-workspace--spacious .operator-attachment, + .operator-workspace--spacious .operator-document, + .operator-workspace--spacious .operator-field-card, + .operator-workspace--spacious .operator-result-card, + .operator-workspace--spacious .operator-text, + .operator-workspace--spacious .operator-json, + .operator-workspace--spacious .nl2sql-output { padding: 16px; } +} diff --git a/app/static/portal/employee-operations/promotion/index.html b/app/static/portal/employee-operations/promotion/index.html index 150c94c..72a3230 100644 --- a/app/static/portal/employee-operations/promotion/index.html +++ b/app/static/portal/employee-operations/promotion/index.html @@ -3,14 +3,14 @@ - 推介材料生成 · 运营工作台 + 推介材料生成 · 南方财富 - - + + -
+

产品运营 · 资料编排

推介材料生成

维护结构化产品资料,上传来源附件,生成可审核的演示文稿、宣传长图和可选 PDF。

结构化输入材料生成合规审核
运营人员权限加载中
@@ -29,18 +29,18 @@

产品信息

管理人信息

团队与策略

-

费用、业绩与风险

+

费用、业绩与风险

业务附件

-
+

生成、合规、审核与交付

- + diff --git a/app/static/portal/employee-operations/promotion/promotion.css b/app/static/portal/employee-operations/promotion/promotion.css index 995c3ce..a800b19 100644 --- a/app/static/portal/employee-operations/promotion/promotion.css +++ b/app/static/portal/employee-operations/promotion/promotion.css @@ -6,3 +6,77 @@ * `test_every_portal_page_has_local_js_and_css_entry` 守着这条)—— * 这样页面将来需要专属样式时有确定的位置,而不是继续往共用文件里堆。 */ + +/* 当前页表单项较多,统一同组控件尺寸,长文本框保留手动拉伸能力。 */ +.promotion-page .form-field__input { + height: 50px; +} + +.promotion-page .operator-form-grid > .form-field { + min-width: 0; + align-self: stretch; +} + +.promotion-page .operator-form-grid > .form-field > .form-field__input { + height: 72px; +} + +.promotion-page .operator-form-grid textarea.form-field__input { + height: 72px; + min-height: 72px; + max-height: 420px; + resize: vertical; + overflow-y: auto; + line-height: 1.7; + padding-top: 12px; + padding-bottom: 12px; +} + +.promotion-page .operator-form-grid input[type="file"].form-field__input { + height: 52px; + padding: 8px 12px; +} + +.promotion-page .operator-form-grid input[type="file"].form-field__input::file-selector-button { + min-height: 34px; + margin-right: 10px; + padding: 0 12px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + color: var(--ink); + background: var(--surface-soft); + cursor: pointer; +} + +.promotion-page .promotion-attachments-grid { + grid-template-columns: repeat(2, minmax(280px, 420px)); + justify-content: center; + gap: 24px; +} + +.promotion-page .operator-checkboxes { + margin-top: 8px; + gap: 16px 24px; +} + +@media (max-width: 900px) { + .promotion-page .promotion-attachments-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 620px) { + .promotion-page .form-field__input { + height: 64px; + } + + .promotion-page .operator-form-grid textarea.form-field__input { + height: 72px; + min-height: 72px; + } + + .promotion-page .promotion-attachments-grid { + grid-template-columns: 1fr; + gap: 16px; + } +} diff --git a/app/static/portal/employee-operations/promotion/promotion.js b/app/static/portal/employee-operations/promotion/promotion.js index d27f1c9..6f328e8 100644 --- a/app/static/portal/employee-operations/promotion/promotion.js +++ b/app/static/portal/employee-operations/promotion/promotion.js @@ -4,7 +4,7 @@ import { escapeHtml } from '/static/portal/common/formatters.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; import { showToast } from '/static/portal/common/notifications.js'; -const ATTACHMENT_LABELS = { manager_photo: '基金经理照片', performance_data: '业绩数据文件', source_evidence: '资料来源附件', template_file: '固定模板文件' }; +const ATTACHMENT_LABELS = { manager_photo: '基金经理照片', performance_data: '业绩数据文件' }; const FUND_RISK_LEVELS = { 货币型: 'R1', 债券型: 'R2', @@ -13,6 +13,8 @@ const FUND_RISK_LEVELS = { 期货型: 'R5', }; const MANAGER_PROFILE_DEFAULT = '拥有多年证券与衍生品投资研究经验,曾先后任职于国内头部期货公司资产管理部、公募基金量化与衍生品投资部,历任研究员、投资经理、基金经理。对商品期货、股指期货、国债期货及多资产组合管理有深入实战积累,擅长在严格风险预算下运用衍生品工具进行方向性配置与对冲。'; +const RESEARCH_CAPABILITY_DEFAULT = '覆盖宏观、行业、个券和风险管理等研究维度。我们始终相信,超额收益的背后,是研究深度和认知差。在研究体系上,我们坚持“宏观定方向、产业找趋势、公司看质量、估值定纪律”。一方面,通过宏观与策略研究判断周期位置和政策方向;另一方面,深入产业链和公司基本面,验证商业模式、竞争壁垒、管理层质量与盈利可持续性。'; +const STRATEGY_DEFAULT = '通过大类资产配置、基本面研究和风险预算动态调整组合。在投资管理上,我们以大类资产配置定方向,以基本面研究定标的,以风险预算定仓位与节奏。通过自上而下的资产配置判断,结合自下而上的基本面研究,并依据风险预算对组合进行动态调整,力求在多变市场环境中实现风险与收益的平衡。'; const DEFAULT_PROMOTION_INPUTS = { product_info: { fund_type: '混合型', @@ -32,11 +34,11 @@ const DEFAULT_PROMOTION_INPUTS = { }, team_info: { team_description: '由投资、研究和风险管理人员组成完整投研团队,执行独立决策、协同研究和持续风险管理。', - research_capability: '覆盖宏观、行业、个券和风险管理等研究维度。', + research_capability: RESEARCH_CAPABILITY_DEFAULT, }, strategy_info: { investment_scope: '股票、债券、货币市场工具及法律法规允许的其他资产。', - strategy: '通过大类资产配置、基本面研究和风险预算动态调整组合。', + strategy: STRATEGY_DEFAULT, restrictions: '遵守法律法规、基金合同及监管限制。', index_tool_attribute: '支持指数增强与风险预算分析。', }, @@ -51,9 +53,9 @@ const DEFAULT_PROMOTION_INPUTS = { }, performance_info: { as_of_date: '', - history_months: 24, + history_months: '', product_return: '', - max_drawdown: '-3.6%', + max_drawdown: '', volatility: '12%', sharpe_ratio: '0.8', show_product_performance: false, @@ -74,13 +76,25 @@ if (requireOperator()) { function field(name) { return document.querySelector(`[data-promo-field="${name}"]`); } function createField(name) { return document.querySelector(`[data-create-field="${name}"]`); } function selectedFormats() { return [...document.querySelectorAll('[data-format]:checked')].map((item) => item.dataset.format); } + function validateFormats(formats) { + if (!formats.length) throw new Error('至少选择一种输出格式'); + if (formats.length > 2) throw new Error('最多选择两种输出格式'); + return formats; + } function text(name) { return String(field(name)?.value || '').trim(); } function nullable(name) { return text(name) || null; } - function setFieldValue(name, value) { + function setFieldValue(name, value, overwrite = false) { const target = field(name); - if (!target || target.value || value === undefined || value === null || value === '') return; + if (!target || (!overwrite && target.value) || value === undefined || value === null || value === '') return; target.value = String(value); } + function applyPerformanceSummary(summary) { + if (!summary) return; + ['as_of_date', 'history_months', 'product_return', 'max_drawdown'].forEach((key) => { + const value = summary[key]; + if (value !== undefined && value !== null) setFieldValue(`performance_info.${key}`, value, true); + }); + } function applyDefaultInputs() { Object.entries(DEFAULT_PROMOTION_INPUTS).forEach(([group, values]) => { Object.entries(values).forEach(([key, value]) => { @@ -133,35 +147,24 @@ if (requireOperator()) { result.innerHTML = `${generated ? `
材料生成结果已返回
${resultFile('PPTX', generated.pptx_path)}${resultFile('宣传长图', generated.poster_path)}${resultFile('PDF', generated.pdf_path)}${(generated.chart_paths || []).map((item, index) => resultFile(`图表 ${index + 1}`, item)).join('')}
${state.reviewDecision === 'approved' ? `
` : ''}` : ''}

合规检查结果

${findings.length} 条
${findings.length ? findings.map((item) => `
${escapeHtml(item.rule_name || item.rule_code || '合规规则')}
${escapeHtml(item.suggestion || item.hit_text || '已返回检查结果')} · ${escapeHtml(item.severity || '--')}
`).join('') : ''}
`; const reviewDecision = result.querySelector('[data-review-decision]'); if (reviewDecision) reviewDecision.value = state.reviewDecision; - autosizeTextareas(result); - } - function autosizeTextarea(target) { - target.style.height = 'auto'; - target.style.height = `${Math.max(target.scrollHeight, 72)}px`; - } - function autosizeTextareas(root = document) { - root.querySelectorAll('textarea').forEach(autosizeTextarea); } function validateAttachment(type, file) { const extensions = { manager_photo: ['.jpg', '.jpeg', '.png', '.webp'], performance_data: ['.csv', '.xlsx', '.xlsm'], - source_evidence: ['.pdf', '.docx', '.xlsx', '.csv'], - template_file: ['.pptx'], }; const filename = String(file.name || '').toLowerCase(); const extension = filename.includes('.') ? filename.slice(filename.lastIndexOf('.')) : ''; if (!extensions[type]?.includes(extension)) { throw new Error(`${ATTACHMENT_LABELS[type]}仅支持:${extensions[type].join('、')}`); } - const maxSize = type === 'manager_photo' ? 10 * 1024 * 1024 : type === 'performance_data' ? 20 * 1024 * 1024 : type === 'source_evidence' ? 30 * 1024 * 1024 : 50 * 1024 * 1024; + const maxSize = type === 'manager_photo' ? 10 * 1024 * 1024 : 20 * 1024 * 1024; if (file.size > maxSize) throw new Error(`${ATTACHMENT_LABELS[type]}不能超过 ${Math.round(maxSize / 1024 / 1024)} MB`); } async function run(action) { try { if (action === 'create-task') { - const formats = selectedFormats(); - if (!formats.length) throw new Error('至少选择一种输出格式'); + const formats = validateFormats(selectedFormats()); const response = await apiClient.post('PROMOTION_CREATE', taskPayload()); state.taskNo = response.data.task_no; state.task = response.data; setTaskStatus(response.data.status); showToast(`材料任务已创建:${state.taskNo}`); return; } @@ -185,11 +188,14 @@ if (requireOperator()) { validateAttachment(type, file); const form = new FormData(); form.append('file', file, file.name); - await apiClient.upload('PROMOTION_ATTACHMENT', form, { + const response = await apiClient.upload('PROMOTION_ATTACHMENT', form, { pathParams: { taskNo: state.taskNo }, query: { attachment_type: type }, idempotencyKey: `${state.taskNo}-${type}-${file.name}-${file.size}`, }); + if (type === 'performance_data') { + applyPerformanceSummary(response.data.performance_summary); + } uploaded.push(ATTACHMENT_LABELS[type]); } catch (error) { failed.push(`${ATTACHMENT_LABELS[type]}上传失败:${error.message}`); @@ -201,7 +207,11 @@ if (requireOperator()) { } if (action === 'generate') { try { - const response = await apiClient.post('PROMOTION_GENERATE', { output_formats: selectedFormats() }, { pathParams: { taskNo: state.taskNo } }); + const response = await apiClient.post( + 'PROMOTION_GENERATE', + { output_formats: validateFormats(selectedFormats()) }, + { pathParams: { taskNo: state.taskNo } }, + ); state.generated = response.data; state.checks = response.data.findings || state.checks; setTaskStatus(response.data.status); renderResult(); showToast('材料生成结果已返回'); } catch (error) { const findings = error.payload?.data?.findings; @@ -235,7 +245,5 @@ if (requireOperator()) { document.querySelector('[data-promotion-result]').addEventListener('change', (event) => { if (event.target.matches('[data-review-decision]')) { state.reviewDecision = event.target.value; renderResult(); } }); applyDefaultInputs(); syncTaskDefaults(); - document.querySelectorAll('textarea').forEach((textarea) => textarea.addEventListener('input', () => autosizeTextarea(textarea))); - autosizeTextareas(); renderResult(); } diff --git a/tests/contract/test_financial_nl2sql_agent_contract.py b/tests/contract/test_financial_nl2sql_agent_contract.py new file mode 100644 index 0000000..32edb94 --- /dev/null +++ b/tests/contract/test_financial_nl2sql_agent_contract.py @@ -0,0 +1,84 @@ +"""金融 NL2SQL Agent 的注册、声明和只读工具调用契约。""" + +from typing import Any, cast + +import pytest + +from app.core.contracts import ( + AgentDefinition, + AgentRequest, + CoreResult, + RequestContext, + ResolvedAgentConfig, + ToolCallRecord, +) +from app.service.agent.base import BaseAgent +from app.service.agent.factory import AgentFactory +from app.service.agent.implementations.financial_nl2sql import FinancialNL2SQLAgent +from app.service.tool_executor import ToolExecution + + +class StubGovernance: + async def resolve( + self, definition: AgentDefinition, context: RequestContext + ) -> ResolvedAgentConfig: + del definition, context + return ResolvedAgentConfig( + config_version="test", + prompt_version="test", + model_endpoint="", + allowed_tools_by_intent={ + "financial_query": ("query_financial_data",), + "general": ("query_financial_data",), + }, + ) + + async def recall(self, context: RequestContext) -> tuple[Any, ...]: + del context + return () + + async def review(self, result: Any, context: RequestContext, config: ResolvedAgentConfig, + memories: tuple[Any, ...], *, agent_type: str = "") -> Any: + del context, config, memories, agent_type + return result + + +class StubExecutor: + def __init__(self) -> None: + self.calls: list[tuple[str, str, dict[str, Any]]] = [] + + async def execute( + self, *, name: str, arguments: dict[str, Any], intent: str, + configured_tools: dict[str, tuple[str, ...]], context: RequestContext, + ) -> ToolExecution: + del context + assert name in configured_tools[intent] + self.calls.append((name, intent, arguments)) + return ToolExecution( + output={ + "message": "查询成功", + "data": {"total": 1, "rows": [{"nav": "1.250000"}]}, + "sql": "SELECT n.nav AS nav FROM fin_nav n WHERE 1=1 LIMIT 50", + }, + record=ToolCallRecord(tool_name=name, status="succeeded"), + ) + + +@pytest.mark.asyncio +async def test_financial_agent_is_registered_and_calls_read_only_tool() -> None: + executor = StubExecutor() + factory = AgentFactory(cast(Any, StubGovernance()), tool_executor=cast(Any, executor)) + factory.register(FinancialNL2SQLAgent.definition, + lambda _context: FinancialNL2SQLAgent(FinancialNL2SQLAgent.definition)) + context = RequestContext(user_id="10001", trace_id="financial-contract", roles=("operator",), + permissions=("agent:run", "financial:nl2sql:read"), data_scope="all") + agent = factory.create("financial_nl2sql", context) + assert isinstance(agent, BaseAgent) + await agent.resolve_config(context) + request = AgentRequest(agent_type="financial_nl2sql", message="查询15911最新净值", + session_id="financial-session", idempotency_key="financial-key-123456") + result = await agent.handle(request, context) + assert result.text == "查询成功" + assert result.data == {"total": 1, "rows": [{"nav": "1.250000"}]} + assert result.sql == "SELECT n.nav AS nav FROM fin_nav n WHERE 1=1 LIMIT 50" + assert executor.calls[0][0:2] == ("query_financial_data", "financial_query") diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index cb38383..d39a3dc 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -11,7 +11,7 @@ def isolate_offsite_runtime_settings(monkeypatch: pytest.MonkeyPatch) -> None: settings = get_settings().model_copy( update={ "offsite_allowed_senders": ("15008108550@163.com",), - "offsite_mail_return_receiver": "15008108550@163.com", + "offsite_mail_return_receiver": "yuan80818843@163.com", } ) monkeypatch.setattr("app.service.offsite_fund_service.get_settings", lambda: settings) diff --git a/tests/integration/test_offsite_fund_api.py b/tests/integration/test_offsite_fund_api.py index e9650c9..a70486f 100644 --- a/tests/integration/test_offsite_fund_api.py +++ b/tests/integration/test_offsite_fund_api.py @@ -1,5 +1,6 @@ import asyncio from collections.abc import AsyncIterator +from datetime import UTC, datetime from decimal import Decimal from pathlib import Path from uuid import uuid4 @@ -19,6 +20,7 @@ from app.main import app from app.model.audit import InteractionAudit from app.model.offsite_fund import ( OffsiteExecutionPlanTask, + OffsiteFieldCorrection, OffsiteFundAttachment, OffsiteFundDocument, OffsiteFundMail, @@ -142,8 +144,20 @@ def test_offsite_recognized_mail_persists_workflow_and_notification() -> None: json={"application_date": "2026-09-10"}, ) assert recalc.status_code == 200 - assert recalc.json()["data"]["fund_count"] == 1 - assert recalc.json()["data"]["items"][0]["subscription_amount_yuan"] == "0" + assert recalc.json()["data"]["fund_count"] == 0 + assert recalc.json()["data"]["items"] == [] + + fund_recalc = client.post( + "/api/v1/offsite-fund/settlement-statistics/recalculate", + json={"fund_code": "000001", "application_date": "2026-09-10"}, + ) + assert fund_recalc.status_code == 200 + assert fund_recalc.json()["data"] == { + "application_date": "2026-09-10", + "fund_code": None, + "fund_count": 0, + "items": [], + } notice = client.post( f"/api/v1/offsite-fund/documents/{task_id}/notifications", @@ -159,6 +173,194 @@ def test_offsite_recognized_mail_persists_workflow_and_notification() -> None: TEST_TRACE_ID = "" +@pytest.mark.integration +def test_offsite_delete_physically_cleans_workflow_files_and_allows_new_same_kind_mail( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + global TEST_TRACE_ID + TEST_TRACE_ID = f"trace-offsite-delete-{uuid4()}" + settings = get_settings().model_copy( + update={ + "offsite_allowed_senders": ("15008108550@163.com",), + "offsite_mail_storage_dir": str(tmp_path), + } + ) + monkeypatch.setattr( + "app.service.offsite_fund_service.get_settings", lambda: settings + ) + uid = f"offsite-{uuid4()}" + message_id = f"<{uuid4()}@integration.local>" + eml_path = tmp_path / "old-message.eml" + attachment_path = tmp_path / "old-subscription.pdf" + eml_path.write_bytes(b"old eml") + attachment_path.write_bytes(b"old attachment") + payload = _subscription_payload(uid, message_id) + payload["eml_path"] = str(eml_path) + payload["attachments"][0]["original_file_path"] = str(attachment_path) + + app.dependency_overrides[build_request_context] = override_context + app.dependency_overrides[get_session] = override_session + mail_id = "" + task_id = "" + try: + with TestClient(app) as client: + created = _ingest_recognized_mail(client, payload) + assert created["code"] == 0 + mail_id = created["data"]["mail_id"] + document = created["data"]["documents"][0] + task_id = document["task_id"] + attachment_id = f"{mail_id}-A01" + now = datetime.now(UTC).replace(tzinfo=None) + async def add_extra_rows() -> None: + async with SessionFactory() as session, session.begin(): + session.add( + OffsiteFieldCorrection( + target_type="recognition", + mail_id=mail_id, + attachment_id=attachment_id, + task_id=None, + operator_id="1", + original_fields={"基金代码": "000001"}, + corrected_fields={"基金代码": "000002"}, + changed_fields=["基金代码"], + created_at=now, + ) + ) + session.add( + OffsiteRecognitionAttempt( + task_id=None, + mail_id=None, + attachment_id=None, + imap_uid=uid, + message_id=message_id, + filename="申购申请单.pdf", + file_hash=payload["attachments"][0]["file_hash"], + original_file_path=str(attachment_path), + attempt_no=99, + source="automatic", + operator_id=None, + document_type="subscription", + ocr_status="success", + llm_status="success", + extracted_fields={"基金代码": "000001"}, + field_confidence={"基金代码": "0.99"}, + missing_fields=[], + low_confidence_fields=[], + page_evidence={}, + status="success", + error_message=None, + started_at=now, + finished_at=now, + created_at=now, + ) + ) + session.add( + OffsiteFieldCorrection( + target_type="nl2sql", + mail_id=mail_id, + attachment_id=None, + task_id=task_id, + operator_id="1", + original_fields={"最新净值": "1.00"}, + corrected_fields={"最新净值": "1.25"}, + changed_fields=["最新净值"], + created_at=now, + ) + ) + session.add( + OffsiteQueryRecord( + task_id=task_id, + rule_code="subscription_holding_ratio", + natural_language_request="查询测试数据", + script_path="nl2sql_yc.py", + result_summary={"rows": [{"nav": "1.25"}]}, + status="success", + error_message=None, + created_at=now, + ) + ) + session.add( + OffsiteNotification( + notification_type="settlement", + business_key=task_id, + receiver_id="receiver", + operator_id="1", + agent_draft="测试通知", + final_content="测试通知", + payload={"task_id": task_id}, + status="待发送", + retry_count=0, + provider_message_id=None, + failure_reason=None, + created_at=now, + sent_at=None, + updated_at=now, + ) + ) + + asyncio.run(add_extra_rows()) + deleted = client.post( + f"/api/v1/offsite-fund/mails/{mail_id}/deletions", + json={"operator_id": "1"}, + ) + assert deleted.status_code == 200 + assert deleted.json()["code"] == 0 + assert deleted.json()["data"]["status"] == "deleted" + assert deleted.json()["data"]["deleted_file_count"] == 2 + assert not eml_path.exists() + assert not attachment_path.exists() + + listed = client.get("/api/v1/offsite-fund/mails") + assert listed.status_code == 200 + assert all(item["mail_id"] != mail_id for item in listed.json()["data"]["items"]) + detail = client.get(f"/api/v1/offsite-fund/mails/{mail_id}") + assert detail.status_code == 200 + assert detail.json()["code"] == 404 + + async def assert_deleted_rows() -> None: + async with SessionFactory() as session: + for model, criterion in ( + (OffsiteFundMail, OffsiteFundMail.mail_id == mail_id), + (OffsiteFundAttachment, OffsiteFundAttachment.mail_id == mail_id), + (OffsiteFundDocument, OffsiteFundDocument.task_id == task_id), + (OffsiteRecognitionAttempt, OffsiteRecognitionAttempt.mail_id == mail_id), + (OffsiteFieldCorrection, OffsiteFieldCorrection.mail_id == mail_id), + (OffsiteQueryRecord, OffsiteQueryRecord.task_id == task_id), + (OffsiteRuleResult, OffsiteRuleResult.task_id == task_id), + (OffsiteExecutionPlanTask, OffsiteExecutionPlanTask.task_id == task_id), + (OffsiteNotification, OffsiteNotification.business_key == task_id), + ): + assert await _count(session, model, criterion) == 0 + assert await _count( + session, + InteractionAudit, + InteractionAudit.action_type == "offsite.mail_deleted", + ) >= 1 + + asyncio.run(assert_deleted_rows()) + + # 删除后重新收到同类邮件:新消息号、新 UID,应能重新建立完整业务链路。 + new_eml_path = tmp_path / "new-message.eml" + new_attachment_path = tmp_path / "new-subscription.pdf" + new_eml_path.write_bytes(b"new eml") + new_attachment_path.write_bytes(b"new attachment") + new_payload = _subscription_payload( + f"offsite-{uuid4()}", + f"<{uuid4()}@integration.local>", + ) + new_payload["eml_path"] = str(new_eml_path) + new_payload["attachments"][0]["original_file_path"] = str(new_attachment_path) + with TestClient(app) as client: + recreated = _ingest_recognized_mail(client, new_payload) + assert recreated["code"] == 0 + assert recreated["data"]["mail_id"] == mail_id + assert recreated["data"]["documents"] + finally: + asyncio.run(_cleanup_offsite_rows(mail_id, task_id, TEST_TRACE_ID)) + app.dependency_overrides.clear() + TEST_TRACE_ID = "" + + @pytest.mark.integration def test_offsite_trigger_nl2sql_uses_query_dict_adapter() -> None: global TEST_TRACE_ID @@ -659,6 +861,11 @@ async def _cleanup_offsite_rows(mail_id: str, task_id: str, trace_id: str) -> No ) ) if task_id: + await session.execute( + delete(OffsiteFieldCorrection).where( + OffsiteFieldCorrection.task_id == task_id + ) + ) await session.execute(delete(OffsiteRecognitionAttempt).where( OffsiteRecognitionAttempt.task_id == task_id )) @@ -680,6 +887,11 @@ async def _cleanup_offsite_rows(mail_id: str, task_id: str, trace_id: str) -> No delete(OffsiteFundDocument).where(OffsiteFundDocument.task_id == task_id) ) if mail_id: + await session.execute( + delete(OffsiteFieldCorrection).where( + OffsiteFieldCorrection.mail_id == mail_id + ) + ) await session.execute(delete(OffsiteRecognitionAttempt).where( OffsiteRecognitionAttempt.mail_id == mail_id )) diff --git a/tests/integration/test_promotion_material_api.py b/tests/integration/test_promotion_material_api.py index b38e330..16e6076 100644 --- a/tests/integration/test_promotion_material_api.py +++ b/tests/integration/test_promotion_material_api.py @@ -150,6 +150,13 @@ def test_promotion_material_http_workflow_and_advisor_scope(tmp_path: Path) -> N ) assert performance.status_code == 200 assert performance.json()["data"]["attachment_type"] == "performance_data" + assert performance.json()["data"]["performance_summary"] == { + "initial_date": "2025-01-31", + "as_of_date": "2025-08-31", + "history_months": 7, + "product_return": "7.1%", + "max_drawdown": "0%", + } generation_key = _key("generation") idempotency_keys.append(generation_key) diff --git a/tests/unit/api/test_portal_frontend.py b/tests/unit/api/test_portal_frontend.py index 7fda51f..a2a5bff 100644 --- a/tests/unit/api/test_portal_frontend.py +++ b/tests/unit/api/test_portal_frontend.py @@ -71,6 +71,68 @@ def test_business_pages_do_not_call_fetch_directly() -> None: assert direct_fetch_files == ["common/api-client.js"] +def test_nl2sql_page_separates_query_values_and_generated_sql() -> None: + html = (PORTAL / "employee-operations" / "nl2sql" / "index.html").read_text( + encoding="utf-8" + ) + source = (PORTAL / "employee-operations" / "nl2sql" / "nl2sql.js").read_text( + encoding="utf-8" + ) + assert 'data-view="general"' in html + assert "最终查询结果" in source + assert "AI 生成的 SQL" in source + assert "run?.result?.sql" in source + assert "data?.rows" in source + assert "运行编号" not in source + assert "错误码" not in source + + +def test_promotion_page_uses_resizable_uniform_fields_and_cache_busting() -> None: + html = (PORTAL / "employee-operations" / "promotion" / "index.html").read_text( + encoding="utf-8" + ) + css = (PORTAL / "employee-operations" / "promotion" / "promotion.css").read_text( + encoding="utf-8" + ) + source = ( + PORTAL / "employee-operations" / "promotion" / "promotion.js" + ).read_text(encoding="utf-8") + assert "promotion-page" in html + assert "promotion-attachments-grid" in html + assert "promotion.css?v=20260914-layout2" in html + assert "promotion.js?v=20260914-layout2" in html + assert "height: 72px" in css + assert "resize: vertical" in css + assert "justify-content: center" in css + assert "autosizeTextarea" not in source + assert "function validateFormats(formats)" in source + assert "最多选择两种输出格式" in source + assert "validateFormats(selectedFormats())" in source + api_source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8") + assert ( + "PROMOTION_GENERATE: { method: 'POST', " + "path: '/api/v1/fund-promotion-materials/{taskNo}/generations', " + "idempotent: true, timeout: 120000 }" + ) in api_source + + +def test_nl2sql_page_has_aligned_workspace_spacing_and_cache_busting() -> None: + html = (PORTAL / "employee-operations" / "nl2sql" / "index.html").read_text( + encoding="utf-8" + ) + css = (PORTAL / "employee-operations" / "nl2sql" / "nl2sql.css").read_text( + encoding="utf-8" + ) + assert "nl2sql-page" in html + assert "nl2sql.css?v=20260914-layout3" in html + assert "nl2sql.js?v=20260914-layout3" in html + assert "padding: 28px 32px 32px" in css + assert "gap: 32px" in css + assert "resize: vertical" in css + assert "padding: 20px" in css + assert "border-radius: var(--radius-md)" in css + + def test_api_client_registers_all_trading_endpoint_ids() -> None: source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8") for endpoint_id in ("T001", "T002", "T003", "T004", "T005", "T006", "T007", "T008", "T009"): diff --git a/tests/unit/service/test_financial_nl2sql_service.py b/tests/unit/service/test_financial_nl2sql_service.py index 9c57b13..d364501 100644 --- a/tests/unit/service/test_financial_nl2sql_service.py +++ b/tests/unit/service/test_financial_nl2sql_service.py @@ -1,3 +1,5 @@ +from datetime import date + import pytest from app.core.contracts import RequestContext @@ -16,6 +18,12 @@ def context(**updates): return base.model_copy(update=updates) +def test_query_rows_convert_date_values_to_json_strings() -> None: + row = FinancialNL2SQLService._jsonable({"nav_date": date(2026, 9, 10)}) + + assert row == {"nav_date": "2026-09-10"} + + @pytest.mark.asyncio async def test_generates_read_only_market_sql_with_product_filter() -> None: result = await FinancialNL2SQLService().query( diff --git a/tests/unit/service/test_offsite_smtp_adapter.py b/tests/unit/service/test_offsite_smtp_adapter.py index 4b2dcd8..b8e8d4c 100644 --- a/tests/unit/service/test_offsite_smtp_adapter.py +++ b/tests/unit/service/test_offsite_smtp_adapter.py @@ -87,7 +87,7 @@ def test_enabled_real_send_builds_reply_message_and_attaches_original_file() -> assert len(connection.sent_messages) == 1 message = connection.sent_messages[0] assert message["From"] == "15273589815@163.com" - assert message["To"] == "15008108550@163.com" + assert message["To"] == "yuan80818843@163.com" assert message["In-Reply-To"] == "" assert message["References"] == "" assert message["Message-ID"] == result.provider_message_id @@ -138,7 +138,7 @@ def _request( reply_to_message_id: str | None = None, ) -> OffsiteMailReplyRequest: return OffsiteMailReplyRequest( - to_address="15008108550@163.com", + to_address="yuan80818843@163.com", subject="场外基金申购赎回处理结果", body="已处理。", operator_id="operator-001", diff --git a/tests/unit/service/test_promotion_image_service.py b/tests/unit/service/test_promotion_image_service.py new file mode 100644 index 0000000..5ddabd4 --- /dev/null +++ b/tests/unit/service/test_promotion_image_service.py @@ -0,0 +1,41 @@ +from pathlib import Path + +import pytest + +from app.service.promotion_image_service import PromotionImageService + + +def test_background_prompt_forbids_content_reconstruction() -> None: + prompt = PromotionImageService._build_prompt( + style_code="balanced_allocation", + background_theme="data_lines", + fund_type="混合型", + ) + + assert "不生成任何文字、数字、字母" in prompt + assert "Logo" in prompt + + +@pytest.mark.asyncio +async def test_disabled_image_service_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: + class Settings: + 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 + + monkeypatch.setattr( + "app.service.promotion_image_service.get_settings", + lambda: Settings(), + ) + service = PromotionImageService() + + result = await service.generate_background( + output_path=Path("unused.png"), + style_code="balanced_allocation", + background_theme="data_lines", + fund_type="混合型", + ) + + assert result is None diff --git a/tests/unit/service/test_promotion_layout_planner.py b/tests/unit/service/test_promotion_layout_planner.py new file mode 100644 index 0000000..824deca --- /dev/null +++ b/tests/unit/service/test_promotion_layout_planner.py @@ -0,0 +1,119 @@ +import pytest + +from app.service.promotion_layout_planner import PromotionLayoutPlanner + + +def _draft() -> dict[str, object]: + return { + "style_code": "balanced_allocation", + "title": "测试材料", + "chapters": [ + {"title": "产品基本信息", "body": "产品内容"}, + {"title": "风险揭示", "body": "风险内容"}, + ], + } + + +def test_default_plan_uses_content_sensitive_layouts() -> None: + plan = PromotionLayoutPlanner.default_plan(_draft(), has_photo=True, has_chart=False) + + assert plan["cover_layout"] == "cover_left_photo_right" + assert plan["page_plans"][1]["layout"] == "full_width_disclosure" + assert plan["design_profile"]["manager_layout"] == "profile_feature" + + +def test_strategy_heavy_material_uses_asymmetric_design_profile() -> None: + draft = { + **_draft(), + "chapters": [ + { + "title": "投资范围、策略与限制", + "body": "投资策略说明" * 20, + } + ], + } + + plan = PromotionLayoutPlanner.default_plan(draft, has_photo=True, has_chart=True) + + assert plan["design_profile"]["design_tone"] == "institutional_tech" + assert plan["design_profile"]["content_structure"] == "strategy_right_emphasis" + assert plan["design_profile"]["column_ratio"] == "40_60" + + +def test_low_density_chapter_uses_visual_focus_layout() -> None: + draft = { + **_draft(), + "chapters": [{"title": "投资观点", "body": "长期配置。"}], + } + + plan = PromotionLayoutPlanner.default_plan(draft, has_photo=False, has_chart=False) + + assert plan["page_plans"][0]["content_density"] == "low" + assert plan["page_plans"][0]["layout"] == "visual_focus" + + +def test_content_density_respects_existing_chart_signal() -> None: + chapter = {"title": "业绩", "body": "简要说明", "chart_index": 0} + + assert PromotionLayoutPlanner.content_density(chapter) == "medium" + + +def test_validate_rejects_unknown_layout_and_keeps_fallback() -> None: + fallback = PromotionLayoutPlanner.default_plan(_draft(), has_photo=False, has_chart=False) + invalid = { + **fallback, + "page_plans": [ + {"chapter_title": "产品基本信息", "layout": "free_coordinates"}, + {"chapter_title": "风险揭示", "layout": "full_width_disclosure"}, + ], + } + + result = PromotionLayoutPlanner._validate(invalid, _draft(), fallback) + + assert result == fallback + + +def test_validate_preserves_chapter_titles_and_only_accepts_complete_plan() -> None: + fallback = PromotionLayoutPlanner.default_plan(_draft(), has_photo=False, has_chart=False) + value = { + "cover_layout": "cover_visual_right", + "poster_layout": "poster_balanced", + "background_theme": "geometric_grid", + "design_profile": { + "design_tone": "quiet_data", + "content_structure": "balanced_split", + "column_ratio": "50_50", + "manager_layout": "profile_compact", + "performance_layout": "metrics_first", + "background_role": "soft_paper", + }, + "page_plans": [ + {"chapter_title": "风险揭示", "layout": "full_width_disclosure"}, + {"chapter_title": "产品基本信息", "layout": "two_columns"}, + ], + } + + result = PromotionLayoutPlanner._validate(value, _draft(), fallback) + + assert [item["chapter_title"] for item in result["page_plans"]] == [ + "产品基本信息", + "风险揭示", + ] + assert result["page_plans"][0]["layout"] == "two_columns" + + +@pytest.mark.asyncio +async def test_plan_falls_back_when_model_fails(monkeypatch: pytest.MonkeyPatch) -> None: + async def fail_resolve(*, agent_type: str, task_type: str) -> list[object]: + raise RuntimeError("模型不可用") + + monkeypatch.setattr( + "app.service.promotion_layout_planner.DatabaseModelEndpointResolver.resolve", + fail_resolve, + ) + + result = await PromotionLayoutPlanner().plan(_draft(), has_photo=False, has_chart=False) + + assert result == PromotionLayoutPlanner.default_plan( + _draft(), has_photo=False, has_chart=False + ) diff --git a/tests/unit/service/test_promotion_material.py b/tests/unit/service/test_promotion_material.py index 09e2fc0..47e1552 100644 --- a/tests/unit/service/test_promotion_material.py +++ b/tests/unit/service/test_promotion_material.py @@ -2,7 +2,9 @@ from pathlib import Path import pytest +from app.core.errors import ValidationAgentError from app.service.promotion_compliance import PromotionComplianceChecker +from app.service.promotion_material_service import PromotionMaterialService from app.service.promotion_performance import ( create_performance_chart, parse_performance_file, @@ -116,6 +118,19 @@ def test_performance_display_requires_data_attachment() -> None: ) +@pytest.mark.parametrize("attachment_type", ["source_evidence", "template_file"]) +def test_removed_attachment_types_are_rejected(attachment_type: str) -> None: + with pytest.raises(ValidationAgentError, match="不支持的附件类型"): + PromotionMaterialService._validate_attachment( + attachment_type, + "removed.pdf", + "application/pdf", + 1024, + max_photo_size=10 * 1024 * 1024, + max_performance_size=20 * 1024 * 1024, + ) + + def test_performance_file_is_validated_and_chart_is_created(tmp_path: Path) -> None: source = tmp_path / "performance.csv" source.write_text( @@ -135,6 +150,28 @@ def test_performance_file_is_validated_and_chart_is_created(tmp_path: Path) -> N assert output.stat().st_size > 0 +def test_performance_summary_uses_latest_file_date_and_product_series(tmp_path: Path) -> None: + source = tmp_path / "summary.csv" + source.write_text( + "日期,产品收益率,业绩比较基准收益率\n" + "2025-01-31,1%,0.5%\n" + "2025-02-28,5%,1%\n" + "2025-03-31,3%,1.2%\n" + "2026-03-31,8%,2%\n", + encoding="utf-8-sig", + ) + + data = parse_performance_file(source) + + assert data["summary"] == { + "initial_date": "2025-01-31", + "as_of_date": "2026-03-31", + "history_months": 14, + "product_return": "8%", + "max_drawdown": "-1.9%", + } + + def test_performance_chart_contains_chinese_text_without_missing_font_failure( tmp_path: Path, ) -> None: diff --git a/tests/unit/service/test_promotion_poster_renderer.py b/tests/unit/service/test_promotion_poster_renderer.py index 5859fbb..898da34 100644 --- a/tests/unit/service/test_promotion_poster_renderer.py +++ b/tests/unit/service/test_promotion_poster_renderer.py @@ -1,6 +1,6 @@ from pathlib import Path -from PIL import Image +from PIL import Image, ImageDraw, ImageFont from app.service.promotion_poster_renderer import PromotionPosterRenderer @@ -43,3 +43,57 @@ def test_poster_renderer_creates_readable_warm_reference_style(tmp_path: Path) - with Image.open(output) as image: assert image.size == (1800, 2600) assert image.getpixel((10, 10)) == (201, 76, 47) + + +def test_poster_renderer_uses_theme_panels_instead_of_white(tmp_path: Path) -> None: + output = tmp_path / "theme-panel-poster.png" + + PromotionPosterRenderer().render( + _draft("balanced_allocation"), + output, + ) + + with Image.open(output) as image: + assert image.getpixel((120, 620)) == (220, 234, 243) + assert image.getpixel((600, 1200)) == (220, 234, 243) + assert image.getpixel((120, 620)) != (255, 255, 255) + + +def test_poster_renderer_fills_ai_background_and_footer_without_white_bands( + tmp_path: Path, +) -> None: + background = tmp_path / "square-background.png" + output = tmp_path / "filled-poster.png" + Image.new("RGB", (1024, 1024), color=(80, 92, 106)).save(background) + + PromotionPosterRenderer().render( + _draft("balanced_allocation"), + output, + background_path=str(background), + ) + + with Image.open(output) as image: + assert image.getpixel((900, 2450)) != (255, 255, 255) + assert image.getpixel((900, 2570)) != (255, 255, 255) + + +def test_long_strategy_text_changes_ai_50_50_ratio_and_fits_panel() -> None: + renderer = PromotionPosterRenderer() + image = Image.new("RGB", (1800, 2600), "white") + draw = ImageDraw.Draw(image) + font = ImageFont.truetype(r"C:\Windows\Fonts\msyh.ttc", 24) + strategy = "围绕宏观周期、估值水平和基本面趋势开展多资产配置," * 20 + + ratio, fitted_font = renderer._resolve_two_column_layout( + draw, + "基金类型:混合型", + strategy, + total_width=1620, + height=320, + requested_ratio=0.5, + font=font, + ) + + right_width = 1620 - 54 - int((1620 - 54) * ratio) - 68 + assert ratio == 0.4 + assert renderer._text_fits(draw, strategy, right_width, 260, fitted_font) diff --git a/tests/unit/service/test_promotion_renderer.py b/tests/unit/service/test_promotion_renderer.py index 3ab92c1..ffbec5e 100644 --- a/tests/unit/service/test_promotion_renderer.py +++ b/tests/unit/service/test_promotion_renderer.py @@ -4,7 +4,7 @@ from PIL import Image from pptx import Presentation from app.service.promotion_pdf_adapter import PromotionPdfAdapter -from app.service.promotion_renderer import PromotionPptxRenderer +from app.service.promotion_renderer import PromotionPptxRenderer, STYLE_CONFIG, resolve_visual_palette def _draft(style_code: str = "balanced_allocation") -> dict[str, object]: @@ -91,3 +91,15 @@ def test_renderer_applies_reference_palette_to_cover_and_content_slides( content = presentation.slides[1] assert cover.shapes[0].fill.fore_color.rgb == (201, 76, 47) assert content.shapes[0].fill.fore_color.rgb == (255, 249, 242) + + +def test_visual_palette_samples_ai_background_for_content_panels(tmp_path: Path) -> None: + background = tmp_path / "ai-background.png" + Image.new("RGB", (80, 80), color=(80, 92, 106)).save(background) + + palette = resolve_visual_palette("balanced_allocation", background) + + assert palette["panel"] != STYLE_CONFIG["balanced_allocation"]["panel"] + assert palette["panel"] != "FFFFFF" + assert palette["panel_alt"] != "FFFFFF" + assert palette["line"] != "FFFFFF" diff --git a/tests/unit/service/test_run_query_service.py b/tests/unit/service/test_run_query_service.py index 5c0ff28..22129d7 100644 --- a/tests/unit/service/test_run_query_service.py +++ b/tests/unit/service/test_run_query_service.py @@ -29,6 +29,7 @@ class FakeRun: status: str = "running" agent_type: str = "customer_service" session_id: str = "session-1" + request_message_id: int = 1 error_code: str | None = None created_at: datetime = NOW completed_at: datetime | None = None @@ -53,15 +54,29 @@ class FakeRepository: class FakeSession: + def __init__(self, request_message: Any = None) -> None: + self.request_message = request_message + async def __aenter__(self) -> "FakeSession": return self async def __aexit__(self, *exc: object) -> bool: return False + async def get(self, model: Any, message_id: int) -> Any: + del model, message_id + return self.request_message -def patch_repository(monkeypatch: pytest.MonkeyPatch, rows: tuple[Any, Any] | None) -> None: - monkeypatch.setattr("app.service.run_query_service.SessionFactory", FakeSession) + +def patch_repository( + monkeypatch: pytest.MonkeyPatch, + rows: tuple[Any, Any] | None, + request_message: Any = None, +) -> None: + monkeypatch.setattr( + "app.service.run_query_service.SessionFactory", + lambda: FakeSession(request_message), + ) monkeypatch.setattr( "app.service.run_query_service.ConversationRepository", lambda _session: FakeRepository(rows), @@ -111,6 +126,62 @@ async def test_succeeded_run_exposes_result_with_string_confidence( assert snapshot.completed_at is not None and snapshot.completed_at.endswith("Z") +async def test_financial_run_exposes_query_values_and_generated_sql( + monkeypatch: pytest.MonkeyPatch, +) -> None: + message = FakeMessage( + content="查询成功", + tool_calls={ + "calls": [], + "data": {"total": 1, "rows": [{"nav": "1.250000"}]}, + "sql": "SELECT n.nav AS nav FROM fin_nav n WHERE 1=1 LIMIT 50", + }, + ) + patch_repository( + monkeypatch, + (FakeRun(status="succeeded", agent_type="financial_nl2sql", completed_at=NOW), message), + ) + + snapshot = await RunQueryService().get("run-1", CONTEXT) + + assert snapshot.result is not None + assert snapshot.result["data"] == {"total": 1, "rows": [{"nav": "1.250000"}]} + assert snapshot.result["sql"] == "SELECT n.nav AS nav FROM fin_nav n WHERE 1=1 LIMIT 50" + + +async def test_financial_run_recovers_missing_payload_from_original_question( + monkeypatch: pytest.MonkeyPatch, +) -> None: + message = FakeMessage(content="查询成功", tool_calls={"calls": []}) + request_message = SimpleNamespace(content="查询15911的最新净值") + patch_repository( + monkeypatch, + (FakeRun(status="succeeded", agent_type="financial_nl2sql", completed_at=NOW), message), + request_message, + ) + + async def recover( + self: Any, arguments: Any, context: RequestContext + ) -> dict[str, Any]: + del self + assert arguments.question == "查询15911的最新净值" + assert context.user_id == CONTEXT.user_id + return { + "status": "success", + "data": {"total": 1, "rows": [{"nav": "1.250000"}]}, + "sql": "SELECT n.nav AS nav FROM fin_nav n WHERE 1=1 LIMIT 50", + } + + monkeypatch.setattr( + "app.service.run_query_service.FinancialNL2SQLService.query", recover + ) + snapshot = await RunQueryService().get("run-1", CONTEXT) + + assert snapshot.result is not None + assert snapshot.result["data"]["rows"] == [{"nav": "1.250000"}] + assert snapshot.result["sql"].startswith("SELECT ") + + async def test_failed_run_does_not_expose_result(monkeypatch: pytest.MonkeyPatch) -> None: """即使库里有结果消息,失败运行也不得把它当作成功结果返回。""" patch_repository( diff --git a/tools/grant_operator_role.py b/tools/grant_operator_role.py index ce2223e..f1d88be 100644 --- a/tools/grant_operator_role.py +++ b/tools/grant_operator_role.py @@ -34,10 +34,15 @@ import argparse import asyncio import sys from datetime import UTC, datetime +from pathlib import Path from sqlalchemy import text -from app.infrastructure.db import SessionFactory +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from app.infrastructure.db import SessionFactory # noqa: E402 if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] @@ -47,6 +52,8 @@ OPERATOR_ROLE_NAME = "运营专员" #: 运营该有的权限码。`offsite:write` 由场外线创建;其余在本种子的 9051-9056 号段里定义。 OPERATOR_GRANTED_CODES: tuple[str, ...] = ( + # Agent 公共入口:没有该权限,请求会在 Agent 类型和工具权限校验之前被拒绝。 + "agent:run", # 场外运营(角色门槛之外,这个码是场外线自己声明的) "offsite:write", # 金融 NL2SQL:角色白名单 {advisor, operator, admin, super_admin} 含 operator diff --git a/tools/publish_financial_nl2sql_config.py b/tools/publish_financial_nl2sql_config.py new file mode 100644 index 0000000..9fbd556 --- /dev/null +++ b/tools/publish_financial_nl2sql_config.py @@ -0,0 +1,198 @@ +"""发布金融 NL2SQL 的只读工具白名单,默认仅预检。""" + +from __future__ import annotations + +import argparse +import asyncio +import datetime as dt +import json +import sys +import uuid +from pathlib import Path +from typing import Any + +import httpx +import jwt +from sqlalchemy import func, select + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from app.core.config import get_settings # noqa: E402 +from app.infrastructure.db import SessionFactory # noqa: E402 +from app.main import create_app # noqa: E402 +from app.model.configuration import PromptTemplateVersion # noqa: E402 +from app.service.config_release_service import ConfigReleaseService # noqa: E402 + +ADMIN_ID = "9003" +AGENT_TYPE = "financial_nl2sql" +INTENT = "financial_query" +TOOL = "query_financial_data" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="发布金融 NL2SQL 工具白名单") + parser.add_argument("--apply", action="store_true", help="创建并激活新的配置版本") + parser.add_argument( + "--allow-empty-baseline", + action="store_true", + help="确认当前数据库不存在生效配置时,以空配置作为首个发布版本的基线", + ) + return parser.parse_args() + + +def issue_token(subject: str) -> str: + settings = get_settings() + private_key = Path(settings.jwt_private_key_path).read_text(encoding="utf-8") + now = dt.datetime.now(dt.UTC) + return jwt.encode( + { + "sub": subject, + "iss": settings.jwt_issuer, + "aud": settings.jwt_audience, + "exp": now + dt.timedelta(minutes=30), + "nbf": now - dt.timedelta(seconds=5), + "jti": str(uuid.uuid4()), + }, + private_key, + algorithm="RS256", + ) + + +def as_json(value: Any) -> Any: + return json.loads(value) if isinstance(value, str) else value + + +async def active_snapshot() -> dict[str, list[dict[str, Any]]]: + async with SessionFactory() as session: + return await ConfigReleaseService(session).effective_snapshot() + + +def item_payloads(snapshot: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]: + return [ + { + "namespace": row["namespace"], + "item_key": row["config_key"], + "value_json": as_json(row["value_json"]), + "schema_version": row["schema_version"], + } + for row in snapshot["platform_config_item"] + ] + + +async def prompt_payloads( + prompts: list[dict[str, Any]], release_id: int +) -> list[dict[str, Any]]: + payloads: list[dict[str, Any]] = [] + async with SessionFactory() as session: + for row in prompts: + version = await session.scalar( + select(func.max(PromptTemplateVersion.version)).where( + PromptTemplateVersion.prompt_code == row["prompt_code"] + ) + ) + payloads.append( + { + "release_id": release_id, + "prompt_code": row["prompt_code"], + "task_type": row["task_type"], + "agent_type": row["agent_type"], + "version": int(version or 0) + 1, + "system_prompt": row["system_prompt"], + "user_prompt_template": row["user_prompt_template"], + "input_schema": as_json(row["input_schema"]), + "output_schema": as_json(row["output_schema"]), + } + ) + return payloads + + +async def post( + client: httpx.AsyncClient, path: str, auth: dict[str, str], payload: dict[str, Any], + if_match: str | None = None, +) -> httpx.Response: + headers = {**auth, "Idempotency-Key": uuid.uuid4().hex} + if if_match: + headers["If-Match"] = if_match + return await client.post(path, json=payload, headers=headers) + + +async def etag(client: httpx.AsyncClient, path: str, auth: dict[str, str]) -> str | None: + return (await client.get(path, headers=auth)).headers.get("ETag") + + +async def publish(apply: bool, allow_empty_baseline: bool) -> int: + snapshot = await active_snapshot() + inherited = item_payloads(snapshot) + target_key = f"{AGENT_TYPE}:{INTENT}" + existing = next((item for item in inherited if item["item_key"] == target_key), None) + print(f"当前生效配置项:{len(inherited)} 条;提示词:{len(snapshot['prompt_template_version'])} 条") + if apply and not inherited and not snapshot["prompt_template_version"] and not allow_empty_baseline: + print("当前生效配置为空,拒绝发布以避免覆盖或清空已有配置。请先核对数据库连接和生效版本。") + return 1 + if existing is not None: + print(f"{target_key} 已存在,当前值:{existing['value_json']}") + return 0 + if snapshot["model_routing_rule"]: + print("当前版本含模型路由规则,本脚本拒绝发布,避免遗漏继承。") + return 1 + if not apply: + print(f"预检通过;将新增 agent_tools/{target_key} -> [{TOOL}]。传入 --apply 执行发布。") + return 0 + + auth = {"Authorization": f"Bearer {issue_token(ADMIN_ID)}"} + app = create_app() + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + created = await post(client, "/api/v1/admin/config-releases", auth, { + "release_no": f"financial-nl2sql-{uuid.uuid4().hex[:12]}", + "title": "金融 NL2SQL 工具白名单", + "change_summary": "为运营与投顾的金融只读自然语言查询启用工具白名单", + }) + if created.status_code != 201: + print(f"创建配置版本失败:{created.status_code} {created.text[:200]}") + return 1 + release_id = int(created.json()["data"]["id"]) + new_item = { + "namespace": "agent_tools", + "item_key": target_key, + "value_json": {"allowed_tools": [TOOL]}, + "schema_version": "1", + } + for item in [*inherited, new_item]: + response = await post( + client, + f"/api/v1/admin/config-releases/{release_id}/platform-config-items", + auth, + item, + ) + if response.status_code != 201: + print(f"写入 {item['namespace']}/{item['item_key']} 失败:{response.status_code}") + return 1 + for payload in await prompt_payloads(snapshot["prompt_template_version"], release_id): + response = await post(client, "/api/v1/admin/prompt-templates", auth, payload) + if response.status_code not in (200, 201): + print(f"继承提示词失败:{response.status_code} {response.text[:200]}") + return 1 + base = f"/api/v1/admin/config-releases/{release_id}" + for suffix, payload in ( + ("validations", {}), + ("reviews", {"decision": "approved", "comment": "金融 NL2SQL 只读白名单"}), + ("activations", {}), + ): + response = await post(client, f"{base}/{suffix}", auth, payload, await etag(client, base, auth)) + if response.status_code not in (200, 201): + print(f"{suffix} 失败:{response.status_code} {response.text[:200]}") + return 1 + after = await active_snapshot() + keys = {row["config_key"] for row in after["platform_config_item"]} + if target_key not in keys or len(keys) != len(inherited) + 1: + print("发布后配置项校验失败,未确认白名单已生效。") + return 1 + print(f"发布成功:{target_key} 已生效,配置项 {len(keys)} 条。") + return 0 + + +if __name__ == "__main__": + args = parse_args() + sys.exit(asyncio.run(publish(args.apply, args.allow_empty_baseline)))