diff --git a/.env.example b/.env.example index ebc4c09..c9edbf6 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ MYSQL_DSN=mysql+asyncmy://jr_app:change-me@127.0.0.1:3306/jr_agent MYSQL_DSN=mysql+asyncmy://root:123456@127.0.0.1:3306/jr_agent MYSQL_POOL_SIZE=5 MYSQL_MAX_OVERFLOW=10 +MYSQL_POOL_PRE_PING=false MYSQL_TX_ISOLATION=READ COMMITTED REDIS_URL=redis://127.0.0.1:6379/0 @@ -25,6 +26,7 @@ MESSAGE_BROKER_OUTBOX_TOPIC=agent.outbox MESSAGE_BROKER_DLQ_TOPIC=agent.dlq MILVUS_URI=http://127.0.0.1:19530 +MILVUS_LOCAL_URI= MILVUS_TOKEN= MILVUS_COLLECTION=jr_memory @@ -36,6 +38,9 @@ NEO4J_PASSWORD= MODEL_ROUTER_CONFIG_REF=local MODEL_DEFAULT_ENDPOINT= MODEL_FALLBACK_ENDPOINT= +KNOWLEDGE_EMBEDDING_ENDPOINT_CODE= +KNOWLEDGE_EMBEDDING_TIMEOUT_MS=15000 +CUSTOMER_SERVICE_PHONE=15936583816 SSE_HEARTBEAT_SECONDS=15 SSE_MAX_CONNECTION_SECONDS=300 @@ -57,3 +62,42 @@ RATE_LIMIT_ENABLED=true RATE_LIMIT_WINDOW_SECONDS=60 RATE_LIMIT_MAX_REQUESTS=600 RATE_LIMIT_KEY_PREFIX=jr:rate_limit + +# 场外基金邮件处理默认关闭;邮箱、发送人和外部服务凭据均在部署环境单独配置。 +OFFSITE_MAILBOX= +OFFSITE_ALLOWED_SENDERS=[] +OFFSITE_RISK_RECEIVER_ID= +OFFSITE_SETTLEMENT_RECEIVER_ID= +OFFSITE_MAIL_RETURN_RECEIVER= +OFFSITE_MAX_RETRY_COUNT=3 +OFFSITE_IMAP_ENABLED=false +OFFSITE_IMAP_HOST= +OFFSITE_IMAP_PORT=993 +OFFSITE_IMAP_USERNAME= +OFFSITE_IMAP_PASSWORD= +OFFSITE_IMAP_USE_SSL=true +OFFSITE_IMAP_IDLE_ENABLED=false +OFFSITE_MAIL_WORKER_ENABLED=false +OFFSITE_MAIL_WORKER_BATCH_SIZE=20 +OFFSITE_WORKER_USER_ID= +OFFSITE_NOTIFICATION_SENDING_TIMEOUT_SECONDS=900 +OFFSITE_MAIL_STORAGE_DIR=data/offsite_mail +OFFSITE_OCR_ENABLED=false +OFFSITE_OCR_TIMEOUT_SECONDS=30 +OFFSITE_ALIYUN_OCR_ENDPOINT= +OFFSITE_ALIYUN_ACCESS_KEY_ID= +OFFSITE_ALIYUN_ACCESS_KEY_SECRET= +OFFSITE_DEEPSEEK_ENABLED=false +OFFSITE_DEEPSEEK_BASE_URL=https://api.deepseek.com +OFFSITE_DEEPSEEK_API_KEY= +OFFSITE_DEEPSEEK_MODEL=deepseek-v4-flash +OFFSITE_DEEPSEEK_TIMEOUT_SECONDS=30 +OFFSITE_SMTP_ENABLED=false +OFFSITE_SMTP_DRY_RUN=true +OFFSITE_SMTP_HOST= +OFFSITE_SMTP_PORT=465 +OFFSITE_SMTP_USERNAME= +OFFSITE_SMTP_PASSWORD= +OFFSITE_SMTP_SENDER= +OFFSITE_SMTP_USE_SSL=true +OFFSITE_SMTP_TIMEOUT_SECONDS=30 diff --git a/.gitignore b/.gitignore index d1ef047..9b8d394 100644 --- a/.gitignore +++ b/.gitignore @@ -7,11 +7,13 @@ __pycache__/ .ruff_cache/ *.egg-info/ .venv/ +.worktrees/ .coverage htmlcov/ coverage.xml build/ dist/ *.log +data/milvus/ .idea/ .vscode/ diff --git a/alembic/versions/20260910_offsite_fund.py b/alembic/versions/20260910_offsite_fund.py new file mode 100644 index 0000000..473d89f --- /dev/null +++ b/alembic/versions/20260910_offsite_fund.py @@ -0,0 +1,141 @@ +"""add offsite fund operation tables""" +from alembic import op + +revision = "20260910_offsite_fund" +down_revision = "20260909_api_receipt" +branch_labels = None +depends_on = None + +TABLES = [ + """CREATE TABLE offsite_fund_mail ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + mail_id VARCHAR(16) NOT NULL, + imap_uid VARCHAR(64) NOT NULL, + message_id VARCHAR(255) NOT NULL, + received_date DATE NOT NULL, + sender VARCHAR(255) NOT NULL, + return_path VARCHAR(255) NULL, + auth_result JSON NOT NULL, + original_eml_path VARCHAR(500) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'received', + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_offsite_mail_id (mail_id), + UNIQUE KEY uk_offsite_mail_source (imap_uid, message_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci""", + """CREATE TABLE offsite_fund_attachment ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + attachment_id VARCHAR(24) NOT NULL, + mail_id VARCHAR(16) NOT NULL, + filename VARCHAR(255) NOT NULL, + file_hash VARCHAR(128) NOT NULL, + media_type VARCHAR(128) NOT NULL, + size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0, + document_type VARCHAR(24) NOT NULL, + original_file_path VARCHAR(500) NOT NULL, + ocr_text MEDIUMTEXT NOT NULL, + extracted_fields JSON NOT NULL, + field_confidence JSON NOT NULL, + page_evidence JSON NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'recognized', + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_offsite_attachment_id (attachment_id), + UNIQUE KEY uk_offsite_attachment_hash (file_hash, mail_id), + KEY idx_offsite_attachment_mail (mail_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci""", + """CREATE TABLE offsite_fund_document ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_id VARCHAR(24) NOT NULL, + mail_id VARCHAR(16) NOT NULL, + attachment_id VARCHAR(24) NOT NULL, + document_type VARCHAR(24) NOT NULL, + fund_code VARCHAR(32) NULL, + fund_name VARCHAR(255) NULL, + account_identifier VARCHAR(128) NULL, + investor_name VARCHAR(128) NULL, + application_no VARCHAR(128) NULL, + application_date DATE NULL, + raw_application_date VARCHAR(64) NULL, + agency VARCHAR(128) NULL, + subscription_amount_yuan DECIMAL(20,4) NULL, + redemption_shares DECIMAL(20,4) NULL, + operator_decision VARCHAR(16) NOT NULL DEFAULT '未处理', + status VARCHAR(32) NOT NULL DEFAULT 'planned', + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_offsite_document_task (task_id), + KEY idx_offsite_document_summary (fund_code, application_date) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci""", + """CREATE TABLE offsite_execution_plan_task ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_id VARCHAR(24) NOT NULL, + stage VARCHAR(16) NOT NULL, + rule_code VARCHAR(64) NOT NULL, + title VARCHAR(128) NOT NULL, + depends_on JSON NOT NULL, + input_json JSON NOT NULL, + output_json JSON NULL, + status VARCHAR(16) NOT NULL DEFAULT '待执行', + error_message VARCHAR(500) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + KEY idx_offsite_plan_task (task_id, stage, rule_code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci""", + """CREATE TABLE offsite_rule_result ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_id VARCHAR(24) NOT NULL, + rule_code VARCHAR(64) NOT NULL, + rule_name VARCHAR(128) NOT NULL, + result VARCHAR(16) NOT NULL, + document_value JSON NOT NULL, + database_value JSON NOT NULL, + calculation JSON NOT NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_offsite_rule_task (task_id, rule_code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci""", + """CREATE TABLE offsite_query_record ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_id VARCHAR(24) NOT NULL, + rule_code VARCHAR(64) NOT NULL, + natural_language_request MEDIUMTEXT NOT NULL, + script_path VARCHAR(500) NOT NULL, + result_summary JSON NOT NULL, + status VARCHAR(24) NOT NULL, + error_message VARCHAR(500) NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + KEY idx_offsite_query_task (task_id, rule_code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci""", + """CREATE TABLE offsite_notification ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + notification_type VARCHAR(32) NOT NULL, + business_key VARCHAR(64) NOT NULL, + receiver_id VARCHAR(64) NOT NULL, + operator_id VARCHAR(64) NOT NULL, + agent_draft MEDIUMTEXT NOT NULL, + final_content MEDIUMTEXT NOT NULL, + payload JSON NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT '待发送', + retry_count INT UNSIGNED NOT NULL DEFAULT 0, + provider_message_id VARCHAR(128) NULL, + failure_reason VARCHAR(500) NULL, + created_at DATETIME(6) NOT NULL, + sent_at DATETIME(6) NULL, + PRIMARY KEY (id), + KEY idx_offsite_notice_business (business_key, notification_type) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci""", +] + + +def upgrade() -> None: + for statement in TABLES: + op.execute(statement) + + +def downgrade() -> None: + raise RuntimeError("场外运营业务数据禁止自动删除,请使用前向兼容迁移") diff --git a/alembic/versions/20260910_offsite_worker.py b/alembic/versions/20260910_offsite_worker.py new file mode 100644 index 0000000..cdaea37 --- /dev/null +++ b/alembic/versions/20260910_offsite_worker.py @@ -0,0 +1,54 @@ +"""add offsite mail worker state""" + +from alembic import op + +revision = "20260910_offsite_worker" +down_revision = "20260910_offsite_fund" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE offsite_mail_cursor ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + mailbox VARCHAR(255) NOT NULL, + folder VARCHAR(64) NOT NULL DEFAULT 'INBOX', + last_uid VARCHAR(64) NOT NULL DEFAULT '0', + status VARCHAR(32) NOT NULL DEFAULT 'idle', + retry_count INT UNSIGNED NOT NULL DEFAULT 0, + blocked_uid VARCHAR(64) NULL, + blocked_message_id VARCHAR(255) NULL, + last_error VARCHAR(500) NULL, + next_retry_at DATETIME(6) NULL, + lease_id VARCHAR(64) NULL, + lease_until DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_offsite_mail_cursor_scope (mailbox, folder), + KEY idx_offsite_mail_cursor_retry (status, next_retry_at), + KEY idx_offsite_mail_cursor_lease (lease_until) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + op.execute( + """ + ALTER TABLE offsite_fund_mail + ADD COLUMN retry_count INT UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN last_error VARCHAR(500) NULL, + ADD COLUMN next_retry_at DATETIME(6) NULL, + ADD COLUMN last_attempt_at DATETIME(6) NULL + """ + ) + op.execute( + """ + ALTER TABLE offsite_notification + ADD COLUMN updated_at DATETIME(6) NULL + """ + ) + + +def downgrade() -> None: + raise RuntimeError("场外运营业务数据禁止自动删除,请使用前向兼容迁移") diff --git a/app/api/controllers/offsite_fund.py b/app/api/controllers/offsite_fund.py new file mode 100644 index 0000000..f379544 --- /dev/null +++ b/app/api/controllers/offsite_fund.py @@ -0,0 +1,100 @@ +"""场外基金运营接口。""" + +from typing import Any + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.dependencies.auth import build_request_context +from app.api.dependencies.database import get_session +from app.api.schemas.offsite_fund import ( + OffsiteConfirmRequest, + OffsiteMailRecognizeRequest, + OffsiteNotificationRequest, + OffsiteNotificationSendRequest, + OffsiteRecalculateRequest, + OffsiteTriggerNl2SqlRequest, +) +from app.core.contracts import RequestContext +from app.service.offsite_fund_service import OffsiteFundService + +router = APIRouter(prefix="/api/v1/offsite-fund", tags=["offsite-fund"]) +operation_router = APIRouter(prefix="/api", tags=["offsite-fund"]) + + +@router.post("/recognized-mails") +async def receive_recognized_mail( + payload: OffsiteMailRecognizeRequest, + context: RequestContext = Depends(build_request_context), # noqa: B008 + session: AsyncSession = Depends(get_session), # noqa: B008 +) -> dict[str, Any]: + return await OffsiteFundService(session).receive_recognized_mail(payload, context) + + +@router.post("/documents/{task_id}/confirmations") +async def confirm_document( + task_id: str, + payload: OffsiteConfirmRequest, + context: RequestContext = Depends(build_request_context), # noqa: B008 + session: AsyncSession = Depends(get_session), # noqa: B008 +) -> dict[str, Any]: + return await OffsiteFundService(session).confirm_document( + task_id, payload.decision, payload.operator_id, context) + + +@router.post("/documents/{task_id}/notifications") +async def create_notification( + task_id: str, + payload: OffsiteNotificationRequest, + context: RequestContext = Depends(build_request_context), # noqa: B008 + session: AsyncSession = Depends(get_session), # noqa: B008 +) -> dict[str, Any]: + return await OffsiteFundService(session).create_notification( + task_id, payload.notification_type, payload.operator_id, context) + + +@router.post("/notifications/{notification_id}/send") +async def send_notification( + notification_id: int, + payload: OffsiteNotificationSendRequest, + context: RequestContext = Depends(build_request_context), # noqa: B008 + session: AsyncSession = Depends(get_session), # noqa: B008 +) -> dict[str, Any]: + return await OffsiteFundService(session).send_notification( + notification_id, + payload.operator_id, + payload.operator_confirmed, + payload.final_content, + context, + ) + + +@router.post("/settlement-statistics/recalculate") +async def recalculate_statistics( + payload: OffsiteRecalculateRequest, + context: RequestContext = Depends(build_request_context), # noqa: B008 + session: AsyncSession = Depends(get_session), # noqa: B008 +) -> dict[str, Any]: + return await OffsiteFundService(session).recalculate_statistics( + payload.fund_code, payload.application_date, context) + + +@operation_router.post("/tasks/{task_id}/trigger-agent-nl2sql") +async def trigger_agent_nl2sql( + task_id: str, + payload: OffsiteTriggerNl2SqlRequest, + context: RequestContext = Depends(build_request_context), # noqa: B008 + session: AsyncSession = Depends(get_session), # noqa: B008 +) -> dict[str, Any]: + return await OffsiteFundService(session).trigger_agent_nl2sql( + task_id, payload.operator_id, payload.manual_confirmed, context) + + +@operation_router.post("/settlement-statistics/recalculate") +async def recalculate_statistics_compat( + payload: OffsiteRecalculateRequest, + context: RequestContext = Depends(build_request_context), # noqa: B008 + session: AsyncSession = Depends(get_session), # noqa: B008 +) -> dict[str, Any]: + return await OffsiteFundService(session).recalculate_statistics( + payload.fund_code, payload.application_date, context) diff --git a/app/api/controllers/visitor_tokens.py b/app/api/controllers/visitor_tokens.py new file mode 100644 index 0000000..c141bec --- /dev/null +++ b/app/api/controllers/visitor_tokens.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, status + +from app.api.schemas.visitor_tokens import VisitorTokenResponse +from app.core.config import get_settings +from app.core.security import VisitorTokenIssuer + +router = APIRouter(prefix="/api/v1/visitor-tokens", tags=["visitor-tokens"]) + + +@router.post("", response_model=VisitorTokenResponse, status_code=status.HTTP_201_CREATED) +async def issue_visitor_token() -> VisitorTokenResponse: + settings = get_settings() + token, _expires_at = VisitorTokenIssuer(settings).issue() + return VisitorTokenResponse(access_token=token, expires_in=settings.visitor_token_ttl_seconds) diff --git a/app/api/dependencies/auth.py b/app/api/dependencies/auth.py index 8595847..f77c8fa 100644 --- a/app/api/dependencies/auth.py +++ b/app/api/dependencies/auth.py @@ -49,7 +49,8 @@ async def build_request_context( raise unauthorized() try: context = _authenticator().authenticate(credentials.credentials) - context = await IdentityService().resolve(context) + if "visitor" not in context.roles: + context = await IdentityService().resolve(context) except Exception as exc: # 令牌非法、账号停用、角色读取失败一律按 401 处理,形态完全一致。 raise unauthorized() from exc diff --git a/app/api/schemas/offsite_fund.py b/app/api/schemas/offsite_fund.py new file mode 100644 index 0000000..6e87148 --- /dev/null +++ b/app/api/schemas/offsite_fund.py @@ -0,0 +1,45 @@ +"""场外基金接口请求结构。""" + +from pydantic import BaseModel, ConfigDict, Field + +from app.core.offsite_fund_contracts import OperationDecision, ReceiveRecognizedMailRequest + + +class OffsiteMailRecognizeRequest(ReceiveRecognizedMailRequest): + pass + + +class OffsiteConfirmRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + decision: OperationDecision + operator_id: str = Field(min_length=1, max_length=64) + + +class OffsiteRecalculateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + fund_code: str = Field(min_length=1, max_length=32) + application_date: str = Field(min_length=8, max_length=32) + + +class OffsiteNotificationRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + notification_type: str = Field(pattern="^(risk|settlement|mail_return)$") + operator_id: str = Field(min_length=1, max_length=64) + + +class OffsiteNotificationSendRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + operator_id: str = Field(min_length=1, max_length=64) + operator_confirmed: bool + final_content: str | None = Field(default=None, max_length=10000) + + +class OffsiteTriggerNl2SqlRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + operator_id: str = Field(min_length=1, max_length=64) + manual_confirmed: bool diff --git a/app/api/schemas/visitor_tokens.py b/app/api/schemas/visitor_tokens.py new file mode 100644 index 0000000..59f40bc --- /dev/null +++ b/app/api/schemas/visitor_tokens.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class VisitorTokenResponse(BaseModel): + model_config = ConfigDict(frozen=True) + + access_token: str = Field(min_length=1) + token_type: str = "Bearer" + expires_in: int = Field(ge=60, le=3600) diff --git a/app/core/config.py b/app/core/config.py index 67a011e..b7b057f 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -23,9 +23,12 @@ class Settings(BaseSettings): jwt_private_key_path: str = "config/jwt/jwt-private.pem" jwt_public_key_path: str = "config/jwt/jwt-public.pem" jwt_clock_skew_seconds: int = Field(default=30, ge=0) + visitor_token_ttl_seconds: int = Field(default=900, ge=60, le=3600) + customer_service_phone: str = Field(default="15936583816", pattern=r"^1\d{10}$") mysql_dsn: str mysql_pool_size: int = Field(default=5, ge=1) mysql_max_overflow: int = Field(default=10, ge=0) + mysql_pool_pre_ping: bool = False mysql_tx_isolation: str = "READ COMMITTED" redis_url: str redis_connect_timeout_seconds: float = Field(default=2, gt=0) @@ -43,6 +46,7 @@ class Settings(BaseSettings): message_broker_outbox_topic: str = "agent.outbox" message_broker_dlq_topic: str = "agent.dlq" milvus_uri: str + milvus_local_uri: str = "" milvus_token: str = "" milvus_collection: str = "jr_memory" neo4j_uri: str @@ -52,6 +56,8 @@ class Settings(BaseSettings): model_router_config_ref: str = "local" model_default_endpoint: str = "" model_fallback_endpoint: str = "" + knowledge_embedding_endpoint_code: str = "" + knowledge_embedding_timeout_ms: int = Field(default=15000, gt=0) sse_heartbeat_seconds: int = Field(default=15, gt=0) sse_chunk_characters: int = Field(default=256, ge=1, le=4096) sse_max_connection_seconds: int = Field(default=300, gt=0) @@ -63,6 +69,48 @@ class Settings(BaseSettings): risk_scan_run_immediately: bool = False risk_scan_retry_limit: int = Field(default=2, ge=0, le=5) risk_scan_poll_seconds: float = Field(default=30, gt=0) + offsite_mailbox: str = "15273589815@163.com" + offsite_allowed_senders: tuple[str, ...] = ("15008108550@163.com",) + offsite_risk_receiver_id: str = "" + offsite_settlement_receiver_id: str = "" + offsite_mail_return_receiver: str = "15008108550@163.com" + offsite_max_retry_count: int = Field(default=3, ge=0) + offsite_imap_enabled: bool = False + offsite_imap_host: str = "" + offsite_imap_port: int = Field(default=993, gt=0) + offsite_imap_username: str = "" + offsite_imap_password: str = "" + offsite_imap_use_ssl: bool = True + offsite_imap_idle_enabled: bool = False + offsite_mail_worker_enabled: bool = False + offsite_mail_worker_batch_size: int = Field(default=20, ge=1, le=100) + offsite_worker_user_id: str = "" + offsite_notification_sending_timeout_seconds: int = Field(default=900, gt=0) + offsite_mail_storage_dir: str = "data/offsite_mail" + offsite_ocr_enabled: bool = False + offsite_ocr_timeout_seconds: float = Field(default=30, gt=0) + offsite_aliyun_ocr_endpoint: str = "" + offsite_aliyun_access_key_id: str = "" + offsite_aliyun_access_key_secret: str = "" + offsite_deepseek_enabled: bool = False + offsite_deepseek_base_url: str = "https://api.deepseek.com" + offsite_deepseek_api_key: str = "" + offsite_deepseek_model: str = "deepseek-v4-flash" + offsite_deepseek_timeout_seconds: float = Field(default=30, gt=0) + offsite_smtp_enabled: bool = False + offsite_smtp_dry_run: bool = True + offsite_smtp_host: str = "" + offsite_smtp_port: int = Field(default=465, gt=0) + offsite_smtp_username: str = "" + offsite_smtp_password: str = "" + offsite_smtp_sender: str = "" + offsite_smtp_use_ssl: bool = True + offsite_smtp_timeout_seconds: float = Field(default=30, gt=0) + + @property + def resolved_milvus_uri(self) -> str: + """本地开发优先使用 Lite 文件;部署环境仅配置标准 Milvus URI。""" + return self.milvus_local_uri or self.milvus_uri model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") diff --git a/app/core/contracts.py b/app/core/contracts.py index 55f30d7..add064b 100644 --- a/app/core/contracts.py +++ b/app/core/contracts.py @@ -11,6 +11,8 @@ class AgentRequestMetadata(BaseModel): locale: str | None = None client_version: str | None = None ui_entry: str | None = None + # 仅由受理服务写入 Outbox,客户端 API 不接收该字段。 + chitchat_streak: int = Field(default=0, ge=0, le=5) class AgentRequest(BaseModel): @@ -60,6 +62,7 @@ class AgentDefinition(BaseModel): allowed_roles: tuple[str, ...] = () allowed_portals: tuple[str, ...] = () supported_intents: tuple[str, ...] = ("general",) + requires_model_intent_classification: bool = True class ResolvedAgentConfig(BaseModel): diff --git a/app/core/knowledge_contracts.py b/app/core/knowledge_contracts.py new file mode 100644 index 0000000..052aa05 --- /dev/null +++ b/app/core/knowledge_contracts.py @@ -0,0 +1,43 @@ +from pydantic import BaseModel, ConfigDict, Field, field_validator + +ALLOWED_KNOWLEDGE_COLLECTIONS = frozenset({ + "fin_faq_collection", + "fin_product_collection", + "fin_policy_collection", +}) + + +class KnowledgeQuery(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + query: str = Field(min_length=1, max_length=2000) + intents: tuple[str, ...] = Field(min_length=1, max_length=4) + top_k: int = Field(default=5, ge=1, le=20) + + @field_validator("query") + @classmethod + def query_must_not_be_blank(cls, value: str) -> str: + if not value.strip(): + raise ValueError("query must not be blank") + return value + + +class KnowledgeHit(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + knowledge_id: str + collection: str + snippet: str + title: str | None = None + answer: str | None = None + score: float | None = Field(default=None, ge=0, le=1) + version: str | None = None + + +class KnowledgeSearchResult(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + hits: tuple[KnowledgeHit, ...] = () + degraded: bool = False + degradation_reason: str | None = None + searched_collections: tuple[str, ...] = () diff --git a/app/core/nl2sql_catalog.py b/app/core/nl2sql_catalog.py new file mode 100644 index 0000000..75d42ab --- /dev/null +++ b/app/core/nl2sql_catalog.py @@ -0,0 +1,110 @@ +import re + +DOMAINS: dict[str, set[str]] = { + "market_nav": {"fin_market_price", "fin_nav_history", "fin_product"}, + "customer_risk": { + "sys_customer_assignment", "fin_customer_profile", "fin_risk_assessment", + }, + "product_fee": {"fin_product", "fin_fee_rule"}, + "trading_account": { + "fin_sim_order", "fin_transaction", "fin_sim_account", "fin_cash_ledger", + "fin_holding", "fin_product", + }, + "client_content": {"client_facing_content", "fin_customer_profile"}, +} + +TABLE_COLUMNS: dict[str, set[str]] = { + "sys_customer_assignment": { + "id", "customer_id", "employee_id", "employee_role", "assigned_at", "unassigned_at", + }, + "fin_customer_profile": { + "customer_id", "trade_account", "real_name", "investor_type", "total_asset", + "behavior_score", "risk_tags", "opened_at", "updated_at", + }, + "fin_risk_assessment": { + "id", "customer_id", "total_score", "investor_type", "assessed_at", + "valid_until", "created_at", + }, + "fin_product": { + "id", "product_code", "product_name", "exchange_code", "product_category", + "risk_level", "current_nav", "transaction_fee_rate", "status", + "created_at", "updated_at", + }, + "fin_fee_rule": { + "id", "rule_code", "product_id", "exchange_code", "order_side", "customer_tier", + "fee_rate", "minimum_fee", "fixed_fee", "effective_from", "effective_until", + "status", "created_at", "updated_at", + }, + "fin_market_price": { + "id", "product_id", "trade_date", "open_price", "high_price", "low_price", + "close_price", "volume", "turnover_amount", "created_at", + }, + "fin_nav_history": {"id", "product_id", "nav_date", "nav", "created_at"}, + "fin_holding": { + "id", "customer_id", "trade_account", "product_id", "total_quantity", + "available_quantity", "frozen_quantity", "market_value", "profit_loss", + "profit_loss_ratio", "status", "updated_at", + }, + "fin_transaction": { + "id", "transaction_no", "order_id", "customer_id", "account_id", "product_id", + "order_side", "executed_price", "executed_quantity", "gross_amount", + "fee_amount", "net_amount", "quote_at", "executed_at", "created_at", + }, + "fin_sim_order": { + "id", "order_no", "customer_id", "account_id", "product_id", "order_side", + "price_type", "quantity", "quote_price", "quote_at", "advisor_id", + "filled_quantity", "status", "submitted_at", "cancelled_at", "created_at", + "updated_at", + }, + "fin_sim_account": { + "id", "account_no", "customer_id", "currency", "cash_balance", "available_cash", + "frozen_cash", "initial_balance", "status", "version", "created_at", "updated_at", + }, + "fin_cash_ledger": { + "id", "ledger_no", "account_id", "transaction_id", "entry_type", "amount", + "balance_after", "available_cash_after", "frozen_cash_after", "occurred_at", + "created_at", + }, + "client_facing_content": { + "id", "customer_id", "content_type", "draft_content", "review_status", + "reviewer_user_id", "reviewed_at", "published_at", "created_at", "updated_at", + }, +} + +ALLOWED_TABLES = set(TABLE_COLUMNS) +CURRENT_ONLY_TABLES = {"fin_holding", "fin_sim_account", "sys_customer_assignment"} +BANNED_SQL = re.compile( + r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|CREATE|GRANT|REVOKE|CALL|EXEC|MERGE)\b", + re.IGNORECASE, +) + +ALIASES = { + "sys_customer_assignment": "ca", "fin_customer_profile": "cp", + "fin_risk_assessment": "ra", "fin_product": "p", "fin_fee_rule": "f", + "fin_market_price": "m", "fin_nav_history": "n", "fin_holding": "h", + "fin_transaction": "t", "fin_sim_order": "o", "fin_sim_account": "a", + "fin_cash_ledger": "l", "client_facing_content": "c", +} + +JOIN_SQL = { + ("fin_market_price", "fin_product"): "m.product_id = p.id", + ("fin_nav_history", "fin_product"): "n.product_id = p.id", + ("fin_fee_rule", "fin_product"): "f.product_id = p.id", + ("fin_holding", "fin_product"): "h.product_id = p.id", + ("fin_holding", "fin_customer_profile"): "h.customer_id = cp.customer_id", + ("fin_transaction", "fin_product"): "t.product_id = p.id", + ("fin_transaction", "fin_customer_profile"): "t.customer_id = cp.customer_id", + ("fin_transaction", "fin_sim_account"): "t.account_id = a.id", + ("fin_sim_order", "fin_product"): "o.product_id = p.id", + ("fin_sim_order", "fin_customer_profile"): "o.customer_id = cp.customer_id", + ("fin_sim_order", "fin_sim_account"): "o.account_id = a.id", + ("fin_sim_account", "fin_customer_profile"): "a.customer_id = cp.customer_id", + ("fin_cash_ledger", "fin_sim_account"): "l.account_id = a.id", + ("fin_risk_assessment", "fin_customer_profile"): "ra.customer_id = cp.customer_id", + ("sys_customer_assignment", "fin_customer_profile"): "ca.customer_id = cp.customer_id", + ("client_facing_content", "fin_customer_profile"): "c.customer_id = cp.customer_id", +} + + +def alias(table: str) -> str: + return ALIASES[table] diff --git a/app/core/nl2sql_contracts.py b/app/core/nl2sql_contracts.py new file mode 100644 index 0000000..7c92764 --- /dev/null +++ b/app/core/nl2sql_contracts.py @@ -0,0 +1,53 @@ +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class FinancialNL2SQLInput(BaseModel): + """金融 NL2SQL 公共工具入参;身份和权限只信任 RequestContext。""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + question: str = Field(min_length=1, max_length=500) + confirmation: str | None = Field(default=None, max_length=200) + dry_run: bool = False + limit: int = Field(default=50, ge=1, le=200) + + @field_validator("question") + @classmethod + def question_must_not_be_blank(cls, value: str) -> str: + if not value.strip(): + raise ValueError("question must not be blank") + return value.strip() + + +class FinancialQueryPlan(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + intent: str + domains: tuple[str, ...] + tables: tuple[str, ...] + metrics: tuple[str, ...] = () + dimensions: tuple[str, ...] = () + filters: tuple[dict[str, Any], ...] = () + time_mode: str = "none" + time_column: str | None = None + start: str | None = None + end: str | None = None + limit: int = Field(default=50, ge=1, le=200) + confidence: float = Field(default=0.0, ge=0, le=1) + needs_confirmation: bool = False + confirmation_question: str | None = None + unsupported_reason: str | None = None + + +class FinancialNL2SQLResult(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + status: str + message: str + data: dict[str, Any] = Field(default_factory=dict) + query_plan: dict[str, Any] = Field(default_factory=dict) + sql: str | None = None + parameters: dict[str, Any] = Field(default_factory=dict) + audit: dict[str, Any] = Field(default_factory=dict) diff --git a/app/core/offsite_fund_contracts.py b/app/core/offsite_fund_contracts.py new file mode 100644 index 0000000..e5ce02f --- /dev/null +++ b/app/core/offsite_fund_contracts.py @@ -0,0 +1,48 @@ +"""场外基金申购赎回业务契约。""" + +from decimal import Decimal +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +DocumentType = Literal["summary", "subscription", "redemption", "other"] +PlanTaskStatus = Literal["待执行", "执行中", "已完成", "查询失败", "无法判断", "不适用"] +RuleResultStatus = Literal["正常", "异常", "无法判断"] +OperationDecision = Literal["确认正常", "确认异常", "未处理"] +SendStatus = Literal["待发送", "发送中", "发送成功", "发送失败"] + + +class RecognizedAttachment(BaseModel): + model_config = ConfigDict(extra="forbid") + + filename: str = Field(min_length=1, max_length=255) + file_hash: str = Field(min_length=16, max_length=128) + original_file_path: str = Field(default="", max_length=500) + media_type: str = Field(default="application/octet-stream", max_length=128) + size_bytes: int = Field(default=0, ge=0) + document_type: DocumentType + extracted_fields: dict[str, object] = Field(default_factory=dict) + field_confidence: dict[str, Decimal] = Field(default_factory=dict) + ocr_text: str = "" + page_evidence: dict[str, object] = Field(default_factory=dict) + + +class ReceiveRecognizedMailRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + imap_uid: str = Field(min_length=1, max_length=64) + message_id: str = Field(min_length=1, max_length=255) + sender: str = Field(min_length=3, max_length=255) + return_path: str | None = Field(default=None, max_length=255) + auth_result: dict[str, object] = Field(default_factory=dict) + eml_path: str = Field(default="", max_length=500) + attachments: tuple[RecognizedAttachment, ...] + + +class OffsiteDocumentSummary(BaseModel): + model_config = ConfigDict(extra="forbid") + + task_id: str + document_type: DocumentType + status: str + rule_results: dict[str, RuleResultStatus] diff --git a/app/core/security.py b/app/core/security.py index 5d60411..fa1f5e6 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Protocol from uuid import uuid4 @@ -20,6 +21,35 @@ class EmptyRevocationStore: return False +class VisitorTokenIssuer: + """Issues short-lived anonymous tokens for public customer-service access.""" + + def __init__(self, settings: Settings) -> None: + self._settings = settings + self._private_key = self._load_private_key() + + def _load_private_key(self) -> str: + path = Path(self._settings.jwt_private_key_path) + if not path.is_absolute(): + path = Path.cwd() / path + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + raise RuntimeError(f"JWT private key cannot be read: {path}") from exc + + def issue(self) -> tuple[str, datetime]: + now = datetime.now(UTC) + expires_at = now + timedelta(seconds=self._settings.visitor_token_ttl_seconds) + subject = str(uuid4().int % 9_000_000_000_000_000_000 + 1) + token = jwt.encode( + {"sub": subject, "iss": self._settings.jwt_issuer, + "aud": self._settings.jwt_audience, "iat": now, "nbf": now, + "exp": expires_at, "jti": str(uuid4()), "visitor": True}, + self._private_key, algorithm=self._settings.jwt_algorithm, + ) + return token, expires_at + + class JwtAuthenticator: def __init__(self, settings: Settings, revocation_store: RevocationStore | None = None) -> None: self._settings = settings @@ -59,4 +89,10 @@ class JwtAuthenticator: or not subject.isdecimal() or len(subject) > 20 or not 0 < int(subject) <= 18446744073709551615): raise UnauthorizedAgentError("invalid subject") + if claims.get("visitor") is True: + return RequestContext( + user_id=str(subject), trace_id=str(uuid4()), roles=("visitor",), + # 访客仅可运行 Agent 与读取已发布的公共知识,绝不含个人数据权限。 + permissions=("agent:run", "knowledge:query"), data_scope="public", + ) return RequestContext(user_id=str(claims["sub"]), trace_id=str(uuid4())) diff --git a/app/infrastructure/db.py b/app/infrastructure/db.py index bc60deb..a45b664 100644 --- a/app/infrastructure/db.py +++ b/app/infrastructure/db.py @@ -1,5 +1,6 @@ from sqlalchemy.engine import make_url from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool from app.core.config import get_settings @@ -27,5 +28,9 @@ _url = make_url(settings.mysql_dsn) if "init_command" not in _url.query: _url = _url.update_query_dict({"init_command": _SESSION_UTC_INIT_COMMAND}) -engine = create_async_engine(_url, pool_pre_ping=True) +engine = create_async_engine( + _url, + pool_pre_ping=settings.mysql_pool_pre_ping, + poolclass=NullPool, +) SessionFactory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) diff --git a/app/infrastructure/milvus_knowledge_adapter.py b/app/infrastructure/milvus_knowledge_adapter.py new file mode 100644 index 0000000..9c05b8b --- /dev/null +++ b/app/infrastructure/milvus_knowledge_adapter.py @@ -0,0 +1,74 @@ +from typing import Any + +from app.core.errors import ForbiddenAgentError, RecoverableAgentError +from app.core.knowledge_contracts import ALLOWED_KNOWLEDGE_COLLECTIONS + + +class MilvusKnowledgeClient: + def __init__(self, uri: str, token: str | None = None) -> None: + self._uri = uri + self._token = token + self._client: Any | None = None + + async def _ensure_client(self) -> Any: + if self._client is None: + from pymilvus import AsyncMilvusClient # type: ignore[import-untyped] + + self._client = AsyncMilvusClient(uri=self._uri, token=self._token) + return self._client + + async def search( + self, collection: str, vector: list[float], top_k: int + ) -> list[dict[str, Any]]: + if collection not in ALLOWED_KNOWLEDGE_COLLECTIONS: + raise ForbiddenAgentError("未授权的知识集合") + if len(vector) != 1024 or not 1 <= top_k <= 20: + raise RecoverableAgentError("知识检索参数无效") + try: + client = await self._ensure_client() + # Lite 重启后集合默认未加载;远程 Milvus 对重复加载保持幂等。 + load_collection = getattr(client, "load_collection", None) + if load_collection is not None: + await load_collection(collection_name=collection) + batches = await client.search( + collection_name=collection, + data=[vector], + limit=top_k, + output_fields=["knowledge_id", "title", "snippet", "tags", "version"], + search_params={"metric_type": "COSINE"}, + ) + except Exception as exc: + raise RecoverableAgentError("知识检索不可用") from exc + return [ + normalized + for batch in batches + for hit in batch + if (normalized := self._normalize_hit(hit)) is not None + ] + + @staticmethod + def _normalize_hit(hit: Any) -> dict[str, Any] | None: + """统一 Milvus SDK 的平铺与 entity 包装命中格式。""" + raw = dict(hit) + entity = raw.get("entity") + fields = entity if isinstance(entity, dict) else raw + knowledge_id = fields.get("knowledge_id") + snippet = fields.get("snippet") + score = raw.get("score", raw.get("distance", fields.get("score"))) + if ( + not isinstance(knowledge_id, str) + or not isinstance(snippet, str) + or not isinstance(score, (int, float)) + or isinstance(score, bool) + ): + return None + normalized: dict[str, Any] = { + "knowledge_id": knowledge_id, + "snippet": snippet, + "score": float(score), + } + for field in ("title", "tags", "version"): + value = fields.get(field) + if value is not None: + normalized[field] = value + return normalized diff --git a/app/main.py b/app/main.py index 9e9fd2b..ed2d35b 100644 --- a/app/main.py +++ b/app/main.py @@ -1,13 +1,19 @@ +from pathlib import Path + from fastapi import FastAPI, Request from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles from app.api.controllers.admin import router as admin_router from app.api.controllers.agent_runs import router as agent_runs_router from app.api.controllers.conversations import router as conversations_router from app.api.controllers.health import router as health_router from app.api.controllers.knowledge import router as knowledge_router +from app.api.controllers.offsite_fund import operation_router as offsite_operation_router +from app.api.controllers.offsite_fund import router as offsite_fund_router from app.api.controllers.public_platform import router as public_platform_router from app.api.controllers.risk import router as risk_router +from app.api.controllers.visitor_tokens import router as visitor_tokens_router from app.api.middleware import attach_trace_id from app.core.config import get_settings from app.core.errors import AgentError @@ -45,9 +51,17 @@ def create_app() -> FastAPI: application.include_router(conversations_router) application.include_router(public_platform_router) application.include_router(risk_router) + application.include_router(visitor_tokens_router) + application.include_router(offsite_fund_router) + application.include_router(offsite_operation_router) application.include_router(knowledge_router) application.include_router(health_router) application.include_router(admin_router) + application.mount( + "/customer-service-test", + StaticFiles(directory=Path(__file__).resolve().parent / "static", html=True), + name="customer-service-test", + ) return application diff --git a/app/model/knowledge.py b/app/model/knowledge.py new file mode 100644 index 0000000..7306bdd --- /dev/null +++ b/app/model/knowledge.py @@ -0,0 +1,28 @@ +from datetime import date, datetime +from typing import Any + +from sqlalchemy import JSON, BigInteger, Date, DateTime, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.model.base import Base + + +class FinKnowledgeMeta(Base): + __tablename__ = "fin_knowledge_meta" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + knowledge_type: Mapped[str] = mapped_column(String(32)) + title: Mapped[str] = mapped_column(String(256)) + source_file: Mapped[str | None] = mapped_column(String(256)) + minio_path: Mapped[str | None] = mapped_column(String(512)) + milvus_collection: Mapped[str] = mapped_column(String(64)) + version: Mapped[str | None] = mapped_column(String(16)) + effective_date: Mapped[date | None] = mapped_column(Date) + expire_date: Mapped[date | None] = mapped_column(Date) + content_text: Mapped[str] = mapped_column(Text) + tags: Mapped[list[Any] | None] = mapped_column(JSON) + reviewer_id: Mapped[int | None] = mapped_column(BigInteger) + review_status: Mapped[str] = mapped_column(String(16)) + status: Mapped[str] = mapped_column(String(16)) + created_at: Mapped[datetime] = mapped_column(DateTime) + updated_at: Mapped[datetime] = mapped_column(DateTime) diff --git a/app/model/offsite_fund.py b/app/model/offsite_fund.py new file mode 100644 index 0000000..147f351 --- /dev/null +++ b/app/model/offsite_fund.py @@ -0,0 +1,185 @@ +"""场外基金运营独立持久化模型。""" + +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import ( + JSON, + BigInteger, + Date, + DateTime, + Integer, + Numeric, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column + +from app.model.base import Base + + +class OffsiteFundMail(Base): + __tablename__ = "offsite_fund_mail" + __table_args__ = ( + UniqueConstraint("imap_uid", "message_id", name="uk_offsite_mail_source"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + mail_id: Mapped[str] = mapped_column(String(16), unique=True, nullable=False) + imap_uid: Mapped[str] = mapped_column(String(64), nullable=False) + message_id: Mapped[str] = mapped_column(String(255), nullable=False) + received_date: Mapped[date] = mapped_column(Date, nullable=False) + sender: Mapped[str] = mapped_column(String(255), nullable=False) + return_path: Mapped[str | None] = mapped_column(String(255)) + auth_result: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + original_eml_path: Mapped[str] = mapped_column(String(500), nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="received") + retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + last_error: Mapped[str | None] = mapped_column(String(500)) + next_retry_at: Mapped[datetime | None] = mapped_column(DateTime) + last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class OffsiteMailCursor(Base): + """场外收件箱 UID 游标和处理租约。""" + + __tablename__ = "offsite_mail_cursor" + __table_args__ = ( + UniqueConstraint("mailbox", "folder", name="uk_offsite_mail_cursor_scope"), + ) + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), primary_key=True + ) + mailbox: Mapped[str] = mapped_column(String(255), nullable=False) + folder: Mapped[str] = mapped_column(String(64), nullable=False, default="INBOX") + last_uid: Mapped[str] = mapped_column(String(64), nullable=False, default="0") + status: Mapped[str] = mapped_column(String(32), nullable=False, default="idle") + retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + blocked_uid: Mapped[str | None] = mapped_column(String(64)) + blocked_message_id: Mapped[str | None] = mapped_column(String(255)) + last_error: Mapped[str | None] = mapped_column(String(500)) + next_retry_at: Mapped[datetime | None] = mapped_column(DateTime) + lease_id: Mapped[str | None] = mapped_column(String(64)) + lease_until: Mapped[datetime | None] = mapped_column(DateTime) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class OffsiteFundAttachment(Base): + __tablename__ = "offsite_fund_attachment" + __table_args__ = ( + UniqueConstraint("file_hash", "mail_id", name="uk_offsite_attachment_hash"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + attachment_id: Mapped[str] = mapped_column(String(24), unique=True, nullable=False) + mail_id: Mapped[str] = mapped_column(String(16), nullable=False) + filename: Mapped[str] = mapped_column(String(255), nullable=False) + file_hash: Mapped[str] = mapped_column(String(128), nullable=False) + media_type: Mapped[str] = mapped_column(String(128), nullable=False) + size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + document_type: Mapped[str] = mapped_column(String(24), nullable=False) + original_file_path: Mapped[str] = mapped_column(String(500), nullable=False) + ocr_text: Mapped[str] = mapped_column(Text, nullable=False) + extracted_fields: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + field_confidence: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + page_evidence: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="recognized") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class OffsiteFundDocument(Base): + __tablename__ = "offsite_fund_document" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + task_id: Mapped[str] = mapped_column(String(24), unique=True, nullable=False) + mail_id: Mapped[str] = mapped_column(String(16), nullable=False) + attachment_id: Mapped[str] = mapped_column(String(24), nullable=False) + document_type: Mapped[str] = mapped_column(String(24), nullable=False) + fund_code: Mapped[str | None] = mapped_column(String(32)) + fund_name: Mapped[str | None] = mapped_column(String(255)) + account_identifier: Mapped[str | None] = mapped_column(String(128)) + investor_name: Mapped[str | None] = mapped_column(String(128)) + application_no: Mapped[str | None] = mapped_column(String(128)) + application_date: Mapped[date | None] = mapped_column(Date) + raw_application_date: Mapped[str | None] = mapped_column(String(64)) + agency: Mapped[str | None] = mapped_column(String(128)) + subscription_amount_yuan: Mapped[Decimal | None] = mapped_column(Numeric(20, 4)) + redemption_shares: Mapped[Decimal | None] = mapped_column(Numeric(20, 4)) + operator_decision: Mapped[str] = mapped_column(String(16), nullable=False, default="未处理") + status: Mapped[str] = mapped_column(String(32), nullable=False, default="planned") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class OffsiteExecutionPlanTask(Base): + __tablename__ = "offsite_execution_plan_task" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + task_id: Mapped[str] = mapped_column(String(24), nullable=False) + stage: Mapped[str] = mapped_column(String(16), nullable=False) + rule_code: Mapped[str] = mapped_column(String(64), nullable=False) + title: Mapped[str] = mapped_column(String(128), nullable=False) + depends_on: Mapped[list[Any]] = mapped_column(JSON, nullable=False) + input_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + output_json: Mapped[dict[str, Any] | None] = mapped_column(JSON) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="待执行") + error_message: Mapped[str | None] = mapped_column(String(500)) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class OffsiteRuleResult(Base): + __tablename__ = "offsite_rule_result" + __table_args__ = ( + UniqueConstraint("task_id", "rule_code", name="uk_offsite_rule_task"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + task_id: Mapped[str] = mapped_column(String(24), nullable=False) + rule_code: Mapped[str] = mapped_column(String(64), nullable=False) + rule_name: Mapped[str] = mapped_column(String(128), nullable=False) + result: Mapped[str] = mapped_column(String(16), nullable=False) + document_value: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + database_value: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + calculation: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class OffsiteQueryRecord(Base): + __tablename__ = "offsite_query_record" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + task_id: Mapped[str] = mapped_column(String(24), nullable=False) + rule_code: Mapped[str] = mapped_column(String(64), nullable=False) + natural_language_request: Mapped[str] = mapped_column(Text, nullable=False) + script_path: Mapped[str] = mapped_column(String(500), nullable=False) + result_summary: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + status: Mapped[str] = mapped_column(String(24), nullable=False) + error_message: Mapped[str | None] = mapped_column(String(500)) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class OffsiteNotification(Base): + __tablename__ = "offsite_notification" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + notification_type: Mapped[str] = mapped_column(String(32), nullable=False) + business_key: Mapped[str] = mapped_column(String(64), nullable=False) + receiver_id: Mapped[str] = mapped_column(String(64), nullable=False) + operator_id: Mapped[str] = mapped_column(String(64), nullable=False) + agent_draft: Mapped[str] = mapped_column(Text, nullable=False) + final_content: Mapped[str] = mapped_column(Text, nullable=False) + payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="待发送") + retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + provider_message_id: Mapped[str | None] = mapped_column(String(128)) + failure_reason: Mapped[str | None] = mapped_column(String(500)) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + sent_at: Mapped[datetime | None] = mapped_column(DateTime) + updated_at: Mapped[datetime | None] = mapped_column(DateTime) diff --git a/app/model/session.py b/app/model/session.py index abaab22..c4df5d9 100644 --- a/app/model/session.py +++ b/app/model/session.py @@ -1,7 +1,6 @@ from datetime import datetime -from sqlalchemy import String, text -from sqlalchemy.dialects.mysql import BIGINT, DATETIME, INTEGER, TINYINT +from sqlalchemy import BigInteger, DateTime, Integer, String, text from sqlalchemy.orm import Mapped, mapped_column from app.model.base import Base @@ -9,26 +8,26 @@ from app.model.base import Base class ConversationSession(Base): __tablename__ = "svc_conversation_session" - id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) session_id: Mapped[str] = mapped_column(String(64), unique=True) - user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True)) + user_id: Mapped[int] = mapped_column(BigInteger) portal: Mapped[str] = mapped_column(String(32)) agent_type: Mapped[str | None] = mapped_column(String(32)) status: Mapped[str] = mapped_column(String(16), default="active") - clarification_round: Mapped[int] = mapped_column(TINYINT(unsigned=True), default=0) - message_count: Mapped[int] = mapped_column(INTEGER(unsigned=True), default=0) + clarification_round: Mapped[int] = mapped_column(Integer, default=0) + message_count: Mapped[int] = mapped_column(Integer, default=0) last_intent: Mapped[str | None] = mapped_column(String(32)) config_version: Mapped[str | None] = mapped_column(String(64)) started_at: Mapped[datetime] = mapped_column( - DATETIME(fsp=6), server_default=text("CURRENT_TIMESTAMP(6)") + DateTime, server_default=text("CURRENT_TIMESTAMP(6)") ) last_active_at: Mapped[datetime] = mapped_column( - DATETIME(fsp=6), server_default=text("CURRENT_TIMESTAMP(6)") + DateTime, server_default=text("CURRENT_TIMESTAMP(6)") ) - ended_at: Mapped[datetime | None] = mapped_column(DATETIME(fsp=6)) + ended_at: Mapped[datetime | None] = mapped_column(DateTime) created_at: Mapped[datetime] = mapped_column( - DATETIME(fsp=6), server_default=text("CURRENT_TIMESTAMP(6)") + DateTime, server_default=text("CURRENT_TIMESTAMP(6)") ) updated_at: Mapped[datetime] = mapped_column( - DATETIME(fsp=6), server_default=text("CURRENT_TIMESTAMP(6)") + DateTime, server_default=text("CURRENT_TIMESTAMP(6)") ) diff --git a/app/repository/platform_repository.py b/app/repository/platform_repository.py index ef891d6..e81491b 100644 --- a/app/repository/platform_repository.py +++ b/app/repository/platform_repository.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, cast from sqlalchemy import MetaData, Table, insert, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -66,7 +66,9 @@ class PlatformRepository: async def create(self, name: str, values: dict[str, Any]) -> dict[str, Any]: table = await self.table(name) result = await self.session.execute(insert(table).values(**values)) - row_id = int(result.inserted_primary_key[0]) # type: ignore[attr-defined] + inserted_primary_key = cast(Any, result).inserted_primary_key + assert inserted_primary_key is not None + row_id = int(inserted_primary_key[0]) row = await self.get(name, row_id) assert row is not None return row diff --git a/app/service/agent/base.py b/app/service/agent/base.py index 1cfcd5f..535c902 100644 --- a/app/service/agent/base.py +++ b/app/service/agent/base.py @@ -129,11 +129,16 @@ class BaseAgent(ABC): async def recall_memory(self, request: AgentRequest, context: RequestContext) -> None: if self._governance is None: raise RecoverableAgentError("缺少记忆治理依赖") + if "visitor" in context.roles: + self.memories = () + return self.memories = await self._governance.recall(context) if any(memory.customer_id != context.user_id for memory in self.memories): raise RecoverableAgentError("记忆召回越过客户范围") async def classify_intent(self, request: AgentRequest) -> IntentResult | None: + if not self.definition.requires_model_intent_classification: + return None if self._intent_classifier is None or self._intent_endpoint_resolver is None: return None endpoints = await self._intent_endpoint_resolver.resolve( diff --git a/app/service/agent/bootstrap.py b/app/service/agent/bootstrap.py index cdb0ca0..ec46e40 100644 --- a/app/service/agent/bootstrap.py +++ b/app/service/agent/bootstrap.py @@ -7,16 +7,22 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import get_settings from app.core.errors import RecoverableAgentError from app.core.fund_contracts import FundQuoteQuery +from app.core.knowledge_contracts import KnowledgeQuery +from app.core.nl2sql_contracts import FinancialNL2SQLInput from app.core.risk_contracts import RiskAlertEvidenceQuery, RiskAlertQuery from app.infrastructure.fund_quote_cache import FundQuoteCache from app.infrastructure.memory_cache import MemoryCacheAdapter from app.infrastructure.vector_memory import VectorMemoryAdapter +from app.service.agent.customer_service_agent import CustomerServiceAgent from app.service.agent.factory import AgentFactory from app.service.agent.governance import PlatformGovernance from app.service.agent.implementations.fund_query_demo import FundQueryDemoAgent from app.service.agent.implementations.risk_agent import RiskAgent +from app.service.agent.offsite_fund_agent import OffsiteFundAgent +from app.service.financial_nl2sql_service import query_financial_data_tool from app.service.fund_quote_service import query_fund_quote_tool from app.service.intent_classifier import IntentClassifier +from app.service.knowledge_tool_service import query_knowledge_tool from app.service.memory_recall_service import MemoryRecallService from app.service.model_gateway import ( DatabaseModelEndpointResolver, @@ -85,7 +91,7 @@ def get_vector_memory_adapter() -> VectorMemoryAdapter | None: from pymilvus import MilvusClient # type: ignore[import-untyped] settings = get_settings() - client = MilvusClient(uri=settings.milvus_uri, token=settings.milvus_token or None) + client = MilvusClient(uri=settings.resolved_milvus_uri, token=settings.milvus_token or None) return VectorMemoryAdapter(client, settings.milvus_collection) except Exception: logger.warning("vector memory adapter unavailable; semantic recall disabled", @@ -171,6 +177,23 @@ def get_agent_factory() -> AgentFactory: required_permission="risk:alert:read", allowed_roles=("risk_operator", "admin"), )) + registry.register(ToolDefinition( + name="query_financial_data", + input_model=FinancialNL2SQLInput, + handler=cast(Any, query_financial_data_tool), + required_permission="financial:nl2sql:read", + allowed_roles=("advisor", "operator", "admin", "super_admin"), + timeout_seconds=10, + )) + registry.register(ToolDefinition( + name="query_knowledge", + input_model=KnowledgeQuery, + handler=cast(Any, query_knowledge_tool), + required_permission="knowledge:query", + allowed_roles=("visitor", "customer"), + # 15 秒向量端点预算外预留检索和权威回查时间,防止正常降级被工具层提前中断。 + timeout_seconds=20, + )) model_service = get_model_service() endpoint_resolver = DatabaseModelEndpointResolver() factory = AgentFactory( @@ -204,3 +227,11 @@ def register_business_agents(factory: AgentFactory) -> None: RiskAgent.definition, lambda _context: RiskAgent(RiskAgent.definition), ) + factory.register( + OffsiteFundAgent.definition, + lambda _context: OffsiteFundAgent(OffsiteFundAgent.definition), + ) + factory.register( + CustomerServiceAgent.definition, + lambda _context: CustomerServiceAgent(), + ) diff --git a/app/service/agent/customer_service_agent.py b/app/service/agent/customer_service_agent.py new file mode 100644 index 0000000..22e6a30 --- /dev/null +++ b/app/service/agent/customer_service_agent.py @@ -0,0 +1,113 @@ +"""奶龙基金智能助手:只处理一期公开客服范围。""" + +from app.core.config import get_settings +from app.core.contracts import AgentDefinition, AgentRequest, CoreResult, RequestContext +from app.core.errors import RecoverableAgentError +from app.core.knowledge_contracts import KnowledgeSearchResult +from app.service.agent.base import BaseAgent +from app.service.agent.customer_service_routing import CustomerServiceIntentRouter + + +def _human_service_phone() -> str: + return get_settings().customer_service_phone + + +class CustomerServiceAgent(BaseAgent): + definition = AgentDefinition( + agent_type="customer_service", + version="1.0.0", + allowed_tools=("query_knowledge",), + allowed_roles=("visitor", "customer"), + allowed_portals=("api",), + requires_model_intent_classification=False, + supported_intents=( + "security_notice", + "account_entry", + "human_transfer", + "compliance_refusal", + "chitchat", + "public_knowledge", + "faq", + "product_inquiry", + "policy_explain", + ), + ) + + def __init__(self) -> None: + super().__init__(self.definition) + + async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult: + message = request.message.strip() + route = CustomerServiceIntentRouter.classify(message) + if route.intent == "security_notice": + return CoreResult( + text=( + "请立即停止操作,不要继续提供验证码、密码或身份证件信息,并尽快联系" + f"人工客服 {_human_service_phone()}。" + ), + transfer_required=True, + transfer_reason="security_notice", + ) + if route.intent == "compliance_refusal": + return CoreResult( + text=( + "我不能推荐具体产品、承诺收益或代您交易;如需进一步了解,请联系" + f"人工客服 {_human_service_phone()}。" + ), + transfer_required=True, + transfer_reason="compliance_refusal", + ) + if route.intent == "account_entry": + if "visitor" in context.roles: + return CoreResult(text="我无法查询账户数据,请先登录后前往“我的账户”查看相关状态。") + return CoreResult(text="我无法查询账户数据,请前往“我的账户”查看相关状态。") + if route.intent == "human_transfer": + return CoreResult( + text=( + f"您可以联系人工客服 {_human_service_phone()}(工作日 09:00-18:00)" + "进一步核实。" + ), + transfer_required=True, + transfer_reason="human_transfer", + ) + if route.is_chitchat: + if request.metadata.chitchat_streak == 4: + return CoreResult(text="和您聊天很开心呀。您是想了解相关的基金业务或公开信息吗?") + return CoreResult(text="您好呀,我是奶龙基金智能助手,很高兴和您聊天。") + try: + output = await self.call_tool( + "query_knowledge", + {"query": message, "intents": route.knowledge_intents, "top_k": 5}, + intent="public_knowledge", + context=context, + ) + result = ( + output + if isinstance(output, KnowledgeSearchResult) + else KnowledgeSearchResult.model_validate(output) + ) + except RecoverableAgentError: + return self._knowledge_transfer("knowledge_unavailable") + answers = [ + hit.answer.strip() + for hit in result.hits + if isinstance(hit.answer, str) and hit.answer.strip() + ] + if not answers: + return self._knowledge_transfer("knowledge_not_found") + return CoreResult(text=answers[0]) + + @staticmethod + def _knowledge_transfer(reason: str) -> CoreResult: + phone = _human_service_phone() + if reason == "knowledge_unavailable": + text = ( + "您好呀,我暂时无法从公开资料中确认这个问题,请联系" + f"人工客服 {phone} 进一步核实。" + ) + else: + text = ( + "您好呀,我暂时没有查到匹配的公开资料,请联系" + f"人工客服 {phone} 进一步核实。" + ) + return CoreResult(text=text, transfer_required=True, transfer_reason=reason) diff --git a/app/service/agent/customer_service_routing.py b/app/service/agent/customer_service_routing.py new file mode 100644 index 0000000..dc85d95 --- /dev/null +++ b/app/service/agent/customer_service_routing.py @@ -0,0 +1,74 @@ +"""一期客服的确定性路由,先处理安全和边界,再允许公开知识检索。""" + +from collections.abc import Sequence +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CustomerServiceRoute: + intent: str + knowledge_intents: tuple[str, ...] = () + is_chitchat: bool = False + + +class CustomerServiceIntentRouter: + _SECURITY_KEYWORDS = ("验证码", "密码泄露", "被盗", "诈骗", "非本人交易") + _COMPLIANCE_KEYWORDS = ("推荐", "收益最高", "稳赚", "保本", "帮我买", "替我交易") + _ACCOUNT_KEYWORDS = ("持仓", "收益", "订单", "定投", "银行卡", "风险测评", "投诉进度") + _HUMAN_TRANSFER_KEYWORDS = ("转人工", "人工客服", "投诉", "赔偿", "法律", "纠纷") + _POLICY_KEYWORDS = ( + "申购", "赎回", "到账", "费率", "手续费", "确认份额", "交易日", "分红", "规则", "政策" + ) + _PRODUCT_KEYWORDS = ( + "产品", "基金代码", "基金经理", "份额类别", "a类", "c类", "净值", "风险等级" + ) + _CHITCHAT_MESSAGES = frozenset({ + "你好", "您好", "嗨", "哈喽", "在吗", "谢谢", "谢谢你", "再见", "拜拜", + "你是谁", "你叫什么", "你今天开心吗", + }) + _CHITCHAT_PHRASES = ("今天天气", "讲个笑话", "你几岁", "你开心吗", "你忙吗") + + @classmethod + def classify(cls, message: str) -> CustomerServiceRoute: + normalized = message.strip().lower() + if cls._contains(normalized, cls._SECURITY_KEYWORDS): + return CustomerServiceRoute(intent="security_notice") + if cls._contains(normalized, cls._COMPLIANCE_KEYWORDS): + return CustomerServiceRoute(intent="compliance_refusal") + if cls._contains(normalized, cls._ACCOUNT_KEYWORDS): + return CustomerServiceRoute(intent="account_entry") + if cls._contains(normalized, cls._HUMAN_TRANSFER_KEYWORDS): + return CustomerServiceRoute(intent="human_transfer") + if cls._is_chitchat(normalized): + return CustomerServiceRoute(intent="chitchat", is_chitchat=True) + if cls._contains(normalized, cls._POLICY_KEYWORDS): + return CustomerServiceRoute( + intent="public_knowledge", knowledge_intents=("policy_explain",) + ) + if cls._contains(normalized, cls._PRODUCT_KEYWORDS): + return CustomerServiceRoute( + intent="public_knowledge", knowledge_intents=("product_inquiry",) + ) + return CustomerServiceRoute(intent="public_knowledge", knowledge_intents=("faq",)) + + @classmethod + def chitchat_streak(cls, prior_messages: Sequence[str], message: str) -> int: + """返回当前消息在同一会话中连续闲聊的次数,最大只需记录到第五句。""" + if not cls._is_chitchat(message.strip().lower()): + return 0 + streak = 1 + for prior_message in reversed(prior_messages): + if not cls._is_chitchat(prior_message.strip().lower()): + break + streak += 1 + if streak == 5: + break + return streak + + @staticmethod + def _contains(message: str, keywords: tuple[str, ...]) -> bool: + return any(keyword in message for keyword in keywords) + + @classmethod + def _is_chitchat(cls, message: str) -> bool: + return message in cls._CHITCHAT_MESSAGES or cls._contains(message, cls._CHITCHAT_PHRASES) diff --git a/app/service/agent/factory.py b/app/service/agent/factory.py index c6957e0..494d36b 100644 --- a/app/service/agent/factory.py +++ b/app/service/agent/factory.py @@ -59,6 +59,10 @@ class AgentFactory: agent.bind_model_service(self._model_service) if self._tool_executor is not None: agent.bind_tool_executor(self._tool_executor) - if self._intent_classifier is not None and self._intent_endpoint_resolver is not None: + if ( + agent.definition.requires_model_intent_classification + and self._intent_classifier is not None + and self._intent_endpoint_resolver is not None + ): agent.bind_intent_classifier(self._intent_classifier, self._intent_endpoint_resolver) return agent diff --git a/app/service/agent/governance.py b/app/service/agent/governance.py index 579bf4b..d8cad12 100644 --- a/app/service/agent/governance.py +++ b/app/service/agent/governance.py @@ -6,6 +6,7 @@ from typing import Protocol from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession +from app.core.config import get_settings from app.core.contracts import ( AgentDefinition, AgentResult, @@ -135,8 +136,13 @@ def review_output( else: # Apply to text and citation titles, not only to the displayed answer. def redact(value: str) -> str: + # 人工客服电话是唯一经过配置审核、可在对客文本中保留的号码。 + trusted_phone = get_settings().customer_service_phone + protected_phone = "__customer_service_phone__" + value = value.replace(trusted_phone, protected_phone) value = re.sub(r"(? CoreResult: + del context + text = ( + "场外申购赎回后端流程已接入。请通过场外业务接口写入已识别邮件和附件," + "系统会生成邮件编号、附件编号、执行计划、确定性规则结果和待人工确认状态。" + f"本次请求摘要:{request.message[:120]}" + ) + return CoreResult(text=text) diff --git a/app/service/agent_persistence_service.py b/app/service/agent_persistence_service.py index 3fdbbea..afbd4aa 100644 --- a/app/service/agent_persistence_service.py +++ b/app/service/agent_persistence_service.py @@ -9,7 +9,7 @@ from app.core.contracts import AgentResult, DomainEvent from app.core.errors import RunLeaseLostError from app.model.audit import InteractionAudit from app.model.conversation import ConversationMessage -from app.model.platform import AgentRun, DomainEventOutbox, RequestIdempotency +from app.model.platform import AgentRun, DomainEventOutbox, HandoverTicket, RequestIdempotency class AgentPersistenceService: @@ -52,6 +52,31 @@ class AgentPersistenceService: ) self.session.add(message) await self.session.flush() + # 转人工只接受已完成治理决策的结果;工单与回复绑定,便于管理员回看上下文。 + handover_ticket: HandoverTicket | None = None + if result.result.transfer_required: + handover_ticket = HandoverTicket( + ticket_no=f"ticket-{uuid4().hex[:24]}", + session_id=run.session_id, + customer_id=run.user_id, + source_agent=run.agent_type, + source_message_id=message.id, + intent=(result.result.intent.intent if result.result.intent else None), + confidence=( + Decimal(str(result.result.intent.confidence)) + if result.result.intent else None + ), + reason_code=result.result.transfer_reason or "agent_requested", + conversation_summary=result.result.text, + source_references=[ + reference.model_dump(mode="json") + for reference in result.result.source_references + ], + status="pending", + created_at=now, + updated_at=now, + ) + self.session.add(handover_ticket) run.result_message_id = message.id run.status = "succeeded" run.result_version = 1 @@ -63,6 +88,19 @@ class AgentPersistenceService: session_id=run.session_id, portal="agent", action_type="agent.run_completed", detail={"run_id": run_id, "result_message_id": message.id}, created_at=now, )) + if handover_ticket is not None: + # 管理员工单列表之外还需留一条不可变审计,记录 Agent 自动转接来源。 + self.session.add(InteractionAudit( + actor_type="agent", actor_id=run.user_id, target_customer_id=run.user_id, + session_id=run.session_id, portal="agent", + action_type="agent.handover_requested", + detail={ + "run_id": run_id, + "ticket_no": handover_ticket.ticket_no, + "reason_code": handover_ticket.reason_code, + }, + created_at=now, + )) await self.session.execute( update(RequestIdempotency) .where(RequestIdempotency.id == run.idempotency_id) @@ -80,6 +118,14 @@ class AgentPersistenceService: payload={"run_id": run_id, "message_id": message.id, "customer_id": run.user_id}, occurred_at=now, )) + if handover_ticket is not None: + # Outbox 事件由后续管理员通知/工单消费方可靠投递,服务层不直接通知外部系统。 + events.append(DomainEvent( + event_id=str(uuid4()), event_type="conversation.transfer_requested", + aggregate_type="conversation", aggregate_id=run.session_id, + trace_id=run.trace_id, + payload={"ticket_no": handover_ticket.ticket_no}, occurred_at=now, + )) for event in events: self.session.add(DomainEventOutbox( event_id=event.event_id, event_type=event.event_type, diff --git a/app/service/agent_run_application_service.py b/app/service/agent_run_application_service.py index a3cb464..e7f02f8 100644 --- a/app/service/agent_run_application_service.py +++ b/app/service/agent_run_application_service.py @@ -1,5 +1,6 @@ import hashlib import json +from collections.abc import Sequence from dataclasses import dataclass from datetime import UTC, datetime, timedelta from uuid import uuid4 @@ -20,6 +21,7 @@ from app.model.platform import AgentRun, RequestIdempotency from app.model.session import ConversationSession from app.repository.outbox_repository import OutboxRepository from app.service.agent.bootstrap import get_agent_factory +from app.service.agent.customer_service_routing import CustomerServiceIntentRouter from app.service.agent.factory import AgentFactory @@ -30,6 +32,20 @@ class RunAccepted: status: str = "queued" +def build_outbox_metadata( + request: AgentRequest, prior_user_messages: Sequence[str] +) -> dict[str, object]: + """构造 Worker 使用的内部元数据,不信任外部传入的闲聊计数。""" + metadata = request.metadata + if request.agent_type == "customer_service": + metadata = metadata.model_copy(update={ + "chitchat_streak": CustomerServiceIntentRouter.chitchat_streak( + prior_user_messages, request.message + ) + }) + return metadata.model_dump(mode="json") + + class AgentRunApplicationService: def __init__(self, session: AsyncSession, factory: AgentFactory | None = None) -> None: self.session = session @@ -89,6 +105,20 @@ class AgentRunApplicationService: raise RuntimeError("idempotency record has no run") return RunAccepted(run.run_id, run.trace_id, run.status) + # 仅读取当前会话最近三条用户消息;第四条闲聊即触发一次自然业务引导。 + prior_user_messages = list(await self.session.scalars( + select(ConversationMessage.content) + .where( + ConversationMessage.session_id == request.session_id, + ConversationMessage.role == "user", + ) + .order_by(ConversationMessage.id.desc()) + .limit(3) + )) + outbox_metadata = build_outbox_metadata( + request, tuple(reversed(prior_user_messages)) + ) + trace_id = context.trace_id message = ConversationMessage( session_id=request.session_id, customer_id=user_id, portal="api", @@ -118,7 +148,11 @@ class AgentRunApplicationService: await OutboxRepository(self.session).append(DomainEvent( event_id=str(uuid4()), event_type="agent.run_requested", aggregate_type="agent_run", aggregate_id=run_id, trace_id=trace_id, - payload={"run_id": run_id, "metadata": request.metadata.model_dump(mode="json")}, + payload={ + "run_id": run_id, + "actor_type": "visitor" if "visitor" in context.roles else "authenticated", + "metadata": outbox_metadata, + }, occurred_at=now, )) return RunAccepted(run_id, trace_id) diff --git a/app/service/financial_nl2sql_service.py b/app/service/financial_nl2sql_service.py new file mode 100644 index 0000000..446801e --- /dev/null +++ b/app/service/financial_nl2sql_service.py @@ -0,0 +1,499 @@ +from __future__ import annotations + +import re +from collections.abc import Callable +from contextlib import AbstractAsyncContextManager +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from typing import Any + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.contracts import RequestContext +from app.core.errors import ForbiddenAgentError, ValidationAgentError +from app.core.nl2sql_catalog import ( + ALLOWED_TABLES, + BANNED_SQL, + CURRENT_ONLY_TABLES, + DOMAINS, + JOIN_SQL, + TABLE_COLUMNS, + alias, +) +from app.core.nl2sql_contracts import ( + FinancialNL2SQLInput, + FinancialNL2SQLResult, + FinancialQueryPlan, +) +from app.infrastructure.db import SessionFactory + +SessionFactoryType = Callable[[], AbstractAsyncContextManager[AsyncSession]] + + +def _range(question: str) -> tuple[str | None, str | None]: + days = 0 + if "近7天" in question or "最近7天" in question: + days = 7 + elif "近30天" in question or "最近30天" in question or "近一个月" in question: + days = 30 + elif "近90天" in question or "最近90天" in question or "近三个月" in question: + days = 90 + if not days: + return None, None + end = datetime.now(UTC).replace(tzinfo=None) + start = end - timedelta(days=days) + return start.strftime("%Y-%m-%d %H:%M:%S"), end.strftime("%Y-%m-%d %H:%M:%S") + + +def _filters(question: str) -> tuple[dict[str, Any], ...]: + return tuple( + {"field": "fin_product.product_code", "operator": "=", "value": code} + for code in sorted(set(re.findall(r"(? FinancialQueryPlan: + question = query.question + start, end = _range(question) + time_mode = "range" if start and end else "none" + if any(word in question for word in ("截至", "历史时点", "当时", "某日")): + time_mode = "as_of" + filters = _filters(question) + for builder in (self._market_plan, self._trade_plan, self._profile_plan): + plan = builder(query, time_mode, start, end, filters) + if plan is not None: + return plan + return FinancialQueryPlan( + intent="unknown", domains=("customer_risk",), tables=("fin_customer_profile",), + limit=query.limit, confidence=0.45, needs_confirmation=True, + confirmation_question="请明确要查询的客户、产品、时间范围或指标口径。", + ) + + @staticmethod + def _market_plan( + query: FinancialNL2SQLInput, + time_mode: str, + start: str | None, + end: str | None, + filters: tuple[dict[str, Any], ...], + ) -> FinancialQueryPlan | None: + question = query.question + if "净值" in question: + return FinancialQueryPlan( + intent="nav_history_query", domains=("market_nav", "product_fee"), + tables=("fin_nav_history", "fin_product"), metrics=("基金净值",), + dimensions=("fin_product.product_code", "fin_product.product_name", + "fin_nav_history.nav_date"), + filters=filters, time_mode=time_mode, time_column="nav_date", + start=start, end=end, limit=query.limit, confidence=0.90, + ) + if any(word in question for word in ("行情", "收盘", "开盘", "最高价", "最低价")): + return FinancialQueryPlan( + intent="market_price_query", domains=("market_nav", "product_fee"), + tables=("fin_market_price", "fin_product"), metrics=("收盘价",), + dimensions=("fin_product.product_code", "fin_product.product_name", + "fin_market_price.trade_date"), + filters=filters, time_mode=time_mode, time_column="trade_date", + start=start, end=end, limit=query.limit, confidence=0.90, + ) + return None + + @staticmethod + def _trade_plan( + query: FinancialNL2SQLInput, + time_mode: str, + start: str | None, + end: str | None, + filters: tuple[dict[str, Any], ...], + ) -> FinancialQueryPlan | None: + question = query.question + if "资金" in question or "现金流水" in question: + cash_metrics = ( + ("历史资金变化",) + if any(w in question for w in ("汇总", "合计", "变化")) + else () + ) + return FinancialQueryPlan( + intent="cash_ledger_query", domains=("trading_account", "customer_risk"), + tables=("fin_cash_ledger", "fin_sim_account", "fin_customer_profile"), + metrics=cash_metrics, + dimensions=("fin_cash_ledger.occurred_at", "fin_cash_ledger.entry_type"), + filters=filters, time_mode=time_mode, time_column="occurred_at", + start=start, end=end, limit=query.limit, confidence=0.88, + ) + if "持仓" in question: + return FinancialQueryPlan( + intent="holding_query", domains=("trading_account", "customer_risk", "product_fee"), + tables=("fin_holding", "fin_customer_profile", "fin_product"), + metrics=("持仓市值", "浮动盈亏") if "盈亏" in question else ("持仓市值",), + dimensions=("fin_customer_profile.customer_id", "fin_product.product_code", + "fin_product.product_name"), + filters=filters, time_mode=time_mode, time_column="updated_at", + start=start, end=end, limit=query.limit, confidence=0.88, + ) + if any(word in question for word in ("成交", "交易")): + transaction_metrics = ( + ("交易金额", "成交数量") + if any(w in question for w in ("统计", "汇总", "合计")) + else () + ) + return FinancialQueryPlan( + intent="transaction_query", + domains=("trading_account", "customer_risk", "product_fee"), + tables=("fin_transaction", "fin_customer_profile", "fin_product"), + metrics=transaction_metrics, + dimensions=("fin_customer_profile.customer_id", "fin_product.product_code", + "fin_transaction.order_side"), + filters=filters, time_mode=time_mode, time_column="executed_at", + start=start, end=end, limit=query.limit, confidence=0.87, + ) + if "委托" in question or "订单" in question: + return FinancialQueryPlan( + intent="order_query", domains=("trading_account", "customer_risk", "product_fee"), + tables=("fin_sim_order", "fin_customer_profile", "fin_product"), + dimensions=("fin_customer_profile.customer_id", "fin_product.product_code", + "fin_sim_order.status"), + filters=filters, time_mode=time_mode, time_column="submitted_at", + start=start, end=end, limit=query.limit, confidence=0.86, + ) + if "账户" in question or "余额" in question or "现金" in question: + return FinancialQueryPlan( + intent="account_query", domains=("trading_account", "customer_risk"), + tables=("fin_sim_account", "fin_customer_profile"), + metrics=("现金余额", "可用现金"), + dimensions=("fin_customer_profile.customer_id",), + time_mode=time_mode, + time_column="updated_at", + start=start, + end=end, + limit=query.limit, + confidence=0.88, + ) + return None + + @staticmethod + def _profile_plan( + query: FinancialNL2SQLInput, + time_mode: str, + start: str | None, + end: str | None, + filters: tuple[dict[str, Any], ...], + ) -> FinancialQueryPlan | None: + question = query.question + if "怎么样" in question: + return FinancialQueryPlan( + intent="unknown", domains=("customer_risk",), + tables=("fin_customer_profile",), limit=query.limit, + confidence=0.45, needs_confirmation=True, + confirmation_question="请明确要查询客户画像、风险测评、交易、持仓还是账户信息。", + ) + if "费率" in question or "费用" in question: + return FinancialQueryPlan( + intent="fee_rule_query", domains=("product_fee",), + tables=("fin_fee_rule", "fin_product"), + dimensions=("fin_product.product_code", "fin_fee_rule.order_side", + "fin_fee_rule.customer_tier"), + filters=filters, limit=query.limit, confidence=0.87, + ) + if "内容" in question or "审核" in question or "发布" in question: + return FinancialQueryPlan( + intent="client_content_query", domains=("client_content", "customer_risk"), + tables=("client_facing_content", "fin_customer_profile"), + dimensions=( + "fin_customer_profile.customer_id", + "client_facing_content.content_type", + "client_facing_content.review_status", + ), + time_mode=time_mode, time_column="created_at", start=start, end=end, + limit=query.limit, confidence=0.84, needs_confirmation=True, + confirmation_question="请确认要查询的是对客内容的审核或发布记录。", + ) + if "风险" in question or "测评" in question or "画像" in question or "客户" in question: + return FinancialQueryPlan( + intent="customer_risk_query", domains=("customer_risk",), + tables=("fin_risk_assessment", "fin_customer_profile"), + metrics=("客户数",) if any(w in question for w in ("多少", "数量", "统计")) else (), + dimensions=( + "fin_customer_profile.customer_id", + "fin_customer_profile.investor_type", + ), + time_mode=time_mode, time_column="assessed_at", start=start, end=end, + limit=query.limit, confidence=0.86, + ) + return None + + +class FinancialNL2SQLService: + def __init__( + self, + planner: RuleBasedFinancialPlanner | None = None, + session_factory: SessionFactoryType = SessionFactory, + ) -> None: + self.planner = planner or RuleBasedFinancialPlanner() + self.session_factory = session_factory + + async def query( + self, arguments: FinancialNL2SQLInput, context: RequestContext + ) -> dict[str, Any]: + self._require_context(context) + plan = self.planner.plan(arguments) + if arguments.confirmation and plan.needs_confirmation: + plan = plan.model_copy(update={ + "needs_confirmation": False, "confidence": max(0.85, plan.confidence), + }) + valid, message = self._validate_plan(plan, context) + if not valid: + return self._result("rejected", message, arguments, context, plan, None, {}, 0) + if plan.needs_confirmation or plan.confidence < 0.85: + question = plan.confirmation_question or "请确认查询范围、指标口径和时间条件。" + return self._result( + "need_confirmation", question, arguments, context, plan, None, {}, 0 + ) + sql, params = self._compile_sql(plan, context) + self._safe_sql_check(sql, params, plan) + if arguments.dry_run: + return self._result( + "ready", "SQL 已生成并通过只读校验", + arguments, context, plan, sql, params, 0, + ) + rows = await self._execute(sql, params) + result = self._result( + "success", "查询成功", arguments, context, plan, sql, params, len(rows) + ) + result["data"] = {"total": len(rows), "rows": rows} + return result + + @staticmethod + def _require_context(context: RequestContext) -> None: + if "financial:nl2sql:read" not in context.permissions: + raise ForbiddenAgentError("缺少金融 NL2SQL 查询权限") + if not {"advisor", "operator", "admin", "super_admin"}.intersection(context.roles): + raise ForbiddenAgentError("当前角色不能使用金融 NL2SQL 查询") + + @staticmethod + def _validate_plan(plan: FinancialQueryPlan, context: RequestContext) -> tuple[bool, str]: + if len(plan.domains) > 3: + return False, "最多支持跨三个业务域查询" + if not set(plan.tables).issubset(ALLOWED_TABLES): + return False, "查询包含未纳入 NL2SQL 范围的数据表" + allowed = set().union(*(DOMAINS[d] for d in plan.domains if d in DOMAINS)) + if not set(plan.tables).issubset(allowed): + return False, "查询计划的数据表和业务域不匹配" + if plan.time_mode == "as_of" and CURRENT_ONLY_TABLES.intersection(plan.tables): + return False, "当前快照表缺少历史版本,暂不支持该历史时点查询" + if context.data_scope not in {"self", "own_customers", "all"}: + return False, "未知的数据权限范围" + return True, "计划校验通过" + + def _compile_sql( + self, plan: FinancialQueryPlan, context: RequestContext + ) -> tuple[str, dict[str, Any]]: + select_parts: list[str] = [] + group_parts: list[str] = [] + for dimension in plan.dimensions: + table, column = self._split_field(dimension, plan.tables[0]) + self._ensure_column(table, column) + select_parts.append(f"{alias(table)}.{column} AS {column}") + group_parts.append(f"{alias(table)}.{column}") + for metric in plan.metrics: + select_parts.append(self._metric_expression(metric)) + if not select_parts: + select_parts = self._default_select(plan.tables[0]) + params: dict[str, Any] = {} + where = ["1=1"] + base_alias = alias(plan.tables[0]) + if plan.time_column and plan.start: + where.append(f"{base_alias}.{plan.time_column} >= :start_time") + params["start_time"] = plan.start + if plan.time_column and plan.end: + where.append(f"{base_alias}.{plan.time_column} <= :end_time") + params["end_time"] = plan.end + for index, item in enumerate(plan.filters): + table, column = self._split_field(str(item.get("field", "")), plan.tables[0]) + self._ensure_column(table, column) + operator = str(item.get("operator", "=")).upper() + if operator not in {"=", "!=", ">", ">=", "<", "<=", "LIKE"}: + raise ValidationAgentError("查询筛选条件不合法") + key = f"filter_{index}" + where.append(f"{alias(table)}.{column} {operator} :{key}") + params[key] = item.get("value") + self._append_customer_scope(plan, context, where, params) + sql = ( + f"SELECT {', '.join(select_parts)} FROM {self._join(plan.tables)} " + f"WHERE {' AND '.join(where)}" + ) + if plan.metrics and group_parts: + sql += f" GROUP BY {', '.join(group_parts)}" + return f"{sql} LIMIT {plan.limit}", params + + @staticmethod + def _metric_expression(metric: str) -> str: + mapping = { + "交易金额": "SUM(t.gross_amount) AS gross_amount", + "成交数量": "SUM(t.executed_quantity) AS executed_quantity", + "持仓市值": "h.market_value AS market_value", + "浮动盈亏": "h.profit_loss AS profit_loss", + "现金余额": "a.cash_balance AS cash_balance", + "可用现金": "a.available_cash AS available_cash", + "历史资金变化": "SUM(l.amount) AS cash_change", + "收盘价": "m.close_price AS close_price", + "基金净值": "n.nav AS nav", + "客户数": "COUNT(DISTINCT cp.customer_id) AS customer_count", + } + if metric not in mapping: + raise ValidationAgentError(f"暂不支持指标:{metric}") + return mapping[metric] + + @staticmethod + def _default_select(primary: str) -> list[str]: + defaults = { + "fin_fee_rule": [ + "p.product_code AS product_code", + "f.order_side AS order_side", + "f.fee_rate AS fee_rate", + ], + "fin_sim_order": [ + "o.order_no AS order_no", + "o.status AS status", + "o.submitted_at AS submitted_at", + ], + "client_facing_content": [ + "c.content_type AS content_type", + "c.review_status AS review_status", + ], + "fin_transaction": [ + "t.transaction_no AS transaction_no", + "t.gross_amount AS gross_amount", + ], + } + return defaults.get(primary, [f"{alias(primary)}.id AS id"]) + + @staticmethod + def _split_field(field: str, default_table: str) -> tuple[str, str]: + if "." not in field: + return default_table, field + table, column = field.split(".", 1) + return table, column + + @staticmethod + def _ensure_column(table: str, column: str) -> None: + if column not in TABLE_COLUMNS.get(table, set()): + raise ValidationAgentError("查询字段不在白名单内") + + @staticmethod + def _join(tables: tuple[str, ...]) -> str: + primary = tables[0] + joined = {primary} + pending = set(tables) - joined + sql = f"{primary} {alias(primary)}" + while pending: + for table in sorted(pending): + condition = next( + ( + JOIN_SQL.get((existing, table)) or JOIN_SQL.get((table, existing)) + for existing in joined + if JOIN_SQL.get((existing, table)) or JOIN_SQL.get((table, existing)) + ), + None, + ) + if condition: + sql += f" JOIN {table} {alias(table)} ON {condition}" + joined.add(table) + pending.remove(table) + break + else: + raise ValidationAgentError("查询涉及的表缺少合法关联路径") + return sql + + @staticmethod + def _append_customer_scope( + plan: FinancialQueryPlan, context: RequestContext, where: list[str], params: dict[str, Any] + ) -> None: + aliases = [ + alias(table) + for table in plan.tables + if "customer_id" in TABLE_COLUMNS.get(table, set()) + ] + if not aliases or context.data_scope == "all": + return + customer_ids = tuple(context.customer_ids) or (context.user_id,) + keys = [] + for index, customer_id in enumerate(customer_ids): + key = f"scope_customer_{index}" + keys.append(f":{key}") + params[key] = int(customer_id) + where.append(f"{aliases[0]}.customer_id IN ({', '.join(keys)})") + + @staticmethod + def _safe_sql_check(sql: str, params: dict[str, Any], plan: FinancialQueryPlan) -> None: + normalized = re.sub(r"\s+", " ", sql.strip()) + if not normalized.upper().startswith("SELECT "): + raise ForbiddenAgentError("仅支持 SELECT 查询") + if ";" in normalized.rstrip(";") or BANNED_SQL.search(normalized): + raise ForbiddenAgentError("检测到非只读数据库操作") + if "*" in normalized: + raise ForbiddenAgentError("禁止使用 SELECT *") + refs = set(re.findall(r"\b(?:FROM|JOIN)\s+([A-Za-z_][A-Za-z0-9_]*)", normalized, re.I)) + if refs != set(plan.tables): + raise ForbiddenAgentError("SQL 使用的数据表与查询计划不一致") + if len(params) > 30: + raise ForbiddenAgentError("查询参数过多") + + async def _execute(self, sql: str, params: dict[str, Any]) -> list[dict[str, Any]]: + async with self.session_factory() as session: + result = await session.execute(text(sql), params) + return [self._jsonable(dict(row)) for row in result.mappings().all()] + + @staticmethod + def _jsonable(row: dict[str, Any]) -> dict[str, Any]: + converted: dict[str, Any] = {} + for key, value in row.items(): + if isinstance(value, Decimal): + converted[key] = str(value) + elif isinstance(value, datetime): + converted[key] = value.isoformat(sep=" ", timespec="seconds") + else: + converted[key] = value + return converted + + @staticmethod + def _result( + status: str, + message: str, + arguments: FinancialNL2SQLInput, + context: RequestContext, + plan: FinancialQueryPlan, + sql: str | None, + params: dict[str, Any], + row_count: int, + ) -> dict[str, Any]: + audit = { + "tool": "query_financial_data", + "engine_version": "mvp-1", + "trace_id": context.trace_id, + "question": arguments.question, + "query_plan": plan.model_dump(mode="json"), + "generated_sql": sql, + "parameters": params, + "permission_check": { + "status": "passed" if status in {"ready", "success"} else status, + "roles": list(context.roles), + "data_scope": context.data_scope, + "permission": "financial:nl2sql:read", + "allowed_tables": list(plan.tables), + }, + "execution": {"status": status, "row_count": row_count}, + "created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(timespec="seconds"), + } + return FinancialNL2SQLResult( + status=status, message=message, query_plan=plan.model_dump(mode="json"), + sql=sql, parameters=params, audit=audit, + ).model_dump(mode="json") + + +async def query_financial_data_tool( + arguments: FinancialNL2SQLInput, context: RequestContext +) -> dict[str, Any]: + return await FinancialNL2SQLService().query(arguments, context) diff --git a/app/service/health_service.py b/app/service/health_service.py index 322a78e..278f3f5 100644 --- a/app/service/health_service.py +++ b/app/service/health_service.py @@ -68,7 +68,7 @@ class HealthService: finally: if client is not None: try: - await client.aclose() + await client.close() except Exception: pass @@ -79,7 +79,7 @@ class HealthService: try: async with asyncio.timeout(self._milvus_timeout_seconds): client = await asyncio.to_thread( - _create_milvus_client, settings.milvus_uri, settings.milvus_token + _create_milvus_client, settings.resolved_milvus_uri, settings.milvus_token ) await client.get_server_version() return True, "ok" diff --git a/app/service/knowledge_authority.py b/app/service/knowledge_authority.py new file mode 100644 index 0000000..73360ad --- /dev/null +++ b/app/service/knowledge_authority.py @@ -0,0 +1,100 @@ +import json +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql.elements import ColumnElement + +from app.core.knowledge_contracts import KnowledgeHit, KnowledgeQuery +from app.model.knowledge import FinKnowledgeMeta + + +class KnowledgeMysqlAuthority: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def filter_published(self, hits: tuple[KnowledgeHit, ...]) -> list[KnowledgeHit]: + ids = tuple(int(hit.knowledge_id) for hit in hits if hit.knowledge_id.isdecimal()) + if not ids: + return [] + rows = await self._session.scalars( + select(FinKnowledgeMeta).where( + FinKnowledgeMeta.id.in_(ids), *self._published_filters() + ) + ) + approved = {str(row.id): row for row in rows} + result: list[KnowledgeHit] = [] + for hit in hits: + row = approved.get(hit.knowledge_id) + if row is None: + continue + answer = self.extract_answer(row.content_text).strip() + if answer: + result.append(hit.model_copy(update={ + "answer": answer, "version": row.version, "title": row.title, + })) + return result + + async def search_keyword( + self, query: KnowledgeQuery, collections: tuple[str, ...], top_k: int + ) -> list[KnowledgeHit]: + """向量服务不可用时,在原授权集合内执行受限的只读关键词检索。""" + keyword = self._keyword(query.query) + if not collections or not keyword: + return [] + # 显式转义 LIKE 通配符,避免用户输入扩大关键词降级的匹配范围。 + escaped_keyword = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + rows = await self._session.scalars( + select(FinKnowledgeMeta) + .where( + FinKnowledgeMeta.milvus_collection.in_(collections), + *self._published_filters(), + FinKnowledgeMeta.content_text.like(f"%{escaped_keyword}%", escape="\\"), + ) + .order_by(FinKnowledgeMeta.id.desc()) + .limit(top_k) + ) + result: list[KnowledgeHit] = [] + for row in rows: + answer = self.extract_answer(row.content_text).strip() + if not answer: + continue + result.append( + KnowledgeHit( + knowledge_id=str(row.id), + collection=row.milvus_collection, + title=row.title, + snippet=answer[:300], + answer=answer, + version=row.version, + ) + ) + return result + + @staticmethod + def _keyword(query: str) -> str: + """压缩空白并限制关键词长度,避免降级查询承载无界输入。""" + return "".join(query.split())[:64] + + @staticmethod + def _published_filters() -> tuple[ColumnElement[bool], ...]: + today = datetime.now(UTC).date() + return ( + FinKnowledgeMeta.review_status == "published", + FinKnowledgeMeta.status == "active", + (FinKnowledgeMeta.effective_date.is_(None)) + | (FinKnowledgeMeta.effective_date <= today), + (FinKnowledgeMeta.expire_date.is_(None)) + | (FinKnowledgeMeta.expire_date > today), + ) + + @staticmethod + def extract_answer(content_text: str) -> str: + try: + payload = json.loads(content_text) + except json.JSONDecodeError: + return content_text + answer = payload.get("answer") if isinstance(payload, dict) else None + if isinstance(answer, str): + return answer + return content_text diff --git a/app/service/knowledge_config.py b/app/service/knowledge_config.py new file mode 100644 index 0000000..beb2453 --- /dev/null +++ b/app/service/knowledge_config.py @@ -0,0 +1,11 @@ +class KnowledgeRuntimeConfig: + DEFAULT_ROUTES = { + "faq": ("fin_faq_collection", 3), + "product_inquiry": ("fin_product_collection", 5), + "policy_explain": ("fin_policy_collection", 5), + } + + def __init__(self, *, vector_dim: int = 1024, similarity_threshold: float = 0.60) -> None: + self.routes = dict(self.DEFAULT_ROUTES) + self.vector_dim = vector_dim + self.similarity_threshold = similarity_threshold diff --git a/app/service/knowledge_publication_service.py b/app/service/knowledge_publication_service.py new file mode 100644 index 0000000..57d86ff --- /dev/null +++ b/app/service/knowledge_publication_service.py @@ -0,0 +1,156 @@ +"""管理员知识发布编排:客服运行期只读,本模块仅供显式发布工具调用。""" + +from collections import defaultdict +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Protocol + +from app.core.knowledge_contracts import ALLOWED_KNOWLEDGE_COLLECTIONS + +VECTOR_DIMENSION = 1024 + + +class KnowledgePublicationError(RuntimeError): + """发布前置条件、阶段写入或补偿失败时的明确错误。""" + + +@dataclass(frozen=True) +class KnowledgePublicationRecord: + """预检清单中一条已经审核、可供管理员发布的公开知识。""" + + qa_id: str + milvus_collection: str + retrieval_text: str + title: str + snippet: str + tags: tuple[str, ...] + version: str + metadata: Mapping[str, object] + + +@dataclass(frozen=True) +class KnowledgePublicationResult: + """仅返回可审计的业务编号和数据库主键映射,不返回正文或密钥。""" + + knowledge_ids: dict[str, int] + collections: tuple[str, ...] + + +class KnowledgeEmbedder(Protocol): + async def embed(self, text: str) -> list[float]: ... + + +class KnowledgePublicationStore(Protocol): + async def stage(self, records: tuple[KnowledgePublicationRecord, ...]) -> dict[str, int]: ... + + async def publish(self, knowledge_ids: tuple[int, ...], reviewer_id: int) -> None: ... + + async def disable(self, knowledge_ids: tuple[int, ...]) -> None: ... + + +class KnowledgeVectorPublisher(Protocol): + async def upsert(self, collection: str, records: tuple[dict[str, object], ...]) -> None: ... + + async def delete(self, collection: str, knowledge_ids: tuple[str, ...]) -> None: ... + + +class KnowledgePublicationService: + """把发布动作拆为可补偿阶段,任何中断都不能让未索引知识对客可见。""" + + def __init__( + self, + embedder: KnowledgeEmbedder, + store: KnowledgePublicationStore, + vectors: KnowledgeVectorPublisher, + ) -> None: + self._embedder = embedder + self._store = store + self._vectors = vectors + + async def publish( + self, records: Sequence[KnowledgePublicationRecord], *, reviewer_id: int + ) -> KnowledgePublicationResult: + immutable_records = tuple(records) + self._validate(immutable_records, reviewer_id) + embeddings = await self._embeddings(immutable_records) + knowledge_ids = await self._store.stage(immutable_records) + self._validate_staged_ids(immutable_records, knowledge_ids) + payloads = self._payloads(immutable_records, embeddings, knowledge_ids) + try: + for collection, collection_payloads in payloads.items(): + await self._vectors.upsert(collection, tuple(collection_payloads)) + except Exception as exc: + await self._compensate(payloads, tuple(knowledge_ids.values())) + raise KnowledgePublicationError("向量写入失败,知识保持未发布状态") from exc + await self._store.publish(tuple(knowledge_ids.values()), reviewer_id) + return KnowledgePublicationResult( + knowledge_ids=knowledge_ids, + collections=tuple(payloads), + ) + + @staticmethod + def _validate(records: tuple[KnowledgePublicationRecord, ...], reviewer_id: int) -> None: + if reviewer_id <= 0: + raise KnowledgePublicationError("reviewer_id 必须是正整数") + if not records: + raise KnowledgePublicationError("没有可发布的公开知识") + qa_ids = [record.qa_id for record in records] + if len(qa_ids) != len(set(qa_ids)): + raise KnowledgePublicationError("发布清单存在重复 qa_id") + for record in records: + if record.milvus_collection not in ALLOWED_KNOWLEDGE_COLLECTIONS: + raise KnowledgePublicationError("发布清单包含未授权集合") + if not record.retrieval_text.strip(): + raise KnowledgePublicationError(f"{record.qa_id}: 检索文本不能为空") + + async def _embeddings( + self, records: tuple[KnowledgePublicationRecord, ...] + ) -> dict[str, list[float]]: + embeddings: dict[str, list[float]] = {} + for record in records: + vector = await self._embedder.embed(record.retrieval_text) + if len(vector) != VECTOR_DIMENSION: + raise KnowledgePublicationError( + f"{record.qa_id}: 向量维度必须为 {VECTOR_DIMENSION}" + ) + embeddings[record.qa_id] = vector + return embeddings + + @staticmethod + def _validate_staged_ids( + records: tuple[KnowledgePublicationRecord, ...], knowledge_ids: dict[str, int] + ) -> None: + expected = {record.qa_id for record in records} + if set(knowledge_ids) != expected or any(value <= 0 for value in knowledge_ids.values()): + raise KnowledgePublicationError("MySQL 暂存结果与发布清单不一致") + + @staticmethod + def _payloads( + records: tuple[KnowledgePublicationRecord, ...], + embeddings: dict[str, list[float]], + knowledge_ids: dict[str, int], + ) -> dict[str, list[dict[str, object]]]: + payloads: dict[str, list[dict[str, object]]] = defaultdict(list) + for record in records: + payloads[record.milvus_collection].append({ + "knowledge_id": str(knowledge_ids[record.qa_id]), + "embedding": embeddings[record.qa_id], + "title": record.title, + "snippet": record.snippet, + "tags": list(record.tags), + "version": record.version, + }) + return dict(payloads) + + async def _compensate( + self, payloads: dict[str, list[dict[str, object]]], knowledge_ids: tuple[int, ...] + ) -> None: + for collection, items in payloads.items(): + try: + await self._vectors.delete( + collection, tuple(str(item["knowledge_id"]) for item in items) + ) + except Exception: + # MySQL 行仍会被停用,因此清理失败的残余向量无法对客返回。 + pass + await self._store.disable(knowledge_ids) diff --git a/app/service/knowledge_retrieval_service.py b/app/service/knowledge_retrieval_service.py new file mode 100644 index 0000000..54327a8 --- /dev/null +++ b/app/service/knowledge_retrieval_service.py @@ -0,0 +1,66 @@ +from typing import Any, Protocol + +from app.core.contracts import RequestContext +from app.core.errors import RecoverableAgentError +from app.core.knowledge_contracts import KnowledgeHit, KnowledgeQuery, KnowledgeSearchResult +from app.service.knowledge_config import KnowledgeRuntimeConfig + +# ruff: noqa: E501 + + +class KnowledgeEmbedder(Protocol): + async def embed(self, text: str) -> list[float]: ... + + +class KnowledgeVectorStore(Protocol): + async def search(self, collection: str, vector: list[float], top_k: int) -> list[dict[str, Any]]: ... + + +class KnowledgeAuthority(Protocol): + async def filter_published(self, hits: tuple[KnowledgeHit, ...]) -> list[KnowledgeHit]: ... + + async def search_keyword( + self, query: KnowledgeQuery, collections: tuple[str, ...], top_k: int + ) -> list[KnowledgeHit]: ... + + +class KnowledgeRetrievalService: + def __init__( + self, embedder: KnowledgeEmbedder, vector_store: KnowledgeVectorStore, + config: KnowledgeRuntimeConfig, authority: KnowledgeAuthority, + ) -> None: + self._embedder = embedder + self._vector_store = vector_store + self._config = config + self._authority = authority + + async def search(self, query: KnowledgeQuery, context: RequestContext) -> KnowledgeSearchResult: + del context + targets = tuple(self._config.routes[intent] for intent in query.intents if intent in self._config.routes) + if not targets: + return KnowledgeSearchResult() + collections: list[str] = [] + candidates: list[KnowledgeHit] = [] + try: + vector = await self._embedder.embed(query.query) + if len(vector) != self._config.vector_dim: + raise RecoverableAgentError("嵌入维度与集合定义不一致") + for collection, configured_top_k in targets: + collections.append(collection) + for raw in await self._vector_store.search( + collection, vector, min(query.top_k, configured_top_k) + ): + knowledge_id, snippet, score = raw.get("knowledge_id"), raw.get("snippet"), raw.get("score") + if isinstance(knowledge_id, str) and isinstance(snippet, str) and isinstance(score, (int, float)): + if not isinstance(score, bool) and self._config.similarity_threshold <= score <= 1: + candidates.append(KnowledgeHit(knowledge_id=knowledge_id, collection=collection, snippet=snippet, score=float(score))) + except RecoverableAgentError: + fallback_collections = tuple(dict.fromkeys(collection for collection, _ in targets)) + fallback_top_k = max(min(query.top_k, configured_top_k) for _, configured_top_k in targets) + fallback_hits = await self._authority.search_keyword(query, fallback_collections, fallback_top_k) + return KnowledgeSearchResult( + hits=tuple(fallback_hits), degraded=True, degradation_reason="milvus_unavailable", + searched_collections=fallback_collections, + ) + hits = await self._authority.filter_published(tuple(candidates)) + return KnowledgeSearchResult(hits=tuple(hits), searched_collections=tuple(dict.fromkeys(collections))) diff --git a/app/service/knowledge_tool_service.py b/app/service/knowledge_tool_service.py new file mode 100644 index 0000000..2027d10 --- /dev/null +++ b/app/service/knowledge_tool_service.py @@ -0,0 +1,55 @@ +from typing import Protocol + +from app.core.config import get_settings +from app.core.contracts import RequestContext +from app.core.knowledge_contracts import KnowledgeQuery, KnowledgeSearchResult +from app.infrastructure.db import SessionFactory +from app.infrastructure.milvus_knowledge_adapter import MilvusKnowledgeClient +from app.service.knowledge_authority import KnowledgeMysqlAuthority +from app.service.knowledge_config import KnowledgeRuntimeConfig +from app.service.knowledge_retrieval_service import KnowledgeRetrievalService +from app.service.model_gateway import DatabaseModelGateway + + +class EmbeddingGateway(Protocol): + async def embed(self, *, endpoint_code: str, text: str, timeout_ms: int) -> list[float]: ... + + +class DatabaseEmbeddingAdapter: + def __init__( + self, endpoint_code: str, timeout_ms: int, *, gateway: EmbeddingGateway + ) -> None: + self._endpoint_code = endpoint_code + self._timeout_ms = timeout_ms + self._gateway = gateway + + async def embed(self, text: str) -> list[float]: + return await self._gateway.embed( + endpoint_code=self._endpoint_code, text=text, timeout_ms=self._timeout_ms + ) + + +async def query_knowledge_tool( + arguments: KnowledgeQuery, context: RequestContext +) -> KnowledgeSearchResult: + settings = get_settings() + if not settings.knowledge_embedding_endpoint_code: + return KnowledgeSearchResult( + degraded=True, degradation_reason="embedding_endpoint_unconfigured" + ) + # 知识向量端点与默认聊天端点隔离,避免回答模型被误用于检索。 + embedder = DatabaseEmbeddingAdapter( + settings.knowledge_embedding_endpoint_code, + settings.knowledge_embedding_timeout_ms, + gateway=DatabaseModelGateway(), + ) + # 兼容旧测试替身;真实 Settings 会优先提供本地/远程统一解析后的地址。 + milvus_uri = getattr(settings, "resolved_milvus_uri", settings.milvus_uri) + vector_store = MilvusKnowledgeClient(milvus_uri, token=settings.milvus_token or None) + # 权威元数据只读回查,确保对客答案始终来自已发布、有效的知识条目。 + async with SessionFactory() as session: + authority = KnowledgeMysqlAuthority(session) + service = KnowledgeRetrievalService( + embedder, vector_store, KnowledgeRuntimeConfig(), authority + ) + return await service.search(arguments, context) diff --git a/app/service/offsite_document_recognition_adapter.py b/app/service/offsite_document_recognition_adapter.py new file mode 100644 index 0000000..908e184 --- /dev/null +++ b/app/service/offsite_document_recognition_adapter.py @@ -0,0 +1,440 @@ +"""场外基金附件 OCR 与字段识别适配层。""" + +from __future__ import annotations + +import base64 +import json +import re +from collections.abc import Mapping +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from typing import Any, Literal, cast + +import httpx + +from app.core.config import Settings +from app.core.offsite_fund_contracts import DocumentType + +RecognitionStatus = Literal["mock", "success", "disabled", "misconfigured", "error"] + +REQUIRED_FIELDS: dict[str, tuple[str, ...]] = { + "subscription": ( + "基金代码", + "基金名称", + "账户标识", + "申请编号", + "申请日期", + "代销机构", + "申购金额", + "金额单位", + ), + "redemption": ( + "基金代码", + "基金名称", + "账户标识", + "申请编号", + "申请日期", + "代销机构", + "赎回份额", + ), +} + + +@dataclass(frozen=True) +class RecognitionSourceFile: + filename: str + media_type: str + payload: bytes + file_hash: str + original_file_path: str = "" + + +@dataclass(frozen=True) +class OcrRecognitionResult: + status: RecognitionStatus + ocr_text: str + tables: list[object] + page_evidence: dict[str, object] + raw_summary: dict[str, object] + error_message: str | None = None + + +@dataclass(frozen=True) +class StructuredRecognitionResult: + document_type: DocumentType + extracted_fields: dict[str, object] + field_confidence: dict[str, Decimal] + missing_fields: tuple[str, ...] + low_confidence_fields: tuple[str, ...] + page_evidence: dict[str, object] + ocr_text: str + ocr_status: RecognitionStatus + llm_status: RecognitionStatus + error_message: str | None = None + + +class OffsiteDocumentRecognitionAdapter: + """先 OCR,再用 DeepSeek 或本地 Mock 生成统一字段。""" + + def __init__(self, settings: Settings, *, client: httpx.AsyncClient | None = None) -> None: + self.settings = settings + self.client = client + self._owns_client = client is None + + def health_check(self) -> dict[str, object]: + ocr_missing = self._ocr_missing_config() if self.settings.offsite_ocr_enabled else [] + llm_missing = ( + self._deepseek_missing_config() if self.settings.offsite_deepseek_enabled else [] + ) + return { + "ocr": ( + {"status": "misconfigured", "missing": ocr_missing} + if ocr_missing + else {"status": "ok" if self.settings.offsite_ocr_enabled else "disabled"} + ), + "deepseek": ( + {"status": "misconfigured", "missing": llm_missing} + if llm_missing + else { + "status": "ok" if self.settings.offsite_deepseek_enabled else "disabled", + "model": self.settings.offsite_deepseek_model, + } + ), + } + + async def recognize(self, source: RecognitionSourceFile) -> StructuredRecognitionResult: + ocr = await self._recognize_ocr(source) + structured = await self._extract_fields(source, ocr) + if self._owns_client and self.client is not None: + await self.client.aclose() + return structured + + async def _recognize_ocr(self, source: RecognitionSourceFile) -> OcrRecognitionResult: + if not self.settings.offsite_ocr_enabled: + text = _mock_text(source) + return OcrRecognitionResult( + status="mock", + ocr_text=text, + tables=[], + page_evidence={}, + raw_summary={"provider": "mock", "filename": source.filename}, + ) + missing = self._ocr_missing_config() + if missing: + return OcrRecognitionResult( + status="misconfigured", + ocr_text="", + tables=[], + page_evidence={}, + raw_summary={}, + error_message=f"OCR配置缺失:{', '.join(missing)}", + ) + client = self._client() + payload = { + "filename": source.filename, + "media_type": source.media_type, + "file_hash": source.file_hash, + "content_base64": base64.b64encode(source.payload).decode("ascii"), + } + try: + response = await client.post( + self.settings.offsite_aliyun_ocr_endpoint, + headers={ + "X-Acs-AccessKey-Id": self.settings.offsite_aliyun_access_key_id, + "X-Acs-Signed-Gateway": "offsite-fund", + }, + json=payload, + timeout=httpx.Timeout(self.settings.offsite_ocr_timeout_seconds), + ) + response.raise_for_status() + body = _json_mapping(response) + data = _mapping_value(body, "data") + text = _text_from_mapping(data) or _text_from_mapping(body) + tables = _list_value(data, "tables") or _list_value(body, "tables") + page_evidence = _dict_value(data, "page_evidence") or _dict_value(body, "page_evidence") + return OcrRecognitionResult( + status="success", + ocr_text=text, + tables=tables, + page_evidence=page_evidence, + raw_summary={ + "provider": "aliyun_document_ai", + "response_keys": sorted(str(key) for key in body.keys()), + }, + ) + except (httpx.HTTPError, ValueError) as exc: + return OcrRecognitionResult( + status="error", + ocr_text="", + tables=[], + page_evidence={}, + raw_summary={"provider": "aliyun_document_ai"}, + error_message=type(exc).__name__, + ) + + async def _extract_fields( + self, source: RecognitionSourceFile, ocr: OcrRecognitionResult + ) -> StructuredRecognitionResult: + if not self.settings.offsite_deepseek_enabled: + return _mock_structured_result(source, ocr, "mock") + missing = self._deepseek_missing_config() + if missing: + result = _mock_structured_result(source, ocr, "misconfigured") + return _with_error(result, f"DeepSeek配置缺失:{', '.join(missing)}") + try: + body = await self._call_deepseek(source, ocr) + document_type = _document_type(body.get("document_type")) + fields = _dict_value(body, "extracted_fields") + confidence = _confidence_map(_dict_value(body, "field_confidence")) + page_evidence = _dict_value(body, "page_evidence") or ocr.page_evidence + missing_fields = tuple(str(item) for item in _list_value(body, "missing_fields")) + low_fields = tuple(str(item) for item in _list_value(body, "low_confidence_fields")) + if document_type in ("subscription", "redemption") and not missing_fields: + missing_fields = _missing_fields(document_type, fields) + if not low_fields: + low_fields = _low_confidence_fields(confidence) + return StructuredRecognitionResult( + document_type=document_type, + extracted_fields=fields, + field_confidence=confidence, + missing_fields=missing_fields, + low_confidence_fields=low_fields, + page_evidence=page_evidence, + ocr_text=ocr.ocr_text, + ocr_status=ocr.status, + llm_status="success", + error_message=ocr.error_message, + ) + except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc: + result = _mock_structured_result(source, ocr, "error") + return _with_error(result, type(exc).__name__) + + async def _call_deepseek( + self, source: RecognitionSourceFile, ocr: OcrRecognitionResult + ) -> Mapping[str, object]: + client = self._client() + response = await client.post( + self.settings.offsite_deepseek_base_url.rstrip("/") + "/chat/completions", + headers={ + "Authorization": f"Bearer {self.settings.offsite_deepseek_api_key}", + "Content-Type": "application/json", + }, + json={ + "model": self.settings.offsite_deepseek_model, + "messages": [ + { + "role": "system", + "content": ( + "你只输出JSON。字段名必须使用申请日期和申请编号," + "document_type只能是summary、subscription、redemption、other。" + ), + }, + { + "role": "user", + "content": json.dumps( + { + "filename": source.filename, + "media_type": source.media_type, + "ocr_text": ocr.ocr_text[:12000], + "tables": ocr.tables, + "page_evidence": ocr.page_evidence, + }, + ensure_ascii=False, + ), + }, + ], + "temperature": 0, + "response_format": {"type": "json_object"}, + }, + timeout=httpx.Timeout(self.settings.offsite_deepseek_timeout_seconds), + ) + response.raise_for_status() + content = _deepseek_content(_json_mapping(response)) + parsed = json.loads(content) + if not isinstance(parsed, dict): + raise ValueError("DeepSeek响应不是JSON对象") + return cast(Mapping[str, object], parsed) + + def _client(self) -> httpx.AsyncClient: + if self.client is None: + self.client = httpx.AsyncClient() + return self.client + + def _ocr_missing_config(self) -> list[str]: + missing = [] + if not self.settings.offsite_aliyun_ocr_endpoint: + missing.append("OFFSITE_ALIYUN_OCR_ENDPOINT") + if not self.settings.offsite_aliyun_access_key_id: + missing.append("OFFSITE_ALIYUN_ACCESS_KEY_ID") + if not self.settings.offsite_aliyun_access_key_secret: + missing.append("OFFSITE_ALIYUN_ACCESS_KEY_SECRET") + return missing + + def _deepseek_missing_config(self) -> list[str]: + missing = [] + if not self.settings.offsite_deepseek_base_url: + missing.append("OFFSITE_DEEPSEEK_BASE_URL") + if not self.settings.offsite_deepseek_api_key: + missing.append("OFFSITE_DEEPSEEK_API_KEY") + if not self.settings.offsite_deepseek_model: + missing.append("OFFSITE_DEEPSEEK_MODEL") + return missing + + +def _mock_text(source: RecognitionSourceFile) -> str: + text = source.payload.decode("utf-8", errors="ignore").strip() + return text or f"文件名:{source.filename}" + + +def _mock_structured_result( + source: RecognitionSourceFile, ocr: OcrRecognitionResult, llm_status: RecognitionStatus +) -> StructuredRecognitionResult: + document_type = _classify(source.filename + "\n" + ocr.ocr_text) + fields = _extract_simple_fields(ocr.ocr_text) + confidence = {key: Decimal("1") for key in fields} + missing_fields: tuple[str, ...] = () + if document_type in ("subscription", "redemption"): + missing_fields = _missing_fields(document_type, fields) + return StructuredRecognitionResult( + document_type=document_type, + extracted_fields=fields, + field_confidence=confidence, + missing_fields=missing_fields, + low_confidence_fields=(), + page_evidence=ocr.page_evidence, + ocr_text=ocr.ocr_text, + ocr_status=ocr.status, + llm_status=llm_status, + error_message=ocr.error_message, + ) + + +def _with_error(result: StructuredRecognitionResult, message: str) -> StructuredRecognitionResult: + return StructuredRecognitionResult( + document_type=result.document_type, + extracted_fields=result.extracted_fields, + field_confidence=result.field_confidence, + missing_fields=result.missing_fields, + low_confidence_fields=result.low_confidence_fields, + page_evidence=result.page_evidence, + ocr_text=result.ocr_text, + ocr_status=result.ocr_status, + llm_status=result.llm_status, + error_message=message, + ) + + +def _classify(text: str) -> DocumentType: + if "汇总" in text: + return "summary" + if "赎回" in text: + return "redemption" + if "申购" in text: + return "subscription" + return "other" + + +def _extract_simple_fields(text: str) -> dict[str, object]: + fields: dict[str, object] = {} + for name in ( + "基金代码", + "基金名称", + "账户标识", + "投资者名称", + "客户标识", + "申请编号", + "申请日期", + "代销机构", + "申购金额", + "金额单位", + "赎回份额", + ): + value = _find_label_value(text, name) + if value: + fields[name] = value + return fields + + +def _find_label_value(text: str, label: str) -> str | None: + pattern = rf"{re.escape(label)}\s*[::]?\s*([^\s,,;;]+)" + matched = re.search(pattern, text) + if not matched: + return None + return matched.group(1).strip() + + +def _missing_fields(document_type: str, fields: Mapping[str, object]) -> tuple[str, ...]: + required = list(REQUIRED_FIELDS.get(document_type, ())) + if not (fields.get("投资者名称") or fields.get("客户标识")): + required.append("投资者名称/客户标识") + return tuple(name for name in required if not str(fields.get(name) or "").strip()) + + +def _low_confidence_fields(confidence: Mapping[str, Decimal]) -> tuple[str, ...]: + return tuple(name for name, value in confidence.items() if value < Decimal("0.95")) + + +def _confidence_map(value: Mapping[str, object]) -> dict[str, Decimal]: + result: dict[str, Decimal] = {} + for key, raw in value.items(): + try: + result[str(key)] = Decimal(str(raw)) + except (InvalidOperation, ValueError): + continue + return result + + +def _document_type(value: object) -> DocumentType: + if value in ("summary", "subscription", "redemption", "other"): + return value + return "other" + + +def _json_mapping(response: httpx.Response) -> Mapping[str, object]: + body: Any = response.json() + if not isinstance(body, dict): + raise ValueError("响应不是JSON对象") + return cast(Mapping[str, object], body) + + +def _mapping_value(value: Mapping[str, object], key: str) -> Mapping[str, object]: + nested = value.get(key) + if isinstance(nested, dict): + return cast(Mapping[str, object], nested) + return {} + + +def _dict_value(value: Mapping[str, object], key: str) -> dict[str, object]: + nested = value.get(key) + if isinstance(nested, dict): + return dict(cast(Mapping[str, object], nested)) + return {} + + +def _list_value(value: Mapping[str, object], key: str) -> list[object]: + nested = value.get(key) + return list(nested) if isinstance(nested, list) else [] + + +def _text_from_mapping(value: Mapping[str, object]) -> str: + for key in ("ocr_text", "text", "content", "markdown"): + candidate = value.get(key) + if isinstance(candidate, str): + return candidate + return "" + + +def _deepseek_content(body: Mapping[str, object]) -> str: + choices = body.get("choices") + if not isinstance(choices, list) or not choices: + raise ValueError("DeepSeek响应缺少choices") + first = choices[0] + if not isinstance(first, dict): + raise ValueError("DeepSeek响应格式错误") + message = first.get("message") + if not isinstance(message, dict): + raise ValueError("DeepSeek响应缺少message") + content = message.get("content") + if not isinstance(content, str) or not content.strip(): + raise ValueError("DeepSeek响应缺少content") + return content diff --git a/app/service/offsite_fund_rules.py b/app/service/offsite_fund_rules.py new file mode 100644 index 0000000..3ba264a --- /dev/null +++ b/app/service/offsite_fund_rules.py @@ -0,0 +1,152 @@ +"""场外基金申购赎回确定性规则。""" + +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal, InvalidOperation + +from app.core.offsite_fund_contracts import RuleResultStatus + +HUNDRED = Decimal("100") +TEN_PERCENT = Decimal("0.10") +TWENTY_PERCENT = Decimal("0.20") +ONE_YUAN = Decimal("1") + + +@dataclass(frozen=True) +class RuleDecision: + rule_code: str + rule_name: str + result: RuleResultStatus + document_value: dict[str, object] + database_value: dict[str, object] + calculation: dict[str, object] + + +def decimal_from(value: object) -> Decimal | None: + if value is None or value == "": + return None + try: + return Decimal(str(value).replace(",", "").strip()) + except (InvalidOperation, ValueError): + return None + + +def normalize_amount_yuan(raw_value: object, unit: object) -> Decimal | None: + amount = decimal_from(raw_value) + if amount is None: + return None + raw_unit = str(unit or "元").strip() + if raw_unit == "万元": + return amount * Decimal("10000") + if raw_unit in {"元", "人民币", "CNY"}: + return amount + return None + + +def parse_application_date(raw_value: object) -> date | None: + text = str(raw_value or "").strip() + for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y年%m月%d日"): + try: + return datetime.strptime(text, fmt).date() + except ValueError: + continue + return None + + +class OffsiteFundRuleEngine: + """只做程序化计算,不读取数据库,也不调用模型。""" + + def check_subscription( + self, + *, + amount_yuan: Decimal | None, + nav: Decimal | None, + total_fund_shares: Decimal | None, + before_holding_shares: Decimal | None, + ) -> list[RuleDecision]: + decisions = [self._minimum_subscription(amount_yuan)] + if (amount_yuan is None or nav is None or nav <= 0 + or total_fund_shares is None or total_fund_shares == 0): + return decisions + [ + self._unknown("subscription_holding_ratio", "申购后单一投资者持有比例"), + self._unknown("subscription_single_share_limit", "申购单笔份额上限"), + ] + current_shares = amount_yuan / nav + before = before_holding_shares or Decimal("0") + ratio = (before + current_shares) / total_fund_shares + decisions.append(RuleDecision( + rule_code="subscription_holding_ratio", + rule_name="申购后单一投资者持有比例", + result="异常" if ratio > TWENTY_PERCENT else "正常", + document_value={"申购金额元": str(amount_yuan)}, + database_value={"最新净值": str(nav), "基金最新总份额": str(total_fund_shares), + "申请前持有份额": str(before)}, + calculation={"本次申购份额": str(current_shares), "申购后持有比例": str(ratio)}, + )) + limit = total_fund_shares * TEN_PERCENT + decisions.append(RuleDecision( + rule_code="subscription_single_share_limit", + rule_name="申购单笔份额上限", + result="异常" if current_shares > limit else "正常", + document_value={"申购金额元": str(amount_yuan)}, + database_value={"最新净值": str(nav), "基金最新总份额": str(total_fund_shares)}, + calculation={"本次申购份额": str(current_shares), "份额上限": str(limit)}, + )) + return decisions + + def check_redemption( + self, + *, + redemption_shares: Decimal | None, + total_fund_shares: Decimal | None, + available_quantity: Decimal | None, + ) -> list[RuleDecision]: + if redemption_shares is None or total_fund_shares is None or total_fund_shares == 0: + ratio = self._unknown("redemption_large_ratio", "赎回巨额比例") + else: + value = redemption_shares / total_fund_shares + ratio = RuleDecision( + rule_code="redemption_large_ratio", + rule_name="赎回巨额比例", + result="异常" if value > TWENTY_PERCENT else "正常", + document_value={"赎回份额": str(redemption_shares)}, + database_value={"产品最新总份额": str(total_fund_shares)}, + calculation={"赎回比例": str(value)}, + ) + if redemption_shares is None or available_quantity is None: + available = self._unknown("redemption_available_quantity", "账户可用份额") + else: + available = RuleDecision( + rule_code="redemption_available_quantity", + rule_name="账户可用份额", + result="异常" if redemption_shares > available_quantity else "正常", + document_value={"赎回份额": str(redemption_shares)}, + database_value={"当前最新可用份额": str(available_quantity)}, + calculation={"是否超出可用份额": redemption_shares > available_quantity}, + ) + return [ratio, available] + + @staticmethod + def _minimum_subscription(amount_yuan: Decimal | None) -> RuleDecision: + result: RuleResultStatus = "无法判断" + if amount_yuan is not None: + result = "异常" if amount_yuan <= ONE_YUAN else "正常" + return RuleDecision( + rule_code="subscription_minimum_amount", + rule_name="申购最低金额", + result=result, + document_value={"申购金额元": str(amount_yuan) if amount_yuan is not None else None}, + database_value={}, + calculation={"判断口径": "标准化申购金额 <= 1 元为异常"}, + ) + + @staticmethod + def _unknown(rule_code: str, rule_name: str) -> RuleDecision: + return RuleDecision( + rule_code=rule_code, + rule_name=rule_name, + result="无法判断", + document_value={}, + database_value={}, + calculation={"原因": "必要识别字段或查询结果缺失"}, + ) diff --git a/app/service/offsite_fund_service.py b/app/service/offsite_fund_service.py new file mode 100644 index 0000000..952f4ff --- /dev/null +++ b/app/service/offsite_fund_service.py @@ -0,0 +1,710 @@ +"""场外基金申购赎回业务编排服务。""" + +import asyncio +from collections.abc import Sequence +from datetime import UTC, datetime +from decimal import Decimal +from pathlib import Path +from typing import Literal, cast +from zoneinfo import ZoneInfo + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.core.contracts import RequestContext +from app.core.offsite_fund_contracts import ( + DocumentType, + OffsiteDocumentSummary, + OperationDecision, + ReceiveRecognizedMailRequest, + RecognizedAttachment, +) +from app.model.audit import InteractionAudit +from app.model.offsite_fund import ( + OffsiteExecutionPlanTask, + OffsiteFundAttachment, + OffsiteFundDocument, + OffsiteFundMail, + OffsiteNotification, + OffsiteQueryRecord, + OffsiteRuleResult, +) +from app.service.offsite_fund_rules import ( + OffsiteFundRuleEngine, + decimal_from, + normalize_amount_yuan, + parse_application_date, +) +from app.service.offsite_nl2sql_adapter import OffsiteNl2SqlAdapter +from app.service.offsite_smtp_adapter import ( + OffsiteMailReplyRequest, + OffsiteSmtpSender, + SmtpAttachment, + SmtpSendResult, +) + +SHANGHAI = ZoneInfo("Asia/Shanghai") + + +class OffsiteFundService: + def __init__( + self, session: AsyncSession, smtp_sender: OffsiteSmtpSender | None = None + ) -> None: + self.session = session + self.rules = OffsiteFundRuleEngine() + self.nl2sql = OffsiteNl2SqlAdapter() + self.smtp_sender = smtp_sender or OffsiteSmtpSender(get_settings()) + + async def receive_recognized_mail( + self, payload: ReceiveRecognizedMailRequest, context: RequestContext + ) -> dict[str, object]: + denied = self._permission_error(context, ("offsite:write",)) + if denied is not None: + return denied + settings = get_settings() + if payload.sender not in settings.offsite_allowed_senders: + return {"code": 403, "message": "发件人不在场外业务白名单", "data": {}} + business = [item for item in payload.attachments if item.document_type != "other"] + if not business: + return {"code": 0, "message": "ok", "data": {"business": False}} + async with self.session.begin(): + existing = await self.session.scalar(select(OffsiteFundMail).where( + OffsiteFundMail.imap_uid == payload.imap_uid, + OffsiteFundMail.message_id == payload.message_id, + )) + if existing is not None: + return {"code": 0, "message": "ok", "data": {"mail_id": existing.mail_id}} + mail_id = await self._next_mail_id() + now = datetime.now(UTC).replace(tzinfo=None) + mail = OffsiteFundMail( + mail_id=mail_id, imap_uid=payload.imap_uid, message_id=payload.message_id, + received_date=datetime.now(SHANGHAI).date(), sender=payload.sender, + return_path=payload.return_path, auth_result=payload.auth_result, + original_eml_path=payload.eml_path, status="recognized", + created_at=now, updated_at=now, + ) + self.session.add(mail) + summaries: list[OffsiteDocumentSummary] = [] + for index, item in enumerate(payload.attachments, start=1): + attachment_id = f"{mail_id}-A{index:02d}" + self.session.add(OffsiteFundAttachment( + attachment_id=attachment_id, mail_id=mail_id, filename=item.filename, + file_hash=item.file_hash, media_type=item.media_type, + size_bytes=item.size_bytes, document_type=item.document_type, + original_file_path=item.original_file_path, ocr_text=item.ocr_text, + extracted_fields=item.extracted_fields, + field_confidence={ + key: str(value) for key, value in item.field_confidence.items() + }, + page_evidence=item.page_evidence, status="recognized", created_at=now, + )) + if item.document_type == "other": + continue + if item.document_type == "summary": + summaries.append(await self._create_document( + mail_id, f"{attachment_id}-S", attachment_id, item, "subscription", now)) + summaries.append(await self._create_document( + mail_id, f"{attachment_id}-R", attachment_id, item, "redemption", now)) + else: + summaries.append(await self._create_document( + mail_id, attachment_id, attachment_id, item, item.document_type, now)) + self._add_audit(context, "offsite.mail_recognized", { + "mail_id": mail_id, + "imap_uid": payload.imap_uid, + "message_id": payload.message_id, + "business_attachment_count": len(business), + }) + return {"code": 0, "message": "ok", "data": { + "mail_id": mail_id, + "documents": [item.model_dump(mode="json") for item in summaries], + }} + + async def confirm_document( + self, task_id: str, decision: OperationDecision, operator_id: str, + context: RequestContext, + ) -> dict[str, object]: + denied = self._permission_error(context, ("offsite:confirm", "offsite:write")) + if denied is not None: + return denied + async with self.session.begin(): + document = await self.session.scalar(select(OffsiteFundDocument).where( + OffsiteFundDocument.task_id == task_id).with_for_update()) + if document is None: + return {"code": 404, "message": "单据不存在", "data": {}} + document.operator_decision = decision + document.status = "operator_confirmed" + document.updated_at = datetime.now(UTC).replace(tzinfo=None) + self._add_audit(context, "offsite.document_confirmed", { + "task_id": task_id, + "operator_id": operator_id, + "decision": decision, + }) + return {"code": 0, "message": "ok", "data": {"task_id": task_id, "decision": decision}} + + async def recalculate_statistics( + self, fund_code: str, application_date: str, context: RequestContext + ) -> dict[str, object]: + denied = self._permission_error(context, ("offsite:read", "offsite:write")) + if denied is not None: + return denied + target_date = parse_application_date(application_date) + if target_date is None: + return {"code": 422, "message": "申请日期格式不正确", "data": {}} + rows = (await self.session.execute(select(OffsiteFundDocument).where( + OffsiteFundDocument.fund_code == fund_code, + OffsiteFundDocument.application_date == target_date, + OffsiteFundDocument.operator_decision == "确认正常", + ))).scalars().all() + subscription_total = sum( + ((row.subscription_amount_yuan or Decimal("0")) for row in rows), + Decimal("0"), + ) + redemption_total = sum( + ((row.redemption_shares or Decimal("0")) for row in rows), + Decimal("0"), + ) + agency_breakdown = self._agency_breakdown(rows) + redemption_amount_yuan: Decimal | None = None + net_flow_amount_yuan: Decimal | None = subscription_total if redemption_total == 0 else None + return {"code": 0, "message": "ok", "data": { + "fund_code": fund_code, "application_date": target_date.isoformat(), + "fund_name": next((row.fund_name for row in rows if row.fund_name), None), + "subscription_amount_yuan": str(subscription_total), + "subscription_count": sum(1 for row in rows if row.document_type == "subscription"), + "redemption_shares": str(redemption_total), + "redemption_count": sum(1 for row in rows if row.document_type == "redemption"), + "latest_nav": None, + "redemption_amount_yuan": ( + str(redemption_amount_yuan) if redemption_amount_yuan is not None else None + ), + "net_flow_amount_yuan": ( + str(net_flow_amount_yuan) if net_flow_amount_yuan is not None else None + ), + "agency_breakdown": agency_breakdown, + }} + + async def trigger_agent_nl2sql( + self, task_id: str, operator_id: str, manual_confirmed: bool, + context: RequestContext, + ) -> dict[str, object]: + denied = self._permission_error( + context, ("offsite:nl2sql", "offsite:write", "financial:nl2sql:read") + ) + if denied is not None: + return denied + if not manual_confirmed: + return {"code": 422, "message": "必须传递人工已确认原始文件内容状态", "data": {}} + now = datetime.now(UTC).replace(tzinfo=None) + records: list[dict[str, object]] = [] + async with self.session.begin(): + document = await self.session.scalar(select(OffsiteFundDocument).where( + OffsiteFundDocument.task_id == task_id)) + if document is None: + return {"code": 404, "message": "单据不存在", "data": {}} + questions = self._nl2sql_questions(document) + if not questions: + return {"code": 422, "message": "查询条件不足", "data": {}} + for rule_code, question in questions: + result = self.nl2sql.query(question, context) + self.session.add(OffsiteQueryRecord( + task_id=task_id, rule_code=rule_code, + natural_language_request=question, script_path=self.nl2sql.script_path, + result_summary=result, status=str(result.get("status", "error")), + error_message=( + result.get("message") if isinstance(result.get("message"), str) else None + ), + created_at=now, + )) + await self._update_query_plan_status(task_id, rule_code, result, now) + records.append({"rule_code": rule_code, "status": result.get("status")}) + self._add_audit(context, "offsite.nl2sql_triggered", { + "task_id": task_id, + "operator_id": operator_id, + "query_count": len(records), + }) + return {"code": 0, "message": "ok", "data": {"task_id": task_id, "queries": records}} + + async def create_notification( + self, task_id: str, notification_type: str, operator_id: str, + context: RequestContext, + ) -> dict[str, object]: + denied = self._permission_error(context, ("offsite:notify", "offsite:write")) + if denied is not None: + return denied + settings = get_settings() + receiver = { + "risk": settings.offsite_risk_receiver_id, + "settlement": settings.offsite_settlement_receiver_id, + "mail_return": settings.offsite_mail_return_receiver, + }[notification_type] + draft = f"{task_id} 待发送{notification_type}通知" + now = datetime.now(UTC).replace(tzinfo=None) + async with self.session.begin(): + document = await self.session.scalar(select(OffsiteFundDocument).where( + OffsiteFundDocument.task_id == task_id)) + if document is None: + return {"code": 404, "message": "单据不存在", "data": {}} + validation = self._validate_notification(document, notification_type) + if validation is not None: + return validation + rule_results = (await self.session.execute(select(OffsiteRuleResult).where( + OffsiteRuleResult.task_id == task_id + ))).scalars().all() + payload = self._notification_payload(document, rule_results, operator_id) + notice = OffsiteNotification( + notification_type=notification_type, business_key=task_id, + receiver_id=receiver, operator_id=operator_id, agent_draft=draft, + final_content=draft, payload=payload, status="待发送", + created_at=now, updated_at=now, + ) + self.session.add(notice) + await self.session.flush() + notice_id = notice.id + self._add_audit(context, "offsite.notification_created", { + "task_id": task_id, + "notification_type": notification_type, + "operator_id": operator_id, + "receiver_id": receiver, + }) + return {"code": 0, "message": "ok", "data": {"notification_id": str(notice_id)}} + + async def send_notification( + self, + notification_id: int, + operator_id: str, + operator_confirmed: bool, + final_content: str | None, + context: RequestContext, + ) -> dict[str, object]: + denied = self._permission_error(context, ("offsite:notify", "offsite:write")) + if denied is not None: + return denied + if not operator_confirmed: + return {"code": 422, "message": "邮件发送前必须完成运营确认", "data": {}} + prepared = await self._prepare_notification_send( + notification_id, operator_id, final_content, context + ) + if isinstance(prepared, dict): + return prepared + notice, mail, attachment, receiver = prepared + try: + request = self._build_mail_reply_request( + notice, mail, attachment, receiver, operator_id, final_content + ) + result = await asyncio.to_thread(self.smtp_sender.send_reply, request) + except OSError as exc: + result = SmtpSendResult( + status="发送失败", + dry_run=False, + provider_message_id=None, + failure_reason=type(exc).__name__, + retry_count=notice.retry_count + 1, + request_summary={"notification_id": str(notification_id)}, + ) + await self._finish_notification(notification_id, result, context) + return { + "code": 0, + "message": "ok", + "data": { + "notification_id": str(notification_id), + "status": result.status, + "dry_run": result.dry_run, + "provider_message_id": result.provider_message_id, + "failure_reason": result.failure_reason, + "retry_count": result.retry_count, + }, + } + + async def _prepare_notification_send( + self, + notification_id: int, + operator_id: str, + final_content: str | None, + context: RequestContext, + ) -> tuple[ + OffsiteNotification, + OffsiteFundMail, + OffsiteFundAttachment, + str, + ] | dict[str, object]: + settings = get_settings() + async with self.session.begin(): + notice = await self.session.scalar(select(OffsiteNotification).where( + OffsiteNotification.id == notification_id + ).with_for_update()) + if notice is None: + return {"code": 404, "message": "通知不存在", "data": {}} + if notice.status == "发送成功": + return { + "code": 0, + "message": "ok", + "data": { + "notification_id": str(notification_id), + "status": notice.status, + "provider_message_id": notice.provider_message_id, + }, + } + if notice.status == "发送中": + return {"code": 409, "message": "通知正在发送中", "data": {}} + if notice.retry_count > 0 and notice.retry_count >= settings.offsite_max_retry_count: + return {"code": 422, "message": "通知已达到最大重试次数", "data": {}} + document = await self.session.scalar(select(OffsiteFundDocument).where( + OffsiteFundDocument.task_id == notice.business_key)) + if document is None: + return {"code": 404, "message": "通知关联的单据或邮件不存在", "data": {}} + mail = await self.session.scalar(select(OffsiteFundMail).where( + OffsiteFundMail.mail_id == document.mail_id)) + if mail is None: + return {"code": 404, "message": "通知关联的单据或邮件不存在", "data": {}} + if notice.notification_type == "mail_return": + receiver = settings.offsite_mail_return_receiver + else: + receiver = notice.receiver_id if "@" in notice.receiver_id else "" + if not receiver: + return {"code": 422, "message": "通知对象未配置可发送的邮箱地址", "data": {}} + attachment = await self.session.scalar(select(OffsiteFundAttachment).where( + OffsiteFundAttachment.attachment_id == document.attachment_id)) + if attachment is None: + return {"code": 404, "message": "通知关联的原始附件不存在", "data": {}} + if final_content is not None and final_content != notice.final_content: + self._add_audit(context, "offsite.notification_content_changed", { + "notification_id": str(notification_id), + "operator_id": operator_id, + "changed": True, + }) + notice.final_content = final_content + notice.operator_id = operator_id + notice.status = "发送中" + return notice, mail, attachment, receiver + + def _build_mail_reply_request( + self, + notice: OffsiteNotification, + mail: OffsiteFundMail, + attachment: OffsiteFundAttachment, + receiver: str, + operator_id: str, + final_content: str | None, + ) -> OffsiteMailReplyRequest: + path = Path(attachment.original_file_path) + payload = path.read_bytes() + return OffsiteMailReplyRequest( + to_address=receiver, + subject="场外基金申购赎回处理结果", + body=final_content if final_content is not None else notice.final_content, + operator_id=operator_id, + operator_confirmed=True, + reply_to_message_id=mail.message_id, + attachments=( + SmtpAttachment( + filename=attachment.filename, + media_type=attachment.media_type, + payload=payload, + ), + ), + retry_count=notice.retry_count, + ) + + async def _finish_notification( + self, notification_id: int, result: SmtpSendResult, context: RequestContext + ) -> None: + now = datetime.now(UTC).replace(tzinfo=None) + async with self.session.begin(): + notice = await self.session.scalar(select(OffsiteNotification).where( + OffsiteNotification.id == notification_id + ).with_for_update()) + if notice is None: + return + notice.status = result.status + notice.provider_message_id = result.provider_message_id + notice.failure_reason = result.failure_reason + notice.retry_count = result.retry_count + notice.sent_at = now if result.status == "发送成功" else None + notice.updated_at = now + if result.status == "发送成功" and notice.notification_type == "mail_return": + document = await self.session.scalar(select(OffsiteFundDocument).where( + OffsiteFundDocument.task_id == notice.business_key)) + if document is not None: + mail = await self.session.scalar(select(OffsiteFundMail).where( + OffsiteFundMail.mail_id == document.mail_id).with_for_update()) + if mail is not None: + mail.status = "normal_return_sent" + self._add_audit(context, "offsite.notification_send_finished", { + "notification_id": str(notification_id), + "status": result.status, + "dry_run": result.dry_run, + "retry_count": result.retry_count, + }) + + async def _next_mail_id(self) -> str: + today = datetime.now(SHANGHAI).date() + prefix = today.strftime("%Y%m%d") + current = await self.session.scalar(select(func.count()).select_from(OffsiteFundMail).where( + OffsiteFundMail.received_date == today)) + return f"{prefix}-{int(current or 0) + 1:03d}" + + async def _create_document( + self, + mail_id: str, + task_id: str, + attachment_id: str, + item: RecognizedAttachment, + document_type: Literal["subscription", "redemption"], + now: datetime, + ) -> OffsiteDocumentSummary: + fields = item.extracted_fields + raw_date = fields.get("申请日期") + amount_yuan = normalize_amount_yuan(fields.get("申购金额"), fields.get("金额单位")) + redemption_shares = decimal_from(fields.get("赎回份额")) + status = self._recognition_status(item, document_type) + document = OffsiteFundDocument( + task_id=task_id, mail_id=mail_id, attachment_id=attachment_id, + document_type=document_type, fund_code=self._text(fields.get("基金代码")), + fund_name=self._text(fields.get("基金名称")), + account_identifier=self._text(fields.get("账户标识")), + investor_name=self._text(fields.get("投资者名称") or fields.get("客户标识")), + application_no=self._text(fields.get("申请编号")), + application_date=parse_application_date(raw_date), + raw_application_date=self._text(raw_date), agency=self._text(fields.get("代销机构")), + subscription_amount_yuan=amount_yuan, redemption_shares=redemption_shares, + status=status, created_at=now, updated_at=now, + ) + self.session.add(document) + self._append_plan_tasks(task_id, document_type, fields, now) + decisions = self.rules.check_subscription( + amount_yuan=amount_yuan, nav=decimal_from(fields.get("最新净值")), + total_fund_shares=decimal_from(fields.get("基金最新总份额")), + before_holding_shares=decimal_from(fields.get("申请前持有份额")), + ) if document_type == "subscription" else self.rules.check_redemption( + redemption_shares=redemption_shares, + total_fund_shares=decimal_from(fields.get("基金最新总份额")), + available_quantity=decimal_from(fields.get("当前最新可用份额")), + ) + results: dict[str, Literal["正常", "异常", "无法判断"]] = {} + for decision in decisions: + results[decision.rule_code] = decision.result + self.session.add(OffsiteRuleResult( + task_id=task_id, rule_code=decision.rule_code, rule_name=decision.rule_name, + result=decision.result, document_value=decision.document_value, + database_value=decision.database_value, calculation=decision.calculation, + created_at=now, + )) + return OffsiteDocumentSummary( + task_id=task_id, document_type=cast(DocumentType, document_type), status=status, + rule_results=results) + + def _append_plan_tasks( + self, task_id: str, document_type: str, fields: dict[str, object], now: datetime + ) -> None: + rules: tuple[tuple[str, str], ...] = ( + ("subscription_minimum_amount", "申购最低金额"), + ("subscription_holding_ratio", "申购后单一投资者持有比例"), + ("subscription_single_share_limit", "申购单笔份额上限"), + ) + if document_type == "redemption": + rules = (("redemption_large_ratio", "赎回巨额比例"), + ("redemption_available_quantity", "账户可用份额")) + for rule_code, title in rules: + for stage in ("查询", "计算", "核对"): + if rule_code == "subscription_minimum_amount" and stage == "查询": + status = "不适用" + else: + status = "已完成" if stage != "查询" else "待执行" + self.session.add(OffsiteExecutionPlanTask( + task_id=task_id, stage=stage, rule_code=rule_code, title=title, + depends_on=[], input_json=fields, output_json=None, + status=status, + error_message=None, created_at=now, updated_at=now, + )) + + @staticmethod + def _nl2sql_questions(document: OffsiteFundDocument) -> list[tuple[str, str]]: + if not document.fund_code: + return [] + base = f"基金代码为{document.fund_code}" + if document.account_identifier: + base += f",账户标识为{document.account_identifier}" + if document.document_type == "subscription": + return [ + ( + "subscription_holding_ratio", + base + ",查询基金最新总份额、最新净值和申请前持有份额", + ), + ("subscription_single_share_limit", base + ",查询基金最新总份额和最新净值"), + ] + return [ + ("redemption_large_ratio", base + ",查询产品最新总份额"), + ("redemption_available_quantity", base + ",查询账户当前最新可用份额"), + ] + + @staticmethod + def _text(value: object) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + async def _update_query_plan_status( + self, task_id: str, rule_code: str, result: dict[str, object], now: datetime + ) -> None: + task = await self.session.scalar(select(OffsiteExecutionPlanTask).where( + OffsiteExecutionPlanTask.task_id == task_id, + OffsiteExecutionPlanTask.rule_code == rule_code, + OffsiteExecutionPlanTask.stage == "查询", + ).with_for_update()) + if task is None: + return + status = str(result.get("status", "error")) + task.status = "已完成" if status in {"ready", "success"} else "查询失败" + if status == "need_confirmation": + task.status = "无法判断" + task.output_json = result + message = result.get("message") + task.error_message = message if isinstance(message, str) else None + task.updated_at = now + + @staticmethod + def _permission_error( + context: RequestContext, permissions: tuple[str, ...] + ) -> dict[str, object] | None: + roles = {"operator", "risk_operator", "admin", "super_admin"} + if not roles.intersection(context.roles): + return {"code": 403, "message": "当前角色不能操作场外基金流程", "data": {}} + if not set(permissions).intersection(context.permissions): + return {"code": 403, "message": "缺少场外基金操作权限", "data": {}} + return None + + def _add_audit( + self, context: RequestContext, action_type: str, detail: dict[str, object] + ) -> None: + self.session.add(InteractionAudit( + actor_type="user", + actor_id=int(context.user_id) if context.user_id.isdigit() else None, + target_customer_id=None, + session_id=None, + portal=context.portal, + action_type=action_type, + detail={**detail, "trace_id": context.trace_id}, + created_at=datetime.now(UTC).replace(tzinfo=None), + )) + + @staticmethod + def _recognition_status( + item: RecognizedAttachment, document_type: Literal["subscription", "redemption"] + ) -> str: + fields = item.extracted_fields + required = ["基金代码", "基金名称", "账户标识", "申请编号", "申请日期", "代销机构"] + if not (fields.get("投资者名称") or fields.get("客户标识")): + return "recognition_exception" + if document_type == "subscription": + required.extend(["申购金额", "金额单位"]) + else: + required.append("赎回份额") + if any(not str(fields.get(name) or "").strip() for name in required): + return "recognition_exception" + if not item.field_confidence: + return "recognition_review" + confidence_values = [ + value for value in (decimal_from(value) for value in item.field_confidence.values()) + if value is not None + ] + if not confidence_values: + return "recognition_review" + min_confidence = min(confidence_values) + if min_confidence < Decimal("0.80"): + return "recognition_exception" + if min_confidence < Decimal("0.95"): + return "recognition_review" + return "planned" + + @staticmethod + def _agency_breakdown(rows: Sequence[OffsiteFundDocument]) -> list[dict[str, object]]: + grouped: dict[str, dict[str, object]] = {} + for row in rows: + agency = row.agency or "未识别代销机构" + item = grouped.setdefault(agency, { + "agency": agency, + "subscription_amount_yuan": Decimal("0"), + "subscription_count": 0, + "redemption_shares": Decimal("0"), + "redemption_count": 0, + }) + if row.document_type == "subscription": + item["subscription_amount_yuan"] = cast( + Decimal, item["subscription_amount_yuan"] + ) + (row.subscription_amount_yuan or Decimal("0")) + subscription_count = item["subscription_count"] + item["subscription_count"] = ( + subscription_count + 1 if isinstance(subscription_count, int) else 1 + ) + elif row.document_type == "redemption": + item["redemption_shares"] = cast( + Decimal, item["redemption_shares"] + ) + (row.redemption_shares or Decimal("0")) + redemption_count = item["redemption_count"] + item["redemption_count"] = ( + redemption_count + 1 if isinstance(redemption_count, int) else 1 + ) + return [ + { + **item, + "subscription_amount_yuan": str(item["subscription_amount_yuan"]), + "redemption_shares": str(item["redemption_shares"]), + } + for item in grouped.values() + ] + + @staticmethod + def _validate_notification( + document: OffsiteFundDocument, notification_type: str + ) -> dict[str, object] | None: + if notification_type == "risk" and document.operator_decision != "确认异常": + return {"code": 422, "message": "风控通知只允许发送已确认异常单据", "data": {}} + if notification_type == "settlement" and document.operator_decision != "确认正常": + return {"code": 422, "message": "资金清算通知只允许发送已确认正常单据", "data": {}} + if notification_type == "mail_return" and document.operator_decision == "未处理": + return {"code": 422, "message": "邮件返回前必须先完成人工确认", "data": {}} + return None + + @staticmethod + def _notification_payload( + document: OffsiteFundDocument, + rule_results: Sequence[OffsiteRuleResult], + operator_id: str, + ) -> dict[str, object]: + anomalies = [ + { + "rule_code": result.rule_code, + "rule_name": result.rule_name, + "result": result.result, + "document_value": result.document_value, + "database_value": result.database_value, + "calculation": result.calculation, + } + for result in rule_results if result.result == "异常" + ] + return { + "task_id": document.task_id, + "mail_id": document.mail_id, + "attachment_id": document.attachment_id, + "document_type": document.document_type, + "fund_code": document.fund_code, + "fund_name": document.fund_name, + "account_identifier": OffsiteFundService._mask_account(document.account_identifier), + "application_no": document.application_no, + "application_date": ( + document.application_date.isoformat() if document.application_date else None + ), + "agency": document.agency, + "operator_id": operator_id, + "operator_decision": document.operator_decision, + "confirmed_at": datetime.now(UTC).replace(tzinfo=None).isoformat(timespec="seconds"), + "anomalies": anomalies, + } + + @staticmethod + def _mask_account(value: str | None) -> str | None: + if value is None or len(value) <= 4: + return value + return f"{value[:2]}***{value[-2:]}" diff --git a/app/service/offsite_mail_adapter.py b/app/service/offsite_mail_adapter.py new file mode 100644 index 0000000..32a3052 --- /dev/null +++ b/app/service/offsite_mail_adapter.py @@ -0,0 +1,269 @@ +"""场外基金真实邮件接收和原始文件保存适配层。""" + +from __future__ import annotations + +import hashlib +import imaplib +import os +import re +from contextlib import suppress +from dataclasses import dataclass +from datetime import UTC, datetime +from email import message_from_bytes +from email.message import EmailMessage, Message +from email.utils import parseaddr +from pathlib import Path +from typing import Protocol, cast + +from app.core.config import Settings + + +@dataclass(frozen=True) +class RawMailAttachment: + filename: str + media_type: str + payload: bytes + + +@dataclass(frozen=True) +class RawMailMessage: + imap_uid: str + message_id: str + sender: str + return_path: str | None + auth_result: dict[str, object] + raw_message: bytes + received_at: datetime + attachments: tuple[RawMailAttachment, ...] + + +@dataclass(frozen=True) +class SavedMailAttachment: + filename: str + file_hash: str + media_type: str + size_bytes: int + original_file_path: str + + +@dataclass(frozen=True) +class SavedMailMessage: + imap_uid: str + message_id: str + sender: str + return_path: str | None + auth_result: dict[str, object] + eml_path: str + attachments: tuple[SavedMailAttachment, ...] + + +class ImapConnection(Protocol): + def login(self, user: str, password: str) -> tuple[str, list[bytes]]: ... + + def select(self, mailbox: str, readonly: bool = False) -> tuple[str, list[bytes]]: ... + + def uid(self, command: str, *args: object) -> tuple[str, list[object]]: ... + + def noop(self) -> tuple[str, list[bytes]]: ... + + def logout(self) -> tuple[str, list[bytes]]: ... + + +class OffsiteMailStorage: + """保存原始 eml 和附件,避免覆盖已有原始文件。""" + + def __init__(self, root_dir: str | Path) -> None: + self.root_dir = Path(root_dir) + + def save(self, mail: RawMailMessage) -> SavedMailMessage: + folder = self._folder_for(mail) + folder.mkdir(parents=True, exist_ok=True) + eml_path = folder / "message.eml" + self._write_once(eml_path, mail.raw_message) + saved = [] + for index, attachment in enumerate(mail.attachments, start=1): + file_hash = hashlib.sha256(attachment.payload).hexdigest() + filename = f"A{index:02d}_{file_hash[:12]}_{self._safe_name(attachment.filename)}" + path = folder / filename + self._write_once(path, attachment.payload) + saved.append(SavedMailAttachment( + filename=attachment.filename, + file_hash=file_hash, + media_type=attachment.media_type, + size_bytes=len(attachment.payload), + original_file_path=str(path), + )) + return SavedMailMessage( + imap_uid=mail.imap_uid, + message_id=mail.message_id, + sender=mail.sender, + return_path=mail.return_path, + auth_result=mail.auth_result, + eml_path=str(eml_path), + attachments=tuple(saved), + ) + + def _folder_for(self, mail: RawMailMessage) -> Path: + day = mail.received_at.strftime("%Y%m%d") + source = mail.message_id or mail.imap_uid + return self.root_dir / day / self._safe_name(source.strip("<>") or mail.imap_uid) + + @staticmethod + def _safe_name(value: str) -> str: + safe = re.sub(r"[^0-9A-Za-z._-]+", "_", value.strip()) + return safe[:120] or "unknown" + + @staticmethod + def _write_once(path: Path, payload: bytes) -> None: + if path.exists(): + return + path.write_bytes(payload) + with suppress(OSError): + os.chmod(path, 0o444) + + +class OffsiteImapReceiver: + """只监听收件箱,按 UID 增量拉取原始邮件。""" + + inbox_name = "INBOX" + + def __init__(self, settings: Settings, connection: ImapConnection | None = None) -> None: + self.settings = settings + self._connection = connection + self.last_scanned_uid: str | None = None + + def health_check(self) -> dict[str, object]: + if not self.settings.offsite_imap_enabled: + return {"status": "disabled", "message": "场外 IMAP 未启用"} + missing = self._missing_config() + if missing: + return {"status": "misconfigured", "missing": missing} + try: + connection = self._ensure_connection() + connection.noop() + return {"status": "ok", "mailbox": self.settings.offsite_mailbox} + except Exception as exc: + self.close() + return {"status": "error", "message": type(exc).__name__} + + def fetch_since(self, last_uid: str | None, *, limit: int = 20) -> tuple[RawMailMessage, ...]: + if not self.settings.offsite_imap_enabled: + return () + self.last_scanned_uid = None + missing = self._missing_config() + if missing: + raise RuntimeError(f"场外IMAP配置缺失:{', '.join(missing)}") + connection = self._ensure_connection() + connection.select(self.inbox_name, readonly=True) + start_uid = int(last_uid) + 1 if last_uid and last_uid.isdigit() else 1 + status, data = connection.uid("search", None, f"UID {start_uid}:*") + search_payload = self._first_bytes(data) + if status != "OK" or not search_payload: + return () + uid_values = search_payload.split()[:limit] + if uid_values: + self.last_scanned_uid = uid_values[-1].decode("ascii") + messages: list[RawMailMessage] = [] + for uid in uid_values: + item = self._fetch_one(connection, uid.decode("ascii")) + if item and item.sender in self.settings.offsite_allowed_senders: + messages.append(item) + return tuple(messages) + + def close(self) -> None: + if self._connection is None: + return + try: + self._connection.logout() + finally: + self._connection = None + + def _ensure_connection(self) -> ImapConnection: + if self._connection is not None: + return self._connection + client_cls = imaplib.IMAP4_SSL if self.settings.offsite_imap_use_ssl else imaplib.IMAP4 + connection = cast( + ImapConnection, + client_cls(self.settings.offsite_imap_host, self.settings.offsite_imap_port), + ) + connection.login(self.settings.offsite_imap_username, self.settings.offsite_imap_password) + self._connection = connection + return connection + + def _fetch_one(self, connection: ImapConnection, uid: str) -> RawMailMessage | None: + status, data = connection.uid("fetch", uid, "(RFC822)") + if status != "OK" or not data: + return None + raw = self._extract_rfc822(data) + if raw is None: + return None + parsed = message_from_bytes(raw) + message = cast(EmailMessage, parsed) + sender = parseaddr(message.get("From", ""))[1] + return_path = parseaddr(message.get("Return-Path", ""))[1] or None + message_id = message.get("Message-ID", f"") + return RawMailMessage( + imap_uid=uid, + message_id=message_id, + sender=sender, + return_path=return_path, + auth_result=self._auth_result(message), + raw_message=raw, + received_at=datetime.now(UTC).replace(tzinfo=None), + attachments=self._attachments(message), + ) + + @staticmethod + def _first_bytes(data: list[object]) -> bytes | None: + for item in data: + if isinstance(item, bytes): + return item + return None + + @staticmethod + def _extract_rfc822(data: list[object]) -> bytes | None: + for item in data: + if isinstance(item, tuple): + payload = item[1] + if isinstance(payload, bytes): + return payload + if isinstance(item, bytes) and item.startswith(b"From:"): + return item + return None + + @staticmethod + def _attachments(message: Message) -> tuple[RawMailAttachment, ...]: + attachments: list[RawMailAttachment] = [] + for part in message.walk(): + if part.is_multipart(): + continue + filename = part.get_filename() + disposition = part.get_content_disposition() + if not filename and disposition != "attachment": + continue + raw_payload = part.get_payload(decode=True) + payload = raw_payload if isinstance(raw_payload, bytes) else b"" + attachments.append(RawMailAttachment( + filename=filename or "attachment.bin", + media_type=part.get_content_type(), + payload=payload, + )) + return tuple(attachments) + + @staticmethod + def _auth_result(message: Message) -> dict[str, object]: + return { + "authentication_results": message.get_all("Authentication-Results", []), + "received_spf": message.get_all("Received-SPF", []), + "dkim_signature_present": bool(message.get("DKIM-Signature")), + } + + def _missing_config(self) -> list[str]: + missing = [] + if not self.settings.offsite_imap_host: + missing.append("OFFSITE_IMAP_HOST") + if not self.settings.offsite_imap_username: + missing.append("OFFSITE_IMAP_USERNAME") + if not self.settings.offsite_imap_password: + missing.append("OFFSITE_IMAP_PASSWORD") + return missing diff --git a/app/service/offsite_nl2sql_adapter.py b/app/service/offsite_nl2sql_adapter.py new file mode 100644 index 0000000..c177209 --- /dev/null +++ b/app/service/offsite_nl2sql_adapter.py @@ -0,0 +1,45 @@ +"""场外基金专用 NL2SQL 适配器。 + +该适配器只隔离调用边界,不改写 `nl2sql_yc.query_dict` 的业务能力。 +""" + +from importlib import import_module +from typing import cast + +from app.core.contracts import RequestContext + + +class OffsiteNl2SqlAdapter: + """把场外基金上下文转换为 `nl2sql_yc.query_dict` 可接受的稳定参数。""" + + script_path = "nl2sql_yc.py" + + def query(self, question: str, context: RequestContext) -> dict[str, object]: + try: + query_dict = getattr(import_module("nl2sql_yc"), "query_dict", None) + if not callable(query_dict): + return {"status": "error", "message": "NL2SQL入口query_dict不存在"} + result = cast(dict[str, object], query_dict( + question, + self._auth_context(context), + use_llm=False, + persist_audit=False, + )) + if isinstance(result, dict): + return result + return {"status": "error", "message": "NL2SQL返回格式不正确"} + except Exception as exc: + return {"status": "error", "message": f"NL2SQL调用失败:{type(exc).__name__}"} + + @staticmethod + def _auth_context(context: RequestContext) -> dict[str, object]: + return { + "user_id": int(context.user_id) if context.user_id.isdigit() else None, + "roles": list(context.roles), + "allowed_domains": ["market_nav", "trading_account", "product_fee"], + "customer_scope": context.data_scope if context.data_scope in { + "self", "own_customers", "all", + } else "all", + "max_rows": 100, + "max_query_seconds": 10, + } diff --git a/app/service/offsite_smtp_adapter.py b/app/service/offsite_smtp_adapter.py new file mode 100644 index 0000000..324dcda --- /dev/null +++ b/app/service/offsite_smtp_adapter.py @@ -0,0 +1,205 @@ +"""场外基金邮件回复 SMTP 适配层。""" + +from __future__ import annotations + +import mimetypes +import smtplib +from dataclasses import dataclass +from email.message import EmailMessage +from email.utils import formatdate, make_msgid +from pathlib import Path +from typing import Protocol, cast + +from app.core.config import Settings +from app.core.offsite_fund_contracts import SendStatus + + +@dataclass(frozen=True) +class SmtpAttachment: + filename: str + media_type: str + payload: bytes + + +@dataclass(frozen=True) +class OffsiteMailReplyRequest: + to_address: str + subject: str + body: str + operator_id: str + operator_confirmed: bool + reply_to_message_id: str | None = None + attachments: tuple[SmtpAttachment, ...] = () + retry_count: int = 0 + + +@dataclass(frozen=True) +class SmtpSendResult: + status: SendStatus + dry_run: bool + provider_message_id: str | None + failure_reason: str | None + retry_count: int + request_summary: dict[str, object] + + +class SmtpConnection(Protocol): + def login(self, user: str, password: str) -> object: ... + + def send_message(self, msg: EmailMessage) -> object: ... + + def quit(self) -> object: ... + + +class OffsiteSmtpSender: + """默认 dry-run,真实发送必须显式启用并经过运营确认。""" + + def __init__(self, settings: Settings, connection: SmtpConnection | None = None) -> None: + self.settings = settings + self._connection = connection + + def health_check(self) -> dict[str, object]: + if not self.settings.offsite_smtp_enabled: + return {"status": "disabled", "message": "场外 SMTP 未启用"} + if self.settings.offsite_smtp_dry_run: + return {"status": "dry_run", "message": "场外 SMTP 处于 dry-run 模式"} + missing = self._missing_config() + if missing: + return {"status": "misconfigured", "missing": missing} + return { + "status": "ready", + "host": self.settings.offsite_smtp_host, + "sender": self.settings.offsite_smtp_sender, + } + + def send_reply(self, request: OffsiteMailReplyRequest) -> SmtpSendResult: + message_id = make_msgid(domain="offsite-fund.local") + summary = self._summary(request) + if not request.operator_confirmed: + return SmtpSendResult( + status="发送失败", + dry_run=self.settings.offsite_smtp_dry_run, + provider_message_id=None, + failure_reason="邮件发送前必须完成运营确认", + retry_count=request.retry_count, + request_summary=summary, + ) + if not self.settings.offsite_smtp_enabled or self.settings.offsite_smtp_dry_run: + return SmtpSendResult( + status="待发送", + dry_run=True, + provider_message_id=None, + failure_reason=None, + retry_count=request.retry_count, + request_summary=summary, + ) + missing = self._missing_config() + if missing: + return SmtpSendResult( + status="发送失败", + dry_run=False, + provider_message_id=None, + failure_reason=f"SMTP配置缺失:{', '.join(missing)}", + retry_count=request.retry_count + 1, + request_summary=summary, + ) + try: + message = self._build_message(request, message_id) + connection = self._ensure_connection() + connection.send_message(message) + return SmtpSendResult( + status="发送成功", + dry_run=False, + provider_message_id=message_id, + failure_reason=None, + retry_count=request.retry_count, + request_summary=summary, + ) + except (OSError, smtplib.SMTPException) as exc: + self.close() + return SmtpSendResult( + status="发送失败", + dry_run=False, + provider_message_id=None, + failure_reason=type(exc).__name__, + retry_count=request.retry_count + 1, + request_summary=summary, + ) + + def close(self) -> None: + if self._connection is None: + return + try: + self._connection.quit() + finally: + self._connection = None + + def _ensure_connection(self) -> SmtpConnection: + if self._connection is not None: + return self._connection + smtp_cls = smtplib.SMTP_SSL if self.settings.offsite_smtp_use_ssl else smtplib.SMTP + connection = cast( + SmtpConnection, + smtp_cls( + self.settings.offsite_smtp_host, + self.settings.offsite_smtp_port, + timeout=self.settings.offsite_smtp_timeout_seconds, + ), + ) + connection.login(self.settings.offsite_smtp_username, self.settings.offsite_smtp_password) + self._connection = connection + return connection + + def _build_message(self, request: OffsiteMailReplyRequest, message_id: str) -> EmailMessage: + message = EmailMessage() + message["From"] = self.settings.offsite_smtp_sender + message["To"] = request.to_address + message["Subject"] = request.subject + message["Date"] = formatdate(localtime=True) + message["Message-ID"] = message_id + if request.reply_to_message_id: + message["In-Reply-To"] = request.reply_to_message_id + message["References"] = request.reply_to_message_id + message.set_content(request.body) + for attachment in request.attachments: + maintype, subtype = self._media_type(attachment) + message.add_attachment( + attachment.payload, + maintype=maintype, + subtype=subtype, + filename=attachment.filename, + ) + return message + + def _missing_config(self) -> list[str]: + missing = [] + if not self.settings.offsite_smtp_host: + missing.append("OFFSITE_SMTP_HOST") + if not self.settings.offsite_smtp_username: + missing.append("OFFSITE_SMTP_USERNAME") + if not self.settings.offsite_smtp_password: + missing.append("OFFSITE_SMTP_PASSWORD") + if not self.settings.offsite_smtp_sender: + missing.append("OFFSITE_SMTP_SENDER") + return missing + + @staticmethod + def _media_type(attachment: SmtpAttachment) -> tuple[str, str]: + media_type = attachment.media_type + if not media_type or "/" not in media_type: + guessed, _ = mimetypes.guess_type(attachment.filename) + media_type = guessed or "application/octet-stream" + maintype, subtype = media_type.split("/", 1) + return maintype, subtype + + @staticmethod + def _summary(request: OffsiteMailReplyRequest) -> dict[str, object]: + return { + "to_address": request.to_address, + "subject": request.subject, + "operator_id": request.operator_id, + "operator_confirmed": request.operator_confirmed, + "reply_to_message_id": request.reply_to_message_id, + "attachment_count": len(request.attachments), + "attachment_names": [Path(item.filename).name for item in request.attachments], + } diff --git a/app/service/tool_executor.py b/app/service/tool_executor.py index 4bdfa79..d94d538 100644 --- a/app/service/tool_executor.py +++ b/app/service/tool_executor.py @@ -93,13 +93,27 @@ class ToolExecutor: record = ToolCallRecord( tool_name=name, status="succeeded", input_summary={key: "[redacted]" for key in arguments}, - output_summary={"result_type": type(output).__name__}, + output_summary=self._output_summary(output), ) await self._audit(name, intent, context, "succeeded", "ok") reference = SourceReference(source_type="tool", source_id=f"{context.trace_id}:{name}", title=name) return ToolExecution(output=output, record=record, references=(reference,)) + @staticmethod + def _output_summary(output: Any) -> dict[str, Any]: + summary: dict[str, Any] = {"result_type": type(output).__name__} + if not isinstance(output, dict): + return summary + summary["status"] = output.get("status") + audit = output.get("audit") + if isinstance(audit, dict): + summary["query_plan"] = audit.get("query_plan") + summary["generated_sql"] = audit.get("generated_sql") + summary["permission_check"] = audit.get("permission_check") + summary["execution"] = audit.get("execution") + return summary + async def _audit( self, name: str, intent: str, context: RequestContext, status: str, reason: str ) -> None: diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..e2b6141 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,155 @@ + + + + + + 奶龙基金智能助手联调 + + + +
+
+
+ +

奶龙基金智能助手

公开知识与客服分流联调页

+
+

访客:正在建立测试连接

+
+
+ + + + + + +
+
+
+ + +
+
+ + + diff --git a/app/worker/__main__.py b/app/worker/__main__.py index bf06d4d..a81bfd7 100644 --- a/app/worker/__main__.py +++ b/app/worker/__main__.py @@ -4,6 +4,7 @@ import logging from app.core.config import get_settings from app.infrastructure.db import engine +from app.worker.offsite_mail_worker import OffsiteMailWorker from app.worker.runtime import WorkerRuntime logger = logging.getLogger(__name__) @@ -12,10 +13,12 @@ logger = logging.getLogger(__name__) async def serve(*, once: bool = False) -> None: settings = get_settings() runtime = WorkerRuntime(settings=settings) + offsite_worker = OffsiteMailWorker(settings) try: while True: try: worked = await runtime.run_once() + worked = await offsite_worker.run_once() or worked except Exception: # 常驻 Worker 不能因为"某一轮"的异常就整体退出:数据库抖动、 # 迁移期间锁表、外部依赖瞬断都会命中这里,而 `run_once` 里的 @@ -31,6 +34,7 @@ async def serve(*, once: bool = False) -> None: if not worked: await asyncio.sleep(settings.worker_poll_seconds) finally: + await offsite_worker.close() await engine.dispose() diff --git a/app/worker/offsite_mail_worker.py b/app/worker/offsite_mail_worker.py new file mode 100644 index 0000000..7828d18 --- /dev/null +++ b/app/worker/offsite_mail_worker.py @@ -0,0 +1,458 @@ +"""场外基金收件箱独立 Worker。""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Awaitable, Callable +from contextlib import suppress +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Protocol +from uuid import uuid4 + +from sqlalchemy import func, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import Settings +from app.core.contracts import RequestContext +from app.core.offsite_fund_contracts import ( + ReceiveRecognizedMailRequest, + RecognizedAttachment, +) +from app.infrastructure.db import SessionFactory +from app.model.audit import InteractionAudit +from app.model.offsite_fund import OffsiteFundMail, OffsiteMailCursor, OffsiteNotification +from app.service.offsite_document_recognition_adapter import ( + OffsiteDocumentRecognitionAdapter, + RecognitionSourceFile, + StructuredRecognitionResult, +) +from app.service.offsite_fund_service import OffsiteFundService +from app.service.offsite_mail_adapter import ( + OffsiteImapReceiver, + OffsiteMailStorage, + RawMailMessage, + SavedMailAttachment, +) + +logger = logging.getLogger(__name__) + + +class MailReceiver(Protocol): + last_scanned_uid: str | None + + def health_check(self) -> dict[str, object]: ... + + def fetch_since(self, last_uid: str | None, *, limit: int) -> tuple[RawMailMessage, ...]: ... + + def close(self) -> None: ... + + +class MailRecognizer(Protocol): + async def recognize(self, source: RecognitionSourceFile) -> StructuredRecognitionResult: ... + + +class MailService(Protocol): + async def receive_recognized_mail( + self, payload: ReceiveRecognizedMailRequest, context: RequestContext + ) -> dict[str, object]: ... + + +class CursorLease: + def __init__(self, lease_id: str, last_uid: str) -> None: + self.lease_id = lease_id + self.last_uid = last_uid + + +class OffsiteMailWorker: + """按 UID 顺序处理邮件,失败时停在当前 UID 并等待补偿。""" + + def __init__( + self, + settings: Settings, + *, + receiver: MailReceiver | None = None, + storage: OffsiteMailStorage | None = None, + recognizer: MailRecognizer | None = None, + identity_resolver: Callable[[RequestContext], Awaitable[RequestContext]] | None = None, + session_factory: Callable[[], AsyncSession] = SessionFactory, + service_factory: Callable[[AsyncSession], MailService] | None = None, + ) -> None: + self.settings = settings + self.receiver = receiver or OffsiteImapReceiver(settings) + self.storage = storage or OffsiteMailStorage(settings.offsite_mail_storage_dir) + self.recognizer = recognizer or OffsiteDocumentRecognitionAdapter(settings) + self.identity_resolver = identity_resolver + self.session_factory = session_factory + self.service_factory = service_factory or OffsiteFundService + + async def run_once(self) -> bool: + if not self.settings.offsite_mail_worker_enabled: + return False + recovered = await self.recover_stale_notifications() + if not self.settings.offsite_imap_enabled: + return recovered + if not self.settings.offsite_worker_user_id: + logger.error("场外 Worker 未配置合法操作用户 ID,拒绝自动写入业务数据") + return recovered + try: + context = await self._resolve_worker_context() + lease = await self._claim_cursor() + if lease is None: + return recovered + return await self._process_batch(lease, context) or recovered + except asyncio.CancelledError: + raise + except Exception: + logger.exception("场外邮件 Worker 执行失败") + return True + + async def close(self) -> None: + self.receiver.close() + close = getattr(self.recognizer, "close", None) + if callable(close): + result = close() + if asyncio.iscoroutine(result): + await result + + async def recover_stale_notifications(self) -> bool: + now = datetime.now(UTC).replace(tzinfo=None) + cutoff = now - timedelta( + seconds=self.settings.offsite_notification_sending_timeout_seconds + ) + async with self.session_factory() as session, session.begin(): + activity = func.coalesce( + OffsiteNotification.updated_at, OffsiteNotification.created_at + ) + result = await session.execute( + update(OffsiteNotification) + .where( + OffsiteNotification.status == "发送中", + activity < cutoff, + ) + .values( + status="发送失败", + failure_reason="发送状态不确定,需人工核验外部邮箱后再决定是否重试", + updated_at=now, + ) + ) + count = int(getattr(result, "rowcount", 0) or 0) + if count: + session.add( + InteractionAudit( + actor_type="system", + actor_id=None, + target_customer_id=None, + session_id=None, + portal="worker", + action_type="offsite.notification_timeout_recovered", + detail={"count": count}, + created_at=now, + ) + ) + return count > 0 + + async def _resolve_worker_context(self) -> RequestContext: + identity = RequestContext( + user_id=self.settings.offsite_worker_user_id, + trace_id=f"offsite-worker-{uuid4()}", + portal="worker", + ) + resolver = self.identity_resolver + if resolver is None: + from app.service.identity_service import IdentityService + + resolver = IdentityService().resolve + context = await resolver(identity) + if not context.roles or not context.permissions: + raise RuntimeError("场外 Worker 操作用户未通过角色和权限校验") + return context + + async def _claim_cursor(self) -> CursorLease | None: + for attempt in range(2): + lease_id = str(uuid4()) + now = datetime.now(UTC).replace(tzinfo=None) + try: + async with self.session_factory() as session, session.begin(): + cursor = await session.scalar( + select(OffsiteMailCursor) + .where( + OffsiteMailCursor.mailbox == self.settings.offsite_mailbox, + OffsiteMailCursor.folder == OffsiteImapReceiver.inbox_name, + ) + .with_for_update() + ) + if cursor is None: + cursor = OffsiteMailCursor( + mailbox=self.settings.offsite_mailbox, + folder=OffsiteImapReceiver.inbox_name, + last_uid="0", + status="idle", + retry_count=0, + created_at=now, + updated_at=now, + ) + session.add(cursor) + await session.flush() + if cursor.status == "blocked": + return None + if cursor.lease_until is not None and cursor.lease_until > now: + return None + if cursor.next_retry_at is not None and cursor.next_retry_at > now: + return None + cursor.status = "processing" + cursor.lease_id = lease_id + cursor.lease_until = now + timedelta( + seconds=self.settings.worker_lease_seconds + ) + cursor.updated_at = now + return CursorLease(lease_id, cursor.last_uid) + except IntegrityError: + if attempt == 1: + raise + return None + + async def _process_batch(self, lease: CursorLease, context: RequestContext) -> bool: + task = asyncio.current_task() + if task is None: + raise RuntimeError("场外 Worker 无法建立当前任务租约") + heartbeat = asyncio.create_task(self._cursor_heartbeat(lease.lease_id, task)) + try: + return await self._process_batch_work(lease, context) + finally: + heartbeat.cancel() + with suppress(asyncio.CancelledError): + await heartbeat + + async def _process_batch_work(self, lease: CursorLease, context: RequestContext) -> bool: + try: + health = await asyncio.to_thread(self.receiver.health_check) + if health.get("status") != "ok": + message = str(health.get("message") or health.get("status") or "IMAP不可用") + raise RuntimeError(f"场外 IMAP 健康检查失败:{message}") + messages = await asyncio.to_thread( + self.receiver.fetch_since, + lease.last_uid, + limit=self.settings.offsite_mail_worker_batch_size, + ) + current_uid = lease.last_uid + for mail in sorted(messages, key=self._uid_sort_key): + if not self._uid_after(mail.imap_uid, current_uid): + continue + try: + await self._process_message(mail, context) + except asyncio.CancelledError: + raise + except Exception as exc: + await self._record_failure(lease, mail, exc) + return True + current_uid = mail.imap_uid + await self._advance_cursor(lease.lease_id, current_uid, release=False) + scanned_uid = self.receiver.last_scanned_uid + if scanned_uid is not None and self._uid_after(scanned_uid, current_uid): + current_uid = scanned_uid + await self._advance_cursor(lease.lease_id, current_uid, release=True) + return bool(messages) or scanned_uid is not None + except asyncio.CancelledError: + raise + except Exception as exc: + await self._record_cursor_failure(lease, exc) + self.receiver.close() + return True + + async def _cursor_heartbeat(self, lease_id: str, task: asyncio.Task[bool]) -> None: + try: + while True: + await asyncio.sleep(self.settings.worker_lease_seconds / 3) + now = datetime.now(UTC).replace(tzinfo=None) + async with self.session_factory() as session, session.begin(): + result = await session.execute( + update(OffsiteMailCursor) + .where(OffsiteMailCursor.lease_id == lease_id) + .values( + lease_until=now + timedelta( + seconds=self.settings.worker_lease_seconds + ), + updated_at=now, + ) + ) + if int(getattr(result, "rowcount", 0) or 0) != 1: + task.cancel() + return + except asyncio.CancelledError: + raise + except Exception: + logger.exception("场外 Worker 游标租约续期失败") + task.cancel() + + async def _process_message(self, mail: RawMailMessage, context: RequestContext) -> None: + saved = await asyncio.to_thread(self.storage.save, mail) + attachments: list[RecognizedAttachment] = [] + for saved_attachment in saved.attachments: + payload = await asyncio.to_thread(Path(saved_attachment.original_file_path).read_bytes) + recognition = await self.recognizer.recognize( + RecognitionSourceFile( + filename=saved_attachment.filename, + media_type=saved_attachment.media_type, + payload=payload, + file_hash=saved_attachment.file_hash, + original_file_path=saved_attachment.original_file_path, + ) + ) + if self._recognition_failed(recognition): + raise RuntimeError("附件识别失败,已保留原始文件并等待补偿") + attachments.append( + self._recognized_attachment(saved_attachment, recognition) + ) + request = ReceiveRecognizedMailRequest( + imap_uid=saved.imap_uid, + message_id=saved.message_id, + sender=saved.sender, + return_path=saved.return_path, + auth_result=saved.auth_result, + eml_path=saved.eml_path, + attachments=tuple(attachments), + ) + async with self.session_factory() as session: + service = self.service_factory(session) + result = await service.receive_recognized_mail(request, context) + if result.get("code") != 0: + raise RuntimeError(f"场外业务入库失败:{result.get('message', '未知错误')}") + + async def _advance_cursor( + self, lease_id: str, last_uid: str, *, release: bool + ) -> None: + now = datetime.now(UTC).replace(tzinfo=None) + async with self.session_factory() as session, session.begin(): + cursor = await session.scalar( + select(OffsiteMailCursor) + .where(OffsiteMailCursor.lease_id == lease_id) + .with_for_update() + ) + if cursor is None: + return + cursor.last_uid = last_uid + cursor.status = "idle" if release else "processing" + if release: + cursor.retry_count = 0 + cursor.last_error = None + cursor.blocked_uid = None + cursor.blocked_message_id = None + cursor.next_retry_at = None + cursor.lease_id = None + cursor.lease_until = None + cursor.updated_at = now + + async def _record_failure( + self, lease: CursorLease, mail: RawMailMessage, exc: Exception + ) -> None: + now = datetime.now(UTC).replace(tzinfo=None) + message = self._error_message(exc) + async with self.session_factory() as session, session.begin(): + cursor = await session.scalar( + select(OffsiteMailCursor) + .where(OffsiteMailCursor.lease_id == lease.lease_id) + .with_for_update() + ) + if cursor is None: + return + cursor.retry_count += 1 + cursor.status = ( + "blocked" + if cursor.retry_count >= self.settings.offsite_max_retry_count + else "failed" + ) + cursor.blocked_uid = mail.imap_uid + cursor.blocked_message_id = mail.message_id + cursor.last_error = message + cursor.next_retry_at = ( + None + if cursor.status == "blocked" + else now + timedelta(seconds=min(300, 2**cursor.retry_count)) + ) + cursor.lease_id = None + cursor.lease_until = None + cursor.updated_at = now + await session.execute( + update(OffsiteFundMail) + .where( + OffsiteFundMail.imap_uid == mail.imap_uid, + OffsiteFundMail.message_id == mail.message_id, + ) + .values( + retry_count=OffsiteFundMail.retry_count + 1, + last_error=message, + next_retry_at=cursor.next_retry_at, + last_attempt_at=now, + updated_at=now, + ) + ) + logger.error( + "场外邮件处理失败 uid=%s message_id=%s error=%s", + mail.imap_uid, + mail.message_id, + message, + ) + + async def _record_cursor_failure(self, lease: CursorLease, exc: Exception) -> None: + message = self._error_message(exc) + now = datetime.now(UTC).replace(tzinfo=None) + async with self.session_factory() as session, session.begin(): + cursor = await session.scalar( + select(OffsiteMailCursor) + .where(OffsiteMailCursor.lease_id == lease.lease_id) + .with_for_update() + ) + if cursor is None: + return + cursor.retry_count += 1 + cursor.status = "failed" + cursor.last_error = message + cursor.next_retry_at = now + timedelta( + seconds=min(300, 2**cursor.retry_count) + ) + cursor.lease_id = None + cursor.lease_until = None + cursor.updated_at = now + logger.error("场外邮件批处理失败 error=%s", message) + + @staticmethod + def _uid_sort_key(mail: RawMailMessage) -> tuple[int, str]: + return (int(mail.imap_uid) if mail.imap_uid.isdigit() else 2**63 - 1, mail.imap_uid) + + @staticmethod + def _uid_after(candidate: str, current: str) -> bool: + if candidate.isdigit() and current.isdigit(): + return int(candidate) > int(current) + return candidate > current + + @staticmethod + def _recognition_failed(result: object) -> bool: + error = getattr(result, "error_message", None) + ocr_status = getattr(result, "ocr_status", None) + llm_status = getattr(result, "llm_status", None) + failed = {"error", "misconfigured"} + return bool(error and (ocr_status in failed or llm_status in failed)) + + @staticmethod + def _recognized_attachment( + saved: SavedMailAttachment, result: StructuredRecognitionResult + ) -> RecognizedAttachment: + return RecognizedAttachment( + filename=saved.filename, + file_hash=saved.file_hash, + original_file_path=saved.original_file_path, + media_type=saved.media_type, + size_bytes=saved.size_bytes, + document_type=result.document_type, + extracted_fields=result.extracted_fields, + field_confidence=result.field_confidence, + ocr_text=result.ocr_text, + page_evidence=result.page_evidence, + ) + + @staticmethod + def _error_message(exc: Exception) -> str: + return f"{type(exc).__name__}: {str(exc)[:450]}" diff --git a/app/worker/runtime.py b/app/worker/runtime.py index 40eb0d0..98ed6c9 100644 --- a/app/worker/runtime.py +++ b/app/worker/runtime.py @@ -10,7 +10,7 @@ from uuid import uuid4 from sqlalchemy import select, update from app.core.config import Settings, get_settings -from app.core.contracts import AgentRequest, AgentResult, RequestContext +from app.core.contracts import AgentRequest, AgentRequestMetadata, AgentResult, RequestContext from app.core.errors import AgentError, RecoverableAgentError, RunLeaseLostError from app.infrastructure.db import SessionFactory from app.model.audit import InteractionAudit @@ -102,6 +102,36 @@ class WorkerRuntime: # episode 聚合是低频批处理,按轮次节流而不是每轮都查。 self._episode_rounds = 0 + async def restore_context( + self, *, actor_type: str, actor_id: str, trace_id: str + ) -> RequestContext: + """按已验证的内部事件身份恢复执行上下文。""" + identity = RequestContext(user_id=actor_id, trace_id=trace_id) + if actor_type == "visitor": + return identity.model_copy(update={ + "roles": ("visitor",), + # 与访客 JWT 对齐,只恢复公开 Agent 和公开知识的最小权限。 + "permissions": ("agent:run", "knowledge:query"), + "data_scope": "public", + }) + return await self.resolve_identity(identity) + + @staticmethod + def should_request_memory_extraction( + *, context: RequestContext, message: str, result: AgentResult, + business_events: tuple[str, ...] | list[str], + ) -> bool: + """只允许已登录用户的明确业务事实进入客户记忆抽取队列。""" + if "visitor" in context.roles: + return False + return MemoryService.should_extract_memory( + conversation_content=message, + role="user", + tool_result=any(call.status == "succeeded" for call in result.result.tool_calls), + event_type=business_events[0] if business_events else None, + signals=MemoryService.detect_memory_signals(message), + ) + async def dispatch_one(self, *, run_id: str | None = None) -> bool: # Outbox acknowledges a durable SQL queue entry, not an in-memory task. async with SessionFactory() as session: @@ -422,14 +452,21 @@ class WorkerRuntime: DomainEventOutbox.event_type == "agent.run_requested").limit(1)) if message is None or idem is None: raise ValueError("run input missing") + metadata = event.payload.get("metadata", {}) if event else {} request = AgentRequest( agent_type=run.agent_type, message=message.content, session_id=run.session_id, idempotency_key=idem.idempotency_key, - metadata=event.payload.get("metadata", {}) if event else {}, + metadata=AgentRequestMetadata.model_validate(metadata), ) - identity = RequestContext(user_id=str(run.user_id), trace_id=run.trace_id) - # Re-check account and permissions at execution time, including delayed jobs. - context = await self.resolve_identity(identity) + actor_type = ( + str(event.payload.get("actor_type", "authenticated")) + if event else "authenticated" + ) + actor_id = str(run.user_id) + trace_id = run.trace_id + context = await self.restore_context( + actor_type=actor_type, actor_id=actor_id, trace_id=trace_id + ) result: AgentResult | None = None async for event_data in AgentExecutor(self.factory).execute( request.agent_type, request, context, run_id @@ -451,17 +488,11 @@ class WorkerRuntime: async with SessionFactory() as session: await AgentPersistenceService(session).complete_run( run_id, result, worker_id=worker_id, - memory_extraction_requested=MemoryService.should_extract_memory( - conversation_content=request.message, - role="user", - # 工具产出的权威事实同样构成持久记忆(工具调用记录来自终态结果)。 - tool_result=any( - call.status == "succeeded" for call in result.result.tool_calls - ), - # 本 run 落库的业务事件(风险评估完成、交易完成等)。 - event_type=business_events[0] if business_events else None, - # 用户明确陈述的偏好/约束/身份/目标,命中才触发抽取。 - signals=MemoryService.detect_memory_signals(request.message), + memory_extraction_requested=self.should_request_memory_extraction( + context=context, + message=request.message, + result=result, + business_events=business_events, ), ) diff --git a/docs/15-金融NL2SQL工具接入说明.md b/docs/15-金融NL2SQL工具接入说明.md new file mode 100644 index 0000000..4b839a0 --- /dev/null +++ b/docs/15-金融NL2SQL工具接入说明.md @@ -0,0 +1,105 @@ +# 金融 NL2SQL 工具接入说明 + +## 1. 工具定位 + +`query_financial_data` 是运营和投顾共用的金融只读 NL2SQL 工具。业务 Agent 只负责判断是否调用工具,不直接连库、不读取密钥、不自行写审计。 + +调用链固定为: + +```text +业务 Agent -> BaseAgent.call_tool -> ToolExecutor -> query_financial_data -> 金融只读表 +``` + +## 2. 工具声明 + +业务 Agent 的 `AgentDefinition.allowed_tools` 需要包含: + +```python +allowed_tools=("query_financial_data",) +supported_intents=("financial_query", "general") +``` + +发布配置需要按意图开启工具白名单: + +```json +{ + "namespace": "agent_tools", + "config_key": ":financial_query", + "value_json": {"allowed_tools": ["query_financial_data"]} +} +``` + +工具权限码: + +```text +financial:nl2sql:read +``` + +允许角色: + +```text +advisor、operator、admin、super_admin +``` + +## 3. 调用示例 + +```python +result = await self.call_tool( + "query_financial_data", + {"question": request.message, "dry_run": False, "limit": 50}, + intent="financial_query", + context=context, +) +``` + +低置信度或模糊问题会返回: + +```json +{"status": "need_confirmation", "message": "请确认查询范围、指标口径和时间条件。"} +``` + +Agent 应把 `message` 原样返回给用户,等待用户补充或确认后再次调用,并传入 `confirmation`。 + +## 4. MVP 范围 + +当前纳入 NL2SQL 的表: + +```text +sys_customer_assignment +fin_customer_profile +fin_risk_assessment +fin_product +fin_fee_rule +fin_market_price +fin_nav_history +fin_holding +fin_transaction +fin_sim_order +fin_sim_account +fin_cash_ledger +client_facing_content +``` + +第一期只支持只读查询;支持跨两个或三个业务域查询;支持历史范围查询。历史时点查询如果依赖当前快照表,例如 `fin_holding`、`fin_sim_account`、`sys_customer_assignment`,会拒绝执行。 + +## 5. 审计与持久化 + +工具输出包含 `audit`,其中有: + +- `query_plan`:查询计划; +- `generated_sql`:生成 SQL; +- `permission_check`:权限校验结果; +- `execution`:执行状态和返回行数。 + +`ToolExecutor` 会把这些摘要写入 `ToolCallRecord.output_summary`,公共运行持久化会最终写入 `conversation_message.tool_calls`。 + +## 6. 接入验收 + +运营或投顾 Agent 接入时至少验证: + +- AgentDefinition 声明了 `query_financial_data`; +- 发布配置按目标意图放行该工具; +- 调用方角色具备 `financial:nl2sql:read`; +- 模糊问题会先要求确认; +- 生成 SQL 只包含 `SELECT`,且表名均在白名单内; +- `conversation_message.tool_calls` 能看到查询计划、SQL 和权限校验摘要。 diff --git a/docs/evidence/20260910-customer-service-knowledge-preflight.json b/docs/evidence/20260910-customer-service-knowledge-preflight.json new file mode 100644 index 0000000..33c31b4 --- /dev/null +++ b/docs/evidence/20260910-customer-service-knowledge-preflight.json @@ -0,0 +1,1192 @@ +{ + "source_name": "客服Agent一期_QA结构化记录_v2_角色路由版.jsonl", + "summary": { + "total_records": 105, + "eligible_records": 52, + "excluded_rule_records": 53, + "publication_state": "pending_review" + }, + "records": [ + { + "qa_id": "RAG-PER-008", + "knowledge_type": "faq", + "title": "你们公司叫什么?", + "milvus_collection": "fin_faq_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "公司名称", + "虚拟设定", + "公司信息" + ], + "content_text": "{\"qa_id\":\"RAG-PER-008\",\"question\":\"你们公司叫什么?\",\"paraphrases\":[\"公司全称是什么?\",\"你是哪家公司?\",\"平台名称是什么?\",\"奶龙基金是哪家?\"],\"answer\":\"公司名称是奶龙基金责任有限公司。公司注册地址和联系地址均为深圳市南湾街道布吉路66号;统一社会信用代码为 91440300MA5H666666,工商登记状态为存续。注册资本为 2000 万元人民币,实缴资本为 1200 万元人民币。公司法人为袁聪,股东及持股比例为张胜宇 51%、王建龙 20%、张帅 10%、李卓凡 10%、梁正樑 8%、袁聪 1%。上述均为本系统虚拟设定。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:你们公司叫什么?\n相似问法:公司全称是什么?;你是哪家公司?;平台名称是什么?;奶龙基金是哪家?\n标签:公司名称、虚拟设定、公司信息", + "snippet": "你们公司叫什么?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PER-009", + "knowledge_type": "faq", + "title": "你们公司地址在哪里?", + "milvus_collection": "fin_faq_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "公司地址", + "联系地址", + "邮寄地址" + ], + "content_text": "{\"qa_id\":\"RAG-PER-009\",\"question\":\"你们公司地址在哪里?\",\"paraphrases\":[\"办公地址是什么?\",\"总部在哪?\",\"公司在哪办公?\",\"联系地址是什么?\"],\"answer\":\"奶龙基金责任有限公司的公司地址是深圳市南湾街道布吉路66号。邮政编码为 518100;前台电话为 15936583816;投诉邮寄地址为深圳市龙岗区南湾街道布吉路66号。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:你们公司地址在哪里?\n相似问法:办公地址是什么?;总部在哪?;公司在哪办公?;联系地址是什么?\n标签:公司地址、联系地址、邮寄地址", + "snippet": "你们公司地址在哪里?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PER-010", + "knowledge_type": "faq", + "title": "你们客服电话是多少?", + "milvus_collection": "fin_faq_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "客服电话", + "服务时间", + "官网", + "App", + "官方渠道" + ], + "content_text": "{\"qa_id\":\"RAG-PER-010\",\"question\":\"你们客服电话是多少?\",\"paraphrases\":[\"怎么打人工?\",\"热线电话是什么?\",\"客服联系方式\",\"有没有客服电话?\"],\"answer\":\"奶龙基金责任有限公司的客服电话为 15936583816,人工服务时间为工作日 09:00-18:00。官网为 www.nailong.com,App 下载入口为 www.nainong.com。请不要相信搜索引擎、短信或陌生人提供的其他号码。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:你们客服电话是多少?\n相似问法:怎么打人工?;热线电话是什么?;客服联系方式;有没有客服电话?\n标签:客服电话、服务时间、官网、App、官方渠道", + "snippet": "你们客服电话是多少?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PER-011", + "knowledge_type": "product_inquiry", + "title": "你们有哪些基金?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "产品清单", + "基金代码", + "产品风险等级" + ], + "content_text": "{\"qa_id\":\"RAG-PER-011\",\"question\":\"你们有哪些基金?\",\"paraphrases\":[\"产品清单在哪?\",\"有没有 QDII?\",\"有哪些基金可以买?\",\"你们卖哪些产品?\"],\"answer\":\"奶龙基金责任有限公司的首版虚拟产品清单包括 NL-M001 奶龙零钱宝货币基金(R1)、NL-B001 奶龙稳享纯债基金(R2)、NL-H001 奶龙均衡优选混合基金(R3)、NL-I001 奶龙核心指数基金(R4)和 NL-G001 奶龙成长精选混合基金(R4)。首版仅配置 A 类和 C 类份额;未进入清单的产品不推断其是否在售或可交易。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:你们有哪些基金?\n相似问法:产品清单在哪?;有没有 QDII?;有哪些基金可以买?;你们卖哪些产品?\n标签:产品清单、基金代码、产品风险等级", + "snippet": "你们有哪些基金?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PER-012", + "knowledge_type": "faq", + "title": "你们公司主要做什么业务?", + "milvus_collection": "fin_faq_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "公司业务", + "服务范围", + "一期边界" + ], + "content_text": "{\"qa_id\":\"RAG-PER-012\",\"question\":\"你们公司主要做什么业务?\",\"paraphrases\":[\"经营范围是什么?\",\"是基金公司吗?\",\"能办理哪些业务?\",\"公司资质如何?\"],\"answer\":\"奶龙基金责任有限公司当前面向个人客户提供基金公开信息、产品资料、交易规则和服务路径说明;首版暂不提供机构客户服务。奶龙基金智能助手不提供个性化投资咨询、资产配置或定制服务,也不会根据您的账户情况推荐产品;需要人工核实服务事项时,请联系人工客服 15936583816。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:你们公司主要做什么业务?\n相似问法:经营范围是什么?;是基金公司吗?;能办理哪些业务?;公司资质如何?\n标签:公司业务、服务范围、一期边界", + "snippet": "你们公司主要做什么业务?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PUB-001", + "knowledge_type": "faq", + "title": "单位净值和累计净值是什么意思?", + "milvus_collection": "fin_faq_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "净值", + "累计净值", + "基础概念" + ], + "content_text": "{\"qa_id\":\"RAG-PUB-001\",\"question\":\"单位净值和累计净值是什么意思?\",\"paraphrases\":[\"单位净值是什么?\",\"累计净值怎么看?\",\"估值能当净值吗?\",\"净值怎么看?\"],\"answer\":\"单位净值反映每一份基金在某一估值日的价值;累计净值通常用于反映基金成立以来的净值表现,并可能包含分红再投资等影响。盘中估值只是一种参考,不等同于最终确认净值。具体口径以基金公告和产品页面说明为准。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:单位净值和累计净值是什么意思?\n相似问法:单位净值是什么?;累计净值怎么看?;估值能当净值吗?;净值怎么看?\n标签:净值、累计净值、基础概念", + "snippet": "单位净值和累计净值是什么意思?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PUB-002", + "knowledge_type": "faq", + "title": "七日年化和万份收益是什么?", + "milvus_collection": "fin_faq_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "七日年化", + "万份收益", + "货币基金" + ], + "content_text": "{\"qa_id\":\"RAG-PUB-002\",\"question\":\"七日年化和万份收益是什么?\",\"paraphrases\":[\"七日年化是什么?\",\"万份收益怎么看?\",\"货币基金收益怎么算?\",\"7 日年化可靠吗?\"],\"answer\":\"七日年化和每万份收益是货币市场基金常见的历史收益展示指标。七日年化是按最近一段期间收益折算的年化参考,不是对未来收益的承诺;每万份收益反映每一万份基金在某日实现的收益。具体数值以基金管理人最新披露为准。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:七日年化和万份收益是什么?\n相似问法:七日年化是什么?;万份收益怎么看?;货币基金收益怎么算?;7 日年化可靠吗?\n标签:七日年化、万份收益、货币基金", + "snippet": "七日年化和万份收益是什么?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PUB-003", + "knowledge_type": "faq", + "title": "基金有哪些费用?", + "milvus_collection": "fin_faq_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "基金费用", + "管理费", + "托管费", + "销售服务费" + ], + "content_text": "{\"qa_id\":\"RAG-PUB-003\",\"question\":\"基金有哪些费用?\",\"paraphrases\":[\"申购费怎么收?\",\"赎回费怎么算?\",\"管理费在哪里扣?\",\"基金手续费有哪些?\"],\"answer\":\"基金费用可能包括申购费、赎回费、管理费、托管费和销售服务费等,具体项目、费率、计提方式和减免条件因基金份额类别及持有期限而异。请以对应基金合同、招募说明书、销售文件和最新公告为准;我不会依据未核验资料计算具体金额。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:基金有哪些费用?\n相似问法:申购费怎么收?;赎回费怎么算?;管理费在哪里扣?;基金手续费有哪些?\n标签:基金费用、管理费、托管费、销售服务费", + "snippet": "基金有哪些费用?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PUB-004", + "knowledge_type": "product_inquiry", + "title": "15 点后还能买基金吗?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "交易日", + "15点", + "申购受理", + "T日" + ], + "content_text": "{\"qa_id\":\"RAG-PUB-004\",\"question\":\"15 点后还能买基金吗?\",\"paraphrases\":[\"周末能申购吗?\",\"T 日是什么意思?\",\"什么时候算当天申请?\",\"交易日怎么判断?\"],\"answer\":\"基金交易申请是否按当日处理,通常与交易日、申请受理截止时间以及基金的具体规则有关。非交易日或截止时间后提交的申请,通常顺延至下一交易日处理。不同基金和渠道可能存在差异,请以产品规则和交易页面提示为准。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:15 点后还能买基金吗?\n相似问法:周末能申购吗?;T 日是什么意思?;什么时候算当天申请?;交易日怎么判断?\n标签:交易日、15点、申购受理、T日", + "snippet": "15 点后还能买基金吗?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PUB-005", + "knowledge_type": "product_inquiry", + "title": "基金可以提现吗,怎么赎回?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "赎回", + "提现", + "办理路径", + "到账" + ], + "content_text": "{\"qa_id\":\"RAG-PUB-005\",\"question\":\"基金可以提现吗,怎么赎回?\",\"paraphrases\":[\"基金怎么卖?\",\"赎回钱什么时候到?\",\"卖出后多久到账?\",\"可以提现吗?\"],\"answer\":\"赎回需由您本人在官方交易渠道提交。到账时间受基金类型、交易申请时间、交易日、登记确认和支付渠道等因素影响,具体以基金合同、公告及交易页面显示为准。我可以解释规则,但不能替您赎回或承诺到账时间。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:基金可以提现吗,怎么赎回?\n相似问法:基金怎么卖?;赎回钱什么时候到?;卖出后多久到账?;可以提现吗?\n标签:赎回、提现、办理路径、到账", + "snippet": "基金可以提现吗,怎么赎回?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PUB-006", + "knowledge_type": "product_inquiry", + "title": "刚买的基金能撤单吗?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "撤单", + "取消申购", + "取消赎回" + ], + "content_text": "{\"qa_id\":\"RAG-PUB-006\",\"question\":\"刚买的基金能撤单吗?\",\"paraphrases\":[\"能撤单吗?\",\"取消申购\",\"赎回可以撤销吗?\",\"当天提交能取消吗?\"],\"answer\":\"能否撤销申请取决于基金类型、申请状态、交易时间和渠道规则。当前客服 Agent 不能读取您的订单或确认撤销入口;如需核实或办理,请保留订单号、错误提示和发生时间后联系人工客服 15936583816。我不能代您撤单或改变订单状态。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:刚买的基金能撤单吗?\n相似问法:能撤单吗?;取消申购;赎回可以撤销吗?;当天提交能取消吗?\n标签:撤单、取消申购、取消赎回", + "snippet": "刚买的基金能撤单吗?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PUB-007", + "knowledge_type": "product_inquiry", + "title": "什么是定投?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "定投", + "定期定额", + "扣款周期" + ], + "content_text": "{\"qa_id\":\"RAG-PUB-007\",\"question\":\"什么是定投?\",\"paraphrases\":[\"怎么做定投?\",\"定投最低多少钱?\",\"哪天扣款?\",\"定投有什么规则?\"],\"answer\":\"定投是按约定周期和金额进行基金投资的一种方式。可设置的扣款周期、扣款日、最低金额和适用基金以官方页面及产品规则为准。定投不能规避市场风险,也不保证收益;具体开通和修改应由您本人在官方渠道操作。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:什么是定投?\n相似问法:怎么做定投?;定投最低多少钱?;哪天扣款?;定投有什么规则?\n标签:定投、定期定额、扣款周期", + "snippet": "什么是定投?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PUB-008", + "knowledge_type": "product_inquiry", + "title": "基金会分红吗?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "基金分红", + "现金分红", + "红利再投资" + ], + "content_text": "{\"qa_id\":\"RAG-PUB-008\",\"question\":\"基金会分红吗?\",\"paraphrases\":[\"现金分红和红利再投资有什么区别?\",\"分红什么时候到账?\",\"能改分红方式吗?\"],\"answer\":\"基金是否分红、分红方式、权益登记日和到账安排以基金合同及最新公告为准。现金分红与红利再投资的适用条件可能不同;涉及本人持有份额或修改分红方式时,应由您本人在官方渠道办理。我不能保证分红发生或代为修改设置。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:基金会分红吗?\n相似问法:现金分红和红利再投资有什么区别?;分红什么时候到账?;能改分红方式吗?\n标签:基金分红、现金分红、红利再投资", + "snippet": "基金会分红吗?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PUB-009", + "knowledge_type": "policy_explain", + "title": "为什么要做风险测评?", + "milvus_collection": "fin_policy_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "风险测评", + "适当性", + "C1-C5", + "R1-R5" + ], + "content_text": "{\"qa_id\":\"RAG-PUB-009\",\"question\":\"为什么要做风险测评?\",\"paraphrases\":[\"不测能买吗?\",\"C3 能买 R4 吗?\",\"风险等级怎么来的?\",\"风险测评过期怎么办?\"],\"answer\":\"风险测评用于了解投资者的风险承受能力,并与产品风险等级严格一一匹配。当前首版规则为 C1 对应 R1、C2 对应 R2、C3 对应 R3、C4 对应 R4;不支持跨级购买、风险揭示后购买或由客服人工放行。首版没有 R5 产品,因此 C5 当前没有对应的可购产品。客服 Agent 只能解释规则,不能替您填写或修改测评、认定资格或绕过系统校验;最终以受控交易页面的实时结果为准。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:为什么要做风险测评?\n相似问法:不测能买吗?;C3 能买 R4 吗?;风险等级怎么来的?;风险测评过期怎么办?\n标签:风险测评、适当性、C1-C5、R1-R5", + "snippet": "为什么要做风险测评?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PUB-014", + "knowledge_type": "policy_explain", + "title": "C3 能买 R4 基金吗?", + "milvus_collection": "fin_policy_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "C3", + "R4", + "适当性", + "风险等级匹配" + ], + "content_text": "{\"qa_id\":\"RAG-PUB-014\",\"question\":\"C3 能买 R4 基金吗?\",\"paraphrases\":[\"平衡型能买高风险基金吗?\",\"C3 买指数基金行不行?\",\"风险等级差一级能买吗?\",\"签风险揭示后能买 R4 吗?\"],\"answer\":\"不能。奶龙基金首版执行严格的风险等级一一匹配规则:C3 仅可购买 R3 产品,不能购买 R4 产品;签署风险揭示、联系人工或咨询客服均不能改变该限制。请以受控交易页面的风险测评和产品风险等级校验结果为准,客服 Agent 不会替您修改测评或绕过拦截。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:C3 能买 R4 基金吗?\n相似问法:平衡型能买高风险基金吗?;C3 买指数基金行不行?;风险等级差一级能买吗?;签风险揭示后能买 R4 吗?\n标签:C3、R4、适当性、风险等级匹配", + "snippet": "C3 能买 R4 基金吗?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PUB-015", + "knowledge_type": "policy_explain", + "title": "为什么没有双录或跨级购买入口?", + "milvus_collection": "fin_policy_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "双录", + "跨级购买", + "风险揭示", + "严格匹配" + ], + "content_text": "{\"qa_id\":\"RAG-PUB-015\",\"question\":\"为什么没有双录或跨级购买入口?\",\"paraphrases\":[\"双录怎么做?\",\"签风险揭示能跨级买吗?\",\"为什么不能买高一级产品?\",\"人工能给我开通吗?\"],\"answer\":\"奶龙基金首版不提供通过双录、风险揭示或人工确认办理跨级购买的能力,而是执行风险等级与产品等级严格一一匹配。请按本人有效风险测评结果选择对应等级产品;客服 Agent 和投资顾问均不能通过聊天或人工方式修改测评结果、开通跨级权限或替您完成交易。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:为什么没有双录或跨级购买入口?\n相似问法:双录怎么做?;签风险揭示能跨级买吗?;为什么不能买高一级产品?;人工能给我开通吗?\n标签:双录、跨级购买、风险揭示、严格匹配", + "snippet": "为什么没有双录或跨级购买入口?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PUB-016", + "knowledge_type": "policy_explain", + "title": "风险测评过期后还能买基金吗?", + "milvus_collection": "fin_policy_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "风险测评有效期", + "重新测评", + "交易限制" + ], + "content_text": "{\"qa_id\":\"RAG-PUB-016\",\"question\":\"风险测评过期后还能买基金吗?\",\"paraphrases\":[\"测评过期可以提现吗?\",\"风评失效能定投吗?\",\"重测前能买 R4 吗?\",\"风险测评多久有效?\"],\"answer\":\"当前虚拟规则中,风险测评有效期为 12 个月。测评过期且未重新完成时,系统会限制新申购、定投和转换等新增交易;您可按规则处理存量持仓的赎回。客服 Agent 不能替您填写、修改或判断结果,也不能为您解除系统限制;如需确认重测办理方式,请联系人工客服 15936583816。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:风险测评过期后还能买基金吗?\n相似问法:测评过期可以提现吗?;风评失效能定投吗?;重测前能买 R4 吗?;风险测评多久有效?\n标签:风险测评有效期、重新测评、交易限制", + "snippet": "风险测评过期后还能买基金吗?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PUB-010", + "knowledge_type": "policy_explain", + "title": "基金保本吗?", + "milvus_collection": "fin_policy_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "基金风险", + "不保本", + "不保证收益", + "风险教育" + ], + "content_text": "{\"qa_id\":\"RAG-PUB-010\",\"question\":\"基金保本吗?\",\"paraphrases\":[\"会不会亏?\",\"风险大不大?\",\"基金跌了怎么办?\",\"收益能保证吗?\"],\"answer\":\"基金投资不等同于存款,存在市场波动和本金损失的可能,不保证本金或未来收益。不同基金风险特征不同,请在交易前阅读基金合同、招募说明书和风险揭示材料,并根据自身情况谨慎决策。过往业绩不代表未来表现。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:基金保本吗?\n相似问法:会不会亏?;风险大不大?;基金跌了怎么办?;收益能保证吗?\n标签:基金风险、不保本、不保证收益、风险教育", + "snippet": "基金保本吗?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PUB-011", + "knowledge_type": "faq", + "title": "基金公告和合同在哪里看?", + "milvus_collection": "fin_faq_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "信息披露", + "基金合同", + "招募说明书", + "公告" + ], + "content_text": "{\"qa_id\":\"RAG-PUB-011\",\"question\":\"基金公告和合同在哪里看?\",\"paraphrases\":[\"招募说明书怎么找?\",\"哪里看最新消息?\",\"定期报告在哪?\",\"基金合同在哪?\"],\"answer\":\"基金合同、招募说明书、定期报告和临时公告应以官方信息披露渠道发布的版本为准。请在本平台官方 App、官网或监管认可的信息披露渠道查询;阅读时注意公告日期和适用基金,避免使用转载或过期内容。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:基金公告和合同在哪里看?\n相似问法:招募说明书怎么找?;哪里看最新消息?;定期报告在哪?;基金合同在哪?\n标签:信息披露、基金合同、招募说明书、公告", + "snippet": "基金公告和合同在哪里看?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-PUB-012", + "knowledge_type": "policy_explain", + "title": "为什么需要补充或更新资料?", + "milvus_collection": "fin_policy_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "客户身份识别", + "资料更新", + "反洗钱" + ], + "content_text": "{\"qa_id\":\"RAG-PUB-012\",\"question\":\"为什么需要补充或更新资料?\",\"paraphrases\":[\"身份证过期怎么办?\",\"为什么问资金来源?\",\"不补可以提现吗?\",\"资料完善有什么用?\"],\"answer\":\"金融机构依法需要持续识别和核实客户身份信息,并在必要时补充或更新资料。资料不完整可能影响部分业务办理,具体以本平台通知和适用规则为准。请仅通过官方渠道更新资料,不要在聊天中提交完整证件号、银行卡号、密码或验证码。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:为什么需要补充或更新资料?\n相似问法:身份证过期怎么办?;为什么问资金来源?;不补可以提现吗?;资料完善有什么用?\n标签:客户身份识别、资料更新、反洗钱", + "snippet": "为什么需要补充或更新资料?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-SVC-001", + "knowledge_type": "faq", + "title": "怎么联系你们?", + "milvus_collection": "fin_faq_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "联系方式", + "人工客服", + "官网", + "App", + "官方微信" + ], + "content_text": "{\"qa_id\":\"NF-SVC-001\",\"question\":\"怎么联系你们?\",\"paraphrases\":[\"怎么联系你们?\",\"客服入口在哪?\",\"除了电话还有什么渠道?\",\"本平台怎么找人工?\"],\"answer\":\"您可通过官网 www.nailong.com、App、官方微信和全国统一客服热线 15936583816 获取服务。官网、App 和官方微信可提供公开信息与自助入口;当前需要人工处理时,请在工作日 09:00-18:00 直接联系人工客服 15936583816。奶龙基金智能助手当前不能确认官网在线人工或人工工单是否已接入。涉及账户和交易问题,请不要在聊天中发送密码、验证码或完整银行卡号。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:怎么联系你们?\n相似问法:怎么联系你们?;客服入口在哪?;除了电话还有什么渠道?;本平台怎么找人工?\n标签:联系方式、人工客服、官网、App、官方微信", + "snippet": "怎么联系你们?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-SVC-003", + "knowledge_type": "faq", + "title": "什么问题能自助解决?", + "milvus_collection": "fin_faq_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "自助服务", + "人工边界", + "服务范围" + ], + "content_text": "{\"qa_id\":\"NF-SVC-003\",\"question\":\"什么问题能自助解决?\",\"paraphrases\":[\"什么问题能自助解决?\",\"为什么要我转人工?\",\"智能客服能办什么?\",\"你和人工有什么区别?\"],\"answer\":\"自助服务适合基金公开信息、交易规则、费率说明、开户和资料完善指引等问题。涉及本人订单、账户异常、投诉纠纷、身份核验、可疑交易、赔偿和个性化投资建议时,需要人工处理;请在工作日 09:00-18:00 联系人工客服 15936583816。客服 Agent 不会代您发起或确认交易。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:什么问题能自助解决?\n相似问法:什么问题能自助解决?;为什么要我转人工?;智能客服能办什么?;你和人工有什么区别?\n标签:自助服务、人工边界、服务范围", + "snippet": "什么问题能自助解决?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-SVC-004", + "knowledge_type": "faq", + "title": "我要开户", + "milvus_collection": "fin_faq_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "开户", + "个人开户", + "身份核验", + "风险测评" + ], + "content_text": "{\"qa_id\":\"NF-SVC-004\",\"question\":\"我要开户\",\"paraphrases\":[\"我要开户\",\"个人怎么开户?\",\"哪里注册?\",\"开本平台账户\"],\"answer\":\"您可从官网 www.nailong.com的“个人开户”入口或奶龙基金 App 办理个人开户。开户应由本人完成身份信息填写、必要核验和风险测评,并按页面提示绑定可用的支付方式。客服 Agent 可以说明步骤,但不会代为开户、代填资料或接收您的证件和验证码。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:我要开户\n相似问法:我要开户;个人怎么开户?;哪里注册?;开本平台账户\n标签:开户、个人开户、身份核验、风险测评", + "snippet": "我要开户", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-SVC-005", + "knowledge_type": "faq", + "title": "在哪里登录?", + "milvus_collection": "fin_faq_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "登录", + "个人账户", + "密码安全" + ], + "content_text": "{\"qa_id\":\"NF-SVC-005\",\"question\":\"在哪里登录?\",\"paraphrases\":[\"在哪里登录?\",\"个人账户怎么进?\",\"官网能登录吗?\",\"App 登录不了怎么办?\"],\"answer\":\"官网 www.nailong.com提供“个人登录”入口,App 也可登录个人直销账户。若您忘记密码、收不到验证码或提示账户异常,请仅在官方登录页处理,并通过官方客服渠道核实。客服 Agent 不会索要密码、验证码、完整身份证号或完整银行卡号。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:在哪里登录?\n相似问法:在哪里登录?;个人账户怎么进?;官网能登录吗?;App 登录不了怎么办?\n标签:登录、个人账户、密码安全", + "snippet": "在哪里登录?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-SVC-006", + "knowledge_type": "faq", + "title": "开户总失败", + "milvus_collection": "fin_faq_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "开户失败", + "实名失败", + "错误码", + "绑卡失败" + ], + "content_text": "{\"qa_id\":\"NF-SVC-006\",\"question\":\"开户总失败\",\"paraphrases\":[\"开户总失败\",\"实名过不了怎么办?\",\"绑卡不成功\",\"人脸识别失败\"],\"answer\":\"请先保留页面错误提示和发生时间,再在官方开户页面重试。首版虚拟错误码包括:NL-BANK-1001 表示银行卡暂不支持,NL-BANK-1002 表示开户身份信息与银行卡预留信息不一致,NL-PAY-2001 表示交易金额超过平台限额,NL-PAY-2002 表示银行卡状态或可用余额异常。仍无法完成时,请在工作日 09:00-18:00 联系人工客服 15936583816;不要在聊天中发送证件照片、完整证件号、卡号、密码或验证码。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:开户总失败\n相似问法:开户总失败;实名过不了怎么办?;绑卡不成功;人脸识别失败\n标签:开户失败、实名失败、错误码、绑卡失败", + "snippet": "开户总失败", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-P1-001", + "knowledge_type": "faq", + "title": "支持哪些银行卡,交易限额是多少?", + "milvus_collection": "fin_faq_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "支持银行卡", + "借记卡", + "交易限额", + "定投限额" + ], + "content_text": "{\"qa_id\":\"RAG-P1-001\",\"question\":\"支持哪些银行卡,交易限额是多少?\",\"paraphrases\":[\"能绑什么卡?\",\"工行卡能用吗?\",\"单笔最多买多少?\",\"定投限额是多少?\"],\"answer\":\"首版支持本人名下的中国银行、中国工商银行、中国农业银行、中国招商银行和中国建设银行借记卡。平台交易限额为单笔 10,000 元、单日累计 500,000 元、单月累计 1,500,000 元;该限额适用于申购和定投,基金自身的申购限额仍以产品页面为准。客服 Agent 不会在聊天中收取完整银行卡号、密码或验证码。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:支持哪些银行卡,交易限额是多少?\n相似问法:能绑什么卡?;工行卡能用吗?;单笔最多买多少?;定投限额是多少?\n标签:支持银行卡、借记卡、交易限额、定投限额", + "snippet": "支持哪些银行卡,交易限额是多少?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-SVC-007", + "knowledge_type": "faq", + "title": "App 去哪下?", + "milvus_collection": "fin_faq_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "App下载", + "官网", + "防诈骗", + "官方渠道" + ], + "content_text": "{\"qa_id\":\"NF-SVC-007\",\"question\":\"App 去哪下?\",\"paraphrases\":[\"App 去哪下?\",\"怎么下载本平台?\",\"有官网吗?\",\"怎么确认不是假 App?\"],\"answer\":\"请通过官网 www.nailong.com展示的 App 下载入口或正规应用商店获取客户端,并核对应用名称、开发者和官网链接。不要通过陌生短信、群聊链接或他人发送的安装包下载应用;如遇冒用本平台名义的链接或客服,请先通过官方渠道核验。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:App 去哪下?\n相似问法:App 去哪下?;怎么下载本平台?;有官网吗?;怎么确认不是假 App?\n标签:App下载、官网、防诈骗、官方渠道", + "snippet": "App 去哪下?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-STS-004", + "knowledge_type": "product_inquiry", + "title": "为什么不能赎回?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "最短持有期", + "滚动持有期", + "赎回限制" + ], + "content_text": "{\"qa_id\":\"NF-STS-004\",\"question\":\"为什么不能赎回?\",\"paraphrases\":[\"为什么不能赎回?\",\"一年持有期能提前卖吗?\",\"滚动持有是什么意思?\",\"锁定期怎么算?\"],\"answer\":\"部分基金设置最短持有期或滚动运作期。投资者在每笔份额的持有期或运作期未届满前,通常不能提出赎回申请;不同份额的可赎回日期可能不同。请以具体基金合同、产品状态表和本人份额的确认日期为准。当前客服 Agent 不能核算您某笔份额的到期日。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:为什么不能赎回?\n相似问法:为什么不能赎回?;一年持有期能提前卖吗?;滚动持有是什么意思?;锁定期怎么算?\n标签:最短持有期、滚动持有期、赎回限制", + "snippet": "为什么不能赎回?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-STS-006", + "knowledge_type": "product_inquiry", + "title": "奶龙基金支持哪些份额类别?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "A类", + "C类", + "份额类别", + "产品范围" + ], + "content_text": "{\"qa_id\":\"NF-STS-006\",\"question\":\"奶龙基金支持哪些份额类别?\",\"paraphrases\":[\"首版支持 A 类还是 C 类?\",\"有没有 B 类或 E 类?\",\"只有 A/C 类吗?\",\"份额能互转吗?\"],\"answer\":\"奶龙基金首版仅配置 A 类和 C 类份额。同一基金的 A/C 类在申购费、销售服务费和适用规则上可能不同,具体以产品资料和交易页面为准;首版未开放 B、E 或其他份额类别,不能将 A/C 类规则套用于未开放类别。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:奶龙基金支持哪些份额类别?\n相似问法:首版支持 A 类还是 C 类?;有没有 B 类或 E 类?;只有 A/C 类吗?;份额能互转吗?\n标签:A类、C类、份额类别、产品范围", + "snippet": "奶龙基金支持哪些份额类别?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-STS-007", + "knowledge_type": "product_inquiry", + "title": "为什么不能转换?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "基金转换", + "转换转入", + "转换转出" + ], + "content_text": "{\"qa_id\":\"NF-STS-007\",\"question\":\"为什么不能转换?\",\"paraphrases\":[\"为什么不能转换?\",\"A 转 C 可以吗?\",\"基金转换灰了\",\"转换入暂停\"],\"answer\":\"基金转换需要同时满足转出基金、转入基金、份额类别、渠道和状态条件。公开状态表会分别展示转换入和转换出是否开放,部分基金或份额类别不支持转换。请先查询两只基金的最新状态;客服 Agent 不能代为发起转换或替您判断具体交易是否成功。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:为什么不能转换?\n相似问法:为什么不能转换?;A 转 C 可以吗?;基金转换灰了;转换入暂停\n标签:基金转换、转换转入、转换转出", + "snippet": "为什么不能转换?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-STS-008", + "knowledge_type": "product_inquiry", + "title": "ETF 为什么不能定投?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "ETF", + "ETF联接", + "场内基金", + "产品范围" + ], + "content_text": "{\"qa_id\":\"NF-STS-008\",\"question\":\"ETF 为什么不能定投?\",\"paraphrases\":[\"ETF 为什么不能定投?\",\"ETF 怎么申购赎回?\",\"ETF 的限额在哪看?\",\"联接基金和 ETF 一样吗?\"],\"answer\":\"ETF 与普通场外开放式基金的申购赎回机制不同,通常需要区分场内 ETF、场外联接基金和普通开放式基金。奶龙基金首版暂不开放 ETF 或 ETF 联接基金,因此不提供 ETF 的申购、赎回、定投或转换办理;我可以说明其通用概念,但不会把其他平台的 ETF 规则套用于本平台。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:ETF 为什么不能定投?\n相似问法:ETF 为什么不能定投?;ETF 怎么申购赎回?;ETF 的限额在哪看?;联接基金和 ETF 一样吗?\n标签:ETF、ETF联接、场内基金、产品范围", + "snippet": "ETF 为什么不能定投?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-STS-009", + "knowledge_type": "product_inquiry", + "title": "本平台有 QDII、FOF 或养老基金吗?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "QDII", + "FOF", + "养老基金", + "Y类", + "未开放产品" + ], + "content_text": "{\"qa_id\":\"NF-STS-009\",\"question\":\"本平台有 QDII、FOF 或养老基金吗?\",\"paraphrases\":[\"有没有 QDII?\",\"有没有 FOF?\",\"养老基金怎么买?\",\"Y 类份额是什么?\"],\"answer\":\"奶龙基金首版暂不开放 QDII、FOF、养老目标基金和个人养老金 Y 类份额,也不支持对应的申购、赎回、定投或转换办理。我可以说明这些产品的通用概念;后续如开放,以届时的官方产品目录、公告和交易页面规则为准。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:本平台有 QDII、FOF 或养老基金吗?\n相似问法:有没有 QDII?;有没有 FOF?;养老基金怎么买?;Y 类份额是什么?\n标签:QDII、FOF、养老基金、Y类、未开放产品", + "snippet": "本平台有 QDII、FOF 或养老基金吗?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-STS-010", + "knowledge_type": "product_inquiry", + "title": "为什么个人买不了?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "个人投资者", + "资格限制", + "产品开放范围" + ], + "content_text": "{\"qa_id\":\"NF-STS-010\",\"question\":\"为什么个人买不了?\",\"paraphrases\":[\"为什么个人买不了?\",\"提示不对个人开放\",\"机构才能买吗?\",\"我的账户不能申购\"],\"answer\":\"部分基金或份额可能不面向个人投资者开放申购、定投或转换转入,或者仅向满足特定账户条件的投资者开放。请以产品状态表、基金合同和交易页面提示为准。客服 Agent 可以解释公开资格限制,但不能绕过系统校验或为您变更投资者身份。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:为什么个人买不了?\n相似问法:为什么个人买不了?;提示不对个人开放;机构才能买吗?;我的账户不能申购\n标签:个人投资者、资格限制、产品开放范围", + "snippet": "为什么个人买不了?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-TRD-001", + "knowledge_type": "product_inquiry", + "title": "怎么买一只基金?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "申购流程", + "买基金", + "风险揭示" + ], + "content_text": "{\"qa_id\":\"NF-TRD-001\",\"question\":\"怎么买一只基金?\",\"paraphrases\":[\"怎么买一只基金?\",\"申购步骤是什么?\",\"第一次下单怎么操作?\",\"为什么让我做测评?\"],\"answer\":\"典型直销申购流程包括:登录本人账户,搜索基金名称或代码,阅读产品资料和风险揭示,确认风险测评与产品匹配,输入金额并由本人确认交易。实际页面会根据基金状态、份额类别和账户资格校验。客服 Agent 只能讲解流程,不能替您下单、输入交易密码或确认交易。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:怎么买一只基金?\n相似问法:怎么买一只基金?;申购步骤是什么?;第一次下单怎么操作?;为什么让我做测评?\n标签:申购流程、买基金、风险揭示", + "snippet": "怎么买一只基金?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-TRD-002", + "knowledge_type": "product_inquiry", + "title": "基金怎么赎回?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "赎回流程", + "卖基金", + "确认到账" + ], + "content_text": "{\"qa_id\":\"NF-TRD-002\",\"question\":\"基金怎么赎回?\",\"paraphrases\":[\"基金怎么赎回?\",\"我要全部卖掉\",\"赎回要填什么?\",\"预计到账金额怎么看?\"],\"answer\":\"赎回应由您本人在官方交易页面选择基金和赎回份额后确认。页面展示的预计到账金额和时间只作交易前参考,实际结果受确认净值、费用、持有期、基金类型和支付处理影响。客服 Agent 不会替您赎回,也不能承诺最终到账金额或到账时间。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:基金怎么赎回?\n相似问法:基金怎么赎回?;我要全部卖掉;赎回要填什么?;预计到账金额怎么看?\n标签:赎回流程、卖基金、确认到账", + "snippet": "基金怎么赎回?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-TRD-003", + "knowledge_type": "product_inquiry", + "title": "15 点后买算哪天?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "交易日", + "15点", + "T日", + "受理日期" + ], + "content_text": "{\"qa_id\":\"NF-TRD-003\",\"question\":\"15 点后买算哪天?\",\"paraphrases\":[\"15 点后买算哪天?\",\"周末提交算什么时候?\",\"今天还能撤吗?\",\"为什么显示下一交易日?\"],\"answer\":\"基金申请的受理日期与交易日和该产品的受理截止时间有关。截止时间后或非交易日提交的申请,通常会顺延至下一交易日处理;具体以基金合同、交易规则和页面提示为准。客服 Agent 不能仅凭聊天时间判断您某笔订单的实际受理日期。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:15 点后买算哪天?\n相似问法:15 点后买算哪天?;周末提交算什么时候?;今天还能撤吗?;为什么显示下一交易日?\n标签:交易日、15点、T日、受理日期", + "snippet": "15 点后买算哪天?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-TRD-008", + "knowledge_type": "product_inquiry", + "title": "怎么设置定投?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "定投设置", + "定期定额", + "定期不定额" + ], + "content_text": "{\"qa_id\":\"NF-TRD-008\",\"question\":\"怎么设置定投?\",\"paraphrases\":[\"怎么设置定投?\",\"定投有哪几种?\",\"每月自动买怎么开?\",\"定期不定额是什么?\"],\"answer\":\"官网 www.nailong.com公开业务规则包含电子直销定期定额和定期不定额投资业务。具体支持的基金、扣款周期、金额、日期、银行卡和限额应以交易页面及对应规则为准。定投不保证收益,且客服 Agent 不会代为开通或设置计划。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:怎么设置定投?\n相似问法:怎么设置定投?;定投有哪几种?;每月自动买怎么开?;定期不定额是什么?\n标签:定投设置、定期定额、定期不定额", + "snippet": "怎么设置定投?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-TRD-011", + "knowledge_type": "policy_explain", + "title": "为什么不让我买?", + "milvus_collection": "fin_policy_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "风险不匹配", + "适当性", + "系统校验", + "风险测评" + ], + "content_text": "{\"qa_id\":\"NF-TRD-011\",\"question\":\"为什么不让我买?\",\"paraphrases\":[\"为什么不让我买?\",\"风险不匹配怎么办?\",\"能跳过测评吗?\",\"我的等级不够能买吗?\"],\"answer\":\"系统会根据有效风险测评结果与产品风险等级进行适当性校验。客服 Agent 可以解释规则和提示重新完成测评,但不能替您修改结果、绕过拦截或将不匹配产品推荐给您。是否可交易以系统实时校验结果为准。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:为什么不让我买?\n相似问法:为什么不让我买?;风险不匹配怎么办?;能跳过测评吗?;我的等级不够能买吗?\n标签:风险不匹配、适当性、系统校验、风险测评", + "snippet": "为什么不让我买?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "NF-CMP-008", + "knowledge_type": "policy_explain", + "title": "为什么让我补资料?", + "milvus_collection": "fin_policy_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "客户身份识别", + "资料更新", + "资金来源", + "反洗钱" + ], + "content_text": "{\"qa_id\":\"NF-CMP-008\",\"question\":\"为什么让我补资料?\",\"paraphrases\":[\"为什么让我补资料?\",\"身份证过期怎么办?\",\"为什么问资金来源?\",\"不更新会怎样?\"],\"answer\":\"金融机构需要持续识别和核实客户身份信息,并在必要时更新资料。资料不完整可能影响部分业务办理;客服 Agent 不会在聊天中收集完整证件信息,也不能代您更新资料。当前如需办理或核实资料更新方式,请联系人工客服 15936583816。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:为什么让我补资料?\n相似问法:为什么让我补资料?;身份证过期怎么办?;为什么问资金来源?;不更新会怎样?\n标签:客户身份识别、资料更新、资金来源、反洗钱", + "snippet": "为什么让我补资料?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-RVW-001", + "knowledge_type": "product_inquiry", + "title": "我搜不到这只基金。", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "基金搜索", + "基金代码", + "份额类别", + "产品目录" + ], + "content_text": "{\"qa_id\":\"RAG-RVW-001\",\"question\":\"我搜不到这只基金。\",\"paraphrases\":[\"基金代码查不到\",\"名字不对吗?\",\"为什么产品列表没有?\",\"是不是下架了?\"],\"answer\":\"请先核对完整基金名称、基金代码和份额类别。同名或相近名称、不同份额类别以及产品状态变化,都可能影响搜索结果。若该产品不在本平台已审核目录中,我不能推断它是否在售、已下架或可交易;请以官方产品目录和公告为准。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:我搜不到这只基金。\n相似问法:基金代码查不到;名字不对吗?;为什么产品列表没有?;是不是下架了?\n标签:基金搜索、基金代码、份额类别、产品目录", + "snippet": "我搜不到这只基金。", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-RVW-005", + "knowledge_type": "policy_explain", + "title": "我已经登录了,为什么还要重新验证身份?", + "milvus_collection": "fin_policy_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "身份核验", + "权限边界", + "敏感数据" + ], + "content_text": "{\"qa_id\":\"RAG-RVW-005\",\"question\":\"我已经登录了,为什么还要重新验证身份?\",\"paraphrases\":[\"提示重新核验\",\"为什么权限不足?\",\"登录了还不能查?\",\"身份验证失败。\"],\"answer\":\"登录状态不等于客服 Agent 已获得读取敏感服务的权限。当前客服 Agent 不读取您的账户、交易或资料数据,也不能为您发起身份核验;如页面提示需要重新核验或无法完成,请联系人工客服 15936583816,且不要在聊天中发送敏感凭据。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:我已经登录了,为什么还要重新验证身份?\n相似问法:提示重新核验;为什么权限不足?;登录了还不能查?;身份验证失败。\n标签:身份核验、权限边界、敏感数据", + "snippet": "我已经登录了,为什么还要重新验证身份?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-RVW-008", + "knowledge_type": "product_inquiry", + "title": "买 1000 元基金实际会扣多少钱?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "申购费", + "赎回费", + "确认份额", + "金额试算" + ], + "content_text": "{\"qa_id\":\"RAG-RVW-008\",\"question\":\"买 1000 元基金实际会扣多少钱?\",\"paraphrases\":[\"手续费怎么算?\",\"到账份额怎么计算?\",\"赎回能拿回多少?\",\"费用能帮我算吗?\"],\"answer\":\"实际扣款金额、确认份额或赎回到账金额会受基金代码、份额类别、费率、优惠、持有期、确认净值和交易状态影响。当前客服 Agent 不具备实时费率、优惠、净值和交易参数的试算能力,不能根据“买 1000 元”承诺实际扣款或到账金额;我可以说明已审核的公开费率规则,具体金额请以最终确认结果或人工客服核实为准。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:买 1000 元基金实际会扣多少钱?\n相似问法:手续费怎么算?;到账份额怎么计算?;赎回能拿回多少?;费用能帮我算吗?\n标签:申购费、赎回费、确认份额、金额试算", + "snippet": "买 1000 元基金实际会扣多少钱?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-RVW-010", + "knowledge_type": "policy_explain", + "title": "未成年人、港澳台或境外人士能开户吗?", + "milvus_collection": "fin_policy_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "开户资格", + "未成年人", + "港澳台", + "境外人士" + ], + "content_text": "{\"qa_id\":\"RAG-RVW-010\",\"question\":\"未成年人、港澳台或境外人士能开户吗?\",\"paraphrases\":[\"孩子能买基金吗?\",\"未成年怎么开户?\",\"港澳台居民能开户吗?\",\"外国人能注册吗?\"],\"answer\":\"开户资格、监护关系、证件要求和适用渠道需以本平台最新开户规则及适用法律要求为准。客服 Agent 不会依据聊天内容判断您的资格,也不会收取证件材料;请先查看官方开户说明,情况特殊时转人工核验。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:未成年人、港澳台或境外人士能开户吗?\n相似问法:孩子能买基金吗?;未成年怎么开户?;港澳台居民能开户吗?;外国人能注册吗?\n标签:开户资格、未成年人、港澳台、境外人士", + "snippet": "未成年人、港澳台或境外人士能开户吗?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-RVW-011", + "knowledge_type": "policy_explain", + "title": "风险测评过期了怎么办?", + "milvus_collection": "fin_policy_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "风险测评有效期", + "重新测评", + "存量赎回" + ], + "content_text": "{\"qa_id\":\"RAG-RVW-011\",\"question\":\"风险测评过期了怎么办?\",\"paraphrases\":[\"测评失效\",\"怎么重新测?\",\"风险等级能改吗?\",\"为什么不让我交易?\"],\"answer\":\"当前虚拟规则中,风险测评有效期为 12 个月。过期且未完成重测时,系统会限制新申购、定投和转换等新增交易;存量持仓可按规则赎回。客服 Agent 不能替您填写、修改结果、解除限制或根据结果推荐具体基金;当前如需确认重测办理方式,请联系人工客服 15936583816。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:风险测评过期了怎么办?\n相似问法:测评失效;怎么重新测?;风险等级能改吗?;为什么不让我交易?\n标签:风险测评有效期、重新测评、存量赎回", + "snippet": "风险测评过期了怎么办?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-CONFIG-001", + "knowledge_type": "product_inquiry", + "title": "奶龙基金有哪些产品?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "产品清单", + "NL-M001", + "NL-B001", + "NL-H001", + "NL-I001", + "NL-G001" + ], + "content_text": "{\"qa_id\":\"RAG-CONFIG-001\",\"question\":\"奶龙基金有哪些产品?\",\"paraphrases\":[\"产品清单\",\"有哪些基金可以买\",\"你们卖什么基金\",\"基金代码是什么\"],\"answer\":\"当前虚拟产品池包括:NL-M001 奶龙零钱宝货币基金(R1)、NL-B001 奶龙稳享纯债基金(R2)、NL-H001 奶龙均衡优选混合基金(R3)、NL-I001 奶龙核心指数基金(R4)和 NL-G001 奶龙成长精选混合基金(R4)。首版仅配置 A 类和 C 类份额;未列入清单的产品,我不能推断其是否在售或可交易。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:奶龙基金有哪些产品?\n相似问法:产品清单;有哪些基金可以买;你们卖什么基金;基金代码是什么\n标签:产品清单、NL-M001、NL-B001、NL-H001、NL-I001、NL-G001", + "snippet": "奶龙基金有哪些产品?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-CONFIG-002", + "knowledge_type": "product_inquiry", + "title": "你们支持哪些类型的基金?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "货币基金", + "债券基金", + "混合基金", + "指数基金", + "未开放产品" + ], + "content_text": "{\"qa_id\":\"RAG-CONFIG-002\",\"question\":\"你们支持哪些类型的基金?\",\"paraphrases\":[\"有 QDII 吗\",\"有 FOF 吗\",\"有 ETF 吗\",\"有养老基金吗\",\"有私募吗\"],\"answer\":\"首版虚拟产品池支持货币基金、纯债基金、混合基金和指数基金。QDII、FOF、ETF、ETF 联接、养老目标基金、个人养老金 Y 类份额和私募基金首版暂不开放;我可以提供这些类型的通用概念说明。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:你们支持哪些类型的基金?\n相似问法:有 QDII 吗;有 FOF 吗;有 ETF 吗;有养老基金吗;有私募吗\n标签:货币基金、债券基金、混合基金、指数基金、未开放产品", + "snippet": "你们支持哪些类型的基金?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-CONFIG-003", + "knowledge_type": "product_inquiry", + "title": "A 类和 C 类有什么区别?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "A类", + "C类", + "申购费", + "销售服务费" + ], + "content_text": "{\"qa_id\":\"RAG-CONFIG-003\",\"question\":\"A 类和 C 类有什么区别?\",\"paraphrases\":[\"份额类别怎么选\",\"A/C 类费率\",\"为什么有两个代码\",\"C 类是不是没有申购费\"],\"answer\":\"首版虚拟规则中,A 类申购费为 0.60%,不收销售服务费;C 类申购费为 0%,按年收取 0.40%销售服务费。不同份额的费用和适用规则不同,不能仅根据持有时间作出购买建议,请以交易确认页和产品资料为准。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:A 类和 C 类有什么区别?\n相似问法:份额类别怎么选;A/C 类费率;为什么有两个代码;C 类是不是没有申购费\n标签:A类、C类、申购费、销售服务费", + "snippet": "A 类和 C 类有什么区别?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-CONFIG-004", + "knowledge_type": "product_inquiry", + "title": "申购费和赎回费怎么收?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "申购费", + "赎回费", + "持有7日", + "费率" + ], + "content_text": "{\"qa_id\":\"RAG-CONFIG-004\",\"question\":\"申购费和赎回费怎么收?\",\"paraphrases\":[\"买入手续费\",\"卖出手续费\",\"持有几天有赎回费\",\"手续费怎么算\"],\"answer\":\"首版虚拟费率规则为:A 类申购费 0.60%,C 类申购费 0%;货币基金申购费和赎回费为 0%;其他演示产品持有少于 7 日按 1.50%收取赎回费,持有满 7 日暂免。管理费、托管费和销售服务费按具体产品及份额类别计提,最终以交易页面显示为准。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:申购费和赎回费怎么收?\n相似问法:买入手续费;卖出手续费;持有几天有赎回费;手续费怎么算\n标签:申购费、赎回费、持有7日、费率", + "snippet": "申购费和赎回费怎么收?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-CONFIG-005", + "knowledge_type": "product_inquiry", + "title": "15 点前后买基金有什么区别?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "交易日", + "15点", + "T日", + "非交易日" + ], + "content_text": "{\"qa_id\":\"RAG-CONFIG-005\",\"question\":\"15 点前后买基金有什么区别?\",\"paraphrases\":[\"什么时候算 T 日\",\"周末申购算哪天\",\"15:00 后还能买吗\",\"交易日怎么判断\"],\"answer\":\"虚拟交易规则为:交易日 15:00 前提交的申请按当日申请处理;15:00 后、周末或法定节假日提交的申请按下一交易日处理。交易日以系统维护的中国内地交易日历为准,具体以交易页面提示为准。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:15 点前后买基金有什么区别?\n相似问法:什么时候算 T 日;周末申购算哪天;15:00 后还能买吗;交易日怎么判断\n标签:交易日、15点、T日、非交易日", + "snippet": "15 点前后买基金有什么区别?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-CONFIG-006", + "knowledge_type": "product_inquiry", + "title": "申购和赎回什么时候确认、到账?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "申购确认", + "T+1", + "赎回到账", + "T+3" + ], + "content_text": "{\"qa_id\":\"RAG-CONFIG-006\",\"question\":\"申购和赎回什么时候确认、到账?\",\"paraphrases\":[\"买完多久有份额\",\"赎回几天到账\",\"T+1 是什么意思\",\"钱什么时候回来\"],\"answer\":\"普通虚拟产品申购按 T+1 交易日确认;NL-M001 货币基金赎回预计 T+1 到账,其余演示产品预计 T+3 到账。实际结果还会受到产品状态、确认净值、费用和支付处理影响,页面预计时间不等于最终承诺。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:申购和赎回什么时候确认、到账?\n相似问法:买完多久有份额;赎回几天到账;T+1 是什么意思;钱什么时候回来\n标签:申购确认、T+1、赎回到账、T+3", + "snippet": "申购和赎回什么时候确认、到账?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-CONFIG-007", + "knowledge_type": "product_inquiry", + "title": "定投怎么设置?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "定投", + "最低100元", + "扣款周期", + "扣款失败" + ], + "content_text": "{\"qa_id\":\"RAG-CONFIG-007\",\"question\":\"定投怎么设置?\",\"paraphrases\":[\"每月自动买\",\"定投最低多少钱\",\"哪天扣款\",\"定投失败怎么办\"],\"answer\":\"首版虚拟规则支持普通定投,最低金额 100 元,扣款周期为每周、每两周或每月,可选日期为每月 1 日、8 日、15 日和 25 日。扣款失败不自动补扣,连续 3 期失败后自动暂停计划;智能定投首版暂不支持。定投设置、修改和终止必须由本人在受控页面操作。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:定投怎么设置?\n相似问法:每月自动买;定投最低多少钱;哪天扣款;定投失败怎么办\n标签:定投、最低100元、扣款周期、扣款失败", + "snippet": "定投怎么设置?", + "review_status": "pending_review", + "status": "active" + }, + { + "qa_id": "RAG-CONFIG-008", + "knowledge_type": "product_inquiry", + "title": "基金转换和转托管支持吗?", + "milvus_collection": "fin_product_collection", + "version": "v5.8", + "source_file": "客服Agent知识库_QA问答对_v5_RAG发布候选版.txt", + "source_type": "qa_pair", + "source_url": null, + "effective_date": null, + "expire_date": null, + "tags": [ + "基金转换", + "转托管", + "份额兼容", + "风险匹配" + ], + "content_text": "{\"qa_id\":\"RAG-CONFIG-008\",\"question\":\"基金转换和转托管支持吗?\",\"paraphrases\":[\"A 转 C\",\"基金换成另一只\",\"能转到别的平台吗\",\"转托管怎么办\"],\"answer\":\"首版虚拟规则支持同一演示产品池内、份额兼容、状态开放且风险匹配的基金转换;转托管首版暂不支持。客服 Agent 只能解释规则,不能替您发起、确认或撤销交易。\",\"audience\":[\"visitor\",\"authenticated_user\"],\"agent_data_access\":\"none\",\"source_version\":\"v5.8\"}", + "retrieval_text": "标准问题:基金转换和转托管支持吗?\n相似问法:A 转 C;基金换成另一只;能转到别的平台吗;转托管怎么办\n标签:基金转换、转托管、份额兼容、风险匹配", + "snippet": "基金转换和转托管支持吗?", + "review_status": "pending_review", + "status": "active" + } + ] +} diff --git a/docs/evidence/foundation-migration-preflight.json b/docs/evidence/foundation-migration-preflight.json new file mode 100644 index 0000000..4f3ecab --- /dev/null +++ b/docs/evidence/foundation-migration-preflight.json @@ -0,0 +1,54 @@ +[ + { + "path": "D:\\桌面\\财富项目\\group_fqcd_jr", + "branch": "develop", + "head": "c6be99078e2be5d00397449c826fa049b60910ba", + "status": [ + "?? \"docs/15-\\347\\237\\245\\350\\257\\206\\346\\243\\200\\347\\264\\242\\346\\216\\245\\345\\205\\245\\346\\226\\271\\346\\241\\210.md\"", + "?? docs/superpowers/plans/2026-09-10-customer-service-rag-plan-a.md" + ] + }, + { + "path": "D:\\桌面\\财富项目\\group_fqcd_jr\\.worktrees\\customer-service-rag", + "branch": "feature/customer-service-rag", + "head": "ad7172367a0d69903e8bb92916f5d927084d8970", + "status": [ + " M app/core/config.py", + " M app/core/contracts.py", + " M app/main.py", + " M app/service/agent/bootstrap.py", + " M app/service/agent/factory.py", + " M app/service/agent/governance.py", + " M app/service/agent_run_application_service.py", + " M app/service/model_gateway.py", + " M app/worker/runtime.py", + " M tests/conftest.py", + " M tests/integration/test_agent_run_acceptance.py", + " M tests/integration/test_worker_runtime_mysql.py", + " M tests/unit/service/test_model_gateway.py", + "?? app/core/knowledge_contracts.py", + "?? app/infrastructure/milvus_knowledge_adapter.py", + "?? app/model/knowledge.py", + "?? app/service/agent/customer_service_agent.py", + "?? app/service/agent/customer_service_routing.py", + "?? app/service/knowledge_authority.py", + "?? app/service/knowledge_config.py", + "?? app/service/knowledge_retrieval_service.py", + "?? app/service/knowledge_tool_service.py", + "?? app/static/", + "?? tests/unit/api/test_customer_service_test_page.py", + "?? tests/unit/core/test_knowledge_contracts.py", + "?? tests/unit/infrastructure/test_milvus_knowledge_adapter.py", + "?? tests/unit/service/test_customer_service_agent.py", + "?? tests/unit/service/test_knowledge_authority.py", + "?? tests/unit/service/test_knowledge_config.py", + "?? tests/unit/service/test_knowledge_retrieval.py" + ] + }, + { + "path": "D:\\桌面\\财富项目\\111\\qyqy_develop", + "branch": "qyqy_develop", + "head": "6516ccb385024f7c9fe9e2207de69cd924e26262", + "status": [] + } +] diff --git a/docs/superpowers/plans/2026-09-10-foundation-safe-migration.md b/docs/superpowers/plans/2026-09-10-foundation-safe-migration.md new file mode 100644 index 0000000..f296a1e --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-foundation-safe-migration.md @@ -0,0 +1,353 @@ +# 新底座无损迁移 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在不改变现有工作区和当前运行数据库的前提下,将 `qyqy_develop` 升级为项目底座并完整保留场外基金、NL2SQL、访客、客服 RAG 与人工转接。 + +**Architecture:** 整合分支以 `qyqy_develop` 为第一父提交,保留其错误信封、限流、embedding、记忆、Worker 租约、Outbox、配置发布和知识引用签名;再语义合并现有业务。客服只经 `BaseAgent`、`AgentFactory`、`ToolExecutor`、`PlatformGovernance` 和 `WorkerRuntime` 运行。数据库仅在独立副本验证,使用 Alembic merge revision 收敛迁移图。 + +**Tech Stack:** Python 3.13、FastAPI、SQLAlchemy Async、Alembic、MySQL、Redis、Milvus、PyMilvus、pytest、Ruff、mypy。 + +**Spec:** `docs/superpowers/specs/2026-09-10-foundation-safe-migration-design.md` + +## Global Constraints + +- 不修改 `develop`、`qyqy_develop`、`feature/customer-service-rag` 或其工作目录。 +- 不停止或重启当前服务;不向当前 MySQL、Redis、Milvus 执行写入、迁移、删除或清理。 +- 客服仅服务 `visitor` 与 `customer`,绝不返回持仓、收益、订单、银行卡或投诉进度。 +- 访客只使用最小 `visitor` 上下文,不查询正式 RBAC、不写客户记忆、不读取客户数据。 +- 真实密钥、密码和 JWT 私钥只留在本地 `.env`,不进入代码、证据、日志或 Git。 +- 不重命名、删除、复用既有数据库表和字段;客服检索只返回已发布、启用、有效知识。 +- 每项代码变更先写并观察失败测试,再实现最小代码;任务完成后运行测试并提交。 + +--- + +### Task 1: 固化迁移前状态并设立停止门禁 + +**Files:** +- Create: `tools/foundation_migration_preflight.py` +- Create: `tests/unit/tools/test_foundation_migration_preflight.py` +- Create: `docs/evidence/foundation-migration-preflight.json` + +**Interfaces:** +- Produces: `GitRunner = Callable[[Path, str, *str], str]` 与 `run_git(worktree: Path, *args: str) -> str`;`run_git` 只能调用 `git -C ` 的只读子命令。 +- Produces: `collect_workspace_state(worktree: Path) -> dict[str, object]`,仅包含 `path`、`branch`、`head`、`status`。 +- Produces: `write_preflight_report(target: Path, states: list[dict[str, object]]) -> None`。 + +- [ ] **Step 1: 写失败测试,证明采集器保留未跟踪文件状态且不读取环境变量。** + +```python +def test_collect_workspace_state_records_untracked_paths_without_environment_values(tmp_path: Path): + state = collect_workspace_state(tmp_path, runner=fake_git_runner) + assert state["branch"] == "feature/customer-service-rag" + assert "app/service/agent/customer_service_agent.py" in state["status"] + assert "MYSQL_PASSWORD" not in json.dumps(state) +``` + +- [ ] **Step 2: 运行测试确认失败。** + +Run: `python -m pytest tests/unit/tools/test_foundation_migration_preflight.py -q -p no:cacheprovider` + +Expected: FAIL,因为采集器尚不存在。 + +- [ ] **Step 3: 实现只读采集器与报告写入。** + +```python +def collect_workspace_state(worktree: Path, runner: GitRunner = run_git) -> dict[str, object]: + return { + "path": str(worktree.resolve()), + "branch": runner(worktree, "branch", "--show-current").strip(), + "head": runner(worktree, "rev-parse", "HEAD").strip(), + "status": runner(worktree, "status", "--short").splitlines(), + } +``` + +`write_preflight_report` 使用 `json.dumps(..., ensure_ascii=False, indent=2)`,输入只能来自 Git 输出。 + +- [ ] **Step 4: 验证、生成报告并提交。** + +Run: `python -m pytest tests/unit/tools/test_foundation_migration_preflight.py -q -p no:cacheprovider` + +Expected: PASS;报告列出三个原工作区状态。提交 `tools`、测试和证据,消息为 `test: record pre-migration workspace evidence`。 + +### Task 2: 语义合并已提交的场外基金和 NL2SQL 功能 + +**Files:** +- Modify: `.env.example`、`app/api/dependencies/auth.py`、`app/core/config.py`、`app/infrastructure/db.py`、`app/main.py` +- Modify: `app/service/agent/bootstrap.py`、`app/service/model_gateway.py`、`app/service/tool_executor.py`、`app/worker/runtime.py` +- Create/Modify: 现有已提交历史中的场外基金、NL2SQL、邮件 Worker 和测试文件。 + +**Interfaces:** +- Consumes: 已提交的 `feature/customer-service-rag` 历史;不改变该工作区的未提交客服文件。 +- Produces: 保留新底座公共链且继续注册场外基金、NL2SQL 的应用。 + +- [ ] **Step 1: 记录新底座合并前测试基线。** + +Run: `python -m pytest tests/unit tests/contract -q -p no:cacheprovider` + +Expected: PASS;失败时记录输出并停止合并。 + +- [ ] **Step 2: 在整合分支进行无提交合并。** + +```powershell +git merge --no-commit --no-ff feature/customer-service-rag +``` + +Expected: 冲突仅出现在整合工作区,原工作区状态不变。 + +- [ ] **Step 3: 语义解决 13 个公共文件冲突。** + +保留新底座的错误信封、限流、追踪标识、embedding、配置发布、Outbox、Worker 租约和记忆链路;恢复场外基金/NL2SQL 的路由、工具注册、邮件 Worker、健康检查和非敏感配置。禁止整文件使用 `--ours` 或 `--theirs` 覆盖。 + +- [ ] **Step 4: 先写路由失败测试再完成注册。** + +```python +def test_app_registers_platform_and_offsite_routes(): + paths = {route.path for route in create_app().routes} + assert "/api/v1/agent-runs" in paths + assert any(path.startswith("/api/v1/offsite") for path in paths) +``` + +Run: `python -m pytest tests/unit/api/test_controller_routing_contract.py -q -p no:cacheprovider` + +Expected: 先 FAIL,路由注册后 PASS。 + +- [ ] **Step 5: 运行业务回归并提交。** + +Run: `python -m pytest tests/unit/api tests/unit/service tests/contract -q -p no:cacheprovider` + +Expected: 无导入错误,场外基金、NL2SQL、模型、工具、错误信封和限流测试通过。提交语义合并,消息为 `merge: preserve existing business features on new foundation`。 + +### Task 3: 迁移访客最小上下文与客服运行生命周期 + +**Files:** +- Modify: `app/core/contracts.py`、`app/core/security.py`、`app/api/dependencies/auth.py` +- Modify: `app/service/agent/base.py`、`app/service/agent/factory.py`、`app/service/agent_run_application_service.py`、`app/worker/runtime.py` +- Test: `tests/unit/api/test_visitor_tokens.py`、`tests/integration/test_agent_run_acceptance.py`、`tests/integration/test_worker_runtime_mysql.py` + +**Interfaces:** +- Produces: `RequestContext(roles=("visitor",), permissions=("agent:run",), data_scope="public")`,仅由服务器验证的访客 token 产生。 +- Produces: `AgentDefinition.requires_model_intent_classification: bool = True`;客服设为 `False`。 +- Produces: `WorkerRuntime.restore_context(actor_type: str, actor_id: str, trace_id: str) -> RequestContext`;仅当 `actor_type == "visitor"` 时跳过 `resolve_identity`。 + +- [ ] **Step 1: 写失败测试,Worker 恢复访客时不得查询身份仓库。** + +```python +async def test_worker_restores_visitor_without_identity_repository_call(): + runtime = WorkerRuntime(resolve_identity=fail_if_called) + context = await runtime.restore_context(actor_type="visitor", actor_id="visitor:test") + assert context.roles == ("visitor",) + assert context.data_scope == "public" +``` + +- [ ] **Step 2: 运行测试确认失败。** + +Run: `python -m pytest tests/integration/test_worker_runtime_mysql.py -k visitor -q -p no:cacheprovider` + +Expected: FAIL,因为新版运行时尚无访客恢复分支。 + +- [ ] **Step 3: 迁移访客实现。** + +访客 token 只含受限 `sub`、`roles=["visitor"]`、`portal="api"` 和短过期时间;客户端不能传角色。Outbox 只保存已验证 `actor_type="visitor"` 和不可关联账户的 actor id。访客不得触发 `IdentityService.resolve()`、客户记忆或客户范围查询。 + +- [ ] **Step 4: 先写客服跳过模型分类测试并实现条件。** + +```python +async def test_customer_service_skips_model_intent_classification(): + assert CustomerServiceAgent.definition.requires_model_intent_classification is False +``` + +`BaseAgent.classify_intent()` 在 `requires_model_intent_classification` 为 `False` 时直接返回 `None`,其它 Agent 使用默认 `True`。 + +- [ ] **Step 5: 验证和提交。** + +Run: `python -m pytest tests/unit/api/test_visitor_tokens.py tests/integration/test_agent_run_acceptance.py tests/integration/test_worker_runtime_mysql.py -q -p no:cacheprovider` + +Expected: 访客只能运行公开客服;正式用户仍在执行期刷新 RBAC。提交消息为 `feat: preserve isolated visitor agent runtime`。 + +### Task 4: 迁移客服公开知识检索与权威降级 + +**Files:** +- Create: `app/core/knowledge_contracts.py`、`app/infrastructure/milvus_knowledge_adapter.py`、`app/model/knowledge.py` +- Create: `app/service/knowledge_config.py`、`app/service/knowledge_authority.py`、`app/service/knowledge_retrieval_service.py`、`app/service/knowledge_tool_service.py` +- Modify: `app/core/config.py`、`app/service/model_gateway.py`、`app/service/agent/bootstrap.py` +- Test: `tests/unit/core/test_knowledge_contracts.py`、`tests/unit/infrastructure/test_milvus_knowledge_adapter.py`、`tests/unit/service/test_knowledge_config.py`、`tests/unit/service/test_knowledge_authority.py`、`tests/unit/service/test_knowledge_retrieval.py` + +**Interfaces:** +- Produces: `KnowledgeQuery(query, intents, top_k)`;调用方不得传集合名。 +- Produces: `KnowledgeRetrievalService.search(query, context) -> KnowledgeSearchResult`。 +- Produces: 只读工具 `query_knowledge`,权限码 `knowledge:query`。 + +- [ ] **Step 1: 写失败测试,FAQ 只能访问 FAQ 集合。** + +```python +async def test_search_uses_faq_collection_for_faq_only(): + result = await service.search(KnowledgeQuery(query="开户", intents=("faq",)), visitor_context) + assert vector_store.calls == [("fin_faq_collection", 3)] + assert result.searched_collections == ("fin_faq_collection",) +``` + +- [ ] **Step 2: 运行测试确认失败。** + +Run: `python -m pytest tests/unit/service/test_knowledge_retrieval.py -q -p no:cacheprovider` + +Expected: FAIL,因为检索服务尚未迁入。 + +- [ ] **Step 3: 迁移受控契约、配置、Milvus 和 MySQL 权威层。** + +路由固定为 `faq -> fin_faq_collection, top_k=3`、`product_inquiry -> fin_product_collection, top_k=5`、`policy_explain -> fin_policy_collection, top_k=5`。维度使用 `vector_dim`,默认 1024,COSINE 检索。Milvus 只允许搜索,不得写集合或向量。 + +- [ ] **Step 4: 写失败测试,Milvus 故障只能降级到有效知识。** + +```python +async def test_milvus_failure_falls_back_to_published_active_unexpired_knowledge(): + result = await service.search(query, visitor_context) + assert result.degraded is True + assert [hit.knowledge_id for hit in result.hits] == ["101"] + assert result.hits[0].answer == "已审核的标准答复" +``` + +- [ ] **Step 5: 实现降级过滤、工具注册、测试和提交。** + +Milvus 命中按阈值去重后,必须回查 `fin_knowledge_meta` 的 `published`、`active`、开始和结束日期。Embedding 数量或维度不符抛 `RecoverableAgentError`;检索失败时仅返回 MySQL 降级结果或客服转人工,禁止猜测答案。 + +Run: `python -m pytest tests/unit/core/test_knowledge_contracts.py tests/unit/infrastructure/test_milvus_knowledge_adapter.py tests/unit/service/test_knowledge_config.py tests/unit/service/test_knowledge_authority.py tests/unit/service/test_knowledge_retrieval.py -q -p no:cacheprovider` + +Expected: 三集合准确路由,未发布、失效、低分知识均不可回答。提交消息为 `feat: add governed public knowledge retrieval`。 + +### Task 5: 迁移客服 Agent、人工转接与同源联调页 + +**Files:** +- Create: `app/service/agent/customer_service_routing.py`、`app/service/agent/customer_service_agent.py`、`app/static/index.html` +- Modify: `app/main.py`、`app/service/agent/bootstrap.py`、`app/service/agent/governance.py` +- Test: `tests/unit/service/test_customer_service_agent.py`、`tests/unit/api/test_customer_service_test_page.py` + +**Interfaces:** +- Produces: `CustomerServiceIntentRouter.classify(message) -> CustomerServiceRoute`。 +- Produces: `CustomerServiceAgent.handle(request, context) -> CoreResult`。 + +- [ ] **Step 1: 写失败测试,账户问题只能返回前端账户入口。** + +```python +async def test_customer_account_question_only_returns_account_entry(): + result = await agent.handle(request("查一下我的订单"), customer_context) + assert result.text == "我无法查询账户数据,请前往“我的账户”查看相关状态。" +``` + +- [ ] **Step 2: 运行测试确认失败。** + +Run: `python -m pytest tests/unit/service/test_customer_service_agent.py -q -p no:cacheprovider` + +Expected: FAIL,因为客服 Agent 尚未注册。 + +- [ ] **Step 3: 迁移固定路由、电话治理和 Agent 注册。** + +优先级固定为安全风险、合规拒答、账户入口、人工转接、闲聊、公开知识。安全风险、保证收益、代客交易、投诉和人工问题不得进入知识检索。第四条连续闲聊只引导一次。客服电话只从受控 `customer_service_phone` 读取,治理层仅保留该号码,其余手机号继续脱敏。 + +- [ ] **Step 4: 写失败测试,检索失败必须转人工。** + +```python +async def test_public_knowledge_failure_requires_human_transfer(): + result = await agent.handle(request("基金怎么开户"), visitor_context) + assert result.transfer_required is True + assert result.transfer_reason == "knowledge_unavailable" +``` + +- [ ] **Step 5: 挂载页面、验证并提交。** + +Run: `python -m pytest tests/unit/service/test_customer_service_agent.py tests/unit/api/test_customer_service_test_page.py -q -p no:cacheprovider` + +Expected: 访客和客户只获得一期公开服务;账户问题不含数据;人工转接含受控电话;页面返回 200。提交消息为 `feat: migrate scoped customer service agent`。 + +### Task 6: 收敛 Alembic 迁移图并验证独立副本库 + +**Files:** +- Create: `alembic/versions/20260910_merge_foundation_offsite.py` +- Create: `tests/integration/test_migration_graph.py` +- Create: `docs/evidence/foundation-migration-database-verification.md` + +**Interfaces:** +- Produces: 唯一 Alembic head `20260910_merge_foundation_offsite`。 +- Produces: `down_revision = ("20260910_drop_review_separation", "20260910_offsite_worker")` 的无 DDL merge revision。 +- Produces: 测试辅助函数 `current_heads() -> list[str]`,用 `ScriptDirectory.from_config(Config("alembic.ini")).get_heads()` 返回排序后的 revision 列表。 + +- [ ] **Step 1: 写失败测试,当前迁移图存在两个 head。** + +```python +def test_alembic_history_has_one_head_after_merge_revision(): + assert current_heads() == ["20260910_merge_foundation_offsite"] +``` + +- [ ] **Step 2: 运行测试确认失败。** + +Run: `python -m pytest tests/integration/test_migration_graph.py -q -p no:cacheprovider` + +Expected: FAIL,显示 `20260910_drop_review_separation` 和 `20260910_offsite_worker`。 + +- [ ] **Step 3: 创建无 DDL merge revision。** + +```python +revision = "20260910_merge_foundation_offsite" +down_revision = ("20260910_drop_review_separation", "20260910_offsite_worker") + +def upgrade() -> None: + pass + +def downgrade() -> None: + pass +``` + +- [ ] **Step 4: 在独立副本库执行迁移、审计和记录。** + +Run: `alembic heads; alembic upgrade head; python tools/migration_state_check.py; python tools/audit_schema.py; python tools/audit_constraints.py` + +Expected: 仅一个 head,副本库升级和结构审计通过。副本连接地址和数据库名必须与当前运行数据库不同。 + +- [ ] **Step 5: 提交迁移图收敛。** + +提交 revision、迁移图测试和副本验证证据,消息为 `chore: merge foundation and offsite migration heads`。 + +### Task 7: 全量回归、原工作区一致性核验与交付 + +**Files:** +- Create: `tests/integration/test_foundation_migration_regression.py` +- Create: `docs/evidence/foundation-migration-regression.md` +- Modify: `docs/20-第一版到当前版本变更与迁移指南.md` + +**Interfaces:** +- Produces: 同时覆盖底座、场外基金、NL2SQL、访客客服、已登录客服和迁移图的回归证据。 + +- [ ] **Step 1: 写失败测试,场外能力与客服页必须同时存在。** + +```python +async def test_regression_keeps_offsite_and_customer_service_available(client): + assert (await client.get("/health")).status_code == 200 + assert (await client.get("/customer-service-test/")).status_code == 200 + assert any(route.path.startswith("/api/v1/offsite") for route in client.app.routes) +``` + +- [ ] **Step 2: 运行测试确认失败或暴露缺失模块。** + +Run: `python -m pytest tests/integration/test_foundation_migration_regression.py -q -p no:cacheprovider` + +Expected: FAIL,直到场外路由与客服页面均已注册。 + +- [ ] **Step 3: 执行全量测试和静态检查。** + +Run in order: `python -m pytest tests/unit tests/contract -q -p no:cacheprovider`; `python -m pytest tests/integration -q -p no:cacheprovider`; `python -m ruff check app tests tools alembic`; `python -m mypy app`. + +Expected: 全部适用测试通过,Ruff 与 mypy 无错误;集成测试仅使用独立副本库。 + +- [ ] **Step 4: 重跑预检工具验证原工作区完全未变。** + +Run: `python tools/foundation_migration_preflight.py --verify docs/evidence/foundation-migration-preflight.json` + +Expected: 三个原工作区的 branch、head 与 status 和迁移前一致;有差异立即停止交付。 + +- [ ] **Step 5: 提交证据和迁移指南。** + +提交回归测试、证据和指南,消息为 `docs: record safe foundation migration verification`。 + +## Execution Order and Stop Conditions + +严格按 Task 1 至 Task 7 执行。出现下列任一情况立即停止:合并不能保留既有功能、访客触发正式身份查询、未发布知识可见、副本库审计失败、全量回归失败或原工作区状态变化。停止时只保留整合分支供诊断,不合并、不推送、不发布数据库迁移。 diff --git a/docs/superpowers/specs/2026-09-10-foundation-safe-migration-design.md b/docs/superpowers/specs/2026-09-10-foundation-safe-migration-design.md new file mode 100644 index 0000000..3207d57 --- /dev/null +++ b/docs/superpowers/specs/2026-09-10-foundation-safe-migration-design.md @@ -0,0 +1,64 @@ +# 新底座无损迁移设计 + +## 目标 + +将 `qyqy_develop` 作为新的 Agent 平台底座,同时完整保留现有项目的场外基金、NL2SQL、访客 token、客服 Agent/RAG、人工转接、联调页面与现有 API 能力。 + +迁移过程不得修改以下对象: + +- `develop` 分支; +- `feature/customer-service-rag` 工作区及其未提交内容; +- `qyqy_develop` 工作区; +- 当前正在使用的 MySQL 数据库、Redis、Milvus 和已运行服务。 + +所有变更仅发生在 `feature/foundation-safe-migration` 工作区;数据库验证只允许使用独立副本库。 + +## 基线与保留范围 + +新底座保留其已修复的公共能力:统一错误信封、限流、追踪标识、模型 embedding、记忆召回、Worker 租约、Outbox、配置发布、知识引用签名和数据库约束纠偏。 + +现有项目必须迁入并保持行为: + +- 场外基金 API、规则、邮件处理与 Worker; +- 金融 NL2SQL 工具与相关 Agent; +- 访客 token 与访客客服对话; +- 已登录用户客服对话; +- 客服的 FAQ、产品、政策知识检索; +- 客服人工转接、安全风险与合规降级; +- 现有测试页和既有接口的业务可用性。 + +客服仍严格不返回任何用户个人持仓、收益、订单、银行卡或投诉进度;个人查询继续由前端独立接口承担。 + +## 迁移策略 + +1. 先从客服工作区提取不可变补丁备份,包含已跟踪修改和未跟踪文件,但不改变原工作区。 +2. 在本分支合并现有功能分支,保留全部历史业务文件;公共冲突文件由人工语义合并,不使用“全选 ours/theirs”。 +3. 对客服未提交改动按模块迁移:知识契约、Milvus 适配器、检索服务、客服路由、客服 Agent、访客运行上下文、联调页和测试。 +4. 以新底座的 Factory、BaseAgent、ToolExecutor、PlatformGovernance、WorkerRuntime 为唯一公共执行链;业务模块不得绕过这些边界。 +5. 合并 Alembic 图:新底座 head `20260910_drop_review_separation` 与场外基金 head `20260910_offsite_worker` 通过新的 merge revision 收敛为一个 head。该 revision 不承载业务 DDL。 + +## 高风险合并规则 + +| 区域 | 迁移规则 | +| --- | --- | +| 认证与访客 | 保留 JWT/RBAC 失败关闭;访客只恢复最小 `visitor` 上下文,不查询正式用户 RBAC,不写客户专属记忆。 | +| 配置与密钥 | 合并非敏感配置键;真实密钥只保留在本地 `.env`,不得写入代码、示例配置或日志。 | +| 模型网关 | 以新底座的单文本 embedding 受控路由为基准,扩展客服批量 embedding 时复用相同密钥解析、端点选择和失败关闭逻辑。 | +| 工具与治理 | 保留工具白名单、权限、超时、审计与脱敏;客服电话是受控公开配置,其他手机号码保持脱敏。 | +| Worker | 保留新底座租约、Outbox、记忆与 episode 链路;仅在身份恢复点加入访客分支。 | +| 数据库 | 不修改历史迁移已表达的字段定义;不在现有库直接执行 `upgrade`;先验证迁移图,再在副本库审计。 | + +## 验收门禁 + +每一阶段失败即停止,不触碰现有工作区或数据库。 + +1. Git 门禁:整合前后原有三个工作区均保持原分支、原状态。 +2. 迁移图门禁:`alembic heads` 只返回一个 head,迁移历史完整,副本库审计通过。 +3. 底座门禁:新底座的单元、契约及可用集成测试通过。 +4. 业务门禁:场外基金、NL2SQL、访客 token、客服 RAG、人工转接、登录客户账户问题拦截均有回归测试。 +5. 安全门禁:访客不能获取客户数据;客服不能返回个人账户数据;未发布或过期知识不可检索;检索失败安全转人工。 +6. 回退门禁:不合并该分支即完全回退;迁移期间不发布数据库结构或服务进程变更。 + +## 完成定义 + +仅当整合工作区测试、结构审计和独立数据库副本验证全部通过,并且原工作区状态未改变时,迁移分支才可以提交给用户审阅和决定是否合并。 diff --git a/docs/场外外部服务灰度与回滚方案.md b/docs/场外外部服务灰度与回滚方案.md new file mode 100644 index 0000000..af4d558 --- /dev/null +++ b/docs/场外外部服务灰度与回滚方案.md @@ -0,0 +1,82 @@ +# 场外基金外部服务灰度与回滚方案 + +## 1. 当前实现边界 + +当前代码已经提供 IMAP、OCR/DeepSeek 和 SMTP 适配层,但所有真实外部调用均由配置开关控制: + +- `OFFSITE_IMAP_ENABLED=false`:不连接收件邮箱。 +- `OFFSITE_MAIL_WORKER_ENABLED=false`:不启动场外邮件自动编排。 +- `OFFSITE_OCR_ENABLED=false`:OCR 使用本地 Mock。 +- `OFFSITE_DEEPSEEK_ENABLED=false`:字段识别使用本地 Mock。 +- `OFFSITE_SMTP_ENABLED=false`:不创建真实 SMTP 发送连接。 +- `OFFSITE_SMTP_DRY_RUN=true`:即使发送接口被调用,也只生成待发送结果,不标记发送成功。 + +通知创建接口只创建待发送记录。真正发送必须调用独立发送接口,并传递运营确认状态。 + +场外 Worker 另受以下安全条件约束: + +- `OFFSITE_MAIL_WORKER_ENABLED=true` 只是允许编排,不代表可以绕过身份校验。 +- `OFFSITE_WORKER_USER_ID` 必须是数据库中已存在、经过实时角色和权限解析的操作用户;为空或无权限时自动写入失败关闭。 +- Worker 使用 `offsite_mail_cursor` 持久化 INBOX UID、失败 UID、重试时间和租约;失败邮件不推进后续 UID。 +- 当前实现使用健康检查加轮询增量补偿;IMAP IDLE 仍需单独联调,不能把轮询版本描述为已完成 IDLE。 + +## 2. 上线前检查 + +上线前必须由技术和运营共同确认: + +1. 收件邮箱、授权码、发件人白名单和 SMTP 发件地址均使用密钥管理系统注入,不写入代码、日志或 Git。 +2. IMAP 账号只能访问指定收件箱,服务账号不使用登录密码,使用邮箱授权码。 +3. 原始 `.eml` 和附件目录有容量、权限、备份和保留期限方案。 +4. OCR 网关已确认请求格式、签名方式、超时和费用上限。 +5. DeepSeek 模型、配额、超时、结构化 JSON 格式和敏感数据处理策略已确认。 +6. SMTP 收件人、回复原邮件规则、附件重新附加规则已经过运营验收。 +7. 监控至少能发现 IMAP 连接失败、邮件解析失败、OCR/模型失败、SMTP 失败、重试耗尽和磁盘写入失败。 + +## 3. 灰度顺序 + +### 阶段一:离线验证 + +保持所有真实开关关闭,使用 Mock 邮件、OCR、DeepSeek 和 SMTP。验证重复邮件、白名单、附件哈希、识别异常、规则核对和审计。 + +### 阶段二:真实 IMAP、业务发送关闭 + +仅打开 `OFFSITE_IMAP_ENABLED=true` 和 `OFFSITE_MAIL_WORKER_ENABLED=true`,保持 OCR、DeepSeek 和 SMTP 关闭,并配置已核验的 `OFFSITE_WORKER_USER_ID`。先只做收件箱健康检查和增量扫描,确认 UID 不回退、断线后能补偿、非白名单邮件不进入业务队列;IMAP IDLE 未联调前按轮询模式验收。 + +### 阶段三:真实识别、发送保持 dry-run + +打开 `OFFSITE_OCR_ENABLED=true` 和 `OFFSITE_DEEPSEEK_ENABLED=true`,保持 `OFFSITE_SMTP_ENABLED=false` 或 `OFFSITE_SMTP_DRY_RUN=true`。使用已脱敏测试附件验证字段、置信度、页面证据、缺失字段和失败降级。 + +### 阶段四:单收件人真实 SMTP + +打开 `OFFSITE_SMTP_ENABLED=true`,保持 `OFFSITE_SMTP_DRY_RUN=false`,只允许运营选择的一封测试邮件和一个确认收件人。核对 `Message-ID`、`In-Reply-To`、附件内容、发送状态和 provider message ID。 + +### 阶段五:小流量业务灰度 + +先限定单个业务时段或有限业务邮件,持续观察一个完整业务周期。未完成发送、失败重试和人工确认记录不得直接计入资金清算统计。 + +### 阶段六:正式运行 + +只有阶段五无未解释失败、重复发送、错发收件人、原始文件覆盖或统计污染后,才扩大到正式业务范围。 + +## 4. 回滚操作 + +发生错发、重复发送、认证失败、外部费用异常、识别结果大面积异常或存储故障时: + +1. 立即将 `OFFSITE_SMTP_ENABLED=false`,停止新的真实发送。 +2. 如收件范围或解析异常,将 `OFFSITE_IMAP_ENABLED=false`,暂停收件扫描。 +3. 如识别服务异常,只关闭 `OFFSITE_OCR_ENABLED` 或 `OFFSITE_DEEPSEEK_ENABLED`,保留原始邮件和附件,禁止用不完整识别结果自动核对。 +4. 保留 `offsite_notification`、原始 `.eml`、附件和审计记录,不执行删除、覆盖或数据库降级迁移。 +5. 将发送中的通知交由运营核对邮箱实际投递结果;不得仅依据本地请求超时再次发送。 +6. 修复后先回到 dry-run 和单收件人灰度,再恢复正式发送。 + +## 5. 回滚后的数据处理 + +- `发送成功` 不自动重发,重复调用只返回已有成功状态。 +- `发送失败` 只按剩余重试次数重试,超过上限转人工处理。 +- `待发送` 记录保留,恢复服务后由运营重新确认。 +- 原始识别值、原始查询值、程序计算值和运营最终发送正文不得互相覆盖。 +- 统计必须重新执行并核对范围,未成功正常返回的邮件不纳入资金清算统计。 + +## 6. 验收记录 + +每次灰度至少记录:部署版本、配置开关、测试邮件标识、测试附件哈希、操作人、开始结束时间、实际收件人、发送状态、provider message ID、失败原因和回滚结论。 diff --git a/docs/场外申购赎回工作流程图.md b/docs/场外申购赎回工作流程图.md new file mode 100644 index 0000000..1371284 --- /dev/null +++ b/docs/场外申购赎回工作流程图.md @@ -0,0 +1,37 @@ +# 场外基金申购赎回工作流程图 + +```mermaid +flowchart TD + A[收件箱收到邮件] --> B[IMAP IDLE 事件] + B --> C[按 UID 拉取完整 MIME 和附件] + C --> D{发件人和邮件认证通过} + D -- 否 --> D1[记录告警并停止进入业务队列] + D -- 是 --> E[保存原始 EML 和附件哈希] + E --> F[OCR/Document AI 一次识别] + F --> G[DeepSeek 分类与字段抽取] + G --> H{附件类型} + H -- other --> H1[只归档] + H -- summary/subscription/redemption --> I[生成 mail_id 和 attachment_id] + I --> J[SSE 推送新业务邮件事件] + J --> K[生成查询/计算/核对三阶段执行计划] + K --> L[调用 nl2sql_yc 只读查询] + L --> M[确定性金额、份额、比例计算] + M --> N[固定申购/赎回规则核对] + N --> O{识别或查询是否异常} + O -- 是 --> P[运营人工确认异常或未处理] + O -- 否 --> Q[运营确认正常] + P --> R[独立邮件返回或风控通知候选] + Q --> S[正常邮件返回] + S --> T[资金清算统计重算] + R --> U[发送状态、重试和审计] + T --> U + U --> V{邮件内单据均有最终人工状态} + V -- 否 --> W[保持待处理] + V -- 是 --> X[邮件完成并保留全链路审计] +``` + +## 边界 + +- 本流程只覆盖后端、Agent、数据、接口、异步事件和审计。 +- 反洗钱和开放期规则不纳入本次实现。 +- 邮件返回、风控通知和资金清算通知互相独立,不自动联动。 diff --git a/docs/客服Agent一期远程整合测试手册.md b/docs/客服Agent一期远程整合测试手册.md new file mode 100644 index 0000000..15ccf90 --- /dev/null +++ b/docs/客服Agent一期远程整合测试手册.md @@ -0,0 +1,101 @@ +# 客服 Agent 一期远程整合测试手册 + +版本:v1.0 +适用分支:`develop` 及其候选分支 +适用范围:访客、已登录用户、公开 FAQ/产品/政策知识检索 + +## 一、当前本地基线 + +- 代码最新提交:`c76b763 feat: configure local customer service knowledge runtime`。 +- 一期公开知识:52 条,FAQ 15 条、产品 26 条、政策 11 条。 +- 知识权威状态:MySQL `published + active`;Milvus 仅保存召回投影。 +- Embedding:Qwen `text-embedding-v3`,要求 1024 维;密钥只通过 `env:QWEN_API_KEY` 引用。 +- 本地开发向量库:Milvus Lite;团队/生产环境应使用受管 Milvus。 +- 一期客服白名单:仅 `query_knowledge`,不开放账户、订单、持仓、收益、银行卡、定投、风险测评或投诉进度工具。 + +## 二、干净环境初始化顺序 + +1. 安装项目依赖: + + ```powershell + python -m pip install -e ".[dev]" + ``` + +2. 配置 `.env`。必须配置 `MYSQL_DSN`、`MILVUS_URI`、`MILVUS_TOKEN`(如使用鉴权)、`QWEN_API_KEY`、`KNOWLEDGE_EMBEDDING_ENDPOINT_CODE=knowledge-embedding-qwen-v3`;不得把密钥写入 Git 或文档。 + +3. 执行完整数据库迁移: + + ```powershell + python -m alembic upgrade heads + ``` + +4. 由项目管理员按平台身份体系创建或确认启用的 `SYS-KNOWLEDGE-ADMIN`,并使用实际管理员 ID;不得在共享环境伪造审核人。 + +5. 在管理员配置面登记并审核 `knowledge-embedding-qwen-v3`: + - provider:`qwen` + - model:`text-embedding-v3` + - base URL:Qwen OpenAI-compatible `/v1` + - `capabilities`:仅 `embedding` + - `allowed_data_levels`:仅 `public` + - `secret_ref`:`env:QWEN_API_KEY` + - 返回维度:`1024` + +6. 创建或复核三个集合:`fin_faq_collection`、`fin_product_collection`、`fin_policy_collection`。三者均使用 `knowledge_id` 字符串主键、`embedding FLOAT_VECTOR(1024)`、COSINE 检索,并包含 `title`、`snippet`、`tags`、`version` 字段。 + +7. 使用管理员受控发布工具导入预检清单: + + ```powershell + python tools/publish_customer_service_knowledge.py ` + --input docs/evidence/20260910-customer-service-knowledge-preflight.json ` + --reviewer-id <真实管理员ID> ` + --apply ` + --confirm-count 52 + ``` + +8. 激活一期客服配置版本,只给四个公开知识意图配置 `query_knowledge`。 + +## 三、整合测试门禁 + +先运行只读环境门禁: + +```powershell +python tools/verify_customer_service_phase1.py +``` + +预期输出中 `failures` 为空,且公开记录总数为 52。该脚本不会创建、更新或删除任何数据。 + +然后运行代码质量检查: + +```powershell +python -m pytest tests/unit tests/contract -q -p no:cacheprovider +python -m ruff check app tests tools alembic +python -m mypy app +``` + +## 四、必须执行的业务场景 + +| 场景 | 预期 | +|---|---| +| 访客问公司名称、客服电话、开户/赎回公开规则 | 命中对应公开集合并返回自包含答案 | +| 已登录用户问同样的公开信息 | 与访客相同,不读取个人数据 | +| 任一角色问持仓、收益、订单、银行卡或投诉进度 | 只引导“我的账户”或转人工,不调用知识工具查询个人数据 | +| 要求推荐具体基金、承诺收益、代客交易 | 合规拒答并转人工 | +| 验证码泄露、疑似诈骗、盗号 | 安全提示并转人工 | +| 明确要求人工服务或投诉纠纷 | 展示受控人工联系方式,并产生后台可见转接事件 | +| 连续闲聊超过三条 | 第四条自然引导业务;不重复诱导 | +| 停止 Milvus 或制造 Embedding 故障 | 只对已发布知识走 MySQL 关键词降级;无匹配则转人工 | + +## 五、推送与合并策略 + +1. 从当前 `develop` 创建候选分支,例如 `feature/customer-service-phase1-rc`。 +2. 在候选分支运行本手册第三节的门禁和第四节的业务场景。 +3. 远程环境通过后,再发起合并请求或快进合并到共享 `develop`。 +4. 不把 `.env`、Milvus Lite 数据文件、测试账号、个人数据或模型密钥推送到远程仓库。 +5. 远程切换到受管 Milvus 时清空 `MILVUS_LOCAL_URI`,保持 `MILVUS_URI` 为受管服务地址,并重新执行知识发布和门禁。 + +## 六、失败处理 + +- 数据库迁移失败:停止整合,不修改历史迁移文件。 +- Embedding 维度不是 1024:停止发布,保留知识为不可见状态。 +- Milvus 写入失败:发布工具会禁用暂存 MySQL 记录并尝试清理向量;修复后重新执行。 +- 业务边界测试失败:禁止合并,优先修复路由或白名单,不通过扩大客服 Agent 权限解决。 diff --git a/hq.py b/hq.py new file mode 100644 index 0000000..d7a4ccb --- /dev/null +++ b/hq.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- +"""南方基金指定产品行情模块。""" +from __future__ import annotations +import re +import time +import logging +from datetime import date, datetime, time as clock_time +from typing import Any +import httpx + +logger = logging.getLogger(__name__) + +NAV_API = "https://api.fund.eastmoney.com/f10/lsjz" +RETURN_API = "https://api.fund.eastmoney.com/pinzhong/LJSYLZS" +QUOTE_API = "https://push2.eastmoney.com/api/qt/ulist.np/get" +DETAIL_API = "https://fund.eastmoney.com/pingzhongdata/{code}.js" +REQUEST_TIMEOUT = 12.0 +MAX_FUNDS_PER_CALL = 1000 +SOUTHERN_FUND_CODES = tuple(dict.fromkeys("202308 020480 007161 003776 020281 018019 014189 018020 020553 016449 008854 008264 008736 010592 160127 588890 020839 589700 159382 159511 002900 021958 159948 009059 001421".split())) +FUND_TYPE_GROUPS = { + "货币型": ("202308", "020480"), + "债券型": ("007161", "003776", "020281"), + "混合型": ("018019", "014189", "018020"), + "股票型": ("020553", "016449", "008854", "008264", "008736", "010592", "160127", "588890", "020839", "589700", "159382", "159511", "002900", "021958", "159948", "009059", "001421"), +} +FUND_TYPE_BY_CODE = {code: fund_type for fund_type, codes in FUND_TYPE_GROUPS.items() for code in codes} +HEADERS = {"User-Agent": "Mozilla/5.0", "Referer": "https://fund.eastmoney.com/"} +_history_date: str | None = None +_history_cache: dict[str, dict[str, str | None]] = {} +_name_cache: dict[str, str] = {} + + +def is_market_trading_time(now: datetime | None = None) -> bool: + """判断中国大陆工作日盘中时段。""" + current = now or datetime.now() + if current.weekday() >= 5: + return False + current_time = current.time() + return (clock_time(9, 30) <= current_time <= clock_time(11, 30) + or clock_time(13, 0) <= current_time <= clock_time(15, 0)) + + +def get_southern_fund_market(target_date: str | None = None, limit: int | None = None, fund_type: str | None = None) -> list[dict[str, Any]]: + """获取指定南方基金的完整行情表。 + + 每次调用刷新整张表的实时行情;历史收益同一日期只请求一次并保存在进程缓存。 + limit 不传时返回 SOUTHERN_FUND_CODES 中的全部产品。 + """ + query_date = target_date or date.today().isoformat() + date.fromisoformat(query_date) + if fund_type and fund_type not in FUND_TYPE_GROUPS: + raise ValueError("基金类型必须是货币型、债券型、混合型或股票型") + available_codes = FUND_TYPE_GROUPS[fund_type] if fund_type else SOUTHERN_FUND_CODES + count = len(available_codes) if limit is None else min(max(limit, 1), MAX_FUNDS_PER_CALL) + codes = list(available_codes[:count]) + names = _get_names(codes) + history = _get_history(codes, query_date) + quotes = _get_quotes(codes) if is_market_trading_time() else {} + now = time.strftime("%Y-%m-%d") + rows = [] + for code in codes: + old = history.get(code, {}) + live = quotes.get(code, {}) + rows.append({ + "基金代码": code, "基金名称": names.get(code, f"南方基金 {code}"), + "基金类型": FUND_TYPE_BY_CODE.get(code, "未分类"), + "基金净值": live.get("基金净值") or old.get("基金净值"), + "日期": live.get("日期") or old.get("日期"), + "日涨幅": live.get("日涨幅") or old.get("日涨幅"), + "最近半年": old.get("最近半年"), "最近一年": old.get("最近一年"), + "今年以来": old.get("今年以来"), "成立以来": old.get("成立以来"), + "行情时间": now, + "行情来源": "盘中实时行情" if live.get("基金净值") else "收盘后最新净值", + "是否盘中": is_market_trading_time(), + }) + return rows + + +def _get_names(codes: list[str]) -> dict[str, str]: + for code in codes: + if code in _name_cache: + continue + try: + response = httpx.get(DETAIL_API.format(code=code), headers=HEADERS, timeout=REQUEST_TIMEOUT) + response.raise_for_status() + match = re.search(r"var\s+fS_name\s*=\s*[\"']([^\"']+)", response.text) + _name_cache[code] = match.group(1).strip() if match else f"南方基金 {code}" + except (httpx.HTTPError, UnicodeError) as exc: + logger.warning("基金名称接口失败 code=%s error=%s", code, type(exc).__name__) + _name_cache[code] = f"南方基金 {code}" + return {code: _name_cache.get(code, f"南方基金 {code}") for code in codes} + + +def _get_quotes(codes: list[str]) -> dict[str, dict[str, str | None]]: + secids = ",".join(("1." if code.startswith(("5", "6", "9")) else "0.") + code for code in codes) + try: + response = httpx.get(QUOTE_API, params={"fltt": 2, "invt": 2, "fields": "f12,f2,f3", "secids": secids}, headers=HEADERS, timeout=REQUEST_TIMEOUT) + response.raise_for_status() + items = ((response.json().get("data") or {}).get("diff") or []) + except (httpx.HTTPError, ValueError, TypeError) as exc: + logger.warning("实时行情接口失败 count=%s error=%s", len(codes), type(exc).__name__) + return {} + return {str(item["f12"]): {"基金净值": str(item["f2"]) if item.get("f2") not in (None, "-") else None, "日期": None, "日涨幅": f"{item.get('f3')}%" if item.get("f3") not in (None, "-") else None} for item in items if item.get("f12")} + + +def _get_history(codes: list[str], query_date: str) -> dict[str, dict[str, str | None]]: + global _history_date, _history_cache + if _history_date == query_date and all(code in _history_cache for code in codes): + return _history_cache + result = {} + for code in codes: + try: + result[code] = _get_history_snapshot(code, query_date) + except (httpx.HTTPError, ValueError, TypeError) as exc: + logger.warning("历史净值计算失败 code=%s error=%s", code, type(exc).__name__) + result[code] = _empty_returns() + _history_date, _history_cache = query_date, result + return result + + +def _get_history_snapshot(fund_code: str, query_date: str) -> dict[str, str | None]: + """读取净值与累计收益率快照。""" + latest_records = _fetch_nav_records(fund_code, all_pages=False) + valid = _valid_records(latest_records) + if not valid: + return _empty_returns() + target = date.fromisoformat(query_date) + latest_date, _, latest = next((item for item in valid if item[0] == target), valid[0]) + return { + "基金净值": latest.get("DWJZ"), + "日期": latest.get("FSRQ"), + "日涨幅": _format_percent(latest.get("JZZZL")), + "最近半年": _fetch_return_rate(fund_code, "6月"), + "最近一年": _fetch_return_rate(fund_code, "1年"), + "今年以来": _fetch_return_rate(fund_code, "今年来"), + "成立以来": _fetch_return_rate(fund_code, "成立来"), + } + + +def _fetch_nav_page(fund_code: str, page_index: int = 1, start_date: str | None = None, end_date: str | None = None) -> tuple[list[dict[str, Any]], int | None]: + """读取一页历史净值,并返回接口提供的总条数。""" + response = httpx.get(NAV_API, params={"fundCode": fund_code, "pageIndex": page_index, "pageSize": 30, "startDate": start_date or "", "endDate": end_date or ""}, headers=HEADERS, timeout=REQUEST_TIMEOUT) + response.raise_for_status() + data = response.json().get("Data") or {} + records = data.get("LSJZList") or [] + total = data.get("TotalCount") or data.get("totalCount") + try: + total = int(total) if total is not None else None + except (TypeError, ValueError): + total = None + return records, total + + +def _fetch_nav_records(fund_code: str, start_date: str | None = None, end_date: str | None = None, all_pages: bool = False) -> list[dict[str, Any]]: + """调用历史净值接口;区间收益需要时读取完整分页,避免重复使用同一基准日。""" + records, total = _fetch_nav_page(fund_code, 1, start_date, end_date) + if not all_pages or not total or total <= len(records): + return records + page_count = min((total + 29) // 30, 40) + for page_index in range(2, page_count + 1): + page_records, _ = _fetch_nav_page(fund_code, page_index, start_date, end_date) + if not page_records: + break + records.extend(page_records) + return records + + +def _fetch_return_rate(fund_code: str, period: str) -> str | None: + """通过东方财富累计收益率接口读取指定区间的最新收益率。""" + period_map = { + "1月": "m", + "3月": "q", + "6月": "hy", + "1年": "y", + "3年": "try", + "5年": "fiy", + "今年来": "sy", + "成立来": "se", + } + try: + response = httpx.get( + RETURN_API, + params={"fundCode": fund_code, "indexcode": "000300", "type": period_map[period]}, + headers={"Referer": "https://fund.eastmoney.com/"}, + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + payload = response.json() + series = (((payload.get("Data") or [{}])[0]).get("data") or []) + if not series: + return None + latest = series[-1] + if isinstance(latest, dict): + value = latest.get("y") + elif isinstance(latest, (list, tuple)) and len(latest) >= 2: + value = latest[1] + else: + value = None + if value in (None, ""): + return None + return _format_percent(value) + except (httpx.HTTPError, ValueError, TypeError, KeyError, IndexError) as exc: + logger.warning("累计收益率接口失败 code=%s period=%s error=%s", fund_code, period, type(exc).__name__) + return None + +def _empty_returns() -> dict[str, str | None]: + return {key: None for key in ("基金净值", "日期", "日涨幅", "最近半年", "最近一年", "今年以来", "成立以来")} + + +def _valid_records(records: list[dict[str, Any]]) -> list[tuple[date, float, dict[str, Any]]]: + valid = [] + for item in records: + try: + valid.append((date.fromisoformat(item["FSRQ"]), float(item["DWJZ"]), item)) + except (KeyError, TypeError, ValueError): + continue + return sorted(valid, key=lambda item: item[0], reverse=True) + + +def _format_percent(value: Any) -> str | None: + if value in (None, ""): + return None + text = str(value).strip() + return text if text.endswith("%") else f"{text}%" diff --git a/nl2sql_yc.py b/nl2sql_yc.py new file mode 100644 index 0000000..e437d1c --- /dev/null +++ b/nl2sql_yc.py @@ -0,0 +1,648 @@ +"""金融 NL2SQL MVP。 + +查询数据只允许使用参数化 SELECT;审计记录可通过回调写入 +conversation_message.tool_calls。该文件可被运营和投顾 Agent 直接导入调用。 +""" +from __future__ import annotations + +import json +import logging +import os +import re +import uuid +from dataclasses import asdict, dataclass, field +from datetime import datetime, time, timedelta +from pathlib import Path +from typing import Any, Callable + +logger = logging.getLogger(__name__) + +ALLOWED_TABLES = { + "sys_customer_assignment", "fin_customer_profile", "fin_risk_assessment", + "fin_product", "fin_fee_rule", "fin_market_price", "fin_nav_history", + "fin_holding", "fin_transaction", "fin_sim_order", "fin_sim_account", + "fin_cash_ledger", "client_facing_content", +} + +DOMAINS = { + "market_nav": {"fin_market_price", "fin_nav_history", "fin_product"}, + "customer_risk": { + "sys_customer_assignment", "fin_customer_profile", "fin_risk_assessment", + }, + "product_fee": {"fin_product", "fin_fee_rule"}, + "trading_account": { + "fin_sim_order", "fin_transaction", "fin_sim_account", + "fin_cash_ledger", "fin_holding", "fin_product", + }, + "client_content": {"client_facing_content"}, +} + +TABLE_COLUMNS = { + "sys_customer_assignment": { + "id", "customer_id", "employee_id", "employee_role", "assigned_at", "unassigned_at", + }, + "fin_customer_profile": { + "customer_id", "trade_account", "real_name", "birth_date", "occupation", + "mobile_masked", "investor_type", "investment_horizon", "preferred_asset_class", + "trading_frequency", "last_active_at", "total_asset", "behavior_score", + "risk_tags", "opened_at", "updated_at", + }, + "fin_risk_assessment": { + "id", "customer_id", "questionnaire_version", "answers", "total_score", + "investor_type", "assessed_at", "valid_until", "created_at", + }, + "fin_product": { + "id", "product_code", "product_name", "exchange_code", "product_category", + "risk_level", "fund_manager", "currency", "lot_size", "price_tick", + "current_nav", "current_nav_at", "min_amount", "open_start_at", "open_end_at", + "open_period_start", "open_period_end", "transaction_fee_rate", + "single_investor_max_holding_ratio", "management_fee_rate", "custodian_fee_rate", + "risk_disclosure_required", "second_confirmation_required", "recording_required", + "status", "created_at", "updated_at", + }, + "fin_fee_rule": { + "id", "rule_code", "product_id", "exchange_code", "order_side", "customer_tier", + "min_trade_amount", "max_trade_amount", "fee_rate", "minimum_fee", "fixed_fee", + "priority", "effective_from", "effective_until", "status", "created_at", "updated_at", + }, + "fin_market_price": { + "id", "product_id", "trade_date", "open_price", "high_price", "low_price", + "close_price", "volume", "turnover_amount", "total_fund_shares", "source", + "source_updated_at", "created_at", + }, + "fin_nav_history": {"id", "product_id", "nav_date", "nav", "created_at"}, + "fin_holding": { + "id", "customer_id", "trade_account", "product_id", "total_quantity", + "shares", "available_quantity", "frozen_quantity", "average_cost", "cost_amount", + "market_value", "current_value", "profit_loss", "profit_loss_ratio", "status", + "first_acquired_at", "version", "updated_at", + }, + "fin_transaction": { + "id", "transaction_no", "order_id", "work_order_id", "customer_id", "account_id", + "product_id", "order_side", "transaction_type", "executed_price", "nav", "executed_quantity", + "shares", "gross_amount", "amount", "fee_rule_id", "fee_rate_snapshot", "fee_amount", + "fee", "net_amount", "quote_at", "quote_source", "executed_at", "confirmed_at", + "confirmed_by", "auto_confirmed", "created_at", + }, + "fin_sim_order": { + "id", "order_no", "customer_id", "account_id", "product_id", "order_side", + "price_type", "quantity", "limit_price", "quote_price", "quote_at", "quote_source", + "channel", "advisor_id", "filled_quantity", "average_executed_price", "status", + "risk_rule_hits", "risk_disclosure_ack_at", "second_confirmation_at", + "recording_reference", "ops_handler_id", "ops_handled_at", "compliance_handler_id", + "compliance_handled_at", "reject_reason", "submitted_at", "cancelled_at", + "created_at", "updated_at", + }, + "fin_sim_account": { + "id", "account_no", "customer_id", "currency", "cash_balance", "available_cash", + "frozen_cash", "initial_balance", "status", "version", "created_at", "updated_at", + }, + "fin_cash_ledger": { + "id", "ledger_no", "account_id", "transaction_id", "entry_type", "amount", + "balance_after", "available_cash_after", "frozen_cash_after", "idempotency_key", + "occurred_at", "created_at", + }, + "client_facing_content": { + "id", "customer_id", "content_type", "draft_content", "generated_by_portal", + "review_status", "reviewer_user_id", "reviewed_at", "published_at", + "created_at", "updated_at", + }, +} + +JOIN_SQL = { + ("fin_transaction", "fin_product"): "t.product_id = p.id", + ("fin_holding", "fin_product"): "h.product_id = p.id", + ("fin_market_price", "fin_product"): "m.product_id = p.id", + ("fin_nav_history", "fin_product"): "n.product_id = p.id", + ("fin_sim_order", "fin_product"): "o.product_id = p.id", + ("fin_fee_rule", "fin_product"): "f.product_id = p.id", + ("fin_transaction", "fin_customer_profile"): "t.customer_id = cp.customer_id", + ("fin_holding", "fin_customer_profile"): "h.customer_id = cp.customer_id", + ("fin_sim_account", "fin_customer_profile"): "a.customer_id = cp.customer_id", + ("fin_cash_ledger", "fin_sim_account"): "l.account_id = a.id", + ("fin_sim_account", "fin_customer_profile"): "a.customer_id = cp.customer_id", + ("fin_cash_ledger", "fin_sim_account"): "l.account_id = a.id", + ("fin_transaction", "fin_sim_account"): "t.account_id = a.id", + ("fin_transaction", "sys_customer_assignment"): "t.customer_id = ca.customer_id", + ("fin_holding", "sys_customer_assignment"): "h.customer_id = ca.customer_id", + ("fin_customer_profile", "sys_customer_assignment"): "cp.customer_id = ca.customer_id", +} + + +def _load_local_config() -> dict[str, str]: + """加载标准环境变量,并兼容 0901/.evn 的中文标签密钥格式。""" + values = dict(os.environ) + path = Path(__file__).with_name(".evn") + if not path.exists(): + return values + raw_lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + for line in raw_lines: + if "=" in line: + key, value = line.split("=", 1) + values.setdefault(key.strip(), value.strip().strip('"').strip("'")) + if raw_lines: + first_value = raw_lines[0].split(":", 1)[-1].strip() if ":" in raw_lines[0] else raw_lines[0] + values.setdefault("DEEPSEEK_API_KEY_NL2SQL", first_value) + if len(raw_lines) > 1: + second_value = raw_lines[1].split(":", 1)[-1].strip() if ":" in raw_lines[1] else raw_lines[1] + values.setdefault("ALIYUN_API_KEY", second_value) + return values + + +CONFIG = _load_local_config() +DATABASE_URL = CONFIG.get("DATABASE_URL", "") +LLM_BASE_URL = CONFIG.get("DEEPSEEK_BASE_URL") or "https://api.deepseek.com" +LLM_MODEL = CONFIG.get("DEEPSEEK_NL2SQL_MODEL") or CONFIG.get("DEEPSEEK_MODEL") or "deepseek-chat" +LLM_KEY = CONFIG.get("DEEPSEEK_API_KEY_NL2SQL") or CONFIG.get("DEEPSEEK_API_KEY", "") + + +@dataclass +class AuthContext: + user_id: int | None = None + roles: list[str] = field(default_factory=list) + allowed_domains: list[str] = field(default_factory=lambda: list(DOMAINS)) + customer_scope: str = "all" + allowed_fields: list[str] | None = None + masked_fields: list[str] = field(default_factory=list) + max_rows: int = 500 + max_query_seconds: int = 10 + + +@dataclass +class QueryRequest: + question: str + auth_context: AuthContext + conversation_id: str | None = None + request_id: str | None = None + timezone: str = "Asia/Shanghai" + confirmation: str | None = None + + +@dataclass +class QueryPlan: + intent: str + domains: list[str] + tables: list[str] + time_mode: str = "none" + time_column: str | None = None + start: str | None = None + end: str | None = None + metrics: list[str] = field(default_factory=list) + dimensions: list[str] = field(default_factory=list) + filters: list[dict[str, Any]] = field(default_factory=list) + sort: list[dict[str, str]] = field(default_factory=list) + limit: int = 50 + confidence: float = 0.0 + needs_confirmation: bool = False + confirmation_question: str | None = None + unsupported_reason: str | None = None + + +def _json_object(text_value: str) -> dict[str, Any]: + cleaned = re.sub(r"```(?:json)?|```", "", text_value or "").strip() + match = re.search(r"\{.*\}", cleaned, flags=re.S) + if not match: + raise ValueError("模型未返回结构化查询计划") + value = json.loads(match.group(0)) + if not isinstance(value, dict): + raise ValueError("查询计划必须是 JSON 对象") + return value + + +def _days_range(days: int) -> tuple[str, str]: + end = datetime.now() + start = end - timedelta(days=days) + return start.strftime("%Y-%m-%d %H:%M:%S"), end.strftime("%Y-%m-%d %H:%M:%S") + + +def _catalog_text(tables: set[str]) -> str: + lines = ["只允许使用以下表和字段:"] + for table in sorted(tables): + lines.append(f"{table}: {', '.join(sorted(TABLE_COLUMNS[table]))}") + lines.append("指标口径:交易金额=gross_amount;净交易金额=net_amount;收盘价=close_price;基金净值=nav;") + lines.append("成交价格=executed_price;当前持仓市值=market_value;历史资金变化=fin_cash_ledger.amount。") + return "\n".join(lines) + + +def _route_keywords(question: str) -> list[str]: + routes = [] + if any(word in question for word in ("行情", "收盘", "开盘", "最高价", "最低价")): + routes.append("market_nav") + if "净值" in question: + routes.append("market_nav") + if any(word in question for word in ("客户", "风险", "测评", "画像", "投顾", "运营")): + routes.append("customer_risk") + if any(word in question for word in ("产品", "基金", "费率", "适配", "风险等级")): + routes.append("product_fee") + if any(word in question for word in ("委托", "成交", "交易", "持仓", "账户", "资金", "现金", "盈亏")): + routes.append("trading_account") + if any(word in question for word in ("内容", "审核", "发布")): + routes.append("client_content") + return list(dict.fromkeys(routes)) or ["trading_account"] + + +def _llm_plan(request: QueryRequest, domains: list[str]) -> dict[str, Any]: + try: + from openai import OpenAI + except ImportError as exc: + raise RuntimeError("缺少 openai 依赖,无法调用模型") from exc + tables = set().union(*(DOMAINS[d] for d in domains)) + system = f"""你是金融查询计划生成器。只输出 JSON,不输出解释。 +用户身份已由后端确认,customer_scope={request.auth_context.customer_scope}。 +{_catalog_text(tables)} +返回字段:intent, domains, tables, time_mode, time_column, start, end, metrics, +dimensions, filters, sort, limit, confidence, needs_confirmation, confirmation_question。 +只从给定表和字段中选择;无法准确判断时降低 confidence。历史时点查询不能使用当前 +fin_holding、fin_sim_account 或当前归属表冒充历史数据。""" + client = OpenAI(api_key=LLM_KEY, base_url=LLM_BASE_URL) + response = client.chat.completions.create( + model=LLM_MODEL, + messages=[{"role": "system", "content": system}, {"role": "user", "content": request.question}], + temperature=0, + response_format={"type": "json_object"}, + timeout=request.auth_context.max_query_seconds, + ) + return _json_object(response.choices[0].message.content) + + +def _normalize_plan(raw: dict[str, Any], request: QueryRequest, domains: list[str]) -> QueryPlan: + tables = [name for name in raw.get("tables", []) if name in ALLOWED_TABLES] + if not tables: + tables = sorted(set().union(*(DOMAINS[d] for d in domains))) + confidence = float(raw.get("confidence", 0.0) or 0.0) + confidence = max(0.0, min(confidence, 1.0)) + plan = QueryPlan( + intent=str(raw.get("intent") or "unknown"), + domains=[d for d in raw.get("domains", domains) if d in DOMAINS] or domains, + tables=tables, + time_mode=str(raw.get("time_mode") or raw.get("temporal", {}).get("mode") or "none"), + time_column=raw.get("time_column") or raw.get("temporal", {}).get("time_column"), + start=raw.get("start") or raw.get("temporal", {}).get("start"), + end=raw.get("end") or raw.get("temporal", {}).get("end"), + metrics=list(raw.get("metrics") or []), + dimensions=list(raw.get("dimensions") or []), + filters=list(raw.get("filters") or []), + sort=list(raw.get("sort") or []), + limit=min(max(int(raw.get("limit", 50) or 50), 1), request.auth_context.max_rows), + confidence=confidence, + needs_confirmation=bool(raw.get("needs_confirmation", False)), + confirmation_question=raw.get("confirmation_question"), + unsupported_reason=raw.get("unsupported_reason"), + ) + if plan.confidence < 0.60: + plan.needs_confirmation = True + plan.confirmation_question = plan.confirmation_question or "请补充客户、产品、时间范围或指标口径。" + elif plan.confidence < 0.85: + plan.needs_confirmation = True + plan.confirmation_question = plan.confirmation_question or "请确认我对查询范围和指标口径的理解是否正确。" + return plan + + +def _validate_plan(plan: QueryPlan, auth: AuthContext) -> tuple[bool, str]: + allowed_tables = set().union(*(DOMAINS[d] for d in auth.allowed_domains if d in DOMAINS)) + if not set(plan.tables).issubset(allowed_tables): + return False, "查询包含当前角色未授权的数据表" + if len(plan.tables) > 8: + return False, "查询涉及的数据表过多" + if plan.time_mode in {"range", "as_of"} and not plan.time_column: + return False, "历史查询缺少时间字段" + temporal_current = {"fin_holding", "fin_sim_account", "sys_customer_assignment"} + if plan.time_mode == "as_of" and temporal_current.intersection(plan.tables): + return False, "当前表缺少历史时点来源,暂不支持该历史时点查询" + if plan.start and plan.end and plan.start > plan.end: + return False, "查询开始时间不能晚于结束时间" + return True, "计划校验通过" + + +def _field_allowed(table: str, field_name: str, auth: AuthContext) -> bool: + if field_name not in TABLE_COLUMNS.get(table, set()): + return False + if auth.allowed_fields is None: + return True + return f"{table}.{field_name}" in auth.allowed_fields or field_name in auth.allowed_fields + + +def _safe_sql_check(sql: str, params: dict[str, Any], plan: QueryPlan, auth: AuthContext) -> tuple[bool, str]: + normalized = re.sub(r"\s+", " ", sql.strip()) + upper = normalized.upper() + if not upper.startswith("SELECT "): + return False, "仅支持 SELECT 查询" + if ";" in normalized.rstrip(";"): + return False, "禁止执行多语句" + if re.search(r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|CREATE|GRANT|REVOKE|CALL|INTO\s+OUTFILE)\b", upper): + return False, "检测到禁止的数据库操作" + if "*" in normalized: + return False, "禁止使用 SELECT *" + table_refs = set(re.findall(r"\b(?:FROM|JOIN)\s+([A-Za-z_][A-Za-z0-9_]*)", normalized, flags=re.I)) + if not table_refs.issubset(set(plan.tables)): + return False, "SQL 使用了计划外的数据表" + for table in table_refs: + aliases = re.findall(rf"\b{re.escape(table)}\s+(?:AS\s+)?([A-Za-z_][A-Za-z0-9_]*)", normalized, flags=re.I) + del aliases + if plan.time_mode in {"range", "as_of"} and plan.time_column and plan.time_column not in normalized: + return False, "历史查询缺少计划中的时间条件" + if len(params) > 30: + return False, "查询参数过多" + return True, "SQL 安全校验通过" + + +def _alias(table: str) -> str: + return { + "fin_transaction": "t", "fin_product": "p", "fin_holding": "h", + "fin_market_price": "m", "fin_nav_history": "n", "fin_sim_order": "o", + "fin_sim_account": "a", "fin_cash_ledger": "l", + "fin_customer_profile": "cp", "sys_customer_assignment": "ca", + "fin_risk_assessment": "ra", "fin_fee_rule": "f", + "client_facing_content": "c", + }.get(table, table[:1]) + + +def _compile_sql(plan: QueryPlan, auth: AuthContext) -> tuple[str, dict[str, Any]]: + if plan.unsupported_reason: + raise ValueError(plan.unsupported_reason) + primary = plan.tables[0] + alias = _alias(primary) + select_parts = [] + group_parts = [] + for dimension in plan.dimensions: + if "." not in dimension: + dimension = f"{primary}.{dimension}" + table, column = dimension.split(".", 1) + if not _field_allowed(table, column, auth): + raise PermissionError(f"字段未授权:{dimension}") + select_parts.append(f"{_alias(table)}.{column} AS {column}") + group_parts.append(f"{_alias(table)}.{column}") + metric_map = { + "交易金额": "SUM(t.gross_amount) AS gross_amount", + "gross_amount": "SUM(t.gross_amount) AS gross_amount", + "净交易金额": "SUM(t.net_amount) AS net_amount", + "net_amount": "SUM(t.net_amount) AS net_amount", + "成交数量": "SUM(t.executed_quantity) AS executed_quantity", + "持仓数量": "h.total_quantity AS total_quantity", + "持仓市值": "h.market_value AS market_value", + "浮动盈亏": "h.profit_loss AS profit_loss", + "可用现金": "a.available_cash AS available_cash", + "现金余额": "a.cash_balance AS cash_balance", + "历史资金变化": "SUM(l.amount) AS cash_change", + "收盘价": "m.close_price AS close_price", + "基金净值": "n.nav AS nav", + "成交价格": "t.executed_price AS executed_price", + "客户数": "COUNT(DISTINCT cp.customer_id) AS customer_count", + } + for metric in plan.metrics or ["客户数"]: + expression = metric_map.get(str(metric)) + if not expression: + raise ValueError(f"暂不支持指标:{metric}") + select_parts.append(expression) + if not select_parts: + raise ValueError("查询计划没有可返回的字段") + params: dict[str, Any] = {} + where = ["1=1"] + if primary == "fin_transaction": + where.append("t.order_side IN ('买入', '卖出')") + if plan.time_column and plan.start: + params["start_time"] = plan.start + where.append(f"{alias}.{plan.time_column} >= :start_time") + if plan.time_column and plan.end: + params["end_time"] = plan.end + where.append(f"{alias}.{plan.time_column} <= :end_time") + for index, item in enumerate(plan.filters): + field_name = item.get("field") + operator = str(item.get("operator", "=")).upper() + if not isinstance(field_name, str) or "." not in field_name or operator not in {"=", "!=", ">", ">=", "<", "<=", "LIKE"}: + raise ValueError("查询筛选条件不合法") + table, column = field_name.split(".", 1) + if not _field_allowed(table, column, auth): + raise PermissionError(f"字段未授权:{field_name}") + key = f"filter_{index}" + params[key] = item.get("value") + where.append(f"{_alias(table)}.{column} {operator} :{key}") + from_sql = f"{primary} {alias}" + pending = set(plan.tables) - {primary} + joined = {primary} + while pending: + progress = False + for table in sorted(pending): + join = None + for existing in joined: + join = JOIN_SQL.get((existing, table)) or JOIN_SQL.get((table, existing)) + if join: + break + if not join: + continue + from_sql += f" JOIN {table} {_alias(table)} ON {join}" + joined.add(table) + pending.remove(table) + progress = True + if not progress: + break + if pending: + raise ValueError(f"缺少合法 Join 路径:{', '.join(sorted(pending))}") + sql = f"SELECT {', '.join(select_parts)} FROM {from_sql} WHERE {' AND '.join(where)}" + if group_parts: + sql += f" GROUP BY {', '.join(group_parts)}" + if plan.sort: + sort_items = [] + for item in plan.sort: + field = str(item.get("field", "")).split(".")[-1] + direction = "DESC" if str(item.get("direction", "desc")).lower() == "desc" else "ASC" + if field not in {part.split(" AS ")[-1] for part in select_parts}: + continue + sort_items.append(f"{field} {direction}") + if sort_items: + sql += " ORDER BY " + ", ".join(sort_items) + sql += f" LIMIT {min(plan.limit, auth.max_rows)}" + return sql, params + + +def _mock_plan(request: QueryRequest, domains: list[str]) -> dict[str, Any]: + """无模型或测试时的保守规则,便于单元测试和离线联调。""" + question = request.question + if "近30天" in question or "最近30天" in question: + start, end = _days_range(30) + else: + start = end = None + if "收盘" in question or "行情" in question: + return {"intent": "market_price", "domains": ["market_nav"], "tables": ["fin_market_price", "fin_product"], + "time_mode": "range", "time_column": "trade_date", "start": start, "end": end, + "metrics": ["收盘价"], "dimensions": ["fin_product.product_name", "fin_market_price.trade_date"], + "confidence": 0.88} + if "净值" in question: + return {"intent": "nav_history", "domains": ["market_nav"], "tables": ["fin_nav_history", "fin_product"], + "time_mode": "range", "time_column": "nav_date", "start": start, "end": end, + "metrics": ["基金净值"], "dimensions": ["fin_product.product_name", "fin_nav_history.nav_date"], + "confidence": 0.88} + if "当前持仓" in question or "持仓市值" in question: + return {"intent": "current_holding", "domains": ["customer_risk", "trading_account"], + "tables": ["fin_holding", "fin_product", "fin_customer_profile"], + "metrics": ["持仓市值"], "dimensions": ["fin_customer_profile.real_name", "fin_product.product_name"], + "confidence": 0.87} + if "账户余额" in question or "可用现金" in question: + return {"intent": "current_account", "domains": ["trading_account"], + "tables": ["fin_sim_account", "fin_customer_profile"], "metrics": ["可用现金"], + "dimensions": ["fin_customer_profile.real_name"], "confidence": 0.87} + if "资金变动" in question or "资金流水" in question: + return {"intent": "cash_ledger", "domains": ["trading_account"], + "tables": ["fin_cash_ledger", "fin_sim_account"], "time_mode": "range", + "time_column": "occurred_at", "start": start, "end": end, + "metrics": ["历史资金变化"], "dimensions": ["fin_cash_ledger.occurred_at"], "confidence": 0.87} + return {"intent": "unknown", "domains": domains, "tables": sorted(set().union(*(DOMAINS[d] for d in domains))), + "confidence": 0.45, "needs_confirmation": True, + "confirmation_question": "请明确要查询的业务对象、指标和时间范围。"} + + +def _audit_payload(request: QueryRequest, plan: QueryPlan, sql: str | None, params: dict[str, Any], + validation: dict[str, Any], execution: dict[str, Any]) -> dict[str, Any]: + return { + "tool": "nl2sql", "engine_version": "mvp-1", "request_id": request.request_id, + "conversation_id": request.conversation_id, "intent": plan.intent, + "confidence": plan.confidence, "domains": plan.domains, + "query_plan": asdict(plan), "generated_sql": sql, + "parameters": {key: "" if "password" in key.lower() else value for key, value in params.items()}, + "authorized_context": {"user_id": request.auth_context.user_id, "roles": request.auth_context.roles, + "customer_scope": request.auth_context.customer_scope}, + "validation": validation, "execution": execution, + "created_at": datetime.now().isoformat(timespec="seconds"), + } + + +def write_conversation_tool_calls(connection: Any, request: QueryRequest, audit: dict[str, Any]) -> None: + """将审计对象写入已有 conversation_message;宿主也可使用 audit_writer 自行落库。""" + from sqlalchemy import text + connection.execute( + text("""INSERT INTO conversation_message + (session_id, customer_id, portal, role, content, tool_calls, intent, confidence, created_at) + VALUES (:session_id, :customer_id, :portal, 'assistant', :content, :tool_calls, + :intent, :confidence, :created_at)"""), + { + "session_id": request.conversation_id or request.request_id, + "customer_id": request.auth_context.user_id, + "portal": "nl2sql", + "content": audit.get("execution", {}).get("status", "nl2sql"), + "tool_calls": json.dumps(audit, ensure_ascii=False, default=str), + "intent": audit.get("intent"), + "confidence": audit.get("confidence"), + "created_at": datetime.now(), + }, + ) + + +def query(request: QueryRequest, *, db_engine: Any = None, + audit_writer: Callable[[dict[str, Any]], None] | None = None, + use_llm: bool = True, persist_audit: bool = False) -> dict[str, Any]: + """统一入口:返回结构化结果,供运营和投顾 Agent 直接调用。""" + if not request.question or not request.question.strip(): + return {"status": "rejected", "message": "查询问题不能为空"} + request.request_id = request.request_id or str(uuid.uuid4()) + domains = _route_keywords(request.question) + try: + raw = _llm_plan(request, domains) if use_llm and LLM_KEY else _mock_plan(request, domains) + plan = _normalize_plan(raw, request, domains) + if request.confirmation and plan.needs_confirmation: + plan.needs_confirmation = False + plan.confidence = max(plan.confidence, 0.85) + valid, message = _validate_plan(plan, request.auth_context) + if not valid: + audit = _audit_payload(request, plan, None, {}, {"valid": False, "message": message}, {"status": "rejected"}) + if audit_writer: + audit_writer(audit) + if persist_audit and db_engine is not None: + with db_engine.begin() as connection: + write_conversation_tool_calls(connection, request, audit) + return {"status": "rejected", "message": message, "audit": audit} + if plan.needs_confirmation: + audit = _audit_payload(request, plan, None, {}, {"valid": True, "message": "等待确认"}, {"status": "waiting"}) + if audit_writer: + audit_writer(audit) + if persist_audit and db_engine is not None: + with db_engine.begin() as connection: + write_conversation_tool_calls(connection, request, audit) + return {"status": "need_confirmation", "message": plan.confirmation_question, "query_plan": asdict(plan), "audit": audit} + sql, params = _compile_sql(plan, request.auth_context) + safe, safety_message = _safe_sql_check(sql, params, plan, request.auth_context) + if not safe: + raise PermissionError(safety_message) + if db_engine is None: + execution = {"status": "not_executed", "reason": "未提供数据库连接,仅返回已校验 SQL"} + audit = _audit_payload(request, plan, sql, params, {"valid": True, "message": safety_message}, execution) + if audit_writer: + audit_writer(audit) + if persist_audit and db_engine is not None: + with db_engine.begin() as connection: + write_conversation_tool_calls(connection, request, audit) + return {"status": "ready", "sql": sql, "parameters": params, "query_plan": asdict(plan), "audit": audit} + from sqlalchemy import text + with db_engine.connect() as connection: + result = connection.execute(text(sql), params) + columns = list(result.keys()) + rows = [dict(zip(columns, row)) for row in result.fetchmany(request.auth_context.max_rows)] + execution = {"status": "success", "row_count": len(rows), "truncated": len(rows) >= request.auth_context.max_rows} + audit = _audit_payload(request, plan, sql, params, {"valid": True, "message": safety_message}, execution) + if audit_writer: + audit_writer(audit) + if persist_audit: + with db_engine.begin() as connection: + write_conversation_tool_calls(connection, request, audit) + return {"status": "success", "data": {"total": len(rows), "rows": rows}, + "query_plan": asdict(plan), "sql": sql, "audit": audit} + except (ValueError, PermissionError, RuntimeError) as exc: + logger.warning("NL2SQL业务失败:%s", exc) + return {"status": "error", "message": str(exc), "request_id": request.request_id} + except Exception: + logger.exception("NL2SQL执行失败") + return {"status": "error", "message": "查询执行失败,请稍后重试", "request_id": request.request_id} + + +def build_request(question: str, auth_context: dict[str, Any], **kwargs: Any) -> QueryRequest: + """将 Agent 的字典请求转换为统一请求对象。""" + return QueryRequest(question=question, auth_context=AuthContext(**auth_context), **kwargs) + + +def query_dict(question: str, auth_context: dict[str, Any], *, + db_engine: Any = None, + audit_writer: Callable[[dict[str, Any]], None] | None = None, + use_llm: bool = True, persist_audit: bool = False, + **request_kwargs: Any) -> dict[str, Any]: + """给 Agent 使用的字典式快捷入口。""" + return query( + build_request(question, auth_context, **request_kwargs), + db_engine=db_engine, + audit_writer=audit_writer, + use_llm=use_llm, + persist_audit=persist_audit, + ) + + +def get_tool_definition() -> dict[str, Any]: + """返回可注册到运营或投顾 Agent 的统一工具定义。""" + return { + "name": "financial_nl2sql", + "description": "对金融业务数据执行只读自然语言查询;低置信度时先确认。", + "input_schema": { + "type": "object", + "required": ["question", "auth_context"], + "properties": { + "question": {"type": "string"}, + "auth_context": { + "type": "object", + "required": ["roles", "customer_scope"], + "properties": { + "user_id": {"type": ["integer", "null"]}, + "roles": {"type": "array", "items": {"type": "string"}}, + "customer_scope": {"type": "string", "enum": ["self", "own_customers", "all"]}, + "allowed_domains": {"type": "array", "items": {"type": "string"}}, + }, + }, + "conversation_id": {"type": ["string", "null"]}, + "request_id": {"type": ["string", "null"]}, + "timezone": {"type": "string"}, + "confirmation": {"type": ["string", "null"]}, + }, + }, + } + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + demo = build_request("查询最近30天的行情收盘价", {"roles": ["advisor"], "customer_scope": "all"}) + print(json.dumps(query(demo, use_llm=False), ensure_ascii=False, indent=2, default=str)) diff --git a/pyproject.toml b/pyproject.toml index 6af7355..2d05779 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "redis>=5.2,<6", "neo4j>=5.28,<6", "pymilvus>=2.5,<3", + "milvus-lite>=3.2,<4", "PyJWT>=2.10,<3", "cryptography>=44,<51", "httpx>=0.28,<1", @@ -32,6 +33,7 @@ dependencies = [ dev = [ "pytest>=8.3,<9", "pytest-asyncio>=0.25,<1", + "aiosqlite>=0.20,<1", "ruff>=0.9,<1", "mypy>=1.14,<2", ] @@ -42,6 +44,7 @@ include = ["app*"] [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" markers = ["integration: requires local database services"] [tool.ruff] diff --git a/requirements.txt b/requirements.txt index 21f646b..3710a29 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ pymysql>=1.1,<2 redis>=5.2,<6 neo4j>=5.28,<6 pymilvus>=2.5,<3 +milvus-lite>=3.2,<4 PyJWT>=2.10,<3 cryptography>=44,<51 httpx>=0.28,<1 diff --git a/tests/conftest.py b/tests/conftest.py index 860ce92..e2c9a45 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,11 @@ +import asyncio +import sys + import pytest +if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + from app.core.contracts import AgentDefinition, CoreResult, ResolvedAgentConfig from app.service.agent.base import BaseAgent from app.service.agent.factory import AgentFactory diff --git a/tests/contract/test_financial_nl2sql_tool_contract.py b/tests/contract/test_financial_nl2sql_tool_contract.py new file mode 100644 index 0000000..929ed3a --- /dev/null +++ b/tests/contract/test_financial_nl2sql_tool_contract.py @@ -0,0 +1,10 @@ +from app.service.agent.bootstrap import get_agent_factory + + +def test_financial_nl2sql_tool_is_registered_as_read_only() -> None: + factory = get_agent_factory() + tool = factory._tool_executor.registry.get("query_financial_data") + assert tool.read_only is True + assert tool.required_permission == "financial:nl2sql:read" + assert "operator" in tool.allowed_roles + assert "advisor" in tool.allowed_roles diff --git a/tests/integration/test_offsite_fund_api.py b/tests/integration/test_offsite_fund_api.py new file mode 100644 index 0000000..8ec75c9 --- /dev/null +++ b/tests/integration/test_offsite_fund_api.py @@ -0,0 +1,424 @@ +import asyncio +from collections.abc import AsyncIterator +from pathlib import Path +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.dependencies.auth import build_request_context +from app.api.dependencies.database import get_session +from app.core.contracts import RequestContext +from app.infrastructure.db import SessionFactory +from app.main import app +from app.model.audit import InteractionAudit +from app.model.offsite_fund import ( + OffsiteExecutionPlanTask, + OffsiteFundAttachment, + OffsiteFundDocument, + OffsiteFundMail, + OffsiteNotification, + OffsiteQueryRecord, + OffsiteRuleResult, +) +from app.service.offsite_smtp_adapter import SmtpSendResult + +TEST_TRACE_ID = "" + + +async def override_context() -> RequestContext: + return RequestContext( + user_id="1", + trace_id=TEST_TRACE_ID or str(uuid4()), + roles=("operator",), + permissions=("offsite:write",), + data_scope="all", + ) + + +async def override_session() -> AsyncIterator[AsyncSession]: + async with SessionFactory() as session: + yield session + + +@pytest.mark.integration +def test_offsite_recognized_mail_persists_workflow_and_notification() -> None: + global TEST_TRACE_ID + TEST_TRACE_ID = f"trace-offsite-{uuid4()}" + uid = f"offsite-{uuid4()}" + message_id = f"<{uuid4()}@integration.local>" + payload = { + "imap_uid": uid, + "message_id": message_id, + "sender": "15008108550@163.com", + "return_path": "15008108550@163.com", + "auth_result": {"spf": "pass", "dkim": "pass"}, + "eml_path": "mock/offsite.eml", + "attachments": [ + { + "filename": "申购申请单.pdf", + "file_hash": f"hash-{uuid4().hex}", + "media_type": "application/pdf", + "size_bytes": 2048, + "document_type": "subscription", + "ocr_text": "基金代码 000001 申购金额 10000", + "extracted_fields": { + "基金代码": "000001", + "基金名称": "测试基金", + "账户标识": "ACCT-001", + "投资者名称": "测试客户", + "申请编号": f"SUB-{uuid4().hex[:8]}", + "申请日期": "2026-09-10", + "代销机构": "测试代销", + "申购金额": "10000", + "金额单位": "元", + "最新净值": "1.0000", + "基金最新总份额": "1000000", + "申请前持有份额": "1000", + }, + "field_confidence": {"基金代码": "0.99", "申购金额": "0.98"}, + "page_evidence": {"基金代码": [1], "申购金额": [1]}, + } + ], + } + + 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: + response = client.post("/api/v1/offsite-fund/recognized-mails", json=payload) + assert response.status_code == 200 + body = response.json() + assert body["code"] == 0 + mail_id = body["data"]["mail_id"] + task_id = body["data"]["documents"][0]["task_id"] + assert body["data"]["documents"][0]["rule_results"] == { + "subscription_minimum_amount": "正常", + "subscription_holding_ratio": "正常", + "subscription_single_share_limit": "正常", + } + + duplicate = client.post("/api/v1/offsite-fund/recognized-mails", json=payload) + assert duplicate.status_code == 200 + assert duplicate.json()["data"] == {"mail_id": mail_id} + + confirm = client.post( + f"/api/v1/offsite-fund/documents/{task_id}/confirmations", + json={"decision": "确认正常", "operator_id": "operator-001"}, + ) + assert confirm.status_code == 200 + assert confirm.json()["code"] == 0 + + recalc = client.post( + "/api/v1/offsite-fund/settlement-statistics/recalculate", + json={"fund_code": "000001", "application_date": "2026-09-10"}, + ) + assert recalc.status_code == 200 + assert recalc.json()["data"]["subscription_amount_yuan"] == "10000.0000" + + notice = client.post( + f"/api/v1/offsite-fund/documents/{task_id}/notifications", + json={"notification_type": "settlement", "operator_id": "operator-001"}, + ) + assert notice.status_code == 200 + assert notice.json()["data"]["notification_id"] + + asyncio.run(_assert_offsite_rows(mail_id, task_id, TEST_TRACE_ID)) + 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 + TEST_TRACE_ID = f"trace-offsite-nl2sql-{uuid4()}" + uid = f"offsite-{uuid4()}" + message_id = f"<{uuid4()}@integration.local>" + payload = _subscription_payload(uid, message_id) + 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 = client.post("/api/v1/offsite-fund/recognized-mails", json=payload) + assert created.status_code == 200 + body = created.json() + mail_id = body["data"]["mail_id"] + task_id = body["data"]["documents"][0]["task_id"] + + response = client.post( + f"/api/tasks/{task_id}/trigger-agent-nl2sql", + json={"operator_id": "1", "manual_confirmed": True}, + ) + assert response.status_code == 200 + data = response.json()["data"] + assert data["task_id"] == task_id + assert len(data["queries"]) == 2 + assert {item["status"] for item in data["queries"]} <= { + "ready", "success", "need_confirmation", "rejected", "error", + } + + asyncio.run(_assert_nl2sql_rows(task_id)) + 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_low_confidence_document_goes_to_recognition_exception() -> None: + global TEST_TRACE_ID + TEST_TRACE_ID = f"trace-offsite-low-confidence-{uuid4()}" + uid = f"offsite-{uuid4()}" + message_id = f"<{uuid4()}@integration.local>" + payload = _subscription_payload(uid, message_id) + payload["attachments"][0]["field_confidence"] = {"基金代码": "0.79", "申购金额": "0.99"} + 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: + response = client.post("/api/v1/offsite-fund/recognized-mails", json=payload) + assert response.status_code == 200 + body = response.json() + mail_id = body["data"]["mail_id"] + document = body["data"]["documents"][0] + task_id = document["task_id"] + assert document["status"] == "recognition_exception" + 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_service_rejects_missing_permission() -> None: + async def no_permission_context() -> RequestContext: + return RequestContext( + user_id="1", + trace_id=f"trace-offsite-denied-{uuid4()}", + roles=("operator",), + permissions=(), + ) + + payload = _subscription_payload(f"offsite-{uuid4()}", f"<{uuid4()}@integration.local>") + app.dependency_overrides[build_request_context] = no_permission_context + app.dependency_overrides[get_session] = override_session + try: + with TestClient(app) as client: + response = client.post("/api/v1/offsite-fund/recognized-mails", json=payload) + assert response.status_code == 200 + assert response.json()["code"] == 403 + assert "缺少场外基金操作权限" in response.json()["message"] + finally: + app.dependency_overrides.clear() + + +@pytest.mark.integration +def test_offsite_mail_return_send_updates_notification_without_external_call( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + global TEST_TRACE_ID + TEST_TRACE_ID = f"trace-offsite-send-{uuid4()}" + uid = f"offsite-{uuid4()}" + message_id = f"<{uuid4()}@integration.local>" + payload = _subscription_payload(uid, message_id) + attachment_path = tmp_path / "申购申请单.pdf" + attachment_path.write_bytes(b"pdf-bytes") + payload["attachments"][0]["original_file_path"] = str(attachment_path) + + class FakeDryRunSender: + def __init__(self, _settings: object) -> None: + pass + + def send_reply(self, request: object) -> SmtpSendResult: + del request + return SmtpSendResult( + status="待发送", + dry_run=True, + provider_message_id=None, + failure_reason=None, + retry_count=0, + request_summary={"provider": "test"}, + ) + + monkeypatch.setattr("app.service.offsite_fund_service.OffsiteSmtpSender", FakeDryRunSender) + app.dependency_overrides[build_request_context] = override_context + app.dependency_overrides[get_session] = override_session + mail_id = "" + task_id = "" + notification_id = 0 + try: + with TestClient(app) as client: + created = client.post("/api/v1/offsite-fund/recognized-mails", json=payload) + assert created.status_code == 200 + data = created.json()["data"] + mail_id = data["mail_id"] + task_id = data["documents"][0]["task_id"] + + confirmed = client.post( + f"/api/v1/offsite-fund/documents/{task_id}/confirmations", + json={"decision": "确认正常", "operator_id": "operator-001"}, + ) + assert confirmed.status_code == 200 + + notice = client.post( + f"/api/v1/offsite-fund/documents/{task_id}/notifications", + json={"notification_type": "mail_return", "operator_id": "operator-001"}, + ) + assert notice.status_code == 200 + notification_id = int(notice.json()["data"]["notification_id"]) + + sent = client.post( + f"/api/v1/offsite-fund/notifications/{notification_id}/send", + json={ + "operator_id": "operator-001", + "operator_confirmed": True, + "final_content": "运营确认后的回复正文", + }, + ) + assert sent.status_code == 200 + assert sent.json()["data"] == { + "notification_id": str(notification_id), + "status": "待发送", + "dry_run": True, + "provider_message_id": None, + "failure_reason": None, + "retry_count": 0, + } + + asyncio.run(_assert_notification_pending(notification_id)) + finally: + asyncio.run(_cleanup_offsite_rows(mail_id, task_id, TEST_TRACE_ID)) + app.dependency_overrides.clear() + TEST_TRACE_ID = "" + + +def _subscription_payload(uid: str, message_id: str) -> dict[str, object]: + return { + "imap_uid": uid, + "message_id": message_id, + "sender": "15008108550@163.com", + "return_path": "15008108550@163.com", + "auth_result": {"spf": "pass", "dkim": "pass"}, + "eml_path": "mock/offsite.eml", + "attachments": [ + { + "filename": "申购申请单.pdf", + "file_hash": f"hash-{uuid4().hex}", + "original_file_path": "mock/申购申请单.pdf", + "media_type": "application/pdf", + "size_bytes": 2048, + "document_type": "subscription", + "ocr_text": "基金代码 000001 申购金额 10000", + "extracted_fields": { + "基金代码": "000001", + "基金名称": "测试基金", + "账户标识": "ACCT-001", + "投资者名称": "测试客户", + "申请编号": f"SUB-{uuid4().hex[:8]}", + "申请日期": "2026-09-10", + "代销机构": "测试代销", + "申购金额": "10000", + "金额单位": "元", + "最新净值": "1.0000", + "基金最新总份额": "1000000", + "申请前持有份额": "1000", + }, + "field_confidence": {"基金代码": "0.99", "申购金额": "0.98"}, + "page_evidence": {"基金代码": [1], "申购金额": [1]}, + } + ], + } + + +async def _assert_offsite_rows(mail_id: str, task_id: str, trace_id: str) -> None: + async with SessionFactory() as session: + assert await _count(session, OffsiteFundMail, OffsiteFundMail.mail_id == mail_id) == 1 + assert await _count( + session, OffsiteFundAttachment, OffsiteFundAttachment.mail_id == mail_id + ) == 1 + assert await _count( + session, OffsiteFundDocument, OffsiteFundDocument.task_id == task_id + ) == 1 + assert await _count(session, OffsiteRuleResult, OffsiteRuleResult.task_id == task_id) == 3 + assert await _count( + session, OffsiteExecutionPlanTask, OffsiteExecutionPlanTask.task_id == task_id + ) == 9 + assert await _count( + session, OffsiteNotification, OffsiteNotification.business_key == task_id + ) == 1 + assert await _count( + session, InteractionAudit, InteractionAudit.action_type.like("offsite.%") + ) >= 1 + + +async def _assert_nl2sql_rows(task_id: str) -> None: + async with SessionFactory() as session: + assert await _count(session, OffsiteQueryRecord, OffsiteQueryRecord.task_id == task_id) == 2 + tasks = (await session.execute(select(OffsiteExecutionPlanTask).where( + OffsiteExecutionPlanTask.task_id == task_id, + OffsiteExecutionPlanTask.stage == "查询", + OffsiteExecutionPlanTask.rule_code != "subscription_minimum_amount", + ))).scalars().all() + assert {task.status for task in tasks} <= {"已完成", "查询失败", "无法判断"} + assert all(task.output_json is not None for task in tasks) + + +async def _assert_notification_pending(notification_id: int) -> None: + async with SessionFactory() as session: + notice = await session.scalar(select(OffsiteNotification).where( + OffsiteNotification.id == notification_id + )) + assert notice is not None + assert notice.status == "待发送" + assert notice.provider_message_id is None + assert notice.failure_reason is None + assert notice.sent_at is None + + +async def _count(session: AsyncSession, model: type, criterion: object) -> int: + return int(await session.scalar(select(func.count()).select_from(model).where(criterion)) or 0) + + +async def _cleanup_offsite_rows(mail_id: str, task_id: str, trace_id: str) -> None: + if not mail_id and not task_id: + return + async with SessionFactory() as session, session.begin(): + if trace_id: + await session.execute( + delete(InteractionAudit).where( + InteractionAudit.detail["trace_id"].as_string() == trace_id + ) + ) + if task_id: + await session.execute( + delete(OffsiteNotification).where(OffsiteNotification.business_key == task_id) + ) + await session.execute( + delete(OffsiteQueryRecord).where(OffsiteQueryRecord.task_id == task_id) + ) + await session.execute( + delete(OffsiteRuleResult).where(OffsiteRuleResult.task_id == task_id) + ) + await session.execute( + delete(OffsiteExecutionPlanTask).where( + OffsiteExecutionPlanTask.task_id == task_id + ) + ) + await session.execute( + delete(OffsiteFundDocument).where(OffsiteFundDocument.task_id == task_id) + ) + if mail_id: + await session.execute( + delete(OffsiteFundAttachment).where(OffsiteFundAttachment.mail_id == mail_id) + ) + await session.execute(delete(OffsiteFundMail).where(OffsiteFundMail.mail_id == mail_id)) diff --git a/tests/integration/test_offsite_notification_send.py b/tests/integration/test_offsite_notification_send.py new file mode 100644 index 0000000..210a7fb --- /dev/null +++ b/tests/integration/test_offsite_notification_send.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from pathlib import Path +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.dependencies.auth import build_request_context +from app.api.dependencies.database import get_session +from app.core.contracts import RequestContext +from app.infrastructure.db import SessionFactory +from app.main import app +from app.model.audit import InteractionAudit +from app.model.offsite_fund import ( + OffsiteExecutionPlanTask, + OffsiteFundAttachment, + OffsiteFundDocument, + OffsiteFundMail, + OffsiteNotification, + OffsiteQueryRecord, + OffsiteRuleResult, +) +from app.service.offsite_smtp_adapter import SmtpSendResult + +TRACE_ID = "" + + +async def override_context() -> RequestContext: + return RequestContext( + user_id="1", + trace_id=TRACE_ID or str(uuid4()), + roles=("operator",), + permissions=("offsite:write",), + data_scope="all", + ) + + +async def override_session() -> AsyncIterator[AsyncSession]: + async with SessionFactory() as session: + yield session + + +@pytest.mark.integration +def test_successful_notification_send_is_idempotent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + global TRACE_ID + TRACE_ID = f"trace-send-success-{uuid4()}" + payload = _subscription_payload() + attachment_path = tmp_path / "申购申请单.pdf" + attachment_path.write_bytes(b"original-pdf") + payload["attachments"][0]["original_file_path"] = str(attachment_path) + sent_requests: list[object] = [] + + class FakeSuccessSender: + def __init__(self, _settings: object) -> None: + pass + + def send_reply(self, request: object) -> SmtpSendResult: + sent_requests.append(request) + return SmtpSendResult( + status="发送成功", + dry_run=False, + provider_message_id="provider-test-001", + failure_reason=None, + retry_count=0, + request_summary={"provider": "test"}, + ) + + monkeypatch.setattr("app.service.offsite_fund_service.OffsiteSmtpSender", FakeSuccessSender) + app.dependency_overrides[build_request_context] = override_context + app.dependency_overrides[get_session] = override_session + mail_id = "" + task_id = "" + notification_id = 0 + try: + with TestClient(app) as client: + created = client.post("/api/v1/offsite-fund/recognized-mails", json=payload) + data = created.json()["data"] + mail_id = data["mail_id"] + task_id = data["documents"][0]["task_id"] + _confirm_and_create_notice(client, task_id) + notice = client.post( + f"/api/v1/offsite-fund/documents/{task_id}/notifications", + json={"notification_type": "mail_return", "operator_id": "operator-001"}, + ) + notification_id = int(notice.json()["data"]["notification_id"]) + + first = client.post( + f"/api/v1/offsite-fund/notifications/{notification_id}/send", + json={"operator_id": "operator-001", "operator_confirmed": True}, + ) + second = client.post( + f"/api/v1/offsite-fund/notifications/{notification_id}/send", + json={"operator_id": "operator-001", "operator_confirmed": True}, + ) + + assert first.json()["data"]["status"] == "发送成功" + assert first.json()["data"]["provider_message_id"] == "provider-test-001" + assert second.json()["data"]["status"] == "发送成功" + assert len(sent_requests) == 1 + asyncio.run(_assert_notification(notification_id, "发送成功", "provider-test-001")) + asyncio.run(_assert_mail_status(mail_id, "normal_return_sent")) + finally: + asyncio.run(_cleanup(mail_id, task_id, notification_id, TRACE_ID)) + app.dependency_overrides.clear() + TRACE_ID = "" + + +@pytest.mark.integration +def test_failed_notification_send_persists_failure_and_retry_count( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + global TRACE_ID + TRACE_ID = f"trace-send-failure-{uuid4()}" + payload = _subscription_payload() + attachment_path = tmp_path / "申购申请单.pdf" + attachment_path.write_bytes(b"original-pdf") + payload["attachments"][0]["original_file_path"] = str(attachment_path) + + class FakeFailureSender: + def __init__(self, _settings: object) -> None: + pass + + def send_reply(self, request: object) -> SmtpSendResult: + del request + return SmtpSendResult( + status="发送失败", + dry_run=False, + provider_message_id=None, + failure_reason="SMTPException", + retry_count=1, + request_summary={"provider": "test"}, + ) + + monkeypatch.setattr("app.service.offsite_fund_service.OffsiteSmtpSender", FakeFailureSender) + app.dependency_overrides[build_request_context] = override_context + app.dependency_overrides[get_session] = override_session + mail_id = "" + task_id = "" + notification_id = 0 + try: + with TestClient(app) as client: + created = client.post("/api/v1/offsite-fund/recognized-mails", json=payload) + data = created.json()["data"] + mail_id = data["mail_id"] + task_id = data["documents"][0]["task_id"] + _confirm_and_create_notice(client, task_id) + notice = client.post( + f"/api/v1/offsite-fund/documents/{task_id}/notifications", + json={"notification_type": "mail_return", "operator_id": "operator-001"}, + ) + notification_id = int(notice.json()["data"]["notification_id"]) + sent = client.post( + f"/api/v1/offsite-fund/notifications/{notification_id}/send", + json={"operator_id": "operator-001", "operator_confirmed": True}, + ) + + assert sent.json()["data"]["status"] == "发送失败" + assert sent.json()["data"]["failure_reason"] == "SMTPException" + assert sent.json()["data"]["retry_count"] == 1 + asyncio.run(_assert_notification(notification_id, "发送失败", None)) + finally: + asyncio.run(_cleanup(mail_id, task_id, notification_id, TRACE_ID)) + app.dependency_overrides.clear() + TRACE_ID = "" + + +def _confirm_and_create_notice(client: TestClient, task_id: str) -> None: + confirmed = client.post( + f"/api/v1/offsite-fund/documents/{task_id}/confirmations", + json={"decision": "确认正常", "operator_id": "operator-001"}, + ) + assert confirmed.status_code == 200 + + +def _subscription_payload() -> dict[str, object]: + suffix = uuid4().hex[:8] + return { + "imap_uid": f"offsite-{suffix}", + "message_id": f"<{suffix}@integration.local>", + "sender": "15008108550@163.com", + "return_path": "15008108550@163.com", + "auth_result": {"spf": "pass", "dkim": "pass"}, + "eml_path": "mock/offsite.eml", + "attachments": [ + { + "filename": "申购申请单.pdf", + "file_hash": f"hash-{uuid4().hex}", + "original_file_path": "mock/申购申请单.pdf", + "media_type": "application/pdf", + "size_bytes": 2048, + "document_type": "subscription", + "ocr_text": "基金代码 000001 申购金额 10000", + "extracted_fields": { + "基金代码": "000001", + "基金名称": "测试基金", + "账户标识": "ACCT-001", + "投资者名称": "测试客户", + "申请编号": f"SUB-{suffix}", + "申请日期": "2026-09-10", + "代销机构": "测试代销", + "申购金额": "10000", + "金额单位": "元", + "最新净值": "1.0000", + "基金最新总份额": "1000000", + "申请前持有份额": "1000", + }, + "field_confidence": {"基金代码": "0.99", "申购金额": "0.98"}, + "page_evidence": {"基金代码": [1], "申购金额": [1]}, + } + ], + } + + +async def _assert_notification( + notification_id: int, status: str, provider_message_id: str | None +) -> None: + async with SessionFactory() as session: + notice = await session.scalar(select(OffsiteNotification).where( + OffsiteNotification.id == notification_id + )) + assert notice is not None + assert notice.status == status + assert notice.provider_message_id == provider_message_id + if status == "发送成功": + assert notice.sent_at is not None + else: + assert notice.sent_at is None + + +async def _assert_mail_status(mail_id: str, status: str) -> None: + async with SessionFactory() as session: + mail = await session.scalar(select(OffsiteFundMail).where( + OffsiteFundMail.mail_id == mail_id + )) + assert mail is not None + assert mail.status == status + + +async def _cleanup(mail_id: str, task_id: str, notification_id: int, trace_id: str) -> None: + async with SessionFactory() as session, session.begin(): + if trace_id: + await session.execute(delete(InteractionAudit).where( + InteractionAudit.detail["trace_id"].as_string() == trace_id + )) + if notification_id: + await session.execute(delete(OffsiteNotification).where( + OffsiteNotification.id == notification_id + )) + if task_id: + await session.execute(delete(OffsiteQueryRecord).where( + OffsiteQueryRecord.task_id == task_id + )) + await session.execute(delete(OffsiteRuleResult).where( + OffsiteRuleResult.task_id == task_id + )) + await session.execute(delete(OffsiteExecutionPlanTask).where( + OffsiteExecutionPlanTask.task_id == task_id + )) + await session.execute(delete(OffsiteFundDocument).where( + OffsiteFundDocument.task_id == task_id + )) + if mail_id: + await session.execute(delete(OffsiteFundAttachment).where( + OffsiteFundAttachment.mail_id == mail_id + )) + await session.execute(delete(OffsiteFundMail).where( + OffsiteFundMail.mail_id == mail_id + )) diff --git a/tests/unit/api/test_controller_routing_contract.py b/tests/unit/api/test_controller_routing_contract.py index d2da2e0..aa8925c 100644 --- a/tests/unit/api/test_controller_routing_contract.py +++ b/tests/unit/api/test_controller_routing_contract.py @@ -73,6 +73,20 @@ async def test_unknown_path_is_not_found() -> None: assert response.status_code == 404 +def test_app_registers_platform_and_offsite_routes() -> None: + """整合后的应用必须同时保留新底座平台路由和场外基金业务路由。""" + paths = { + child.path + for route in create_app().routes + if hasattr(route, "original_router") + for child in route.original_router.routes + if hasattr(child, "path") + } + + assert "/api/v1/agent-runs" in paths + assert any(path.startswith("/api/v1/offsite-fund") for path in paths) + + async def test_unauthorized_envelope_shape() -> None: """401 的错误信封必须与文档一致,否则客户端无法统一处理。""" response = await send("GET", "/api/v1/agent-runs/run-x") diff --git a/tests/unit/api/test_customer_service_test_page.py b/tests/unit/api/test_customer_service_test_page.py new file mode 100644 index 0000000..5cd05da --- /dev/null +++ b/tests/unit/api/test_customer_service_test_page.py @@ -0,0 +1,13 @@ +from fastapi.testclient import TestClient + +from app.main import create_app + + +def test_customer_service_test_page_exposes_visitor_agent_flow() -> None: + with TestClient(create_app()) as client: + response = client.get("/customer-service-test/") + + assert response.status_code == 200 + assert "奶龙基金智能助手" in response.text + assert "/api/v1/visitor-tokens" in response.text + assert "/api/v1/agent-runs" in response.text diff --git a/tests/unit/api/test_visitor_tokens.py b/tests/unit/api/test_visitor_tokens.py new file mode 100644 index 0000000..bc448f1 --- /dev/null +++ b/tests/unit/api/test_visitor_tokens.py @@ -0,0 +1,14 @@ +from fastapi.testclient import TestClient + +from app.main import create_app + + +def test_visitor_token_endpoint_returns_short_lived_bearer_token() -> None: + with TestClient(create_app()) as client: + response = client.post("/api/v1/visitor-tokens") + + assert response.status_code == 201 + body = response.json() + assert isinstance(body["access_token"], str) + assert body["token_type"] == "Bearer" + assert body["expires_in"] == 900 diff --git a/tests/unit/core/test_security.py b/tests/unit/core/test_security.py index 6bdf3e8..722a211 100644 --- a/tests/unit/core/test_security.py +++ b/tests/unit/core/test_security.py @@ -6,7 +6,7 @@ import pytest from app.core.config import Settings from app.core.errors import UnauthorizedAgentError -from app.core.security import JwtAuthenticator +from app.core.security import JwtAuthenticator, VisitorTokenIssuer def _settings() -> Settings: @@ -39,6 +39,35 @@ def test_authenticate_valid_token() -> None: assert context.trace_id +def test_authenticate_visitor_token_returns_limited_anonymous_context() -> None: + now = datetime.now(UTC) + private_key = Path("config/jwt/jwt-private.pem").read_text(encoding="utf-8") + token = jwt.encode( + {"sub": "2", "iss": "jr-auth", "aud": "jr-agent-platform", "iat": now, + "nbf": now, "exp": now + timedelta(minutes=5), "jti": "visitor-jti-1", + "visitor": True}, + private_key, + algorithm="RS256", + ) + + context = JwtAuthenticator(_settings()).authenticate(token) + + assert context.roles == ("visitor",) + assert context.permissions == ("agent:run", "knowledge:query") + assert context.customer_ids == () + assert context.data_scope == "public" + + +def test_visitor_token_issuer_creates_short_lived_limited_token() -> None: + token, expires_at = VisitorTokenIssuer(_settings()).issue() + + context = JwtAuthenticator(_settings()).authenticate(token) + + assert context.roles == ("visitor",) + assert context.permissions == ("agent:run", "knowledge:query") + assert expires_at > datetime.now(UTC) + + @pytest.mark.parametrize("subject", ["abc", "", "0", "-1", "1.5", "12", "1" * 21, "18446744073709551616"]) def test_invalid_numeric_subject_is_unauthorized(subject): @@ -62,6 +91,37 @@ def test_signed_invalid_subject_returns_401_before_identity_query(monkeypatch): resolve.assert_not_awaited() +@pytest.mark.asyncio +async def test_visitor_token_skips_identity_database_resolution(monkeypatch): + from unittest.mock import AsyncMock + + from fastapi.security import HTTPAuthorizationCredentials + from starlette.requests import Request + + from app.api.dependencies.auth import _authenticator, build_request_context + + now = datetime.now(UTC) + private_key = Path("config/jwt/jwt-private.pem").read_text(encoding="utf-8") + token = jwt.encode( + {"sub": "2", "iss": "jr-local", "aud": "jr-agent-platform", "iat": now, + "nbf": now, "exp": now + timedelta(minutes=5), "jti": "visitor-jti-2", + "visitor": True}, + private_key, + algorithm="RS256", + ) + resolve = AsyncMock() + monkeypatch.setattr("app.service.identity_service.IdentityService.resolve", resolve) + _authenticator.cache_clear() + request = Request({"type": "http", "method": "GET", "path": "/", "headers": []}) + + context = await build_request_context( + request, HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) + ) + + assert context.roles == ("visitor",) + resolve.assert_not_awaited() + + def test_authenticate_rejects_expired_token() -> None: with pytest.raises(UnauthorizedAgentError): now = datetime.now(UTC) diff --git a/tests/unit/infrastructure/test_milvus_knowledge_adapter.py b/tests/unit/infrastructure/test_milvus_knowledge_adapter.py new file mode 100644 index 0000000..f730288 --- /dev/null +++ b/tests/unit/infrastructure/test_milvus_knowledge_adapter.py @@ -0,0 +1,47 @@ +import pytest + +from app.core.errors import ForbiddenAgentError +from app.infrastructure.milvus_knowledge_adapter import MilvusKnowledgeClient + + +class FakeMilvus: + def __init__(self) -> None: + self.kwargs = None + + async def search(self, **kwargs): + self.kwargs = kwargs + return [[{ + "distance": 0.91, + "entity": { + "knowledge_id": "101", + "snippet": "开户说明", + "title": "基金开户", + "tags": ["开户"], + "version": "v1", + }, + }]] + + +@pytest.mark.asyncio +async def test_knowledge_adapter_uses_cosine_and_minimal_public_projection() -> None: + client = MilvusKnowledgeClient("http://unused") + fake = FakeMilvus() + client._client = fake + + hits = await client.search("fin_faq_collection", [0.1] * 1024, 3) + + assert hits[0]["knowledge_id"] == "101" + assert hits[0]["snippet"] == "开户说明" + assert hits[0]["score"] == 0.91 + assert fake.kwargs["collection_name"] == "fin_faq_collection" + assert fake.kwargs["limit"] == 3 + assert fake.kwargs["search_params"] == {"metric_type": "COSINE"} + assert fake.kwargs["output_fields"] == ["knowledge_id", "title", "snippet", "tags", "version"] + + +@pytest.mark.asyncio +async def test_knowledge_adapter_rejects_non_public_collection() -> None: + client = MilvusKnowledgeClient("http://unused") + + with pytest.raises(ForbiddenAgentError): + await client.search("customer_vectors", [0.1] * 1024, 3) diff --git a/tests/unit/repository/test_fund_readonly_contract.py b/tests/unit/repository/test_fund_readonly_contract.py index 6f014cb..91bb2ba 100644 --- a/tests/unit/repository/test_fund_readonly_contract.py +++ b/tests/unit/repository/test_fund_readonly_contract.py @@ -9,6 +9,7 @@ from typing import Any import pytest +import app.model.fund as fund_models from app.model.base import Base REPOSITORY_PATH = "app/repository/fund_query_repository.py" @@ -123,10 +124,11 @@ def test_fund_model_module_contains_only_column_mappings() -> None: def test_fund_models_cover_all_fin_tables_with_expected_columns() -> None: + assert fund_models tables: dict[str, Any] = { name: table for name, table in Base.metadata.tables.items() - if name.startswith("fin_") + if name in FUND_TABLES } assert set(tables) == set(FUND_TABLES) for name, (column_count, primary_key) in FUND_TABLES.items(): @@ -137,7 +139,7 @@ def test_fund_models_cover_all_fin_tables_with_expected_columns() -> None: def test_fund_models_declare_no_foreign_keys_so_no_cascade_writes() -> None: for name, table in Base.metadata.tables.items(): - if not name.startswith("fin_"): + if name not in FUND_TABLES: continue for column in table.columns: assert not column.foreign_keys, f"{name}.{column.name}" diff --git a/tests/unit/service/test_agent_governance.py b/tests/unit/service/test_agent_governance.py index c809b05..8e11842 100644 --- a/tests/unit/service/test_agent_governance.py +++ b/tests/unit/service/test_agent_governance.py @@ -23,6 +23,36 @@ def test_all_governance_hooks_protected(name): type("Bypass", (BaseAgent,), {name: lambda *args: None}) +@pytest.mark.asyncio +async def test_visitor_does_not_recall_customer_memory() -> None: + """访客不能以匿名主体标识读取任何客户记忆。""" + class Demo(BaseAgent): + async def handle(self, request, context): + return CoreResult(text="unused") + + class FailingGovernance: + async def recall(self, context): + raise AssertionError("visitor memory recall is forbidden") + + definition = AgentDefinition( + agent_type="demo", version="1", allowed_roles=("visitor",), allowed_portals=("api",) + ) + agent = Demo(definition) + agent.bind_governance(FailingGovernance()) + request = AgentRequest( + agent_type="demo", message="公开问题", session_id="visitor-session", + idempotency_key="visitor-memory-request-0001", + ) + context = RequestContext( + user_id="visitor-id", trace_id="visitor-trace", roles=("visitor",), + permissions=("agent:run",), data_scope="public", + ) + + await agent.recall_memory(request, context) + + assert agent.memories == () + + async def test_resolve_recall_handle_review_order_and_snapshot(governance): calls = [] config = ResolvedAgentConfig(config_version="released", prompt_version="p", model_endpoint="m") diff --git a/tests/unit/service/test_agent_persistence_handover.py b/tests/unit/service/test_agent_persistence_handover.py new file mode 100644 index 0000000..544fa45 --- /dev/null +++ b/tests/unit/service/test_agent_persistence_handover.py @@ -0,0 +1,121 @@ +from datetime import UTC, datetime +from typing import Any + +import pytest + +from app.core.contracts import AgentResult, CoreResult, IntentResult +from app.model.conversation import ConversationMessage +from app.model.platform import AgentRun, DomainEventOutbox, HandoverTicket +from app.service.agent_persistence_service import AgentPersistenceService + + +class FakeTransaction: + async def __aenter__(self) -> None: + return None + + async def __aexit__( + self, exc_type: object, exc_value: object, traceback: object + ) -> bool: + return False + + +class FakeSession: + """仅收集持久化服务在同一事务内计划写入的 ORM 实体。""" + + def __init__(self, run: AgentRun) -> None: + self.run = run + self.added: list[Any] = [] + self.executed: list[Any] = [] + + def begin(self) -> FakeTransaction: + return FakeTransaction() + + async def scalar(self, statement: object) -> AgentRun: + return self.run + + def add(self, item: Any) -> None: + self.added.append(item) + + async def flush(self) -> None: + for item in self.added: + if isinstance(item, ConversationMessage) and item.id is None: + item.id = 901 + + async def execute(self, statement: object) -> None: + self.executed.append(statement) + + +def queued_run() -> AgentRun: + now = datetime.now(UTC).replace(tzinfo=None) + return AgentRun( + id=1, + run_id="run-transfer-1", + idempotency_id=2, + session_id="session-transfer-1", + user_id=7, + agent_type="customer_service", + trace_id="trace-transfer-1", + request_message_id=800, + status="queued", + created_at=now, + updated_at=now, + ) + + +def result(*, transfer_required: bool) -> AgentResult: + return AgentResult( + run_id="run-transfer-1", + result=CoreResult( + text="已为您转接人工客服。", + intent=IntentResult(intent="human_handover", confidence=1), + transfer_required=transfer_required, + transfer_reason="user_requested" if transfer_required else None, + ), + ) + + +def added_of(items: list[Any], model: type[Any]) -> list[Any]: + return [item for item in items if isinstance(item, model)] + + +@pytest.mark.asyncio +async def test_transfer_required_result_creates_pending_ticket_and_outbox_event() -> None: + """Agent 发起的转人工必须在完成运行的事务内留待处理工单和通知事件。""" + session = FakeSession(queued_run()) + + await AgentPersistenceService(session).complete_run( + "run-transfer-1", result(transfer_required=True), memory_extraction_requested=False + ) + + tickets = added_of(session.added, HandoverTicket) + events = added_of(session.added, DomainEventOutbox) + assert len(tickets) == 1 + assert tickets[0].status == "pending" + assert tickets[0].session_id == "session-transfer-1" + assert tickets[0].customer_id == 7 + assert tickets[0].source_agent == "customer_service" + assert tickets[0].source_message_id == 901 + assert tickets[0].reason_code == "user_requested" + assert len(events) == 2 # agent.run_completed + conversation.transfer_requested + transfer_event = next( + event for event in events if event.event_type == "conversation.transfer_requested" + ) + assert transfer_event.aggregate_type == "conversation" + assert transfer_event.aggregate_id == "session-transfer-1" + assert transfer_event.payload["ticket_no"] == tickets[0].ticket_no + + +@pytest.mark.asyncio +async def test_normal_result_does_not_create_handover_ticket_or_event() -> None: + """非转人工回答不得污染管理员待处理队列。""" + session = FakeSession(queued_run()) + + await AgentPersistenceService(session).complete_run( + "run-transfer-1", result(transfer_required=False), memory_extraction_requested=False + ) + + assert added_of(session.added, HandoverTicket) == [] + assert all( + event.event_type != "conversation.transfer_requested" + for event in added_of(session.added, DomainEventOutbox) + ) diff --git a/tests/unit/service/test_bootstrap.py b/tests/unit/service/test_bootstrap.py index d5905df..75cedb6 100644 --- a/tests/unit/service/test_bootstrap.py +++ b/tests/unit/service/test_bootstrap.py @@ -11,3 +11,7 @@ def test_bootstrap_assembles_common_model_and_tool_services() -> None: assert isinstance(factory._intent_classifier, IntentClassifier) assert factory._intent_endpoint_resolver is not None assert factory._tool_executor.registry.get("check_suitability").read_only is True + knowledge_tool = factory._tool_executor.registry.get("query_knowledge") + assert knowledge_tool.required_permission == "knowledge:query" + assert knowledge_tool.allowed_roles == ("visitor", "customer") + assert knowledge_tool.read_only is True diff --git a/tests/unit/service/test_customer_service_agent.py b/tests/unit/service/test_customer_service_agent.py new file mode 100644 index 0000000..9ea6e36 --- /dev/null +++ b/tests/unit/service/test_customer_service_agent.py @@ -0,0 +1,171 @@ +import pytest + +from app.core.contracts import AgentRequest, AgentRequestMetadata, RequestContext +from app.core.errors import RecoverableAgentError +from app.core.knowledge_contracts import KnowledgeHit, KnowledgeSearchResult +from app.service.agent.base import BaseAgent +from app.service.agent.bootstrap import get_agent_factory +from app.service.agent.customer_service_agent import CustomerServiceAgent +from app.service.agent.customer_service_routing import CustomerServiceIntentRouter + + +def request(message: str, *, chitchat_streak: int = 0) -> AgentRequest: + return AgentRequest( + agent_type="customer_service", + message=message, + session_id="customer-service-session", + idempotency_key="customer-service-idempotency-key", + metadata=AgentRequestMetadata(chitchat_streak=chitchat_streak), + ) + + +def context(role: str) -> RequestContext: + return RequestContext( + user_id="1", + trace_id="customer-service-trace", + roles=(role,), + permissions=("agent:run", "knowledge:query"), + data_scope="public" if role == "visitor" else "self", + ) + + +@pytest.mark.asyncio +async def test_visitor_account_question_only_returns_login_entry() -> None: + result = await CustomerServiceAgent().handle( + request("我的持仓收益是多少"), context("visitor") + ) + + assert result.text == "我无法查询账户数据,请先登录后前往“我的账户”查看相关状态。" + assert result.transfer_required is False + + +@pytest.mark.asyncio +async def test_authenticated_account_question_only_returns_account_entry() -> None: + result = await CustomerServiceAgent().handle(request("查一下我的订单"), context("customer")) + + assert result.text == "我无法查询账户数据,请前往“我的账户”查看相关状态。" + assert result.transfer_required is False + + +@pytest.mark.asyncio +async def test_security_question_requires_human_transfer() -> None: + result = await CustomerServiceAgent().handle( + request("验证码已经发给别人了"), context("visitor") + ) + + assert result.transfer_required is True + assert result.transfer_reason == "security_notice" + + +@pytest.mark.asyncio +async def test_personalized_investment_advice_is_refused_and_transferred() -> None: + result = await CustomerServiceAgent().handle( + request("帮我推荐一只收益最高的基金"), context("customer") + ) + + assert result.transfer_required is True + assert result.transfer_reason == "compliance_refusal" + + +def test_customer_service_agent_is_registered_for_visitor_role() -> None: + agent = get_agent_factory().create("customer_service", context("visitor")) + + assert isinstance(agent, CustomerServiceAgent) + + +def test_chitchat_streak_is_derived_from_continuous_prior_messages() -> None: + assert CustomerServiceIntentRouter.chitchat_streak( + ("你好", "讲个笑话", "你开心吗"), "在吗" + ) == 4 + assert CustomerServiceIntentRouter.chitchat_streak( + ("你好", "基金怎么开户"), "在吗" + ) == 1 + + +@pytest.mark.asyncio +async def test_policy_question_uses_only_policy_knowledge(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + async def fake_call_tool( + self: BaseAgent, + name: str, + arguments: dict[str, object], + *, + intent: str, + context: RequestContext, + ) -> KnowledgeSearchResult: + captured.update(name=name, arguments=arguments, intent=intent) + return KnowledgeSearchResult( + hits=( + KnowledgeHit( + knowledge_id="101", + collection="fin_policy_collection", + snippet="赎回规则摘要", + answer="这是已审核的赎回公开规则。", + ), + ) + ) + + monkeypatch.setattr(BaseAgent, "call_tool", fake_call_tool) + + result = await CustomerServiceAgent().handle( + request("基金赎回到账规则是什么"), context("customer") + ) + + assert result.text == "这是已审核的赎回公开规则。" + assert captured == { + "name": "query_knowledge", + "arguments": { + "query": "基金赎回到账规则是什么", + "intents": ("policy_explain",), + "top_k": 5, + }, + "intent": "public_knowledge", + } + + +@pytest.mark.asyncio +async def test_knowledge_failure_requires_human_transfer(monkeypatch: pytest.MonkeyPatch) -> None: + async def unavailable_tool( + self: BaseAgent, + name: str, + arguments: dict[str, object], + *, + intent: str, + context: RequestContext, + ) -> KnowledgeSearchResult: + raise RecoverableAgentError("知识检索不可用") + + monkeypatch.setattr(BaseAgent, "call_tool", unavailable_tool) + + result = await CustomerServiceAgent().handle(request("基金怎么开户"), context("visitor")) + + assert result.transfer_required is True + assert result.transfer_reason == "knowledge_unavailable" + + +@pytest.mark.asyncio +async def test_fourth_chitchat_message_is_guided_once_without_knowledge_lookup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def unexpected_tool( + self: BaseAgent, + name: str, + arguments: dict[str, object], + *, + intent: str, + context: RequestContext, + ) -> KnowledgeSearchResult: + raise AssertionError("闲聊不应调用公开知识工具") + + monkeypatch.setattr(BaseAgent, "call_tool", unexpected_tool) + + guided = await CustomerServiceAgent().handle( + request("你今天开心吗", chitchat_streak=4), context("visitor") + ) + ordinary = await CustomerServiceAgent().handle( + request("你今天开心吗", chitchat_streak=5), context("visitor") + ) + + assert "基金业务" in guided.text + assert "基金业务" not in ordinary.text diff --git a/tests/unit/service/test_customer_service_chitchat_metadata.py b/tests/unit/service/test_customer_service_chitchat_metadata.py new file mode 100644 index 0000000..17fa6b8 --- /dev/null +++ b/tests/unit/service/test_customer_service_chitchat_metadata.py @@ -0,0 +1,16 @@ +from app.core.contracts import AgentRequest, AgentRequestMetadata +from app.service.agent_run_application_service import build_outbox_metadata + + +def test_customer_service_outbox_metadata_overrides_supplied_chitchat_streak() -> None: + request = AgentRequest( + agent_type="customer_service", + message="在吗", + session_id="customer-service-session", + idempotency_key="customer-service-idempotency-key", + metadata=AgentRequestMetadata(chitchat_streak=5), + ) + + metadata = build_outbox_metadata(request, ("你好", "基金怎么开户")) + + assert metadata["chitchat_streak"] == 1 diff --git a/tests/unit/service/test_financial_nl2sql_agent_integration.py b/tests/unit/service/test_financial_nl2sql_agent_integration.py new file mode 100644 index 0000000..1437f14 --- /dev/null +++ b/tests/unit/service/test_financial_nl2sql_agent_integration.py @@ -0,0 +1,104 @@ +from typing import Any, cast + +import pytest +from _pytest.monkeypatch import MonkeyPatch + +from app.core.contracts import ( + AgentDefinition, + AgentRequest, + AgentResult, + CoreResult, + RecalledMemory, + RequestContext, + ResolvedAgentConfig, +) +from app.core.nl2sql_contracts import FinancialNL2SQLInput +from app.service.agent.base import BaseAgent +from app.service.agent.factory import AgentFactory +from app.service.financial_nl2sql_service import query_financial_data_tool +from app.service.tool_executor import ToolDefinition, ToolExecutor, ToolRegistry + + +class ExampleBusinessAgent(BaseAgent): + definition = AgentDefinition( + agent_type="example_business", + version="1.0.0", + allowed_tools=("query_financial_data",), + allowed_roles=("advisor", "operator"), + allowed_portals=("api",), + supported_intents=("financial_query",), + ) + + async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult: + result = cast(dict[str, Any], await self.call_tool( + "query_financial_data", + {"question": request.message, "dry_run": True}, + intent="financial_query", + context=context, + )) + return CoreResult(text=result["message"]) + + +@pytest.mark.asyncio +async def test_external_agent_can_call_financial_tool(monkeypatch: MonkeyPatch) -> None: + registry = ToolRegistry() + registry.register(ToolDefinition( + name="query_financial_data", + input_model=FinancialNL2SQLInput, + handler=cast(Any, query_financial_data_tool), + required_permission="financial:nl2sql:read", + allowed_roles=("advisor", "operator"), + )) + executor = ToolExecutor(registry) + monkeypatch.setattr(executor, "_audit", lambda *args: __import__("asyncio").sleep(0)) + + class TestGovernance: + async def resolve( + self, definition: AgentDefinition, context: RequestContext + ) -> ResolvedAgentConfig: + del definition, context + return ResolvedAgentConfig( + config_version="test", + prompt_version="test", + model_endpoint="test", + allowed_tools_by_intent={ + "financial_query": ("query_financial_data",) + }, + ) + + async def recall(self, context: RequestContext) -> tuple[RecalledMemory, ...]: + del context + return () + + async def review( + self, + result: AgentResult, + context: RequestContext, + config: ResolvedAgentConfig, + memories: tuple[RecalledMemory, ...], + ) -> AgentResult: + del context, config, memories + return result + + factory = AgentFactory(TestGovernance(), tool_executor=executor) + factory.register( + ExampleBusinessAgent.definition, + lambda _context: ExampleBusinessAgent(ExampleBusinessAgent.definition), + ) + context = RequestContext( + user_id="1", + trace_id="trace-agent-integration", + roles=("advisor",), + permissions=("agent:run", "financial:nl2sql:read"), + data_scope="all", + ) + agent = factory.create("example_business", context) + await agent.resolve_config(context) + request = AgentRequest( + agent_type="example_business", + message="查询159511近30天行情收盘价", + session_id="s", + idempotency_key="integration-key-123456", + ) + result = await agent.handle(request, context) + assert result.text == "SQL 已生成并通过只读校验" diff --git a/tests/unit/service/test_financial_nl2sql_golden_cases.py b/tests/unit/service/test_financial_nl2sql_golden_cases.py new file mode 100644 index 0000000..2d16bed --- /dev/null +++ b/tests/unit/service/test_financial_nl2sql_golden_cases.py @@ -0,0 +1,100 @@ +import pytest + +from app.core.contracts import RequestContext +from app.core.nl2sql_contracts import FinancialNL2SQLInput +from app.service.financial_nl2sql_service import FinancialNL2SQLService + + +def case( + question: str, status: str, intent: str, tables: set[str] +) -> tuple[str, str, str, set[str]]: + return question, status, intent, tables + + +GOLDEN_CASES = [ + case("查询159511近30天行情收盘价", "ready", "market_price_query", + {"fin_market_price", "fin_product"}), + case("查询588890最近7天行情", "ready", "market_price_query", + {"fin_market_price", "fin_product"}), + case("查看159948近90天收盘价", "ready", "market_price_query", + {"fin_market_price", "fin_product"}), + case("查询159511近30天净值", "ready", "nav_history_query", + {"fin_nav_history", "fin_product"}), + case("查看588890最近7天基金净值", "ready", "nav_history_query", + {"fin_nav_history", "fin_product"}), + case("查询客户当前持仓市值", "ready", "holding_query", + {"fin_holding", "fin_customer_profile", "fin_product"}), + case("查询159511客户持仓盈亏", "ready", "holding_query", + {"fin_holding", "fin_customer_profile", "fin_product"}), + case("统计客户成交交易金额", "ready", "transaction_query", + {"fin_transaction", "fin_customer_profile"}), + case("查询159511成交明细", "ready", "transaction_query", + {"fin_transaction", "fin_product"}), + case("汇总最近30天交易金额和成交数量", "ready", "transaction_query", + {"fin_transaction"}), + case("查询客户委托订单状态", "ready", "order_query", + {"fin_sim_order", "fin_customer_profile"}), + case("查看159511委托状态", "ready", "order_query", + {"fin_sim_order", "fin_product"}), + case("查询客户账户余额", "ready", "account_query", + {"fin_sim_account", "fin_customer_profile"}), + case("查看客户可用现金", "ready", "account_query", + {"fin_sim_account", "fin_customer_profile"}), + case("查询近7天资金流水变化", "ready", "cash_ledger_query", + {"fin_cash_ledger", "fin_sim_account"}), + case("汇总最近30天资金变动", "ready", "cash_ledger_query", + {"fin_cash_ledger", "fin_sim_account"}), + case("查询客户现金流水", "ready", "cash_ledger_query", + {"fin_cash_ledger", "fin_sim_account"}), + case("查询159511费率规则", "ready", "fee_rule_query", + {"fin_fee_rule", "fin_product"}), + case("查看基金费用规则", "ready", "fee_rule_query", + {"fin_fee_rule", "fin_product"}), + case("查询买入卖出费率", "ready", "fee_rule_query", {"fin_fee_rule"}), + case("查询客户风险测评", "ready", "customer_risk_query", + {"fin_risk_assessment"}), + case("统计客户风险等级数量", "ready", "customer_risk_query", + {"fin_customer_profile"}), + case("查看客户画像", "ready", "customer_risk_query", {"fin_customer_profile"}), + case("查询近30天客户风险测评", "ready", "customer_risk_query", + {"fin_risk_assessment"}), + case("查询对客内容审核状态", "need_confirmation", "client_content_query", + {"client_facing_content"}), + case("查看客户内容发布记录", "need_confirmation", "client_content_query", + {"client_facing_content"}), + case("截至某日客户当前持仓市值", "rejected", "holding_query", {"fin_holding"}), + case("截至历史时点账户余额", "rejected", "account_query", {"fin_sim_account"}), + case("帮我看看这个情况", "need_confirmation", "unknown", {"fin_customer_profile"}), + case("这个客户怎么样", "need_confirmation", "unknown", {"fin_customer_profile"}), +] + + +def context() -> RequestContext: + return RequestContext( + user_id="1", + trace_id="trace-golden", + roles=("operator",), + permissions=("financial:nl2sql:read",), + data_scope="all", + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("question", "status", "intent", "tables"), + GOLDEN_CASES, +) +async def test_financial_nl2sql_golden_cases( + question: str, status: str, intent: str, tables: set[str] +) -> None: + result = await FinancialNL2SQLService().query( + FinancialNL2SQLInput(question=question, dry_run=True), + context(), + ) + assert result["status"] == status + assert result["query_plan"]["intent"] == intent + assert tables.issubset(set(result["query_plan"]["tables"])) + + +def test_golden_case_count_reaches_mvp_threshold() -> None: + assert len(GOLDEN_CASES) >= 30 diff --git a/tests/unit/service/test_financial_nl2sql_service.py b/tests/unit/service/test_financial_nl2sql_service.py new file mode 100644 index 0000000..9c57b13 --- /dev/null +++ b/tests/unit/service/test_financial_nl2sql_service.py @@ -0,0 +1,72 @@ +import pytest + +from app.core.contracts import RequestContext +from app.core.nl2sql_contracts import FinancialNL2SQLInput +from app.service.financial_nl2sql_service import FinancialNL2SQLService + + +def context(**updates): + base = RequestContext( + user_id="1", + trace_id="trace-nl2sql", + roles=("advisor",), + permissions=("financial:nl2sql:read",), + data_scope="all", + ) + return base.model_copy(update=updates) + + +@pytest.mark.asyncio +async def test_generates_read_only_market_sql_with_product_filter() -> None: + result = await FinancialNL2SQLService().query( + FinancialNL2SQLInput(question="查询159511近30天行情收盘价", dry_run=True), + context(), + ) + assert result["status"] == "ready" + assert result["sql"].startswith("SELECT ") + assert "fin_market_price" in result["sql"] + assert "DROP" not in result["sql"] + assert result["parameters"]["filter_0"] == "159511" + assert result["audit"]["permission_check"]["status"] == "passed" + + +@pytest.mark.asyncio +async def test_low_confidence_question_requires_confirmation() -> None: + result = await FinancialNL2SQLService().query( + FinancialNL2SQLInput(question="帮我看看这个情况", dry_run=True), + context(), + ) + assert result["status"] == "need_confirmation" + assert result["query_plan"]["confidence"] < 0.85 + + +@pytest.mark.asyncio +async def test_as_of_query_rejects_current_snapshot_tables() -> None: + result = await FinancialNL2SQLService().query( + FinancialNL2SQLInput(question="截至某日客户当前持仓市值", dry_run=True), + context(), + ) + assert result["status"] == "rejected" + assert "历史版本" in result["message"] + + +@pytest.mark.asyncio +async def test_customer_scope_is_injected_for_non_all_scope() -> None: + result = await FinancialNL2SQLService().query( + FinancialNL2SQLInput(question="查询客户账户余额", dry_run=True), + context(data_scope="own_customers", customer_ids=("7", "8")), + ) + assert "customer_id IN" in result["sql"] + assert result["parameters"]["scope_customer_0"] == 7 + assert result["parameters"]["scope_customer_1"] == 8 + + +@pytest.mark.asyncio +async def test_cash_ledger_is_in_nl2sql_scope() -> None: + result = await FinancialNL2SQLService().query( + FinancialNL2SQLInput(question="查询近7天资金流水变化", dry_run=True), + context(), + ) + assert result["status"] == "ready" + assert "fin_cash_ledger" in result["sql"] + assert result["query_plan"]["intent"] == "cash_ledger_query" diff --git a/tests/unit/service/test_intent_agent_entrypoint.py b/tests/unit/service/test_intent_agent_entrypoint.py index 5f66483..f227721 100644 --- a/tests/unit/service/test_intent_agent_entrypoint.py +++ b/tests/unit/service/test_intent_agent_entrypoint.py @@ -51,6 +51,34 @@ async def test_execute_classifies_before_handle_and_attaches_result(governance) assert result["intent"]["confidence"] == 0.9 +@pytest.mark.asyncio +async def test_fixed_route_agent_skips_model_intent_classification(governance) -> None: + """固定路由 Agent 不能因模型意图端点不可用而阻断服务。""" + definition = AgentDefinition( + agent_type="demo", version="1", allowed_roles=("customer",), + allowed_portals=("api",), requires_model_intent_classification=False, + ) + factory = AgentFactory( + governance=governance, + intent_classifier=IntentClassifier(StubModel()), + intent_endpoint_resolver=StubResolver(), + ) + factory.register(definition, lambda _context: DemoAgent(definition)) + context = RequestContext( + user_id="1", trace_id="fixed-route", roles=("customer",), permissions=("agent:run",) + ) + request = AgentRequest( + agent_type="demo", message="本地路由", session_id="s", + idempotency_key="fixed-route-request-0001", + ) + + events = [ + event async for event in factory.create("demo", context).execute(request, context, "run") + ] + + assert events[-1].payload["result"]["result"]["intent"] is None + + def test_business_agent_cannot_override_intent_governance() -> None: with pytest.raises(TypeError, match="classify_intent"): class InvalidAgent(BaseAgent): diff --git a/tests/unit/service/test_knowledge_authority.py b/tests/unit/service/test_knowledge_authority.py new file mode 100644 index 0000000..79982f1 --- /dev/null +++ b/tests/unit/service/test_knowledge_authority.py @@ -0,0 +1,61 @@ +import pytest + +from app.core.knowledge_contracts import KnowledgeHit, KnowledgeQuery +from app.service.knowledge_authority import KnowledgeMysqlAuthority + + +class FakeRow: + id = 101 + title = "申购规则" + content_text = '{"answer":"工作日确认"}' + version = "v2" + milvus_collection = "fin_policy_collection" + + +class FakeSession: + statement = None + + async def scalars(self, statement): + self.statement = statement + return [FakeRow()] + + +@pytest.mark.asyncio +async def test_authority_filters_to_published_active_effective_knowledge() -> None: + session = FakeSession() + authority = KnowledgeMysqlAuthority(session) + + hits = await authority.filter_published(( + KnowledgeHit( + knowledge_id="101", collection="fin_policy_collection", snippet="摘要", score=0.91, + ), + )) + + statement = str(session.statement) + assert "review_status" in statement + assert "status" in statement + assert "effective_date" in statement + assert "expire_date" in statement + assert hits[0].answer == "工作日确认" + assert hits[0].version == "v2" + + +@pytest.mark.asyncio +async def test_authority_keyword_fallback_only_returns_effective_public_knowledge() -> None: + session = FakeSession() + authority = KnowledgeMysqlAuthority(session) + + hits = await authority.search_keyword( + KnowledgeQuery(query="基金 申购确认", intents=("policy_explain",)), + ("fin_policy_collection",), + 5, + ) + + statement = str(session.statement) + assert "milvus_collection" in statement + assert "content_text" in statement + assert "review_status" in statement + assert "effective_date" in statement + assert hits[0].knowledge_id == "101" + assert hits[0].collection == "fin_policy_collection" + assert hits[0].answer == "工作日确认" diff --git a/tests/unit/service/test_knowledge_publication_service.py b/tests/unit/service/test_knowledge_publication_service.py new file mode 100644 index 0000000..e1efa8a --- /dev/null +++ b/tests/unit/service/test_knowledge_publication_service.py @@ -0,0 +1,119 @@ +from dataclasses import dataclass + +import pytest + +from app.service.knowledge_publication_service import ( + KnowledgePublicationError, + KnowledgePublicationService, +) + + +@dataclass(frozen=True) +class Record: + qa_id: str + milvus_collection: str + retrieval_text: str + title: str = "基金开户" + snippet: str = "基金开户" + tags: tuple[str, ...] = ("开户",) + version: str = "v5.8" + + +class FakeEmbedder: + def __init__(self, vector: list[float] | None = None) -> None: + self.vector = vector or [0.1] * 1024 + self.calls: list[str] = [] + + async def embed(self, text: str) -> list[float]: + self.calls.append(text) + return self.vector + + +class FakeStore: + def __init__(self) -> None: + self.staged: list[Record] = [] + self.published: list[int] = [] + self.disabled: list[int] = [] + + async def stage(self, records: tuple[Record, ...]) -> dict[str, int]: + self.staged.extend(records) + return {record.qa_id: index for index, record in enumerate(records, start=101)} + + async def publish(self, knowledge_ids: tuple[int, ...], reviewer_id: int) -> None: + assert reviewer_id == 9 + self.published.extend(knowledge_ids) + + async def disable(self, knowledge_ids: tuple[int, ...]) -> None: + self.disabled.extend(knowledge_ids) + + +class FakeVectors: + def __init__(self, *, fail_collection: str | None = None) -> None: + self.fail_collection = fail_collection + self.upserts: list[tuple[str, tuple[dict[str, object], ...]]] = [] + self.deleted: list[tuple[str, tuple[str, ...]]] = [] + + async def upsert(self, collection: str, records: tuple[dict[str, object], ...]) -> None: + self.upserts.append((collection, records)) + if collection == self.fail_collection: + raise RuntimeError("milvus unavailable") + + async def delete(self, collection: str, knowledge_ids: tuple[str, ...]) -> None: + self.deleted.append((collection, knowledge_ids)) + + +@pytest.mark.asyncio +async def test_publication_stages_vectors_then_publishes_after_all_collections_succeed() -> None: + store = FakeStore() + vectors = FakeVectors() + service = KnowledgePublicationService(FakeEmbedder(), store, vectors) + records = ( + Record("FAQ-001", "fin_faq_collection", "标准问题:基金开户"), + Record("POL-001", "fin_policy_collection", "标准问题:风险测评"), + ) + + result = await service.publish(records, reviewer_id=9) + + assert result.knowledge_ids == {"FAQ-001": 101, "POL-001": 102} + assert [collection for collection, _payload in vectors.upserts] == [ + "fin_faq_collection", "fin_policy_collection" + ] + assert store.published == [101, 102] + assert store.disabled == [] + payload = vectors.upserts[0][1][0] + assert payload["knowledge_id"] == "101" + assert len(payload["embedding"]) == 1024 + + +@pytest.mark.asyncio +async def test_vector_failure_keeps_staged_rows_unpublished_and_compensates_vectors() -> None: + store = FakeStore() + vectors = FakeVectors(fail_collection="fin_policy_collection") + service = KnowledgePublicationService(FakeEmbedder(), store, vectors) + records = ( + Record("FAQ-001", "fin_faq_collection", "标准问题:基金开户"), + Record("POL-001", "fin_policy_collection", "标准问题:风险测评"), + ) + + with pytest.raises(KnowledgePublicationError, match="向量写入失败"): + await service.publish(records, reviewer_id=9) + + assert store.published == [] + assert store.disabled == [101, 102] + assert vectors.deleted == [ + ("fin_faq_collection", ("101",)), + ("fin_policy_collection", ("102",)), + ] + + +@pytest.mark.asyncio +async def test_invalid_embedding_dimension_blocks_all_database_and_vector_writes() -> None: + store = FakeStore() + vectors = FakeVectors() + service = KnowledgePublicationService(FakeEmbedder([0.1] * 512), store, vectors) + + with pytest.raises(KnowledgePublicationError, match="1024"): + await service.publish((Record("FAQ-001", "fin_faq_collection", "基金开户"),), reviewer_id=9) + + assert store.staged == [] + assert vectors.upserts == [] diff --git a/tests/unit/service/test_knowledge_retrieval.py b/tests/unit/service/test_knowledge_retrieval.py new file mode 100644 index 0000000..8bc112b --- /dev/null +++ b/tests/unit/service/test_knowledge_retrieval.py @@ -0,0 +1,87 @@ +import pytest + +from app.core.contracts import RequestContext +from app.core.errors import RecoverableAgentError +from app.core.knowledge_contracts import KnowledgeHit, KnowledgeQuery +from app.service.knowledge_config import KnowledgeRuntimeConfig +from app.service.knowledge_retrieval_service import KnowledgeRetrievalService + +# ruff: noqa: E501 + + +class FakeEmbedder: + async def embed(self, text: str) -> list[float]: + assert text == "开户" + return [0.1] * 1024 + + +class FakeVectorStore: + def __init__(self) -> None: + self.calls: list[tuple[str, int]] = [] + + async def search(self, collection: str, vector: list[float], top_k: int) -> list[dict[str, object]]: + assert len(vector) == 1024 + self.calls.append((collection, top_k)) + return [] + + +class FakeAuthority: + async def filter_published(self, hits: tuple[object, ...]) -> list[object]: + return [] + + async def search_keyword(self, query: object, collections: tuple[str, ...], top_k: int) -> list[object]: + return [] + + +class BrokenVectorStore: + async def search(self, collection: str, vector: list[float], top_k: int) -> list[dict[str, object]]: + raise RecoverableAgentError("知识检索不可用") + + +class FallbackAuthority: + def __init__(self) -> None: + self.calls: list[tuple[tuple[str, ...], int]] = [] + + async def filter_published(self, hits: tuple[object, ...]) -> list[object]: + return [] + + async def search_keyword(self, query: KnowledgeQuery, collections: tuple[str, ...], top_k: int) -> list[KnowledgeHit]: + self.calls.append((collections, top_k)) + return [KnowledgeHit( + knowledge_id="101", collection="fin_policy_collection", snippet="确认规则", + answer="工作日确认", score=1.0, + )] + + +@pytest.mark.asyncio +async def test_search_uses_faq_collection_for_faq_only() -> None: + vector_store = FakeVectorStore() + service = KnowledgeRetrievalService( + FakeEmbedder(), vector_store, KnowledgeRuntimeConfig(), FakeAuthority() + ) + + result = await service.search( + KnowledgeQuery(query="开户", intents=("faq",)), + RequestContext(user_id="visitor-1", trace_id="trace", roles=("visitor",), data_scope="public"), + ) + + assert vector_store.calls == [("fin_faq_collection", 3)] + assert result.searched_collections == ("fin_faq_collection",) + + +@pytest.mark.asyncio +async def test_milvus_failure_falls_back_to_published_active_unexpired_knowledge() -> None: + authority = FallbackAuthority() + service = KnowledgeRetrievalService( + FakeEmbedder(), BrokenVectorStore(), KnowledgeRuntimeConfig(), authority + ) + + result = await service.search( + KnowledgeQuery(query="开户", intents=("policy_explain",)), + RequestContext(user_id="visitor-1", trace_id="trace", roles=("visitor",), data_scope="public"), + ) + + assert authority.calls == [(("fin_policy_collection",), 5)] + assert result.degraded is True + assert result.degradation_reason == "milvus_unavailable" + assert result.hits[0].answer == "工作日确认" diff --git a/tests/unit/service/test_knowledge_tool_service.py b/tests/unit/service/test_knowledge_tool_service.py new file mode 100644 index 0000000..1310736 --- /dev/null +++ b/tests/unit/service/test_knowledge_tool_service.py @@ -0,0 +1,131 @@ +import pytest + +from app.core.contracts import RequestContext +from app.core.knowledge_contracts import KnowledgeHit, KnowledgeQuery, KnowledgeSearchResult +from app.service import knowledge_tool_service +from app.service.knowledge_tool_service import DatabaseEmbeddingAdapter, query_knowledge_tool + + +class FakeGateway: + def __init__(self) -> None: + self.calls: list[tuple[str, str, int]] = [] + + async def embed(self, *, endpoint_code: str, text: str, timeout_ms: int) -> list[float]: + self.calls.append((endpoint_code, text, timeout_ms)) + return [0.1] * 1024 + + +@pytest.mark.asyncio +async def test_embedding_adapter_uses_single_text_gateway_contract() -> None: + gateway = FakeGateway() + adapter = DatabaseEmbeddingAdapter("knowledge-embedding", 15000, gateway=gateway) + + vector = await adapter.embed("基金开户") + + assert len(vector) == 1024 + assert gateway.calls == [("knowledge-embedding", "基金开户", 15000)] + + +@pytest.mark.asyncio +async def test_query_tool_degrades_when_embedding_endpoint_is_unconfigured(monkeypatch) -> None: + class Settings: + knowledge_embedding_endpoint_code = "" + + monkeypatch.setattr("app.service.knowledge_tool_service.get_settings", lambda: Settings()) + + result = await query_knowledge_tool( + KnowledgeQuery(query="基金开户", intents=("faq",)), + RequestContext( + user_id="visitor-1", trace_id="trace", roles=("visitor",), data_scope="public" + ), + ) + + assert result.degraded is True + assert result.degradation_reason == "embedding_endpoint_unconfigured" + + +@pytest.mark.asyncio +async def test_query_tool_uses_configured_embedding_endpoint_and_read_only_dependencies( + monkeypatch, +) -> None: + class Settings: + knowledge_embedding_endpoint_code = "knowledge-embedding" + knowledge_embedding_timeout_ms = 15000 + milvus_uri = "http://milvus:19530" + milvus_token = "" + + class FakeGateway: + calls: list[tuple[str, str, int]] = [] + + async def embed( + self, *, endpoint_code: str, text: str, timeout_ms: int + ) -> list[float]: + self.calls.append((endpoint_code, text, timeout_ms)) + return [0.1] * 1024 + + class FakeMilvus: + def __init__(self, uri: str, token: str | None) -> None: + self.uri = uri + self.token = token + + class FakeSession: + async def __aenter__(self) -> object: + return object() + + async def __aexit__(self, exc_type, exc, traceback) -> None: + return None + + class FakeAuthority: + def __init__(self, session: object) -> None: + self.session = session + + class FakeRetrievalService: + def __init__(self, embedder, vector_store, config, authority) -> None: + self.embedder = embedder + self.vector_store = vector_store + self.config = config + self.authority = authority + + async def search( + self, query: KnowledgeQuery, context: RequestContext + ) -> KnowledgeSearchResult: + vector = await self.embedder.embed(query.query) + assert len(vector) == 1024 + assert isinstance(self.vector_store, FakeMilvus) + assert isinstance(self.authority, FakeAuthority) + assert self.config.routes["faq"] == ("fin_faq_collection", 3) + assert context.data_scope == "public" + return KnowledgeSearchResult( + hits=( + KnowledgeHit( + knowledge_id="1", + collection="fin_faq_collection", + snippet="snippet", + answer="answer", + ), + ), + searched_collections=("fin_faq_collection",), + ) + + gateway = FakeGateway() + monkeypatch.setattr(knowledge_tool_service, "get_settings", lambda: Settings()) + monkeypatch.setattr(knowledge_tool_service, "DatabaseModelGateway", lambda: gateway) + monkeypatch.setattr(knowledge_tool_service, "MilvusKnowledgeClient", FakeMilvus) + monkeypatch.setattr(knowledge_tool_service, "KnowledgeMysqlAuthority", FakeAuthority) + monkeypatch.setattr( + knowledge_tool_service, "KnowledgeRetrievalService", FakeRetrievalService + ) + monkeypatch.setattr(knowledge_tool_service, "SessionFactory", FakeSession) + + result = await query_knowledge_tool( + KnowledgeQuery(query="基金开户", intents=("faq",)), + RequestContext( + user_id="visitor-1", + trace_id="trace", + roles=("visitor",), + data_scope="public", + ), + ) + + assert result.hits[0].answer == "answer" + assert gateway.calls == [("knowledge-embedding", "基金开户", 15000)] diff --git a/tests/unit/service/test_offsite_document_recognition_adapter.py b/tests/unit/service/test_offsite_document_recognition_adapter.py new file mode 100644 index 0000000..78bd548 --- /dev/null +++ b/tests/unit/service/test_offsite_document_recognition_adapter.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import json + +import httpx +import pytest + +from app.core.config import Settings +from app.service.offsite_document_recognition_adapter import ( + OffsiteDocumentRecognitionAdapter, + RecognitionSourceFile, +) + + +@pytest.mark.asyncio +async def test_default_recognition_uses_mock_without_external_call() -> None: + source = RecognitionSourceFile( + filename="申购申请单.txt", + media_type="text/plain", + file_hash="hash-subscription", + payload=( + "基金代码:000001 基金名称:测试基金 账户标识:ACCT-001 " + "投资者名称:测试客户 申请编号:SUB-001 申请日期:2026-09-10 " + "代销机构:测试代销 申购金额:10000 金额单位:元" + ).encode(), + ) + + result = await OffsiteDocumentRecognitionAdapter(_settings()).recognize(source) + + assert result.document_type == "subscription" + assert result.ocr_status == "mock" + assert result.llm_status == "mock" + assert result.extracted_fields["申请编号"] == "SUB-001" + assert result.missing_fields == () + + +def test_health_check_reports_missing_external_config_only_when_enabled() -> None: + disabled = OffsiteDocumentRecognitionAdapter(_settings()).health_check() + assert disabled["ocr"]["status"] == "disabled" + assert disabled["deepseek"]["status"] == "disabled" + + enabled = OffsiteDocumentRecognitionAdapter( + _settings( + offsite_ocr_enabled=True, + offsite_deepseek_enabled=True, + offsite_aliyun_ocr_endpoint="", + offsite_aliyun_access_key_id="", + offsite_aliyun_access_key_secret="", + offsite_deepseek_api_key="", + ) + ).health_check() + assert enabled["ocr"]["status"] == "misconfigured" + assert enabled["deepseek"]["status"] == "misconfigured" + assert "OFFSITE_ALIYUN_OCR_ENDPOINT" in enabled["ocr"]["missing"] + assert "OFFSITE_DEEPSEEK_API_KEY" in enabled["deepseek"]["missing"] + + +@pytest.mark.asyncio +async def test_enabled_recognition_calls_ocr_and_deepseek_with_masked_request_body() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if str(request.url) == "https://ocr.example.local/recognize": + return httpx.Response( + 200, + json={ + "data": { + "ocr_text": "基金代码:000001 申请编号:SUB-001 申购金额:10000", + "tables": [{"rows": 1}], + "page_evidence": {"基金代码": [{"page": 1}]}, + } + }, + ) + if str(request.url) == "https://api.deepseek.test/chat/completions": + return httpx.Response( + 200, + json={ + "choices": [ + { + "message": { + "content": json.dumps( + { + "document_type": "subscription", + "extracted_fields": { + "基金代码": "000001", + "基金名称": "测试基金", + "账户标识": "ACCT-001", + "投资者名称": "测试客户", + "申请编号": "SUB-001", + "申请日期": "2026-09-10", + "代销机构": "测试代销", + "申购金额": "10000", + "金额单位": "元", + }, + "field_confidence": {"基金代码": "0.99"}, + "missing_fields": [], + "low_confidence_fields": [], + "page_evidence": {"基金代码": [{"page": 1}]}, + }, + ensure_ascii=False, + ) + } + } + ] + }, + ) + return httpx.Response(404) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + adapter = OffsiteDocumentRecognitionAdapter( + _settings( + offsite_ocr_enabled=True, + offsite_aliyun_ocr_endpoint="https://ocr.example.local/recognize", + offsite_aliyun_access_key_id="aliyun-id", + offsite_aliyun_access_key_secret="aliyun-secret", + offsite_deepseek_enabled=True, + offsite_deepseek_base_url="https://api.deepseek.test", + offsite_deepseek_api_key="deepseek-secret", + ), + client=client, + ) + + try: + result = await adapter.recognize( + RecognitionSourceFile( + filename="申购申请单.pdf", + media_type="application/pdf", + file_hash="hash-subscription", + payload=b"pdf bytes", + ) + ) + finally: + await client.aclose() + + assert result.document_type == "subscription" + assert result.ocr_status == "success" + assert result.llm_status == "success" + assert result.extracted_fields["基金代码"] == "000001" + assert result.field_confidence["基金代码"].to_eng_string() == "0.99" + assert result.missing_fields == () + assert requests[0].headers["x-acs-accesskey-id"] == "aliyun-id" + assert requests[1].headers["authorization"] == "Bearer deepseek-secret" + assert b"aliyun-secret" not in requests[0].content + assert b"deepseek-secret" not in requests[1].content + + +@pytest.mark.asyncio +async def test_deepseek_error_falls_back_to_mock_with_error_status() -> None: + async def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(503) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + adapter = OffsiteDocumentRecognitionAdapter( + _settings( + offsite_deepseek_enabled=True, + offsite_deepseek_base_url="https://api.deepseek.test", + offsite_deepseek_api_key="deepseek-secret", + ), + client=client, + ) + + try: + result = await adapter.recognize( + RecognitionSourceFile( + filename="赎回申请单.txt", + media_type="text/plain", + file_hash="hash-redemption", + payload="基金代码:000001 赎回份额:200".encode(), + ) + ) + finally: + await client.aclose() + + assert result.document_type == "redemption" + assert result.llm_status == "error" + assert result.error_message == "HTTPStatusError" + + +def _settings(**updates: object) -> Settings: + values: dict[str, object] = { + "jwt_issuer": "jr-local", + "jwt_audience": "jr-agent-platform", + "mysql_dsn": "sqlite+aiosqlite:///test.db", + "redis_url": "redis://127.0.0.1:6379/0", + "milvus_uri": "http://127.0.0.1:19530", + "neo4j_uri": "bolt://127.0.0.1:7687", + } + values.update(updates) + return Settings(**values) diff --git a/tests/unit/service/test_offsite_fund_rules.py b/tests/unit/service/test_offsite_fund_rules.py new file mode 100644 index 0000000..379b1a0 --- /dev/null +++ b/tests/unit/service/test_offsite_fund_rules.py @@ -0,0 +1,56 @@ +from decimal import Decimal + +from app.service.offsite_fund_rules import OffsiteFundRuleEngine, normalize_amount_yuan + + +def test_subscription_minimum_amount_boundary() -> None: + engine = OffsiteFundRuleEngine() + below = engine.check_subscription( + amount_yuan=Decimal("0.99"), nav=None, total_fund_shares=None, + before_holding_shares=None) + equal = engine.check_subscription( + amount_yuan=Decimal("1.00"), nav=None, total_fund_shares=None, + before_holding_shares=None) + above = engine.check_subscription( + amount_yuan=Decimal("1.01"), nav=None, total_fund_shares=None, + before_holding_shares=None) + assert below[0].result == "异常" + assert equal[0].result == "异常" + assert above[0].result == "正常" + + +def test_subscription_ratio_and_share_limit_boundary() -> None: + engine = OffsiteFundRuleEngine() + normal = engine.check_subscription( + amount_yuan=Decimal("100"), nav=Decimal("1"), total_fund_shares=Decimal("1000"), + before_holding_shares=Decimal("100")) + abnormal = engine.check_subscription( + amount_yuan=Decimal("101"), nav=Decimal("1"), total_fund_shares=Decimal("1000"), + before_holding_shares=Decimal("100")) + assert {item.rule_code: item.result for item in normal}["subscription_holding_ratio"] == "正常" + assert {item.rule_code: item.result for item in abnormal}[ + "subscription_holding_ratio"] == "异常" + assert {item.rule_code: item.result for item in abnormal}[ + "subscription_single_share_limit"] == "异常" + + +def test_redemption_ratio_and_available_quantity_boundary() -> None: + engine = OffsiteFundRuleEngine() + normal = engine.check_redemption( + redemption_shares=Decimal("200"), total_fund_shares=Decimal("1000"), + available_quantity=Decimal("200")) + abnormal = engine.check_redemption( + redemption_shares=Decimal("201"), total_fund_shares=Decimal("1000"), + available_quantity=Decimal("200")) + assert {item.rule_code: item.result for item in normal}["redemption_large_ratio"] == "正常" + assert {item.rule_code: item.result for item in normal}[ + "redemption_available_quantity"] == "正常" + assert {item.rule_code: item.result for item in abnormal}["redemption_large_ratio"] == "异常" + assert {item.rule_code: item.result for item in abnormal}[ + "redemption_available_quantity"] == "异常" + + +def test_normalize_amount_keeps_original_unit_semantics() -> None: + assert normalize_amount_yuan("2.50", "万元") == Decimal("25000.00") + assert normalize_amount_yuan("2.50", "元") == Decimal("2.50") + assert normalize_amount_yuan("2.50", "美元") is None diff --git a/tests/unit/service/test_offsite_mail_adapter.py b/tests/unit/service/test_offsite_mail_adapter.py new file mode 100644 index 0000000..bb6f66c --- /dev/null +++ b/tests/unit/service/test_offsite_mail_adapter.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import os +from datetime import datetime +from email.message import EmailMessage +from pathlib import Path + +from app.core.config import Settings +from app.service.offsite_mail_adapter import ( + OffsiteImapReceiver, + OffsiteMailStorage, + RawMailAttachment, + RawMailMessage, +) + + +class FakeImapConnection: + def __init__(self, messages: dict[str, bytes] | None = None) -> None: + self.messages = messages or {} + self.noop_count = 0 + self.logout_count = 0 + self.selected: list[tuple[str, bool]] = [] + self.uid_calls: list[tuple[str, tuple[object, ...]]] = [] + + def login(self, user: str, password: str) -> tuple[str, list[bytes]]: + return "OK", [b"LOGIN completed"] + + def select(self, mailbox: str, readonly: bool = False) -> tuple[str, list[bytes]]: + self.selected.append((mailbox, readonly)) + return "OK", [b"2"] + + def uid(self, command: str, *args: object) -> tuple[str, list[object]]: + self.uid_calls.append((command, args)) + if command == "search": + return "OK", [b" ".join(uid.encode("ascii") for uid in self.messages)] + if command == "fetch": + uid = str(args[0]) + return "OK", [(b"RFC822", self.messages[uid]), b")"] + return "NO", [] + + def noop(self) -> tuple[str, list[bytes]]: + self.noop_count += 1 + return "OK", [b"NOOP completed"] + + def logout(self) -> tuple[str, list[bytes]]: + self.logout_count += 1 + return "BYE", [b"LOGOUT completed"] + + +def test_health_check_returns_disabled_when_imap_switch_is_off() -> None: + connection = FakeImapConnection() + receiver = OffsiteImapReceiver(_settings(offsite_imap_enabled=False), connection) + + assert receiver.health_check() == {"status": "disabled", "message": "场外 IMAP 未启用"} + assert connection.noop_count == 0 + + +def test_health_check_reports_missing_required_imap_config() -> None: + connection = FakeImapConnection() + receiver = OffsiteImapReceiver( + _settings( + offsite_imap_enabled=True, + offsite_imap_host="", + offsite_imap_username="", + offsite_imap_password="", + ), + connection, + ) + + result = receiver.health_check() + + assert result["status"] == "misconfigured" + assert result["missing"] == [ + "OFFSITE_IMAP_HOST", + "OFFSITE_IMAP_USERNAME", + "OFFSITE_IMAP_PASSWORD", + ] + assert connection.noop_count == 0 + + +def test_fetch_since_selects_inbox_and_filters_sender_whitelist() -> None: + accepted = _raw_email("15008108550@163.com", "") + rejected = _raw_email("blocked@example.com", "") + connection = FakeImapConnection({"5": accepted, "6": rejected}) + receiver = OffsiteImapReceiver(_settings(offsite_imap_enabled=True), connection) + + messages = receiver.fetch_since("4", limit=20) + + assert connection.selected == [("INBOX", True)] + assert ("search", (None, "UID 5:*")) in connection.uid_calls + assert ("fetch", ("5", "(RFC822)")) in connection.uid_calls + assert ("fetch", ("6", "(RFC822)")) in connection.uid_calls + assert len(messages) == 1 + assert messages[0].imap_uid == "5" + assert messages[0].message_id == "" + assert messages[0].sender == "15008108550@163.com" + assert messages[0].return_path == "15008108550@163.com" + assert messages[0].attachments[0].filename == "申购申请单.pdf" + assert messages[0].auth_result["dkim_signature_present"] is True + + +def test_mail_storage_saves_eml_and_attachments_without_overwriting(tmp_path: Path) -> None: + storage = OffsiteMailStorage(tmp_path) + mail = _raw_mail_message( + raw_message=b"From: first@example.com\r\n\r\nfirst", + attachment_payload=b"first attachment", + ) + + first = storage.save(mail) + second = storage.save( + _raw_mail_message( + raw_message=b"From: second@example.com\r\n\r\nsecond", + attachment_payload=b"second attachment", + ) + ) + + eml_path = Path(first.eml_path) + first_attachment_path = Path(first.attachments[0].original_file_path) + second_attachment_path = Path(second.attachments[0].original_file_path) + assert eml_path.read_bytes() == b"From: first@example.com\r\n\r\nfirst" + assert first_attachment_path.read_bytes() == b"first attachment" + assert second_attachment_path.read_bytes() == b"second attachment" + assert first_attachment_path != second_attachment_path + assert first.attachments[0].file_hash != second.attachments[0].file_hash + + for path in (eml_path, first_attachment_path, second_attachment_path): + os.chmod(path, 0o666) + + +def _settings(**updates: object) -> Settings: + values: dict[str, object] = { + "jwt_issuer": "jr-local", + "jwt_audience": "jr-agent-platform", + "mysql_dsn": "sqlite+aiosqlite:///test.db", + "redis_url": "redis://127.0.0.1:6379/0", + "milvus_uri": "http://127.0.0.1:19530", + "neo4j_uri": "bolt://127.0.0.1:7687", + "offsite_imap_host": "imap.example.local", + "offsite_imap_username": "15273589815@163.com", + "offsite_imap_password": "test-auth-code", + "offsite_allowed_senders": ("15008108550@163.com",), + } + values.update(updates) + return Settings(**values) + + +def _raw_email(sender: str, message_id: str) -> bytes: + message = EmailMessage() + message["From"] = sender + message["To"] = "15273589815@163.com" + message["Message-ID"] = message_id + message["Return-Path"] = sender + message["Authentication-Results"] = "mx.example.local; spf=pass" + message["Received-SPF"] = "pass" + message["DKIM-Signature"] = "v=1; a=rsa-sha256; b=test" + message.set_content("场外基金业务邮件") + message.add_attachment( + b"pdf-bytes", + maintype="application", + subtype="pdf", + filename="申购申请单.pdf", + ) + return message.as_bytes() + + +def _raw_mail_message(raw_message: bytes, attachment_payload: bytes) -> RawMailMessage: + return RawMailMessage( + imap_uid="8", + message_id="", + sender="15008108550@163.com", + return_path="15008108550@163.com", + auth_result={"spf": "pass"}, + raw_message=raw_message, + received_at=datetime(2026, 9, 10, 9, 30, 0), + attachments=( + RawMailAttachment( + filename="申购申请单.pdf", + media_type="application/pdf", + payload=attachment_payload, + ), + ), + ) diff --git a/tests/unit/service/test_offsite_smtp_adapter.py b/tests/unit/service/test_offsite_smtp_adapter.py new file mode 100644 index 0000000..7dd07ee --- /dev/null +++ b/tests/unit/service/test_offsite_smtp_adapter.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import smtplib +from email.message import EmailMessage + +from app.core.config import Settings +from app.service.offsite_smtp_adapter import ( + OffsiteMailReplyRequest, + OffsiteSmtpSender, + SmtpAttachment, +) + + +class FakeSmtpConnection: + def __init__(self, *, fail_send: bool = False) -> None: + self.fail_send = fail_send + self.login_calls: list[tuple[str, str]] = [] + self.sent_messages: list[EmailMessage] = [] + self.quit_count = 0 + + def login(self, user: str, password: str) -> object: + self.login_calls.append((user, password)) + return {} + + def send_message(self, msg: EmailMessage) -> object: + if self.fail_send: + raise smtplib.SMTPException("send failed") + self.sent_messages.append(msg) + return {} + + def quit(self) -> object: + self.quit_count += 1 + return {} + + +def test_health_check_reports_disabled_and_dry_run_modes() -> None: + disabled = OffsiteSmtpSender(_settings()).health_check() + assert disabled == {"status": "disabled", "message": "场外 SMTP 未启用"} + + dry_run = OffsiteSmtpSender( + _settings(offsite_smtp_enabled=True, offsite_smtp_dry_run=True) + ).health_check() + assert dry_run == {"status": "dry_run", "message": "场外 SMTP 处于 dry-run 模式"} + + +def test_send_reply_requires_operator_confirmation_before_any_send() -> None: + connection = FakeSmtpConnection() + sender = OffsiteSmtpSender( + _settings(offsite_smtp_enabled=True, offsite_smtp_dry_run=False), + connection, + ) + + result = sender.send_reply(_request(operator_confirmed=False)) + + assert result.status == "发送失败" + assert result.failure_reason == "邮件发送前必须完成运营确认" + assert connection.sent_messages == [] + + +def test_send_reply_defaults_to_dry_run_without_real_smtp_call() -> None: + connection = FakeSmtpConnection() + sender = OffsiteSmtpSender(_settings(), connection) + + result = sender.send_reply(_request()) + + assert result.status == "待发送" + assert result.dry_run is True + assert result.provider_message_id is None + assert result.failure_reason is None + assert connection.sent_messages == [] + assert result.request_summary["attachment_count"] == 1 + + +def test_enabled_real_send_builds_reply_message_and_attaches_original_file() -> None: + connection = FakeSmtpConnection() + sender = OffsiteSmtpSender( + _settings(offsite_smtp_enabled=True, offsite_smtp_dry_run=False), + connection, + ) + + result = sender.send_reply(_request(reply_to_message_id="")) + + assert result.status == "发送成功" + assert result.dry_run is False + assert result.retry_count == 0 + assert connection.login_calls == [] + 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["In-Reply-To"] == "" + assert message["References"] == "" + assert message["Message-ID"] == result.provider_message_id + attachments = list(message.iter_attachments()) + assert len(attachments) == 1 + assert attachments[0].get_filename() == "申购申请单.pdf" + assert attachments[0].get_payload(decode=True) == b"pdf-bytes" + + +def test_enabled_real_send_reports_failure_and_increments_retry_count() -> None: + connection = FakeSmtpConnection(fail_send=True) + sender = OffsiteSmtpSender( + _settings(offsite_smtp_enabled=True, offsite_smtp_dry_run=False), + connection, + ) + + result = sender.send_reply(_request(retry_count=2)) + + assert result.status == "发送失败" + assert result.failure_reason == "SMTPException" + assert result.retry_count == 3 + assert connection.quit_count == 1 + + +def test_enabled_real_send_fails_closed_when_config_is_missing() -> None: + sender = OffsiteSmtpSender( + _settings( + offsite_smtp_enabled=True, + offsite_smtp_dry_run=False, + offsite_smtp_host="", + offsite_smtp_username="", + offsite_smtp_password="", + offsite_smtp_sender="", + ) + ) + + result = sender.send_reply(_request(retry_count=1)) + + assert result.status == "发送失败" + assert result.retry_count == 2 + assert "OFFSITE_SMTP_HOST" in str(result.failure_reason) + + +def _request( + *, + operator_confirmed: bool = True, + retry_count: int = 0, + reply_to_message_id: str | None = None, +) -> OffsiteMailReplyRequest: + return OffsiteMailReplyRequest( + to_address="15008108550@163.com", + subject="场外基金申购赎回处理结果", + body="已处理。", + operator_id="operator-001", + operator_confirmed=operator_confirmed, + reply_to_message_id=reply_to_message_id, + retry_count=retry_count, + attachments=( + SmtpAttachment( + filename="申购申请单.pdf", + media_type="application/pdf", + payload=b"pdf-bytes", + ), + ), + ) + + +def _settings(**updates: object) -> Settings: + values: dict[str, object] = { + "jwt_issuer": "jr-local", + "jwt_audience": "jr-agent-platform", + "mysql_dsn": "sqlite+aiosqlite:///test.db", + "redis_url": "redis://127.0.0.1:6379/0", + "milvus_uri": "http://127.0.0.1:19530", + "neo4j_uri": "bolt://127.0.0.1:7687", + "offsite_smtp_host": "smtp.example.local", + "offsite_smtp_username": "15273589815@163.com", + "offsite_smtp_password": "test-auth-code", + "offsite_smtp_sender": "15273589815@163.com", + } + values.update(updates) + return Settings(**values) diff --git a/tests/unit/tools/test_foundation_migration_preflight.py b/tests/unit/tools/test_foundation_migration_preflight.py new file mode 100644 index 0000000..0c76e2f --- /dev/null +++ b/tests/unit/tools/test_foundation_migration_preflight.py @@ -0,0 +1,95 @@ +"""验证迁移前状态证据采集器只读取 Git 元数据。""" + +# 导入测试所需的标准库 JSON、路径和子进程结果类型。 +import json +from pathlib import Path +from subprocess import CompletedProcess + +# 导入 pytest 补丁类型以及待验证的预检工具接口。 +from pytest import MonkeyPatch + +from tools.foundation_migration_preflight import ( + collect_workspace_state, + run_git, + write_preflight_report, +) + + +# 验证未跟踪客服文件会被记录,且采集结果不含环境变量名称或值。 +def test_collect_workspace_state_records_untracked_paths_without_environment_values( + tmp_path: Path, +) -> None: + # 构造确定性的 Git 命令替身,不调用真实 Git 或环境变量。 + def fake_git_runner(_worktree: Path, *args: str) -> str: + # 为分支查询返回客服功能分支名称。 + if args == ("branch", "--show-current"): + return "feature/customer-service-rag\n" + # 为提交查询返回固定的非敏感提交标识。 + if args == ("rev-parse", "HEAD"): + return "abc123\n" + # 为状态查询返回一个未跟踪客服文件。 + if args == ("status", "--short"): + return "?? app/service/agent/customer_service_agent.py\n" + # 防止测试静默接受未定义的 Git 查询。 + raise AssertionError(f"unexpected git arguments: {args}") + + # 使用替身采集临时工作区的只读状态。 + state = collect_workspace_state(tmp_path, runner=fake_git_runner) + + # 断言采集到预期的分支名称。 + assert state["branch"] == "feature/customer-service-rag" + # 断言未跟踪客服文件完整保留在状态清单中。 + assert state["status"] == ["?? app/service/agent/customer_service_agent.py"] + # 断言序列化结果不包含任何环境变量敏感字段。 + assert "MYSQL_PASSWORD" not in json.dumps(state) + + +# 验证报告写入器只输出传入的非敏感 Git 状态结构。 +def test_write_preflight_report_persists_utf8_json(tmp_path: Path) -> None: + # 指定临时报告文件,避免写入任何真实工作目录。 + report_path = tmp_path / "preflight.json" + # 构造只含安全 Git 元数据的状态条目。 + states = [{"path": "D:/workspace", "branch": "develop", "head": "abc123", "status": []}] + + # 写入迁移前报告。 + write_preflight_report(report_path, states) + + # 以 UTF-8 读取并解析报告正文。 + report = json.loads(report_path.read_text(encoding="utf-8")) + # 断言报告保留原始状态条目。 + assert report == states + + +# 验证每次 Git 查询只信任当前显式工作区,而不写入全局 Git 配置。 +def test_run_git_scopes_safe_directory_to_the_requested_worktree( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + # 保存被测函数交给子进程层的命令参数。 + captured_commands: list[list[str]] = [] + + # 构造返回固定分支名的无副作用子进程替身。 + def fake_run(command: list[str], **_kwargs: object) -> CompletedProcess[str]: + # 记录命令以便后续断言安全目录范围。 + captured_commands.append(command) + # 返回模拟的 Git 成功结果。 + return CompletedProcess(command, 0, "develop\n", "") + + # 让测试不执行真实 Git。 + monkeypatch.setattr("tools.foundation_migration_preflight.subprocess.run", fake_run) + + # 执行一个受限 Git 分支查询。 + output = run_git(tmp_path, "branch", "--show-current") + + # 断言调用仍返回 Git 输出。 + assert output == "develop\n" + # 断言命令仅为该临时工作区附加安全目录。 + assert captured_commands == [[ + "git", + "-c", + f"safe.directory={tmp_path.resolve().as_posix()}", + "-C", + str(tmp_path.resolve()), + "branch", + "--show-current", + ]] diff --git a/tests/unit/tools/test_knowledge_import_preflight.py b/tests/unit/tools/test_knowledge_import_preflight.py new file mode 100644 index 0000000..b00c7c5 --- /dev/null +++ b/tests/unit/tools/test_knowledge_import_preflight.py @@ -0,0 +1,75 @@ +import json + +import pytest + +from tools.knowledge_import_preflight import build_import_manifest + + +def vector_candidate() -> dict[str, object]: + return { + "qa_id": "RAG-PUB-001", + "title": "基金交易确认时间说明", + "question": "基金什么时候确认?", + "paraphrases": ["申购何时确认", "赎回多久确认"], + "answer": "交易确认时间以产品规则和实际交易日为准。", + "scope": "public", + "intent": "policy_explain", + "collection": "fin_policy_collection", + "execution_mode": "vector_search", + "retrieval_status": "approved_candidate", + "audience": ["visitor", "authenticated_user"], + "agent_data_access": "none", + "tags": ["交易规则", "确认"], + "phase": "phase_1", + "source_type": "qa_pair", + "source_file": "qa-v5.8.txt", + "source_url": None, + "source_version": "v5.8", + "review_status": "approved_candidate", + "status": "active", + "effective_date": None, + "expire_date": None, + } + + +def rule_only_record() -> dict[str, object]: + return { + "qa_id": "RAG-SEC-001", + "scope": "security_notice", + "collection": None, + "execution_mode": "fixed_route", + "retrieval_status": "rule_only", + } + + +def test_manifest_contains_only_eligible_public_records_pending_admin_review() -> None: + manifest = build_import_manifest( + [vector_candidate(), rule_only_record()], source_name="qa-v5.8.jsonl" + ) + + assert manifest["summary"] == { + "total_records": 2, + "eligible_records": 1, + "excluded_rule_records": 1, + "publication_state": "pending_review", + } + entry = manifest["records"][0] + assert entry["qa_id"] == "RAG-PUB-001" + assert entry["knowledge_type"] == "policy_explain" + assert entry["milvus_collection"] == "fin_policy_collection" + assert entry["review_status"] == "pending_review" + assert entry["status"] == "active" + assert entry["retrieval_text"] == ( + "标准问题:基金什么时候确认?\n" + "相似问法:申购何时确认;赎回多久确认\n" + "标签:交易规则、确认" + ) + assert json.loads(entry["content_text"])["answer"] == "交易确认时间以产品规则和实际交易日为准。" + + +def test_invalid_public_record_is_rejected_instead_of_silently_entering_manifest() -> None: + invalid = vector_candidate() + invalid["agent_data_access"] = "account" + + with pytest.raises(ValueError, match="RAG-PUB-001"): + build_import_manifest([invalid], source_name="qa-v5.8.jsonl") diff --git a/tests/unit/tools/test_publish_customer_service_knowledge.py b/tests/unit/tools/test_publish_customer_service_knowledge.py new file mode 100644 index 0000000..a5409a8 --- /dev/null +++ b/tests/unit/tools/test_publish_customer_service_knowledge.py @@ -0,0 +1,40 @@ +import pytest + +from tools.publish_customer_service_knowledge import load_pending_manifest + + +def manifest() -> dict[str, object]: + return { + "summary": {"eligible_records": 1, "publication_state": "pending_review"}, + "records": [{ + "qa_id": "FAQ-001", + "milvus_collection": "fin_faq_collection", + "retrieval_text": "标准问题:基金开户", + "title": "基金开户", + "snippet": "基金开户", + "tags": ["开户"], + "version": "v5.8", + "content_text": "{\"answer\":\"请在官方页面开户。\"}", + "source_file": "qa-v5.8.txt", + "effective_date": None, + "expire_date": None, + "review_status": "pending_review", + "status": "active", + }], + } + + +def test_pending_manifest_is_converted_to_a_publishable_administrator_payload() -> None: + records = load_pending_manifest(manifest()) + + assert len(records) == 1 + assert records[0].qa_id == "FAQ-001" + assert records[0].metadata["content_text"] == "{\"answer\":\"请在官方页面开户。\"}" + + +def test_manifest_that_claims_to_be_published_is_rejected() -> None: + invalid = manifest() + invalid["summary"] = {"eligible_records": 1, "publication_state": "published"} + + with pytest.raises(ValueError, match="pending_review"): + load_pending_manifest(invalid) diff --git a/tests/unit/worker/test_offsite_mail_worker.py b/tests/unit/worker/test_offsite_mail_worker.py new file mode 100644 index 0000000..cfd1054 --- /dev/null +++ b/tests/unit/worker/test_offsite_mail_worker.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest +from sqlalchemy import select, text, update +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from app.core.config import Settings +from app.core.contracts import RequestContext +from app.core.offsite_fund_contracts import ReceiveRecognizedMailRequest +from app.model.base import Base +from app.model.offsite_fund import OffsiteFundMail, OffsiteMailCursor, OffsiteNotification +from app.service.offsite_document_recognition_adapter import StructuredRecognitionResult +from app.service.offsite_mail_adapter import ( + RawMailAttachment, + RawMailMessage, + SavedMailAttachment, + SavedMailMessage, +) +from app.worker.offsite_mail_worker import OffsiteMailWorker + + +@pytest.mark.asyncio +async def test_worker_advances_uid_only_after_each_mail_succeeds(tmp_path: Path) -> None: + maker, engine = await _database() + receiver = FakeReceiver(_raw_mail("5"), _raw_mail("6"), _raw_mail("7")) + service = FakeService(fail_uid="6") + worker = OffsiteMailWorker( + _settings(), + receiver=receiver, + storage=FakeStorage(tmp_path), + recognizer=FakeRecognizer(), + identity_resolver=_identity, + session_factory=maker, + service_factory=lambda _session: service, + ) + + try: + assert await worker.run_once() + cursor = await _cursor(maker) + assert cursor.last_uid == "5" + assert cursor.blocked_uid == "6" + assert cursor.status == "failed" + assert receiver.fetch_last_uid == "0" + assert service.calls == ["5", "6"] + + async with maker() as session, session.begin(): + await session.execute( + update(OffsiteMailCursor).values(next_retry_at=None) + ) + service.fail_uid = None + + assert await worker.run_once() + cursor = await _cursor(maker) + assert cursor.last_uid == "7" + assert cursor.status == "idle" + assert cursor.blocked_uid is None + assert service.calls == ["5", "6", "6", "7"] + assert receiver.fetch_last_uid == "5" + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_worker_does_not_write_without_configured_worker_identity(tmp_path: Path) -> None: + maker, engine = await _database() + receiver = FakeReceiver(_raw_mail("5")) + worker = OffsiteMailWorker( + _settings(offsite_worker_user_id=""), + receiver=receiver, + storage=FakeStorage(tmp_path), + recognizer=FakeRecognizer(), + identity_resolver=_identity, + session_factory=maker, + service_factory=lambda _session: FakeService(), + ) + + try: + assert not await worker.run_once() + assert receiver.health_calls == 0 + async with maker() as session: + assert await session.scalar(select(OffsiteMailCursor.id)) is None + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_worker_wires_saved_attachment_recognition_into_business_service( + tmp_path: Path, +) -> None: + maker, engine = await _database() + service = FakeService() + worker = OffsiteMailWorker( + _settings(), + receiver=FakeReceiver(_raw_mail_with_attachment("5")), + storage=FakeStorage(tmp_path), + recognizer=SuccessfulRecognizer(), + identity_resolver=_identity, + session_factory=maker, + service_factory=lambda _session: service, + ) + + try: + assert await worker.run_once() + assert len(service.payloads) == 1 + attachment = service.payloads[0].attachments[0] + assert attachment.document_type == "subscription" + assert attachment.original_file_path.endswith("5.eml") + assert attachment.extracted_fields["申请编号"] == "SUB-005" + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_worker_recovers_stale_sending_notification_without_marking_success( + tmp_path: Path, +) -> None: + maker, engine = await _database() + worker = OffsiteMailWorker( + _settings(), + receiver=FakeReceiver(), + storage=FakeStorage(tmp_path), + recognizer=FakeRecognizer(), + session_factory=maker, + ) + old = datetime(2026, 9, 1, 0, 0, 0) + try: + async with maker() as session, session.begin(): + session.add( + OffsiteNotification( + id=1, + notification_type="mail_return", + business_key="20260901-001-A01", + receiver_id="15008108550@163.com", + operator_id="operator-001", + agent_draft="draft", + final_content="final", + payload={}, + status="发送中", + retry_count=0, + created_at=old, + updated_at=old, + ) + ) + + assert await worker.recover_stale_notifications() + async with maker() as session: + notification = await session.get(OffsiteNotification, 1) + assert notification is not None + assert notification.status == "发送失败" + assert notification.provider_message_id is None + assert notification.failure_reason is not None + assert "人工核验" in notification.failure_reason + finally: + await engine.dispose() + + +class FakeReceiver: + def __init__(self, *messages: RawMailMessage) -> None: + self.messages = messages + self.last_scanned_uid: str | None = "0" + self.fetch_last_uid: str | None = None + self.health_calls = 0 + + def health_check(self) -> dict[str, object]: + self.health_calls += 1 + return {"status": "ok"} + + def fetch_since(self, last_uid: str | None, *, limit: int) -> tuple[RawMailMessage, ...]: + del limit + self.fetch_last_uid = last_uid + self.last_scanned_uid = self.messages[-1].imap_uid if self.messages else last_uid + return self.messages + + def close(self) -> None: + return None + + +class FakeStorage: + def __init__(self, root: Path) -> None: + self.root = root + + def save(self, mail: RawMailMessage) -> SavedMailMessage: + path = self.root / f"{mail.imap_uid}.eml" + path.write_bytes(mail.raw_message) + return SavedMailMessage( + imap_uid=mail.imap_uid, + message_id=mail.message_id, + sender=mail.sender, + return_path=mail.return_path, + auth_result=mail.auth_result, + eml_path=str(path), + attachments=( + SavedMailAttachment( + filename="empty.txt", + file_hash="a" * 64, + media_type="text/plain", + size_bytes=0, + original_file_path=str(path), + ), + ) if mail.attachments else (), + ) + + +class FakeRecognizer: + async def recognize(self, source: Any) -> Any: + del source + raise AssertionError("本测试邮件没有附件,不应调用识别器") + + +class SuccessfulRecognizer: + async def recognize(self, source: Any) -> StructuredRecognitionResult: + del source + return StructuredRecognitionResult( + document_type="subscription", + extracted_fields={ + "基金代码": "000001", + "基金名称": "测试基金", + "账户标识": "ACCT-001", + "投资者名称": "测试客户", + "申请编号": "SUB-005", + "申请日期": "2026-09-10", + "代销机构": "测试代销", + "申购金额": "10000", + "金额单位": "元", + }, + field_confidence={"申请编号": Decimal("0.99")}, + missing_fields=(), + low_confidence_fields=(), + page_evidence={"申请编号": [{"page": 1}]}, + ocr_text="申请编号:SUB-005", + ocr_status="mock", + llm_status="mock", + ) + + +class FakeService: + def __init__(self, fail_uid: str | None = None) -> None: + self.fail_uid = fail_uid + self.calls: list[str] = [] + self.payloads: list[ReceiveRecognizedMailRequest] = [] + + async def receive_recognized_mail( + self, payload: ReceiveRecognizedMailRequest, context: RequestContext + ) -> dict[str, object]: + del context + self.calls.append(payload.imap_uid) + self.payloads.append(payload) + if payload.imap_uid == self.fail_uid: + return {"code": 500, "message": "模拟入库失败", "data": {}} + return {"code": 0, "message": "ok", "data": {"business": False}} + + +async def _database() -> tuple[async_sessionmaker[AsyncSession], Any]: + engine = create_async_engine( + "sqlite+aiosqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + async with engine.begin() as connection: + await connection.run_sync( + lambda sync: Base.metadata.create_all( + sync, + tables=[ + OffsiteFundMail.__table__, + OffsiteMailCursor.__table__, + OffsiteNotification.__table__, + ], + ) + ) + await connection.execute( + text( + """ + CREATE TABLE interaction_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_type VARCHAR(16) NOT NULL, + actor_id BIGINT NULL, + target_customer_id BIGINT NULL, + session_id VARCHAR(64) NULL, + portal VARCHAR(32) NULL, + action_type VARCHAR(64) NOT NULL, + detail JSON NOT NULL, + created_at DATETIME NOT NULL + ) + """ + ) + ) + return async_sessionmaker(engine, expire_on_commit=False), engine + + +async def _identity(identity: RequestContext) -> RequestContext: + return identity.model_copy( + update={ + "roles": ("operator",), + "permissions": ("offsite:write",), + "data_scope": "all", + } + ) + + +def _settings(**updates: object) -> Settings: + values: dict[str, object] = { + "jwt_issuer": "jr-local", + "jwt_audience": "jr-agent-platform", + "mysql_dsn": "sqlite+aiosqlite:///test.db", + "redis_url": "redis://127.0.0.1:6379/0", + "milvus_uri": "http://127.0.0.1:19530", + "neo4j_uri": "bolt://127.0.0.1:7687", + "offsite_mail_worker_enabled": True, + "offsite_imap_enabled": True, + "offsite_worker_user_id": "1", + "offsite_max_retry_count": 3, + } + values.update(updates) + return Settings(**values) + + +def _raw_mail(uid: str) -> RawMailMessage: + return RawMailMessage( + imap_uid=uid, + message_id=f"<{uid}@worker.test>", + sender="15008108550@163.com", + return_path="15008108550@163.com", + auth_result={"spf": "pass"}, + raw_message=f"mail-{uid}".encode(), + received_at=datetime(2026, 9, 10, 9, 30, 0), + attachments=(), + ) + + +def _raw_mail_with_attachment(uid: str) -> RawMailMessage: + mail = _raw_mail(uid) + return RawMailMessage( + imap_uid=mail.imap_uid, + message_id=mail.message_id, + sender=mail.sender, + return_path=mail.return_path, + auth_result=mail.auth_result, + raw_message=mail.raw_message, + received_at=mail.received_at, + attachments=( + RawMailAttachment( + filename="申购申请单.pdf", + media_type="application/pdf", + payload=b"pdf", + ), + ), + ) + + +async def _cursor(maker: async_sessionmaker[AsyncSession]) -> OffsiteMailCursor: + async with maker() as session: + cursor = await session.scalar(select(OffsiteMailCursor)) + assert cursor is not None + return cursor diff --git a/tests/unit/worker/test_runtime_worker_dispatch.py b/tests/unit/worker/test_runtime_worker_dispatch.py index fa2d8f1..4c9f502 100644 --- a/tests/unit/worker/test_runtime_worker_dispatch.py +++ b/tests/unit/worker/test_runtime_worker_dispatch.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock import pytest +from app.core.contracts import AgentResult, CoreResult, RequestContext from app.service.memory_recall_service import MemoryRecallService from app.service.model_gateway import ModelGenerationService from app.worker.runtime import WorkerRuntime @@ -22,6 +23,40 @@ OUTBOX = { } +@pytest.mark.asyncio +async def test_worker_restores_visitor_without_identity_repository_call() -> None: + """访客异步任务只能恢复最小公开上下文,不能查询正式身份库。""" + runtime = WorkerRuntime.__new__(WorkerRuntime) + runtime.resolve_identity = AsyncMock(side_effect=AssertionError("identity lookup is forbidden")) + + context = await runtime.restore_context( + actor_type="visitor", actor_id="visitor:test", trace_id="trace-test" + ) + + assert context.roles == ("visitor",) + assert context.permissions == ("agent:run", "knowledge:query") + assert context.data_scope == "public" + runtime.resolve_identity.assert_not_awaited() + + +def test_visitor_does_not_request_memory_extraction() -> None: + """访客消息即使包含偏好信号,也不能进入客户记忆抽取队列。""" + context = RequestContext( + user_id="visitor:test", trace_id="visitor-trace", roles=("visitor",), + permissions=("agent:run",), data_scope="public", + ) + result = AgentResult(run_id="visitor-run", result=CoreResult(text="公开答复")) + + requested = WorkerRuntime.should_request_memory_extraction( + context=context, + message="我的风险偏好是稳健型", + result=result, + business_events=(), + ) + + assert requested is False + + class FakeSession(AbstractAsyncContextManager["FakeSession"]): def __init__(self) -> None: self.scalar = AsyncMock(return_value="event-1") diff --git a/to_do_list.md b/to_do_list.md new file mode 100644 index 0000000..e4083c9 --- /dev/null +++ b/to_do_list.md @@ -0,0 +1,37 @@ +# 场外基金申购赎回后续任务清单 + +> 更新方式:完成一项实时打勾,并补充验证结果。 +> 范围:场外基金申购赎回独立接口、独立表、真实能力配置入口和可回归测试。 + +- [x] 1. 阅读项目规范、现有代码、调用链、数据流、权限和影响面。 + 备注:已阅读根目录与子项目 AGENTS、CLAUDE、AI 阅读/执行/测试/输出规范、MVC 规范、Agent 接入说明、数据库基线、建表设计、接口文档、NL2SQL 接入说明、场外流程图和本 SKILL。 +- [x] 2. 明确本次需求并拆分邮件、识别、核对、统计、通知、审计任务。 + 备注:本轮按用户确认分两阶段推进:先做当前业务闭环兼容完善,再按上线方案接入真实 IMAP/OCR/SMTP;不改变现有成功链路和接口路径。 +- [x] 3. 完善邮件接收、补偿、幂等、编号和原始文件保存。 + 备注:新增独立 `OffsiteMailWorker`,按 INBOX UID 增量扫描,持久化游标、失败 UID、重试时间和租约;成功邮件才推进游标,失败邮件停留并可重启补偿。当前采用健康检查 + 轮询补偿,IMAP IDLE 事件循环仍未实现,不能据此直接进入正式生产。 +- [x] 4. 完善附件分类、OCR/LLM 一次识别和识别异常处理。 + 备注:保持 summary 拆分、other 归档;新增字段缺失、低置信识别状态分流,不覆盖原始识别字段。 +- [x] 5. 完善 NL2SQL、Plan and Execute 和查询失败隔离。 + 备注:恢复并复用 `nl2sql_yc.query_dict`;新增场外专用适配器隔离调用参数和副作用;触发查询后独立保存每条规则查询记录并回填查询阶段计划状态。 +- [x] 6. 完善确定性计算、申购规则和赎回规则。 + 备注:继续使用确定性规则引擎;已覆盖申购金额 0.99/1.00/1.01 元、20% 比例边界、赎回可用份额等于和超出边界。 +- [ ] 7. 完善人工确认、汇总统计、邮件返回和独立通知路由。 + 备注:已补充人工确认审计、统计扩展字段、通知发送前状态约束、脱敏 payload 和发送中超时接管;邮件整体完成判定、资金清算触发及完整混合邮件路由仍未闭环。 +- [x] 8. 完善配置、权限、日志、重试、审计和数据安全。 + 备注:场外写接口在 Service 层增加角色和权限二次校验;人工确认、NL2SQL 触发、通知创建写入 `interaction_audit`;通知 payload 对账户标识脱敏。 +- [x] 9. 新增真实 IMAP 收信适配层,默认关闭并支持健康检查、自动重连和增量补偿扫描。 + 备注:新增 `OffsiteImapReceiver`,默认由 `OFFSITE_IMAP_ENABLED=false` 关闭;支持健康检查、缺配置识别、只选 `INBOX`、按 UID 增量扫描、白名单发件人过滤、扫描边界记录和连接关闭后下一轮重连。IMAP IDLE 仍未实现,当前由 Worker 轮询补偿。 +- [x] 10. 新增原始 MIME 邮件和附件受控保存流程,保留 UID、Message-ID、附件哈希、路径、失败原因和重试记录。 + 备注:新增 `OffsiteMailStorage` 保存原始 `.eml` 与附件;附件路径包含 SHA256 前缀,避免同名不同内容覆盖;Worker 失败时持久化游标和邮件级错误/重试字段,并保留原始文件。真实对象存储、容量治理、备份和保留期限仍需上线前配置。 +- [x] 11. 新增阿里云 OCR/Document AI 与 DeepSeek 识别适配层,默认 Mock,可配置真实调用、超时、降级和脱敏日志。 + 备注:新增 `OffsiteDocumentRecognitionAdapter`,默认 OCR/DeepSeek 均关闭并走本地 Mock;开启后通过配置化 HTTP endpoint 调用 OCR 网关,通过 DeepSeek OpenAI-compatible `/chat/completions` 进行分类和字段 JSON 映射;支持超时、缺配置识别、失败关闭、Mock 降级、结构化字段、字段置信度、缺失字段和低置信字段输出。阿里云官方直连签名/SDK 接入仍需在真实联调阶段确认,不在本轮新增依赖。 +- [x] 12. 新增 SMTP 邮件发送适配层,默认 dry-run,发送前必须运营确认,记录发送状态、provider message ID、失败原因和重试次数。 + 备注:新增 `OffsiteSmtpSender`,默认 `OFFSITE_SMTP_ENABLED=false` 且 `OFFSITE_SMTP_DRY_RUN=true`;发送前强制检查 `operator_confirmed`,支持回复原邮件、重新附加原附件、返回发送状态、失败原因和重试次数。新增发送中超时接管为“发送失败 + 人工核验”,不自动判断外部是否已投递;真实 SMTP 灰度尚未执行。 +- [x] 13. 为真实外部服务补齐 Mock 单元测试、集成测试、灰度验证步骤和上线回滚方案。 + 备注:新增 IMAP/原始文件保存、OCR/DeepSeek 识别、SMTP dry-run/真实发送 Mock 单元测试;新增通知成功幂等、失败状态回写和正常回复完成状态集成测试;新增 `docs/场外外部服务灰度与回滚方案.md`。真实生产灰度尚未执行,需上线前按文档人工执行。 +- [x] 14. 执行代码检查和自动化验证。 + 备注:本轮迁移后 `pytest` 全量 183 passed;`ruff`、`mypy`、权威文档检查和数据库结构审计均通过,当前数据库为 59 张业务表。 +- [x] 15. 再次阅读本 SKILL,核查是否有遗漏的内容和任务未生成到清单执行。 + 备注:已再次阅读桌面 SKILL;真实 IMAP/OCR/SMTP、真实发送状态机和邮件完成判定保留在后续未完成任务中。 +- [x] 16. 对功能和 BUG 进行测试。 + 备注:新增并通过场外专项测试,覆盖 `nl2sql_yc.query_dict` 适配、计划查询状态回填、低置信识别异常、缺权限拦截、IMAP 健康检查、只读收件箱增量扫描、白名单过滤、原始文件防覆盖保存、Worker UID 断点/失败恢复/身份拒绝/附件接线、SMTP dry-run、通知成功幂等、失败状态回写、发送中超时接管和正常回复完成状态。 diff --git a/tools/audit_schema.py b/tools/audit_schema.py index 8ab6972..776ea0c 100644 --- a/tools/audit_schema.py +++ b/tools/audit_schema.py @@ -37,6 +37,14 @@ INCREMENTAL_TABLES = { "outbox_delivery", "svc_conversation_session", "api_request_receipt", + "offsite_fund_mail", + "offsite_mail_cursor", + "offsite_fund_attachment", + "offsite_fund_document", + "offsite_execution_plan_task", + "offsite_rule_result", + "offsite_query_record", + "offsite_notification", } diff --git a/tools/dependency_health_check.py b/tools/dependency_health_check.py index 6225a53..56eb826 100644 --- a/tools/dependency_health_check.py +++ b/tools/dependency_health_check.py @@ -25,7 +25,7 @@ async def main() -> None: finally: await driver.close() try: - connections.connect(alias="default", uri=settings.milvus_uri) + connections.connect(alias="default", uri=settings.resolved_milvus_uri) print("milvus", "ok") except Exception as exc: print("milvus", f"unavailable:{type(exc).__name__}") diff --git a/tools/foundation_migration_preflight.py b/tools/foundation_migration_preflight.py new file mode 100644 index 0000000..10aa99a --- /dev/null +++ b/tools/foundation_migration_preflight.py @@ -0,0 +1,117 @@ +"""为无损底座迁移保存并校验工作区的只读 Git 状态证据。""" + +# 导入命令行参数解析器以支持生成和校验报告。 +import argparse +# 导入 JSON 序列化工具以写入 UTF-8 状态证据。 +import json +# 导入子进程工具以直接调用 Git 而不经过 shell。 +import subprocess +# 导入可调用协议和类型别名支持。 +from collections.abc import Callable +# 导入路径类型以约束工作区和报告位置。 +from pathlib import Path +# 导入任意 JSON 对象的静态类型。 +from typing import Any + +# 定义只允许执行的 Git 只读子命令首参数集合。 +READ_ONLY_GIT_COMMANDS = frozenset({"branch", "rev-parse", "status"}) +# 定义便于测试注入的 Git 调用函数类型。 +GitRunner = Callable[..., str] + + +# 执行受限的 Git 只读命令并返回标准输出文本。 +def run_git(worktree: Path, *args: str) -> str: + # 解析目标工作区以确保 Git 和安全目录使用同一个绝对路径。 + resolved = worktree.resolve() + # 拒绝空命令,避免形成未约束的 Git 调用。 + if not args: + raise ValueError("git command is required") + # 拒绝任何不在白名单中的 Git 子命令。 + if args[0] not in READ_ONLY_GIT_COMMANDS: + raise ValueError("git command is not read-only") + # 将安全目录限定为当前查询工作区,避免写入全局 Git 配置。 + safe_directory = resolved.as_posix() + # 以参数数组执行 Git,禁止 shell 解释路径或输入内容。 + result = subprocess.run( + ["git", "-c", f"safe.directory={safe_directory}", "-C", str(resolved), *args], + check=True, + capture_output=True, + encoding="utf-8", + errors="replace", + ) + # 返回 Git 的标准输出,供调用方以确定性方式解析。 + return result.stdout + + +# 收集一个工作区的分支、提交与简短状态,不读取环境变量或业务数据。 +def collect_workspace_state(worktree: Path, runner: GitRunner = run_git) -> dict[str, object]: + # 解析绝对路径,防止报告中出现随当前目录变化的相对路径。 + resolved = worktree.resolve() + # 依次执行已白名单化的 Git 查询并构造安全状态字典。 + return { + "path": str(resolved), + "branch": runner(resolved, "branch", "--show-current").strip(), + "head": runner(resolved, "rev-parse", "HEAD").strip(), + "status": runner(resolved, "status", "--short").splitlines(), + } + + +# 将已收集的状态以 UTF-8 JSON 写入调用者指定的报告文件。 +def write_preflight_report(target: Path, states: list[dict[str, object]]) -> None: + # 确保报告父目录存在,但不创建或改动任何工作区内容。 + target.parent.mkdir(parents=True, exist_ok=True) + # 使用稳定缩进和 UTF-8 编码写入仅由调用方提供的状态数据。 + target.write_text(json.dumps(states, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +# 从磁盘读取先前报告并验证指定工作区状态完全一致。 +def verify_preflight_report(target: Path, worktrees: list[Path]) -> list[str]: + # 以 UTF-8 读取 JSON 报告,避免系统默认编码影响比较。 + expected_value: Any = json.loads(target.read_text(encoding="utf-8")) + # 拒绝非列表报告,防止错误文件被误当作迁移证据。 + if not isinstance(expected_value, list): + raise ValueError("preflight report must be a list") + # 为每个当前工作区重新采集只读 Git 状态。 + current = [collect_workspace_state(worktree) for worktree in worktrees] + # 返回 JSON 表示不同的工作区路径,空列表代表完全一致。 + return [ + str(item["path"]) + for item, expected in zip(current, expected_value, strict=True) + if item != expected + ] + + +# 解析 CLI 工作区参数并执行报告生成或校验。 +def main() -> int: + # 创建命令行解析器并限定所有输入为显式路径。 + parser = argparse.ArgumentParser(description=__doc__) + # 要求调用者指定报告文件路径。 + parser.add_argument("--report", type=Path, required=True) + # 允许多次提供要采集或校验的工作区路径。 + parser.add_argument("--worktree", type=Path, action="append", required=True) + # 启用校验模式时不覆盖报告。 + parser.add_argument("--verify", action="store_true") + # 解析用户传入的参数。 + arguments = parser.parse_args() + # 在校验模式下输出差异并返回非零状态。 + if arguments.verify: + # 比较当前状态和报告状态。 + differences = verify_preflight_report(arguments.report, arguments.worktree) + # 输出机器和人工都可识别的校验结果。 + print("UNCHANGED" if not differences else f"CHANGED: {', '.join(differences)}") + # 有任何差异时返回失败状态。 + return 0 if not differences else 1 + # 收集调用方明确列出的工作区状态。 + states = [collect_workspace_state(worktree) for worktree in arguments.worktree] + # 写入新的迁移前证据报告。 + write_preflight_report(arguments.report, states) + # 输出报告保存位置,避免输出任何敏感运行配置。 + print(f"WROTE: {arguments.report}") + # 报告创建成功时返回零状态。 + return 0 + + +# 仅在脚本直接执行时运行命令行入口。 +if __name__ == "__main__": + # 用 main 的返回值作为进程退出码。 + raise SystemExit(main()) diff --git a/tools/knowledge_import_preflight.py b/tools/knowledge_import_preflight.py new file mode 100644 index 0000000..ea6d927 --- /dev/null +++ b/tools/knowledge_import_preflight.py @@ -0,0 +1,221 @@ +"""将一期公开 QA 候选转换为不含外部写入的待审核导入清单。""" + +import argparse +import json +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +from app.core.knowledge_contracts import ALLOWED_KNOWLEDGE_COLLECTIONS +from app.service.knowledge_config import KnowledgeRuntimeConfig + + +# 仅允许一期三类公开知识按既定路由进入后续发布流程。 +EXPECTED_COLLECTIONS = { + intent: collection + for intent, (collection, _top_k) in KnowledgeRuntimeConfig.DEFAULT_ROUTES.items() +} +# 公开候选必须具备的字段,缺失时不能生成不完整的发布清单。 +REQUIRED_PUBLIC_FIELDS = frozenset({ + "qa_id", + "title", + "question", + "paraphrases", + "answer", + "scope", + "intent", + "collection", + "execution_mode", + "retrieval_status", + "audience", + "agent_data_access", + "tags", + "source_type", + "source_file", + "source_version", + "review_status", + "status", +}) + + +def _required_string(record: Mapping[str, object], field: str, qa_id: str) -> str: + """读取非空字符串字段,拒绝将不完整资料带入后续发布阶段。""" + value = record.get(field) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{qa_id}: {field} must be a non-empty string") + return value.strip() + + +def _required_strings(record: Mapping[str, object], field: str, qa_id: str) -> list[str]: + """读取非空字符串数组,并保持来源中已经审核的条目顺序。""" + value = record.get(field) + if not isinstance(value, list) or not value: + raise ValueError(f"{qa_id}: {field} must be a non-empty string list") + strings = [item.strip() for item in value if isinstance(item, str) and item.strip()] + if len(strings) != len(value): + raise ValueError(f"{qa_id}: {field} must contain only non-empty strings") + return strings + + +def _is_rule_only(record: Mapping[str, object]) -> bool: + """控制类记录必须由应用层固定路由处理,绝不能进入向量导入清单。""" + return ( + record.get("collection") is None + and record.get("execution_mode") == "fixed_route" + and record.get("retrieval_status") == "rule_only" + ) + + +def _validate_public_record(record: Mapping[str, object]) -> None: + """验证候选是否符合一期公开知识与最小权限边界。""" + qa_id = _required_string(record, "qa_id", "") + missing = sorted(field for field in REQUIRED_PUBLIC_FIELDS if field not in record) + if missing: + raise ValueError(f"{qa_id}: missing required fields: {', '.join(missing)}") + intent = _required_string(record, "intent", qa_id) + collection = _required_string(record, "collection", qa_id) + if intent not in EXPECTED_COLLECTIONS: + raise ValueError(f"{qa_id}: unsupported public intent: {intent}") + if collection not in ALLOWED_KNOWLEDGE_COLLECTIONS: + raise ValueError(f"{qa_id}: collection is not allowlisted: {collection}") + if collection != EXPECTED_COLLECTIONS[intent]: + raise ValueError(f"{qa_id}: collection does not match intent route") + if record.get("scope") != "public": + raise ValueError(f"{qa_id}: public vector record must have scope=public") + if record.get("execution_mode") != "vector_search": + raise ValueError(f"{qa_id}: public record must use vector_search") + if record.get("retrieval_status") != "approved_candidate": + raise ValueError(f"{qa_id}: record is not an approved candidate") + if record.get("review_status") != "approved_candidate": + raise ValueError(f"{qa_id}: review state cannot enter preflight") + if record.get("status") != "active": + raise ValueError(f"{qa_id}: inactive record cannot enter preflight") + if record.get("agent_data_access") != "none": + raise ValueError(f"{qa_id}: agent data access must remain none") + audience = _required_strings(record, "audience", qa_id) + if set(audience) != {"visitor", "authenticated_user"}: + raise ValueError(f"{qa_id}: audience must be visitor and authenticated_user") + _required_string(record, "title", qa_id) + _required_string(record, "question", qa_id) + _required_strings(record, "paraphrases", qa_id) + _required_string(record, "answer", qa_id) + _required_strings(record, "tags", qa_id) + _required_string(record, "source_type", qa_id) + _required_string(record, "source_file", qa_id) + _required_string(record, "source_version", qa_id) + + +def _public_entry(record: Mapping[str, object]) -> dict[str, object]: + """构造供管理员审核的确定性条目,不生成数据库主键或向量。""" + qa_id = _required_string(record, "qa_id", "") + question = _required_string(record, "question", qa_id) + paraphrases = _required_strings(record, "paraphrases", qa_id) + tags = _required_strings(record, "tags", qa_id) + answer = _required_string(record, "answer", qa_id) + content = { + "qa_id": qa_id, + "question": question, + "paraphrases": paraphrases, + "answer": answer, + "audience": _required_strings(record, "audience", qa_id), + "agent_data_access": "none", + "source_version": _required_string(record, "source_version", qa_id), + } + return { + "qa_id": qa_id, + "knowledge_type": _required_string(record, "intent", qa_id), + "title": _required_string(record, "title", qa_id), + "milvus_collection": _required_string(record, "collection", qa_id), + "version": _required_string(record, "source_version", qa_id), + "source_file": _required_string(record, "source_file", qa_id), + "source_type": _required_string(record, "source_type", qa_id), + "source_url": record.get("source_url"), + "effective_date": record.get("effective_date"), + "expire_date": record.get("expire_date"), + "tags": tags, + "content_text": json.dumps(content, ensure_ascii=False, separators=(",", ":")), + "retrieval_text": ( + f"标准问题:{question}\n" + f"相似问法:{';'.join(paraphrases)}\n" + f"标签:{'、'.join(tags)}" + ), + "snippet": question[:300], + # 管理员填入真实审核人并批准前,预检清单绝不伪装成已发布数据。 + "review_status": "pending_review", + "status": "active", + } + + +def build_import_manifest( + records: Sequence[Mapping[str, object]], *, source_name: str +) -> dict[str, object]: + """构建可复查的导入清单,并拒绝任何不满足公开边界的非控制类记录。""" + entries: list[dict[str, object]] = [] + seen_ids: set[str] = set() + excluded_rule_records = 0 + for record in records: + if _is_rule_only(record): + excluded_rule_records += 1 + continue + _validate_public_record(record) + entry = _public_entry(record) + qa_id = str(entry["qa_id"]) + if qa_id in seen_ids: + raise ValueError(f"duplicate qa_id: {qa_id}") + seen_ids.add(qa_id) + entries.append(entry) + return { + "source_name": source_name, + "summary": { + "total_records": len(records), + "eligible_records": len(entries), + "excluded_rule_records": excluded_rule_records, + "publication_state": "pending_review", + }, + "records": entries, + } + + +def load_jsonl(source: Path) -> list[dict[str, object]]: + """读取 UTF-8 JSONL,并为每个无效 JSON 行返回带行号的明确错误。""" + records: list[dict[str, object]] = [] + for line_number, line in enumerate(source.read_text(encoding="utf-8").splitlines(), start=1): + if not line.strip(): + continue + try: + value: Any = json.loads(line) + except json.JSONDecodeError as exc: + raise ValueError(f"line {line_number}: invalid JSON") from exc + if not isinstance(value, dict): + raise ValueError(f"line {line_number}: record must be an object") + records.append(value) + return records + + +def write_manifest(target: Path, manifest: Mapping[str, object]) -> None: + """仅在调用者显式传入输出路径时,写入本地待审核 JSON 清单。""" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + + +def main() -> int: + """执行本地预检;该入口不读取配置也不连接任何外部服务。""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input", type=Path, required=True) + parser.add_argument("--output", type=Path) + arguments = parser.parse_args() + manifest = build_import_manifest( + load_jsonl(arguments.input), source_name=arguments.input.name + ) + if arguments.output is None: + print(json.dumps(manifest, ensure_ascii=False, indent=2)) + else: + write_manifest(arguments.output, manifest) + print(f"WROTE: {arguments.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/publish_customer_service_knowledge.py b/tools/publish_customer_service_knowledge.py new file mode 100644 index 0000000..242eecf --- /dev/null +++ b/tools/publish_customer_service_knowledge.py @@ -0,0 +1,283 @@ +"""管理员显式批准后发布一期客服公开知识;默认只验证清单,不写外部服务。""" + +import argparse +import asyncio +import json +import sys +from collections.abc import Mapping +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, cast + +from sqlalchemy import select, text, update + +# 直接执行 tools 脚本时优先解析当前工作树,避免误导入相邻 worktree 的 app 包。 +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from app.service.knowledge_publication_service import ( # noqa: E402 + KnowledgePublicationRecord, + KnowledgePublicationService, +) + +# 外部服务依赖仅在显式 --apply 时载入,dry-run 不需要本地 .env 或服务可达。 +SessionFactory: Any +InteractionAudit: Any +FinKnowledgeMeta: Any +DatabaseModelGateway: Any +get_settings: Any + + +class DatabaseKnowledgeEmbedder: + """发布工具只走专用知识向量端点,禁止意外使用聊天端点。""" + + async def embed(self, text_value: str) -> list[float]: + settings = get_settings() + endpoint_code = settings.knowledge_embedding_endpoint_code + if not endpoint_code: + raise RuntimeError("KNOWLEDGE_EMBEDDING_ENDPOINT_CODE 未配置") + return cast( + list[float], + await DatabaseModelGateway().embed( + endpoint_code=endpoint_code, + text=text_value, + timeout_ms=settings.knowledge_embedding_timeout_ms, + ), + ) + + +class SqlAlchemyKnowledgePublicationStore: + """使用现有知识表暂存、发布和停用记录,不变更数据库表结构。""" + + def __init__(self, reviewer_id: int) -> None: + self._reviewer_id = reviewer_id + + async def stage(self, records: tuple[KnowledgePublicationRecord, ...]) -> dict[str, int]: + now = datetime.now(UTC).replace(tzinfo=None) + async with SessionFactory() as session, session.begin(): + await self._assert_reviewer(session) + await self._reject_existing_qa_ids(session, records) + rows: list[Any] = [] + for record in records: + row = FinKnowledgeMeta( + knowledge_type=str(record.metadata["knowledge_type"]), + title=record.title, + source_file=str(record.metadata["source_file"]), + minio_path=None, + milvus_collection=record.milvus_collection, + version=record.version, + effective_date=record.metadata.get("effective_date"), + expire_date=record.metadata.get("expire_date"), + content_text=str(record.metadata["content_text"]), + tags=list(record.tags), + reviewer_id=None, + review_status="pending", + status="disabled", + created_at=now, + updated_at=now, + ) + session.add(row) + rows.append(row) + await session.flush() + session.add(InteractionAudit( + actor_type="admin", + actor_id=self._reviewer_id, + portal="admin", + action_type="knowledge.publication_staged", + detail={"qa_ids": [record.qa_id for record in records]}, + created_at=now, + )) + return {record.qa_id: int(row.id) for record, row in zip(records, rows, strict=True)} + + async def publish(self, knowledge_ids: tuple[int, ...], reviewer_id: int) -> None: + now = datetime.now(UTC).replace(tzinfo=None) + async with SessionFactory() as session, session.begin(): + await session.execute( + update(FinKnowledgeMeta) + .where(FinKnowledgeMeta.id.in_(knowledge_ids)) + .values( + reviewer_id=reviewer_id, + review_status="published", + status="active", + updated_at=now, + ) + ) + session.add(InteractionAudit( + actor_type="admin", + actor_id=reviewer_id, + portal="admin", + action_type="knowledge.publication_completed", + detail={"knowledge_ids": list(knowledge_ids)}, + created_at=now, + )) + + async def disable(self, knowledge_ids: tuple[int, ...]) -> None: + now = datetime.now(UTC).replace(tzinfo=None) + async with SessionFactory() as session, session.begin(): + await session.execute( + update(FinKnowledgeMeta) + .where(FinKnowledgeMeta.id.in_(knowledge_ids)) + .values(status="disabled", updated_at=now) + ) + session.add(InteractionAudit( + actor_type="system", + actor_id=None, + portal="admin", + action_type="knowledge.publication_failed", + detail={"knowledge_ids": list(knowledge_ids)}, + created_at=now, + )) + + async def _assert_reviewer(self, session: Any) -> None: + row = await session.execute( + text( + "SELECT id FROM sys_user " + "WHERE id = :reviewer_id AND status IN ('正常', 'active') " + "AND user_type IN ('employee', 'admin')" + ), + {"reviewer_id": self._reviewer_id}, + ) + if row.scalar_one_or_none() is None: + raise RuntimeError("reviewer_id 不是有效的在职管理员或员工账号") + + @staticmethod + async def _reject_existing_qa_ids( + session: Any, records: tuple[KnowledgePublicationRecord, ...] + ) -> None: + collections = tuple({record.milvus_collection for record in records}) + rows = await session.scalars( + select(FinKnowledgeMeta) + .where(FinKnowledgeMeta.milvus_collection.in_(collections)) + .with_for_update() + ) + existing_ids: set[str] = set() + for row in rows: + try: + content = json.loads(row.content_text) + except json.JSONDecodeError: + continue + qa_id = content.get("qa_id") if isinstance(content, dict) else None + if isinstance(qa_id, str): + existing_ids.add(qa_id) + duplicates = sorted(existing_ids & {record.qa_id for record in records}) + if duplicates: + raise RuntimeError(f"qa_id 已存在,拒绝重复发布: {', '.join(duplicates)}") + + +class MilvusKnowledgePublicationStore: + """管理员发布期的最小 Milvus 写适配器;客服 Agent 运行期仍只能检索。""" + + def __init__(self) -> None: + settings = get_settings() + self._uri = settings.resolved_milvus_uri + self._token = settings.milvus_token or None + self._client: Any | None = None + + async def _client_instance(self) -> Any: + if self._client is None: + from pymilvus import AsyncMilvusClient # type: ignore[import-untyped] + + self._client = AsyncMilvusClient(uri=self._uri, token=self._token) + return self._client + + async def upsert(self, collection: str, records: tuple[dict[str, object], ...]) -> None: + client = await self._client_instance() + payload = [ + {**record, "tags": json.dumps(record["tags"], ensure_ascii=False)} + for record in records + ] + await client.upsert(collection_name=collection, data=payload) + + async def delete(self, collection: str, knowledge_ids: tuple[str, ...]) -> None: + if not all(knowledge_id.isdecimal() for knowledge_id in knowledge_ids): + raise ValueError("knowledge_id 必须为十进制主键") + client = await self._client_instance() + values = ", ".join(json.dumps(knowledge_id) for knowledge_id in knowledge_ids) + await client.delete(collection_name=collection, filter=f"knowledge_id in [{values}]") + + +def load_pending_manifest(value: Mapping[str, object]) -> tuple[KnowledgePublicationRecord, ...]: + """只接受预检工具输出的 pending_review 清单,拒绝手工伪造已发布状态。""" + summary = value.get("summary") + records = value.get("records") + if not isinstance(summary, dict) or summary.get("publication_state") != "pending_review": + raise ValueError("发布清单必须处于 pending_review 状态") + if not isinstance(records, list) or summary.get("eligible_records") != len(records): + raise ValueError("发布清单记录数与汇总不一致") + result: list[KnowledgePublicationRecord] = [] + for raw in records: + if not isinstance(raw, dict): + raise ValueError("发布清单记录必须是对象") + required = ("qa_id", "milvus_collection", "retrieval_text", "title", "snippet", "version") + if any(not isinstance(raw.get(field), str) or not raw[field].strip() for field in required): + raise ValueError("发布清单缺少字符串字段") + tags = raw.get("tags") + if not isinstance(tags, list) or not all(isinstance(tag, str) and tag for tag in tags): + raise ValueError("发布清单标签无效") + if raw.get("review_status") != "pending_review" or raw.get("status") != "active": + raise ValueError("只有预检待审核记录可以发布") + result.append(KnowledgePublicationRecord( + qa_id=str(raw["qa_id"]), + milvus_collection=str(raw["milvus_collection"]), + retrieval_text=str(raw["retrieval_text"]), + title=str(raw["title"]), + snippet=str(raw["snippet"]), + tags=tuple(tags), + version=str(raw["version"]), + metadata=dict(raw), + )) + return tuple(result) + + +def _read_manifest(path: Path) -> tuple[KnowledgePublicationRecord, ...]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError("发布清单根节点必须是对象") + return load_pending_manifest(value) + + +async def _apply(records: tuple[KnowledgePublicationRecord, ...], reviewer_id: int) -> None: + global DatabaseModelGateway, FinKnowledgeMeta, InteractionAudit, SessionFactory, get_settings + from app.core.config import get_settings + from app.infrastructure.db import SessionFactory + from app.model.audit import InteractionAudit + from app.model.knowledge import FinKnowledgeMeta + from app.service.model_gateway import DatabaseModelGateway + + service = KnowledgePublicationService( + DatabaseKnowledgeEmbedder(), + SqlAlchemyKnowledgePublicationStore(reviewer_id), + MilvusKnowledgePublicationStore(), + ) + result = await service.publish(records, reviewer_id=reviewer_id) + print( + json.dumps( + {"published_records": len(result.knowledge_ids), "collections": result.collections}, + ensure_ascii=False, + ) + ) + + +def main() -> int: + """默认 dry-run,且 apply 必须三重确认,防止候选资料被误发布。""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input", type=Path, required=True) + parser.add_argument("--reviewer-id", type=int) + parser.add_argument("--apply", action="store_true") + parser.add_argument("--confirm-count", type=int) + arguments = parser.parse_args() + records = _read_manifest(arguments.input) + if not arguments.apply: + print(f"DRY RUN: {len(records)} records are pending administrator review") + return 0 + if arguments.reviewer_id is None or arguments.reviewer_id <= 0: + raise SystemExit("--apply requires a positive --reviewer-id") + if arguments.confirm_count != len(records): + raise SystemExit("--apply requires --confirm-count equal to the manifest record count") + asyncio.run(_apply(records, arguments.reviewer_id)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/verify_customer_service_phase1.py b/tools/verify_customer_service_phase1.py new file mode 100644 index 0000000..a45a27e --- /dev/null +++ b/tools/verify_customer_service_phase1.py @@ -0,0 +1,165 @@ +"""只读核验一期客服 Agent 的数据库、知识库和向量运行环境。""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from collections.abc import Iterable +from pathlib import Path +from typing import Any + +from sqlalchemy import text + +# 直接执行 tools 脚本时优先解析当前工作树,避免误导入相邻 worktree 的 app 包。 +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 + +COLLECTIONS = ( + "fin_faq_collection", + "fin_product_collection", + "fin_policy_collection", +) +EXPECTED_KNOWLEDGE_COUNTS = { + "fin_faq_collection": 15, + "fin_product_collection": 26, + "fin_policy_collection": 11, +} + + +def _failures(values: Iterable[str]) -> list[str]: + """统一收集失败项,保证脚本最后一次性输出可操作结果。""" + return [value for value in values if value] + + +async def _database_checks() -> list[str]: + """只读检查管理员、Embedding 端点、配置版本与知识发布状态。""" + failures: list[str] = [] + async with SessionFactory() as session: + admin = await session.execute(text(""" + SELECT id FROM sys_user + WHERE id = 9003 AND user_no = 'SYS-KNOWLEDGE-ADMIN' + AND user_type IN ('employee', 'admin') + AND status IN ('正常', 'active') + """)) + if admin.scalar_one_or_none() is None: + failures.append("缺少启用的 SYS-KNOWLEDGE-ADMIN(9003)") + + endpoint = await session.execute(text(""" + SELECT endpoint_code, model_name, secret_ref, capabilities, status + FROM model_endpoint_config + WHERE endpoint_code = 'knowledge-embedding-qwen-v3' + AND status = 'active' + """)) + endpoint_row = endpoint.mappings().first() + if endpoint_row is None: + failures.append("Qwen Embedding 端点未激活") + else: + if endpoint_row["model_name"] != "text-embedding-v3": + failures.append("Embedding 模型不是 text-embedding-v3") + if not str(endpoint_row["secret_ref"]).startswith("env:"): + failures.append("Embedding 密钥不是 env: 引用") + + release = await session.execute(text(""" + SELECT id FROM config_release + WHERE release_no = 'customer-service-phase1-public-kb-v1' + AND status = 'active' + """)) + release_id = release.scalar_one_or_none() + if release_id is None: + failures.append("一期客服公开检索配置未激活") + else: + tools = await session.execute(text(""" + SELECT config_key, value_json + FROM platform_config_item + WHERE release_id = :release_id AND namespace = 'agent_tools' + """), {"release_id": release_id}) + configured: dict[str, Any] = {} + for row in tools.mappings(): + raw_value = row["value_json"] + configured[str(row["config_key"])] = ( + json.loads(raw_value) if isinstance(raw_value, str) else raw_value + ) + expected_keys = { + "customer_service:public_knowledge", + "customer_service:faq", + "customer_service:product_inquiry", + "customer_service:policy_explain", + } + if set(configured) != expected_keys: + failures.append("一期客服工具白名单缺失或包含额外意图") + if any(value != {"allowed_tools": ["query_knowledge"]} for value in configured.values()): + failures.append("一期客服工具白名单不是仅 query_knowledge") + + knowledge = await session.execute(text(""" + SELECT milvus_collection, review_status, status, COUNT(*) AS count + FROM fin_knowledge_meta + GROUP BY milvus_collection, review_status, status + """)) + actual: dict[str, int] = {} + for row in knowledge.mappings(): + if row["review_status"] == "published" and row["status"] == "active": + actual[str(row["milvus_collection"])] = int(row["count"]) + if actual != EXPECTED_KNOWLEDGE_COUNTS: + failures.append(f"公开知识数量不符合预期: {actual}") + return failures + + +async def _milvus_checks() -> list[str]: + """只读检查三类集合的存在、维度、主键和行数。""" + settings = get_settings() + failures: list[str] = [] + try: + from pymilvus import AsyncMilvusClient # type: ignore[import-untyped] + + client: Any = AsyncMilvusClient( + uri=settings.resolved_milvus_uri, token=settings.milvus_token or None + ) + for collection in COLLECTIONS: + if not await client.has_collection(collection_name=collection): + failures.append(f"集合不存在: {collection}") + continue + description = await client.describe_collection(collection_name=collection) + fields = {field["name"]: field for field in description.get("fields", [])} + embedding = fields.get("embedding", {}) + if embedding.get("params", {}).get("dim") != 1024: + failures.append(f"集合 {collection} 不是 1024 维") + if not fields.get("knowledge_id", {}).get("is_primary"): + failures.append(f"集合 {collection} 缺少 knowledge_id 主键") + await client.load_collection(collection_name=collection) + stats = await client.get_collection_stats(collection_name=collection) + expected = EXPECTED_KNOWLEDGE_COUNTS[collection] + if int(stats.get("row_count", -1)) != expected: + failures.append(f"集合 {collection} 行数不符合预期: {stats}") + await client.close() + except Exception as exc: + failures.append(f"Milvus 检查失败: {type(exc).__name__}") + return failures + + +async def verify() -> int: + """执行所有只读门禁并返回适合 CI 的退出码。""" + failures = _failures([*(await _database_checks()), *(await _milvus_checks())]) + settings = get_settings() + print({ + "milvus_uri_mode": "local" if settings.milvus_local_uri else "remote", + "knowledge_embedding_endpoint": settings.knowledge_embedding_endpoint_code, + "expected_public_records": sum(EXPECTED_KNOWLEDGE_COUNTS.values()), + "failures": failures, + }) + return 1 if failures else 0 + + +def main() -> None: + """命令行入口;保留无参数形式,便于整合测试直接调用。""" + argparse.ArgumentParser(description=__doc__).parse_args() + raise SystemExit(asyncio.run(verify())) + + +if __name__ == "__main__": + main()