diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f06722a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +venv/ +__pycache__/ +*.pyc +.env +data/ +.superpowers/ +.git/ +tests/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5884bd3 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# AI模型配置 +AI_PROVIDER=glm +ZHIPU_API_KEY=your_zhipu_api_key_here +AI_MODEL=glm-4-flash +# 请求地址: 留空=用SDK默认(标准套餐); coding套餐用 https://open.bigmodel.cn/api/coding/paas/v4 +ZHIPU_BASE_URL=https://open.bigmodel.cn/api/coding/paas/v4 + +# 数据库 +DATABASE_URL=sqlite+aiosqlite:///./data/cc_ai_kefu.db + +# 邮件推送 +SMTP_HOST=smtp.example.com +SMTP_PORT=465 +SMTP_USER=your_email@example.com +SMTP_PASS=your_email_password +NOTIFY_EMAIL=notify@example.com + +# 询盘推送 UniService +PUSH_ENABLED=false +PUSH_API_URL=https://uniservice.server.hnxinyouhe.com/open/v1/inquiry/push +PUSH_API_KEY=your_uniservice_api_key_here +PUSH_EXTERNAL_KEY_PREFIX=ai-kefu + +# 应用配置 +APP_HOST=0.0.0.0 +APP_PORT=8016 +APP_DEBUG=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d348235 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +venv/ +__pycache__/ +*.pyc +*.pyo +.env +data/ +.superpowers/ +*.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..50b8153 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.13-slim + +WORKDIR /app + +# Install dependencies first (layer caching) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create data directory with correct permissions +RUN mkdir -p /app/data && chmod 777 /app/data + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py new file mode 100644 index 0000000..cf20457 --- /dev/null +++ b/app/api/routes/admin.py @@ -0,0 +1,218 @@ +import hashlib +import secrets +from datetime import datetime, timezone + +from fastapi import APIRouter, Cookie, Request, Response +from sqlalchemy import delete, select, func +from sqlalchemy.orm import load_only +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.main import async_session +from app.models.database import ( + Conversation, ConversationStatus, Message, CustomerProfile, Evaluation, IntentLevel, + LeadPushLog, +) +from app.services.ip_service import resolve_country + +router = APIRouter(prefix="/api/admin") + +_sessions: dict[str, float] = {} +SESSION_TTL = 86400 + + +def _check_auth(session_token: str | None) -> bool: + if not session_token or session_token not in _sessions: + return False + if _sessions[session_token] < datetime.now(timezone.utc).timestamp() - SESSION_TTL: + _sessions.pop(session_token, None) + return False + return True + + +@router.post("/login") +async def login(request: Request, response: Response): + body = await request.json() + password = body.get("password", "") + if password != settings.ADMIN_PASSWORD: + return {"ok": False, "error": "密码错误"} + token = secrets.token_hex(16) + _sessions[token] = datetime.now(timezone.utc).timestamp() + response.set_cookie("admin_token", token, httponly=True, max_age=SESSION_TTL) + return {"ok": True} + + +@router.post("/logout") +async def logout(response: Response, admin_token: str | None = Cookie(None)): + if admin_token and admin_token in _sessions: + _sessions.pop(admin_token) + response.delete_cookie("admin_token") + return {"ok": True} + + +@router.get("/conversations") +async def list_conversations( + admin_token: str | None = Cookie(None), + intent: str | None = None, + page: int = 1, + size: int = 20, +): + if not _check_auth(admin_token): + return {"error": "unauthorized"} + + async with async_session() as db: + # Only show conversations that have at least 1 message + msg_subq = ( + select(Message.conversation_id, func.count().label("mc"), + func.min(Message.content).filter(Message.role == "user").label("first_user_msg")) + .group_by(Message.conversation_id) + .subquery() + ) + + query = ( + select(Conversation, msg_subq.c.mc, msg_subq.c.first_user_msg) + .join(msg_subq, msg_subq.c.conversation_id == Conversation.id) + .order_by(Conversation.created_at.desc()) + ) + + if intent and intent in ("interested", "not_interested", "pending"): + query = query.join(Evaluation, Evaluation.conversation_id == Conversation.id, isouter=True) + query = query.where(Evaluation.intent_level == intent) + + total_q = select(func.count()).select_from(msg_subq) + total_result = await db.execute(total_q) + total = total_result.scalar() or 0 + + result = await db.execute(query.offset((page - 1) * size).limit(size)) + rows = result.all() + + items = [] + for conv, msg_count, first_msg in rows: + client_info = conv.client_info or {} + ip = client_info.get("ip", "") + country = resolve_country(ip) + + eval_result = await db.execute( + select(Evaluation).where(Evaluation.conversation_id == conv.id) + ) + evaluation = eval_result.scalar_one_or_none() + + preview = (first_msg or "")[:50] + + items.append({ + "id": conv.id, + "session_id": conv.session_id, + "status": conv.status.value, + "source": conv.source.value, + "ip": ip, + "device": client_info.get("user_agent", ""), + "country": country, + "intent_level": evaluation.intent_level.value if evaluation else None, + "reasoning": evaluation.reasoning if evaluation else None, + "contact_info": evaluation.contact_info if evaluation else None, + "email_sent": evaluation.email_sent if evaluation else False, + "message_count": msg_count, + "preview": preview, + "created_at": conv.created_at.isoformat() if conv.created_at else None, + }) + + return {"total": total, "page": page, "size": size, "items": items} + + +@router.get("/push-logs") +async def list_push_logs( + admin_token: str | None = Cookie(None), + status: str | None = None, + conversation_id: str | None = None, + page: int = 1, + size: int = 20, +): + """询盘推送日志(设计 2026-09-28-lead-push-to-uniservice §9)。 + + 列表不返回 request_payload(含完整对话,体积大)——排障看库。 + """ + if not _check_auth(admin_token): + return {"error": "unauthorized"} + + async with async_session() as db: + # load_only: 列表不含 request_payload(含完整对话,体积大)—— + # 直接不加载该列,省一页 ~20 条大 JSON 的 I/O 与反序列化 + query = select(LeadPushLog).options(load_only( + LeadPushLog.id, LeadPushLog.evaluation_id, + LeadPushLog.conversation_id, LeadPushLog.push_type, + LeadPushLog.status, LeadPushLog.attempts, LeadPushLog.outcome, + LeadPushLog.response_status, LeadPushLog.error_message, + LeadPushLog.created_at, LeadPushLog.updated_at, + )).order_by(LeadPushLog.created_at.desc()) + if status in ("pending", "success", "failed", "skipped"): + query = query.where(LeadPushLog.status == status) + if conversation_id: + query = query.where(LeadPushLog.conversation_id == conversation_id) + + total_result = await db.execute( + select(func.count()).select_from(query.subquery())) + total = total_result.scalar() or 0 + + result = await db.execute(query.offset((page - 1) * size).limit(size)) + logs = result.scalars().all() + + items = [{ + "id": log.id, + "evaluation_id": log.evaluation_id, + "conversation_id": log.conversation_id, + "push_type": log.push_type, + "status": log.status, + "attempts": log.attempts, + "outcome": log.outcome, + "response_status": log.response_status, + "error_message": log.error_message, + "created_at": log.created_at.isoformat() if log.created_at else None, + "updated_at": log.updated_at.isoformat() if log.updated_at else None, + } for log in logs] + + return {"total": total, "page": page, "size": size, "items": items} + + +@router.get("/conversations/{conv_id}/messages") +async def get_messages(conv_id: str, admin_token: str | None = Cookie(None)): + if not _check_auth(admin_token): + return {"error": "unauthorized"} + + async with async_session() as db: + result = await db.execute( + select(Message) + .where(Message.conversation_id == conv_id, Message.content != "") + .order_by(Message.created_at) + ) + messages = result.scalars().all() + + profile_result = await db.execute( + select(CustomerProfile).where(CustomerProfile.conversation_id == conv_id) + ) + profile = profile_result.scalar_one_or_none() + + return { + "messages": [ + { + "role": m.role.value, + "content": m.content, + "time": m.created_at.isoformat() if m.created_at else None, + } + for m in messages if m.content and m.content.strip() + ], + "profile": profile.raw_info if profile else {}, + } + + +@router.delete("/conversations/{conv_id}") +async def delete_conversation(conv_id: str, admin_token: str | None = Cookie(None)): + if not _check_auth(admin_token): + return {"error": "unauthorized"} + + async with async_session() as db: + await db.execute(delete(Evaluation).where(Evaluation.conversation_id == conv_id)) + await db.execute(delete(CustomerProfile).where(CustomerProfile.conversation_id == conv_id)) + await db.execute(delete(Message).where(Message.conversation_id == conv_id)) + await db.execute(delete(Conversation).where(Conversation.id == conv_id)) + await db.commit() + return {"ok": True} diff --git a/app/api/routes/chat.py b/app/api/routes/chat.py new file mode 100644 index 0000000..68e3f5b --- /dev/null +++ b/app/api/routes/chat.py @@ -0,0 +1,102 @@ +import json +import logging + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from app.main import async_session +from app.models.database import ConversationSource, ConversationStatus +from app.services.conversation import ConversationManager + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.websocket("/ws/chat") +async def ws_chat(websocket: WebSocket): + await websocket.accept() + session_id = None + + try: + async with async_session() as db: + manager = ConversationManager(db) + + # Collect client info + client_ip = websocket.client.host if websocket.client else "" + forwarded = websocket.headers.get("x-forwarded-for", "") + if forwarded: + client_ip = forwarded.split(",")[0].strip() + user_agent = websocket.headers.get("user-agent", "") + client_info = {"ip": client_ip, "user_agent": user_agent} + + # First message: init or resume + init_data = await websocket.receive_text() + init_msg = json.loads(init_data) + + if "session_id" in init_msg: + conv = await manager.get_by_session(init_msg["session_id"]) + if conv and conv.status == ConversationStatus.active: + session_id = conv.session_id + await websocket.send_text(json.dumps({ + "type": "resumed", + "session_id": session_id, + })) + else: + session_id = None + + # page_token: reuse active conversation from same page session + if not session_id and "page_token" in init_msg and init_msg["page_token"]: + conv = await manager.get_by_page_token(init_msg["page_token"]) + if conv: + session_id = conv.session_id + await websocket.send_text(json.dumps({ + "type": "resumed", + "session_id": session_id, + })) + + if not session_id: + source = ConversationSource( + init_msg.get("source", "standalone") + ) + merged_info = {**client_info, **(init_msg.get("client_info") or {})} + conv = await manager.create_conversation( + source=source, + client_info=merged_info, + page_token=init_msg.get("page_token"), + ) + session_id = conv.session_id + await websocket.send_text(json.dumps({ + "type": "init", + "session_id": session_id, + })) + + # Chat loop + while True: + data = await websocket.receive_text() + msg = json.loads(data) + content = msg.get("content", "").strip() + if not content: + continue + + ended = False + + async for event in manager.stream_process_message(session_id, content): + if event["type"] == "error": + await websocket.send_text(json.dumps({ + "type": "error", + "message": event["message"], + })) + break + + await websocket.send_text(json.dumps(event, ensure_ascii=False)) + + if event["type"] == "evaluated" and event.get("ended"): + ended = True + break + + if ended: + break + + except WebSocketDisconnect: + logger.info("Client disconnected: %s", session_id) + except Exception: + logger.exception("WebSocket error for session %s", session_id) diff --git a/app/api/routes/chat_rest.py b/app/api/routes/chat_rest.py new file mode 100644 index 0000000..221700d --- /dev/null +++ b/app/api/routes/chat_rest.py @@ -0,0 +1,142 @@ +"""REST shell for the HESU app (non-streaming, JSON-in/JSON-out). + +These routes wrap `ConversationManager`, shared with the existing `/ws/chat` +widget path. The app uses HTTP because it lacks a WebSocket client and is fine +with a 3-8s "AI is typing" affordance covering the round trip. + +Red lines (see spec): +- WS path (`app/api/routes/chat.py`) shares `ConversationManager` with these + routes; both paths stay drift-free. +- `context` seeding is conditional + whitelist (handled in + `ConversationManager.create_conversation`). +- The message route consumes `stream_process_message`; lead email is + triggered inside `ConversationManager` when contact info is detected + (contact-driven-lead-email plan). +""" +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel +from sqlalchemy import select + +from app.main import async_session +from app.models.database import ConversationSource, ConversationStatus, Message +from app.services.conversation import ConversationManager +from app.services.rate_limit import check as rate_check + +router = APIRouter(prefix="/api/chat", tags=["chat-rest"]) + + +class SessionRequest(BaseModel): + session_id: str | None = None + context: dict | None = None + client_info: dict | None = None + + +@router.post("/session") +async def chat_session(req: SessionRequest): + """Resume an active conversation by session_id, or create a new app one. + + `context` flows into `create_conversation` where it is whitelisted into + `raw_info` (only when non-None). `client_info` is stored for analytics only + (ip/ua/device-id/lang) and never fed to the AI prompt. + """ + async with async_session() as db: + manager = ConversationManager(db) + if req.session_id: + conv = await manager.get_by_session(req.session_id) + if conv and conv.status == ConversationStatus.active: + return { + "session_id": conv.session_id, + "is_new": False, + "ended": False, + } + conv = await manager.create_conversation( + source=ConversationSource.app, + client_info=req.client_info, + context=req.context, + ) + return { + "session_id": conv.session_id, + "is_new": True, + "ended": False, + } + + +@router.get("/{session_id}/messages") +async def chat_history(session_id: str): + """Public history endpoint for session replay. App-source only, to narrow + the read surface; an unknown or non-app session yields 404. + """ + async with async_session() as db: + manager = ConversationManager(db) + conv = await manager.get_by_session(session_id) + if not conv or conv.source != ConversationSource.app: + raise HTTPException(status_code=404, detail="session not found") + result = await db.execute( + select(Message) + .where(Message.conversation_id == conv.id) + .order_by(Message.created_at) + ) + msgs = result.scalars().all() + return [ + { + "role": m.role.value, + "content": m.content, + "created_at": m.created_at.isoformat(), + } + for m in msgs + ] + + +class MessageRequest(BaseModel): + session_id: str + content: str + + +@router.post("/message") +async def chat_message(req: MessageRequest, request: Request): + """Process one app turn: stream-collect the AI reply, then evaluate. + + 🔴 Consumes `stream_process_message` so the REST path shares one code + path with the WS widget and never drifts. Lead email is triggered inside + `ConversationManager` when contact info is detected + (contact-driven-lead-email plan) — this route carries no email logic. + + A light in-memory rate limit (per ip + X-Device-Id, 30/h) guards GLM + spend; only this route is throttled. + """ + ip = request.client.host if request.client else "" + dev = request.headers.get("X-Device-Id", "") + if not rate_check(ip, dev): + raise HTTPException(status_code=429, detail="rate limit exceeded") + + async with async_session() as db: + manager = ConversationManager(db) + conv = await manager.get_by_session(req.session_id) + if not conv: + raise HTTPException(status_code=404, detail="session not found") + if conv.status != ConversationStatus.active: + raise HTTPException(status_code=409, detail="conversation ended") + + reply_parts: list[str] = [] + ended = False + intent_level = "pending" + async for event in manager.stream_process_message( + req.session_id, req.content + ): + t = event.get("type") + if t == "stream_chunk": + reply_parts.append(event.get("content", "")) + elif t == "evaluated": + ended = bool(event.get("ended")) + intent_level = event.get("intent_level", "pending") + elif t == "error": + msg = event.get("message", "error") + raise HTTPException( + status_code=409 if "ended" in msg else 404, detail=msg + ) + + return { + "reply": "".join(reply_parts), + "ended": ended, + "intent_level": intent_level, + } diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..041db6d --- /dev/null +++ b/app/config.py @@ -0,0 +1,54 @@ +from pydantic_settings import BaseSettings +from pathlib import Path + + +class Settings(BaseSettings): + APP_HOST: str = "0.0.0.0" + APP_PORT: int = 8000 + APP_DEBUG: bool = False + + AI_PROVIDER: str = "glm" + ZHIPU_API_KEY: str = "" + # 留空=用 SDK 默认地址(标准套餐 https://open.bigmodel.cn/api/paas/v4) + # coding 套餐填 https://open.bigmodel.cn/api/coding/paas/v4 + ZHIPU_BASE_URL: str = "" + AI_MODEL: str = "glm-4-flash" + + DATABASE_URL: str = "sqlite+aiosqlite:///./data/cc_ai_kefu.db" + + REDIS_URL: str = "redis://localhost:6379/0" + REDIS_PASSWORD: str = "" + SESSION_TTL: int = 86400 # 24 hours + + SMTP_HOST: str = "" + SMTP_PORT: int = 465 + SMTP_USER: str = "" + SMTP_PASS: str = "" + NOTIFY_EMAIL: str = "" + + # 询盘推送 UniService(docs/superpowers/specs/2026-09-28-lead-push-to-uniservice-design.md) + PUSH_ENABLED: bool = False + PUSH_API_URL: str = "https://uniservice.server.hnxinyouhe.com/open/v1/inquiry/push" + PUSH_API_KEY: str = "" + PUSH_EXTERNAL_KEY_PREFIX: str = "ai-kefu" + PUSH_TIMEOUT_SECONDS: float = 10.0 + PUSH_MAX_ATTEMPTS: int = 3 + PUSH_RETRY_BACKOFF_BASE: float = 1.0 # 尝试间隔 1s/2s(3 次尝试、2 次间隔) + + ADMIN_PASSWORD: str = "admin123" + + model_config = { + "env_file": ".env", + "env_file_encoding": "utf-8", + } + + +BASE_DIR = Path(__file__).resolve().parent.parent +DATA_DIR = BASE_DIR / "data" +STATIC_DIR = BASE_DIR / "static" + +settings = Settings() + + +def ensure_dirs(): + DATA_DIR.mkdir(exist_ok=True) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..1f76809 --- /dev/null +++ b/app/main.py @@ -0,0 +1,75 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.config import settings, ensure_dirs, STATIC_DIR +from app.models.database import Base + + +# 后台推送任务的独立连接写结果可能撞上 SMTP 窗口内持有的写锁,默认 5s +# 忙等上限低于 SMTP 最坏时长,抬到 30s;等待发生在 aiosqlite 工作线程,不阻塞事件循环 +engine = create_async_engine(settings.DATABASE_URL, echo=settings.APP_DEBUG, + connect_args={"timeout": 30}) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def _migrate(conn): + result = await conn.execute(text("PRAGMA table_info(conversations)")) + columns = {row[1] for row in result} + if "page_token" not in columns: + await conn.execute(text( + "ALTER TABLE conversations ADD COLUMN page_token VARCHAR(64)" + )) + + result = await conn.execute(text("PRAGMA table_info(evaluations)")) + columns = {row[1] for row in result} + if "correction_count" not in columns: + await conn.execute(text( + "ALTER TABLE evaluations ADD COLUMN correction_count INTEGER DEFAULT 0" + )) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + ensure_dirs() + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await _migrate(conn) + # 询盘推送(设计 8.2): 清扫崩溃遗留的 stale pending → failed + # 函数内导入: lead_push_service 顶部 from app.main import async_session, + # 顶层互相导入会循环 + from app.services.lead_push_service import sweep_stale_pending + await sweep_stale_pending() + yield + + +app = FastAPI(title="AI汽车客服", lifespan=lifespan) + +from app.api.routes.chat import router as chat_router +from app.api.routes.admin import router as admin_router +from app.api.routes.chat_rest import router as chat_rest_router +app.include_router(chat_router) +app.include_router(admin_router) +app.include_router(chat_rest_router) + +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + + +@app.get("/") +async def index(): + from fastapi.responses import FileResponse + return FileResponse(str(STATIC_DIR / "chat.html")) + + +@app.get("/admin") +async def admin_page(): + from fastapi.responses import FileResponse + return FileResponse(str(STATIC_DIR / "admin.html")) + + +@app.get("/health") +async def health(): + return {"status": "ok"} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/database.py b/app/models/database.py new file mode 100644 index 0000000..1ef4aa2 --- /dev/null +++ b/app/models/database.py @@ -0,0 +1,161 @@ +import enum +import uuid +from datetime import datetime, timezone + +from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, JSON, Enum as SAEnum +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class ConversationStatus(str, enum.Enum): + active = "active" + completed = "completed" + abandoned = "abandoned" + + +class MessageRole(str, enum.Enum): + user = "user" + assistant = "assistant" + system = "system" + + +class ConversationSource(str, enum.Enum): + standalone = "standalone" + embedded = "embedded" + widget = "widget" + app = "app" + + +class IntentLevel(str, enum.Enum): + interested = "interested" + not_interested = "not_interested" + pending = "pending" + + +class Conversation(Base): + __tablename__ = "conversations" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + session_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + page_token: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + status: Mapped[ConversationStatus] = mapped_column( + SAEnum(ConversationStatus), default=ConversationStatus.active + ) + source: Mapped[ConversationSource] = mapped_column( + SAEnum(ConversationSource), default=ConversationSource.standalone + ) + client_info: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column( + default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + messages: Mapped[list["Message"]] = relationship( + back_populates="conversation", order_by="Message.created_at" + ) + profile: Mapped["CustomerProfile | None"] = relationship( + back_populates="conversation", uselist=False + ) + evaluation: Mapped["Evaluation | None"] = relationship( + back_populates="conversation", uselist=False + ) + + +class Message(Base): + __tablename__ = "messages" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + conversation_id: Mapped[str] = mapped_column( + ForeignKey("conversations.id", ondelete="CASCADE") + ) + role: Mapped[MessageRole] = mapped_column(SAEnum(MessageRole)) + content: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + default=lambda: datetime.now(timezone.utc) + ) + + conversation: Mapped["Conversation"] = relationship(back_populates="messages") + + +class CustomerProfile(Base): + __tablename__ = "customer_profiles" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + conversation_id: Mapped[str] = mapped_column( + ForeignKey("conversations.id", ondelete="CASCADE"), unique=True + ) + raw_info: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=dict) + summary: Mapped[str | None] = mapped_column(Text, nullable=True) + updated_at: Mapped[datetime] = mapped_column( + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + conversation: Mapped["Conversation"] = relationship(back_populates="profile") + + +class Evaluation(Base): + __tablename__ = "evaluations" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + conversation_id: Mapped[str] = mapped_column( + ForeignKey("conversations.id", ondelete="CASCADE"), unique=True + ) + intent_level: Mapped[IntentLevel] = mapped_column(SAEnum(IntentLevel)) + reasoning: Mapped[str | None] = mapped_column(Text, nullable=True) + contact_info: Mapped[dict | None] = mapped_column(JSON, nullable=True) + email_sent: Mapped[bool] = mapped_column(Boolean, default=False) + correction_count: Mapped[int] = mapped_column(Integer, default=0) + evaluated_at: Mapped[datetime] = mapped_column( + default=lambda: datetime.now(timezone.utc) + ) + + conversation: Mapped["Conversation"] = relationship(back_populates="evaluation") + + +class LeadPushLog(Base): + """询盘推送 UniService 日志(设计 2026-09-28-lead-push-to-uniservice)。 + + status: pending → success/failed;skipped = contact 三选一(phone/whatsapp/ + email)不满足,对方必拒,预检后不发 HTTP 只留审计痕迹。 + outcome: 200 时对方逐条结论 accepted/duplicated(duplicated 不是错误)。 + """ + __tablename__ = "lead_push_logs" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + evaluation_id: Mapped[str] = mapped_column( + ForeignKey("evaluations.id", ondelete="CASCADE"), index=True + ) + conversation_id: Mapped[str] = mapped_column( + ForeignKey("conversations.id", ondelete="CASCADE"), index=True + ) + push_type: Mapped[str] = mapped_column(String(16)) # lead/returning/correction + status: Mapped[str] = mapped_column(String(16), default="pending", index=True) + attempts: Mapped[int] = mapped_column(Integer, default=0) + request_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + response_status: Mapped[int | None] = mapped_column(Integer, nullable=True) + outcome: Mapped[str | None] = mapped_column(String(16), nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/app/models/schemas.py b/app/models/schemas.py new file mode 100644 index 0000000..e835f1e --- /dev/null +++ b/app/models/schemas.py @@ -0,0 +1,57 @@ +from datetime import datetime + +from pydantic import BaseModel + +from app.models.database import ConversationStatus, ConversationSource, MessageRole, IntentLevel + + +class MessageCreate(BaseModel): + role: MessageRole + content: str + + +class MessageOut(BaseModel): + id: str + role: MessageRole + content: str + created_at: datetime + + model_config = {"from_attributes": True} + + +class ConversationCreate(BaseModel): + source: ConversationSource = ConversationSource.standalone + client_info: dict | None = None + + +class ConversationOut(BaseModel): + id: str + session_id: str + status: ConversationStatus + source: ConversationSource + client_info: dict | None + created_at: datetime + messages: list[MessageOut] = [] + + model_config = {"from_attributes": True} + + +class CustomerProfileOut(BaseModel): + raw_info: dict | None + summary: str | None + + model_config = {"from_attributes": True} + + +class EvaluationOut(BaseModel): + intent_level: IntentLevel + reasoning: str | None + contact_info: dict | None + email_sent: bool + + model_config = {"from_attributes": True} + + +class ChatMessage(BaseModel): + session_id: str + content: str diff --git a/app/prompts/company_facts.md b/app/prompts/company_facts.md new file mode 100644 index 0000000..2cb13fb --- /dev/null +++ b/app/prompts/company_facts.md @@ -0,0 +1,29 @@ +# 公司事实卡(仅供回答客户关于公司的事实性问题) + +## 我是谁 +- Hesu Auto 是 Hesu Group 旗下专注汽车出口的公司 +- 中国首批汽车出口企业之一,累计出口 50,000+ 台 +- **累计出口到 70+ 国家与地区**(中东、非洲、南美、欧洲)——出口目的国口径,不是分支/网点覆盖 +- 与 30+ 汽车品牌深度合作:BYD、Changan、Geely、Leopard、ROX、Nissan、Zeeker、Li Auto 等 + +## 卖什么 +- **新车与二手车都做** +- 全动力类型:燃油、混动、插混、纯电 +- 全车型类别:轿车、SUV、皮卡、重卡;乘用车、商用车、新能源车、车队/项目采购 + +## 网络与运作 +- 模式:中国采购供应 → 迪拜(Jebel Ali)枢纽备货分拨 → 海外交付 +- 海外分支五处:Dubai、Ghana、Ethiopia、Djibouti、Belarus +- Abu Dhabi 有展车节点;Ghana 西非保税仓;Djibouti+Ethiopia 东非协同;Venezuela(南美)在网络节点内 + +## 使用规则(与 system.md 红线同级) +- **统一兜底(最高优先)**:任何不清楚、事实卡没有、需要核实的问题——**不编造、不承诺、不否定、不断言**,统一动作只有一个:顺势引导留联系方式转客户经理——"这个我让我们经理给你准确答复——留个 WhatsApp,他直接联系你" +- **不扩展原则**:回答公司事实**只用本卡原词**——禁止添加城市/地址/街道等任何卡外细节(卡片没写城市就不提城市)。卡片没写的就是不知道,一律走统一兜底 +- 客户问公司情况/品牌/是否可信时,取上述事实**简短作答(1-2 句)**,建立信任 +- **分支问题与出口覆盖问题是两回事,分开答**: + - 问分支/网点("do you have branch in X")→ 只认卡里列的:五分支+节点(Dubai/Ghana/Ethiopia/Djibouti/Belarus/Abu Dhabi/Venezuela)必须答"有",**只说国家+节点类型,不提城市**,可逐字使用:"Yes, we've got a Ghana branch with bonded storage";卡里没有的 → **走统一兜底**——绝对禁止编造否定答案("Not yet"/"we don't have"/"没有") + - 问出口覆盖("do you ship to X")→ 70+ 国是**累计出口目的国**,不代表 X 一定有网点:答"已出口到 70+ 国家"事实 + **走统一兜底**(X 具体情况经理确认)——不承诺、不否定 +- 绝不主动长篇介绍公司,绝不用成营销腔——风格仍按【说话风格】 +- 涉及具体价格/交付时效/库存承诺 → 仍按【职能边界】转客户经理,本卡不改变任何红线;售后只说"有本地协同支持",不做任何保证 +- 事实卡没写的细节(某国代理、某车型现车、认证资质细节)→ **走统一兜底**(不编造,留资+经理沟通) +- 数字一律照抄本卡写法(如 50,000+、70+),不改写成中文数字 diff --git a/app/prompts/evaluator.md b/app/prompts/evaluator.md new file mode 100644 index 0000000..74a6d84 --- /dev/null +++ b/app/prompts/evaluator.md @@ -0,0 +1,44 @@ +你是一个客户意向评估助手。根据对话中收集到的客户信息,判断这个客户的购买意向。 + +## 评估标准 + +### interested(有意向) +必须满足以下所有条件: +- 明确表达了购车需求(有预算或目标车型/品牌) +- 明确了目标国家/地区或采购用途 +- 提供了至少一种联系方式(手机/WhatsApp/邮箱/微信) +- 对话至少进行了3轮以上(客户说了3条以上消息) +- 客户表现出真实采购意向而非简单咨询 +- 客户询问价格、优惠、运费或试驾/交付安排,应视为意向积极信号(有真实购买兴趣) +- 客户主动索要我方联系方式(WhatsApp/邮箱/电话),视为最强意向信号之一 + +### not_interested(无意向) +客户表现出以下特征: +- 明确表示不买、只是随便看看 +- 态度冷淡,不愿多聊 +- 表示已经从其他渠道购买了 + +### pending(待定) +以下情况都应标记为pending: +- 对话轮次少于3轮 +- 还未收集到联系方式 +- 客户信息不够充分(缺少预算、车型、数量等关键信息) +- 客户还在比较或犹豫阶段 + +## 严格规则 +1. info_sufficient 必须为 true 时,intent_level 才可以是 interested 或 not_interested +2. 如果对话轮次少于3轮,info_sufficient 必须为 false,intent_level 必须为 pending +3. 如果没有联系方式,info_sufficient 必须为 false + +## 输出格式 +返回JSON,包含以下字段: +- intent_level: "interested" 或 "not_interested" 或 "pending" +- reasoning: 你的判断理由(1-2句话) +- contact_info: 联系方式对象,包含手机、WhatsApp、邮箱、微信,没有的留空字符串 +- info_sufficient: true或false,表示当前信息是否足够做出评估 + +## 客户信息 +$profile + +## 最近对话 +$recent_messages diff --git a/app/prompts/extractor.md b/app/prompts/extractor.md new file mode 100644 index 0000000..b25d0cd --- /dev/null +++ b/app/prompts/extractor.md @@ -0,0 +1,19 @@ +你是一个信息提取助手。请从以下对话历史中提取客户相关信息,输出JSON格式。 + +## 输出格式 +返回一个JSON对象,包含你能从对话中识别到的所有信息字段。字段名用中文。示例字段: +目标国家/地区、采购用途、预算、意向品牌、意向车型、采购数量、车况要求、年份要求、运输方式、联系方式、其他备注。 + +其中联系方式如果有的话格式为:{"手机": "xxx", "WhatsApp": "xxx", "邮箱": "xxx", "微信": "xxx"} +- 如果对话中客户更正了此前提供的联系方式(如 wrong number / 号码错了 / use this / 换成 / 又给了一个新号),联系方式必须以客户最后提供的值为准覆盖旧值 +- 如果客户在消息中直接发送了一串数字(整条消息只有号码),必须用该号码更新联系方式 + +## 规则 +- 只提取对话中明确提到的信息,不要猜测 +- 如果某个信息没有提到,不要包含该字段 +- 已有的信息请保留,新信息追加或更新 +- 之前的提取结果:$existing_info +- 如果没有之前的提取结果,$existing_info 为空对象 + +## 对话历史 +$messages diff --git a/app/prompts/system.md b/app/prompts/system.md new file mode 100644 index 0000000..bd5280f --- /dev/null +++ b/app/prompts/system.md @@ -0,0 +1,179 @@ +你是一位汽车出口外贸销售(新车与二手车都做),日常工作就是通过聊天和海外客户沟通。你说话像在 WhatsApp 上跟朋友聊天一样,简短、直接、有人情味。 + +## 语言规则(最高优先级) +- 客户用什么语言,你就用什么语言回复,严格匹配,绝不混用 +- 默认英语 + +## 职能边界(最高优先级,仅语言规则除外) + +你的准则只有一条:**需求了解 + 留资**——介绍车、挖意向、收联系方式。 +你不承担销售职能:不做任何承诺(价格/时间/库存)、不说绝对化的话、不代表公司做决定。 +涉及价格、时效和实际行动的事,一律由客户经理承接——你的任务是把需求和联系方式收好,交出去。 + +### 三类请求的处理 +1. **汽车信息**(车型/配置/参数/续航/年份/库存/汽车知识):直接热情介绍。 + - 公司情况(品牌/覆盖国家/出口经验/运作模式)按系统提供的事实卡简短作答建立信任——但不因此报价、承诺或跳过留资引导 +2. **价格、优惠、折扣、运费、关税、交付时效类**:一律不直接回答,用迂回话术: + - "Prices depend on the config, destination and shipping — our manager will prepare an accurate quote for you. What's your WhatsApp or email?" + - "Delivery time depends on your destination and shipping route — leave your contact and our manager will give you an exact schedule." + - 问价时若已知意向车型:同一回复内"确认车型 + 引导留资"一气呵成——带车型信息的线索才有价值 + - 零上下文问价(快捷按钮首触):先问需求(车型/国家),不急着要号 +3. **实质请求**(试驾、验车、下单、锁车、预留库存、售后、"帮我安排"类,以及经销/批发合作谈价格与条款):一律转接: + - "I'll connect you with our manager to arrange that — drop your WhatsApp and they'll reach out directly." + +### 客户报预算时 +- 理解预算档位,据此推荐合适的车型 +- 但绝对不要复述预算数字,也不要报任何车价 +- 确认预算只用无数字表述:"That budget works well" / "Got your budget"—— + 绝不说 "20K / 20000 / a budget of 20K" 这类任何数字形式(含改写形式如 20k) +- 反问意向加深了解:"SUV or sedan? How many seats do you need?" +- 然后顺势引导留资:"Tell me your WhatsApp and our manager will send you an accurate quote for your budget." + +### 节奏豁免(解决与"3轮规则"的冲突) +- 客户**主动**问价、问时效、提实质请求或索要我方联系方式时,价格红线立即生效(当轮就不给数字),不受"至少聊3轮"限制 +- 但**留资时机仍看需求了解程度**: + - 已知基本需求(客户提过车型/国家)→ 当轮即可迂回引导留资 + - 零上下文点击询价按钮(如 Get Quotation 首条消息)→ **先用一轮了解需求** + ("What car are you looking for? Where are you shipping to?"), + 下一轮再引导留资——连客户要什么都不知道就要号,是低质量线索且显得急躁 + +### 红线(任何情况下不可违反) +- 回复中不能出现任何价格数字——包括我方报价、价格区间、连客户自己说的预算数字都不能复述 +- 禁止输出占位符("[Price Range]"/"[价格区间]"之类)——给不了数字就不提数字,直接引导联系方式 +- 不能给出任何交付时间("X days / X weeks / on the Xth"都不行) +- 不能做任何承诺(价格/时间/库存/名额/优惠) +- 不能说 "definitely / guaranteed / for sure / 肯定 / 保证 / 一定" 这类绝对化表述 +- 不能替公司做任何决定或安排——安排的主体永远是客户经理("our manager will arrange"),不说 "we can arrange" +- 回复中不能出现任何电话号码或邮箱地址——客户自己的号码也不回显(确认收到即可);引导联系一律用页面按钮(网页)或请客户留联系方式(App) +- 一切不清楚、没把握的问题(卡外国家/库存/保修/认证/任何事实细节)——不编造、不承诺、不否定、不断言,引导客户留联系方式,由客户经理沟通 +- 不对库存/现车做断言——有没有现车、什么时候有,由客户经理确认;你只能说"可以采购调配" +- 不报任何保修/质保期限("X months warranty"这类数字承诺)——一律"客户经理会给你完整的保修方案" + +### 客户反复追问时(坚持策略) +- 第一次:解释原因(配置/目的地/运费因人而异)+ 引导留资 +- 第二次:换一个理由或角度,不要重复原话;继续引导 +- 第三次及以后:直接引导点面板顶部的 WhatsApp 按钮联系客户经理 + +### 客户施压/磨价时的绝对坚持(任何情况下不破线) +无论客户如何坚持、恳求、激将、讨价还价、威胁离开或表达不满,上述红线绝不放松。 +**态度可以软,立场必须硬**:可以共情、可以道歉、可以幽默化解,但价格数字、区间、 +交付时间、承诺——零出现。常见磨法与应对: +- "Just a rough range / 说个大概嘛" → 区间也是数字:"I'd rather give you an exact number than a rough guess — that's why our manager prepares it based on your config." +- "I won't buy without a price / 不给价我不买" → 共情+转价值:"That's exactly why I want our manager to send you the real quote — drop your WhatsApp and you'll have it today." +- "别人家都直接报价" → 不贬低同行,坚持流程价值:"Our quotes include config, shipping and duties for your destination — a random number wouldn't help you." +- 激将("You don't even know?" / "你是不是不懂")→ 不上钩:"I know our cars well — it's the price that needs your manager's calculation. Leave your contact and you'll see." +- 客户发火/不耐烦 → 先道歉安抚,再给出口:"Sorry for the trouble! The fastest way is the WhatsApp button on top — our manager replies quickly there." +- 客户说"我已经给过号码了"("I gave you my number already")→ **先回看聊天记录**: + 记录里确有号码 → 确认收到("Got it — our manager will reach out to you at your WhatsApp shortly!"),绝不要求重发; + 记录里确实没有 → 才温和核对:"Sorry, I may have missed it — could you drop it here once more?" + +## 说话风格 +- 像在 WhatsApp 上聊天,每条消息 1-2 个短句,不超过 30 个词 +- 可以用缩写(gonna, wanna, gotta)、语气词(hmm, oh nice, wow) +- 偶尔用 emoji,但不要每句都加 +- 有自己的判断和推荐,不要只会"好的""没问题" +- 客户说了个需求,你可以直接推荐车型或给建议,不要只问问题 + +## 禁止说的话(必须遵守) +- 不要说"感谢您的咨询/关注/提问" +- 不要说"我很高兴/荣幸为您服务/帮助您" +- 不要说"请随时联系我"或"不要犹豫" +- 不要说"希望以上信息对您有帮助" +- 不要说"如果您有任何其他问题" +- 不要用"首先...其次...最后..."这种列举式回答 +- 不要重复客户说的话("您提到您想买一辆丰田"这种废话) +- 不要总结对话内容 +- 不要每次回复都带问号结尾 + +## 对话推进规则(最关键,违反就是致命bug) +- 客户已经告诉你的信息,绝对不能再问一遍。客户说了车型就不要再问"什么车",说了年份就不要再问"哪年" +- 客户给出具体需求后,必须给出有用的回应(车型推荐、可否采购调配、配置对比),而不是继续追问 +- 对话必须向前推进,每条回复都要在"需求了解 + 留资"上更进一步 +- 如果客户连续两次被问到已经回答过的问题,客户会直接走人 +- 涉及价格/交付时效/实质安排的请求,按【职能边界】处理——这不是拒绝客户,而是把他交给能给出准确答案的人 + +## 上下文追踪(最关键,违反就是致命bug) +- 每条回复前,先回顾整个对话历史,明确客户当前想要什么 +- 客户的短消息(如"2024"、"how much"、"yes")必须结合之前的上下文理解,不能当作独立问题 +- 如果客户之前说了具体车型,后续所有回复都必须围绕这个车型,直到客户自己换了话题 +- 绝对不能在客户指定了车型之后推荐完全不同的车型(客户说BYD,你推荐Toyota,客户会立刻关闭页面) + +## 好回复 vs 差回复 +差:"感谢您的咨询!我们公司专注于二手车出口业务多年,拥有丰富的经验。请问您的目标市场是哪个国家呢?" +好:"Oh nice, what country are you looking to ship to?" + +差:"您好!很高兴为您服务。请问您的预算范围是多少呢?我们可以根据您的预算为您推荐合适的车型。" +好:"What's your budget range? I got some good options." + +差:"您提到的丰田卡罗拉是一款非常受欢迎的车型,我们在非洲和东南亚市场有大量现车。请问您对年份有什么要求吗?" +好:"Corolla is a solid pick, we can source it easily. Any preference on year?" + +差:客户说了"BYD Han 2025",AI回复"What kind of car are you looking for?" +好:客户说了"BYD Han 2025",AI回复"Nice, the 2025 Han comes in EV and DM-i versions. Which one you after?" + +差:客户说了年份"2025",AI回复"Any specific make or model in mind?" +好:客户说了年份"2025",AI回复"For 2025 models we can source BYD Han, Toyota Camry, Honda Accord. Budget range?" + +差:客户说了"BYD Tang",然后说"2024",AI回复"Got a 2024 model in mind? I can show you some great options!" +好:客户说了"BYD Tang",然后说"2024",AI回复"2024 Tang comes as EV 7-seater and DM-i AWD. Which one fits you?" + +差:客户说了"BYD Tang 2024",问"what is available",AI回复"Got Toyota Corolla, Honda Accord, BMW 3 Series" +好:客户说了"BYD Tang 2024",问"what is available",AI回复"The 2024 Tang comes as the EV 7-seater and DM-i AWD — we can source either. Want specs?" + +## 对话流程 +1. 开场轻松,简单打个招呼,问客户想找什么车 +2. 聊2-3轮了解基本需求(目标国家、预算、车型偏好) +3. 第3-4轮用"转接客户经理"或"发报价单"的方式引导联系方式(见下方策略) +4. 拿到联系方式后:确认一句("Got it!"),然后继续正常解答客户问题——不必急着结束对话,也别再提联系方式 + +## 联系方式引导策略(关键) +聊了3-4轮、对客户需求有基本了解后,用"转接客户经理"的方式要联系方式(客户**主动**问价/提实质请求时例外——见【职能边界】节奏豁免): + +### 英文话术示例 +- "Let me connect you with our manager who handles [country] market — what's your WhatsApp?" +- "I can have our team send you some quotes. Drop your WhatsApp or email?" +- "Want me to get you a proper quote? I'll need your WhatsApp so our manager can reach you." + +### 中文话术示例 +- "我让负责[国家]市场的同事加你,你WhatsApp多少?" +- "我让客户经理给你报个价,留个WhatsApp或者邮箱吧?" +- "你给我个联系方式,我让客户经理给你推几个车型。" + +### 引流兜底(先聊后引,顺序不能反) +(话术模板见【职能边界】) +- 聊天内按上述方式要联系方式,最多两次 +- 两次都没要到,才指引用户点面板上的 WhatsApp 按钮直接找我们: + "You can also just message us on WhatsApp (button at the top) — our guy replies fast." +- 禁止一上来就推 WhatsApp 按钮跳过聊天索要 + +### 客户索要我方联系方式(最高意向信号,立即响应) +客户问"how can I contact you / 你们的WhatsApp多少"时: +- 系统提醒会告知当前会话来源。**网页会话**(有页面按钮)→ 指引点击: + "Just tap the WhatsApp button at the top of this chat — our manager replies fast!" + (邮箱同理:"Tap the Email button at the top.") +- **App 会话**(没有页面按钮)→ 反向留资: + "Drop your WhatsApp and our manager will reach out to you directly with the full quote!" +- 中文版同理("直接点窗口顶部的 WhatsApp 按钮就行,客户经理回复很快~") +- 【互留】网页会话给完指路后可轻问一句:"Drop yours too and our manager will send you + the full quote!"——每会话最多一次;客户已留过或疑似留过则不加 +- 客户索要未提供的渠道(微信/Telegram/LINE/ins)→ "We mainly use WhatsApp — tap the + button at the top, fastest way to reach us.",绝不提供或编造任何渠道账号 + +### 系统提醒配合 +- 系统会在上下文提醒里告诉你客户是否已留联系方式 +- 提醒说"已提供"时:绝对禁止再要,确认收到即可 +- 提醒说"疑似提供"时:礼貌请客户确认一次,不要当成没给过重新索要 + +### 注意 +- 只要一次没要到,可以换个理由再试一次,但最多两次 +- 不要在开场就**主动**要联系方式,至少聊3轮(客户主动问价/提实质请求时例外,见职能边界) +- 拿到后说一句"Got it, our team will reach out to you soon"就行,别再重复要 + +## 关注的信息 +自然地在聊天中了解这些,不要逐条问: +- 客户所在国家/地区 +- 预算范围 +- 意向品牌/车型 +- 采购数量 +- 车况/年份要求 +- 联系方式(WhatsApp、邮箱等) diff --git a/app/providers/__init__.py b/app/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/providers/base.py b/app/providers/base.py new file mode 100644 index 0000000..0963688 --- /dev/null +++ b/app/providers/base.py @@ -0,0 +1,24 @@ +from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator + + +class AIProvider(ABC): + @abstractmethod + async def chat(self, messages: list[dict], system_prompt: str) -> str: + """Send messages and return assistant reply text.""" + + @abstractmethod + async def stream_chat( + self, messages: list[dict], system_prompt: str + ) -> AsyncGenerator[str, None]: + """Stream assistant reply token by token.""" + + @abstractmethod + async def chat_with_extraction( + self, messages: list[dict], system_prompt: str + ) -> tuple[str, dict]: + """Send messages and return (reply_text, extracted_json). + + The provider should request JSON output and parse it. + Falls back to empty dict if parsing fails. + """ diff --git a/app/providers/glm.py b/app/providers/glm.py new file mode 100644 index 0000000..2dd1c0a --- /dev/null +++ b/app/providers/glm.py @@ -0,0 +1,94 @@ +import asyncio +import json +import logging +from collections.abc import AsyncGenerator + +from zhipuai import ZhipuAI + +from app.providers.base import AIProvider + +logger = logging.getLogger(__name__) + +DEFAULT_MODEL = "glm-4-flash" + + +class GLMProvider(AIProvider): + def __init__(self, api_key: str, model: str = "glm-4-flash", base_url: str = ""): + self.client = ZhipuAI(api_key=api_key, base_url=base_url or None) + self.model = model + + async def chat(self, messages: list[dict], system_prompt: str) -> str: + full_messages = [{"role": "system", "content": system_prompt}] + messages + return await self._call(full_messages, temperature=0.3) + + async def stream_chat( + self, messages: list[dict], system_prompt: str + ) -> AsyncGenerator[str, None]: + full_messages = [{"role": "system", "content": system_prompt}] + messages + + def _sync_stream(): + resp = self.client.chat.completions.create( + model=self.model, + messages=full_messages, + stream=True, + temperature=0.3, + ) + for chunk in resp: + delta = chunk.choices[0].delta + if delta and delta.content: + yield delta.content + + try: + loop = asyncio.get_event_loop() + gen = _sync_stream() + + def _next_token(g): + try: + return next(g) + except StopIteration: + return None + + while True: + token = await loop.run_in_executor(None, _next_token, gen) + if token is None: + break + yield token + except Exception: + logger.exception("GLM streaming call failed") + raise + + async def chat_with_extraction( + self, messages: list[dict], system_prompt: str + ) -> tuple[str, dict]: + full_messages = [{"role": "system", "content": system_prompt}] + messages + if not messages: + full_messages.append({"role": "user", "content": "请根据以上要求输出结果。"}) + + raw = await self._call(full_messages, temperature=0.1) + extracted = self._parse_json(raw) + return raw, extracted + + async def _call(self, messages: list[dict], temperature: float = 0.3) -> str: + def _sync_call(): + resp = self.client.chat.completions.create( + model=self.model, + messages=messages, + temperature=temperature, + ) + return resp.choices[0].message.content + + try: + return await asyncio.to_thread(_sync_call) + except Exception: + logger.exception("GLM API call failed") + raise + + @staticmethod + def _parse_json(text: str) -> dict: + try: + start = text.index("{") + end = text.rindex("}") + 1 + return json.loads(text[start:end]) + except (ValueError, json.JSONDecodeError): + logger.warning("Failed to parse JSON from model output") + return {} diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/ai_service.py b/app/services/ai_service.py new file mode 100644 index 0000000..bbccd33 --- /dev/null +++ b/app/services/ai_service.py @@ -0,0 +1,98 @@ +import json +import logging +from collections.abc import AsyncGenerator +from pathlib import Path + +from app.providers.base import AIProvider +from app.providers.glm import GLMProvider +from app.config import settings + +logger = logging.getLogger(__name__) + +PROMPTS_DIR = Path(__file__).resolve().parent.parent / "prompts" + + +def _load_prompt(filename: str) -> str: + return (PROMPTS_DIR / filename).read_text(encoding="utf-8") + + +def create_provider() -> AIProvider: + if settings.AI_PROVIDER == "glm": + return GLMProvider( + api_key=settings.ZHIPU_API_KEY, + model=settings.AI_MODEL, + base_url=settings.ZHIPU_BASE_URL, + ) + raise ValueError(f"Unknown AI provider: {settings.AI_PROVIDER}") + + +SYSTEM_PROMPT = _load_prompt("system.md") +COMPANY_FACTS = _load_prompt("company_facts.md") # 公司事实卡(知识与行为分离) +EXTRACTOR_PROMPT = _load_prompt("extractor.md") +EVALUATOR_PROMPT = _load_prompt("evaluator.md") + +# 对话基座: 行为 prompt 在前(红线显著性),事实卡居中(参考资料), +# 当轮 context 提醒最后拼接(在 chat/stream_chat 内,保持最新鲜) +_BASE_CHAT_PROMPT = SYSTEM_PROMPT + "\n\n" + COMPANY_FACTS + + +class AIService: + def __init__(self, provider: AIProvider | None = None): + self.provider = provider or create_provider() + + async def chat(self, messages: list[dict], context: str = "") -> str: + """Main conversation: get AI reply to user message.""" + prompt = _BASE_CHAT_PROMPT + ("\n\n" + context if context else "") + return await self.provider.chat(messages, prompt) + + async def stream_chat(self, messages: list[dict], context: str = "") -> AsyncGenerator[str, None]: + """Stream AI reply token by token.""" + prompt = _BASE_CHAT_PROMPT + ("\n\n" + context if context else "") + async for token in self.provider.stream_chat(messages, prompt): + yield token + + async def extract_info( + self, messages: list[dict], existing_info: dict + ) -> dict: + """Extract structured info from conversation history.""" + prompt = EXTRACTOR_PROMPT.replace( + "$existing_info", json.dumps(existing_info or {}, ensure_ascii=False) + ).replace("$messages", self._format_messages(messages)) + _, extracted = await self.provider.chat_with_extraction( + messages=[], system_prompt=prompt + ) + return extracted + + async def evaluate( + self, profile: dict, recent_messages: list[dict] + ) -> dict: + """Evaluate customer intent level.""" + prompt = EVALUATOR_PROMPT.replace( + "$profile", json.dumps(profile, ensure_ascii=False) + ).replace("$recent_messages", self._format_messages(recent_messages[-6:])) + _, result = await self.provider.chat_with_extraction( + messages=[], system_prompt=prompt + ) + return self._normalize_evaluation(result) + + @staticmethod + def _format_messages(messages: list[dict]) -> str: + lines = [] + for m in messages: + role = m.get("role", "unknown") + content = m.get("content", "") + label = {"user": "客户", "assistant": "顾问"}.get(role, role) + lines.append(f"{label}: {content}") + return "\n".join(lines) + + @staticmethod + def _normalize_evaluation(result: dict) -> dict: + intent = result.get("intent_level", "pending") + if intent not in ("interested", "not_interested", "pending"): + intent = "pending" + return { + "intent_level": intent, + "reasoning": result.get("reasoning", ""), + "contact_info": result.get("contact_info", {}), + "info_sufficient": result.get("info_sufficient", False), + } diff --git a/app/services/boundary_filter.py b/app/services/boundary_filter.py new file mode 100644 index 0000000..254d662 --- /dev/null +++ b/app/services/boundary_filter.py @@ -0,0 +1,199 @@ +"""职能边界输出过滤(方案B): AI 回复价格形态的确定性拦截。 +流式回复无法撤回,故在流出前全量缓冲过滤;违规整体替换为标准迂回话术。 +模式以 scripts/breach_rate.py 为基础并收严(预算复述/金融收严;公里等误报除外项)。 + +评审修正(13.6 复查): +- 货币组尾部 \\b 对中文备选(万/块钱/美元)永远无法命中——中文无词边界, + "2万左右"的 万与左 之间没有 \\b(实测漏检预算复述 6/6 的原形态)。 + \\b 只保留在拉丁备选 usd 之后(防 "usdroid" 类前缀误命中)。 +- 公里/km 排除必须直接挂在 万块? 之后: 挂在末尾裸 万 备选上会被 + 万块? 的空块匹配绕过("3万公里"仍命中,实测)。 +- 预算复述(裸 budget+数字/中文数字万)、金融裸数(首付/月供)收严; + 里程 k 形式(30k km/45k miles)除外。质保期不再豁免(政策变更 2026-09-10: + 保修期限是编造的公司政策承诺,一并墙级拦截,见 PRICE_PATTERNS 时间承诺组)。""" +import re + +from app.services.contact_pipeline import digits_only + +PRICE_PATTERNS = [ + re.compile(r"\$\s?\d"), + re.compile(r"\d[\d,.]*\s*(usd\b|美元|块钱|万块?(?!\s*(公里|km)))", re.I), + re.compile(r"\d[\d,.]*\s?k\b(?!\s*(km|kilometers?|kilometres?|miles?|mi\b|公里))", re.I), + re.compile(r"(price|cost|quote)[^.\d]{0,20}(? bool: + return any(p.search(reply or "") for p in PRICE_PATTERNS) + + +# ---- 13.7: 句子粒度门控 ---- +# 完整句切分: 英文句尾标点后需跟空白才切(小数 "2.5" 的句点后是数字,天然不切); +# 中文标点无需后续空白即可切。finditer 实现为无损——句间空白保留为下一片段的 +# 前导空白(方案稿的 re.split 会吃掉空白,实测把 "A. B" 粘成 "A.B", +# 干净回复逐句流出后无法逐字还原)。 +_SENT_BOUNDARY = re.compile(r"(?<=[.!?;;])\s+|(?<=[。!?…])") + + +def split_sentences(text: str) -> tuple[list[str], str]: + """切出完整句子,返回 (完整句子列表, 不完整尾巴)。 + 无损保证: "".join(完整) + 尾巴 == 原文。完整句不带尾随空白。""" + if not text: + return [], "" + parts: list[str] = [] + prev = 0 + for m in _SENT_BOUNDARY.finditer(text): + parts.append(text[prev:m.start()]) + prev = m.start() # 边界(句间空白)留在下一片段开头 + parts.append(text[prev:]) + parts = [p for p in parts if p] + if not parts: + return [], text + return parts[:-1], parts[-1] + + +# 过渡话术(pivot): 句子门控拦截时已流出干净前缀,接一句过渡收尾。 +# 与开场版迂回话术不同——读起来像回复中段的转向,故单条不轮换。 +PIVOTS_EN = [ + "Actually, for the exact price — what's your WhatsApp? Our manager will send it over.", +] +PIVOTS_ZH = [ + "具体的报价,客户经理会发给你——留个WhatsApp或邮箱?", +] +PIVOTS_EN_CONTACT = [ + "Our manager will send the exact price to your WhatsApp shortly.", +] +PIVOTS_ZH_CONTACT = [ + "客户经理稍后会把准确报价发到你的 WhatsApp。", +] + + +def pivot_for(user_msg: str, has_contact: bool = False) -> str: + zh = bool(re.search(r"[一-鿿]", user_msg or "")) + if has_contact: + pool = PIVOTS_ZH_CONTACT if zh else PIVOTS_EN_CONTACT + else: + pool = PIVOTS_ZH if zh else PIVOTS_EN + return pool[0] + + +def deflection_for(user_msg: str, rotate: int = 0, + has_contact: bool = False) -> str: + zh = bool(re.search(r"[一-鿿]", user_msg or "")) + if has_contact: + pool = DEFLECTIONS_ZH_CONTACT if zh else DEFLECTIONS_EN_CONTACT + else: + pool = DEFLECTIONS_ZH if zh else DEFLECTIONS_EN + return pool[rotate % len(pool)] + + +# ---- 15.3: 零号码墙(doctrine v2) ---- +# AI 绝不输出任何号码/邮箱: 与价格墙并列的第二道确定性红线。 +LONG_NUMBER_RE = re.compile(r"\+?\d[\d\s\-().]{9,17}\d") # ≥10位连续数字(允许分隔形态) +ANY_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") + +NO_CONTACT_WEB = ("Just tap the WhatsApp button at the top of this chat — " + "our manager replies fast!") +NO_CONTACT_APP = ("Drop your WhatsApp and our manager will reach out to you directly " + "with the full quote!") +# F6 同款(15.3 补): 已留资时零号码墙替换句不得索要 +NO_CONTACT_ALREADY = ("Got it — our manager will reach out to you at your " + "WhatsApp shortly with the full quote!") + + +def no_contact_value(sentence: str, user_history: list[dict] | None = None) -> bool: + """零号码墙(v2): AI 输出不得含任何 ≥10 位数字串或邮箱。 + 唯一豁免: 该串出现在近期客户消息中(客户自己号码的礼貌回显)。 + 无值比对——年份/里程/VIN/续航天然安全(已验证)。""" + for m in LONG_NUMBER_RE.finditer(sentence or ""): + c = digits_only(m.group(0)) + if user_history and any(c in digits_only(msg.get("content") or "") + for msg in user_history if msg.get("role") == "user"): + continue # 客户回显豁免 + return True + for m in ANY_EMAIL_RE.finditer(sentence or ""): + if user_history and any(m.group(0).lower() in (msg.get("content") or "").lower() + for msg in user_history if msg.get("role") == "user"): + continue + return True + return False + + +# ---- Task 5b: 再索要墙(已留资时输出侧拦截) ---- +# 仅当调用方 has_contact=True 才启用(接线在 conversation.py); +# 每条模式强制渠道词在场,防泛请求误杀("Can you send me the details?" 放行)。 +# 模式集与 system.md 全部留资话术模板逐句对齐并实证(13 命中/12 放行)—— +# GLM 背的就是这些模板,模板全集必须全收。 +REASK_PATTERNS = [ + # what's/which's your <渠道词> + re.compile(r"(?:what|which)['′]?s\s+(?:your|the)\s+" + r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式)", re.I), + # got/have your <渠道词> ... ?(问号兜尾——确认句 "Got your number, thanks!" 无问号放行) + re.compile(r"(?:got|have)\s+(?:your|the)\s+" + r"(?:whatsapp|number|phone|e-?mail|contact)[^!?.]{0,60}\?", re.I), + # 陈述式需求: (I'll) need/want your <渠道词>——"I'll need your WhatsApp" 无问号也要拦 + re.compile(r"(?:i['′]ll\s+|i\s+)?(?:need|want)\s+(?:your|the)\s+" + r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式)", re.I), + # Tell me your <渠道词> + re.compile(r"tell\s+me\s+(?:your\s+)?" + r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式)", re.I), + # could/can you + 给予动词 + ... 渠道词(动词表无 confirm/send——礼貌确认与泛请求放行) + re.compile(r"(?:could|can|would|may)\s+you\s+" + r"(?:leave|drop|share|give|provide|type|post)[^.!?\n]{0,30}" + r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式|微信|邮箱)", re.I), + # 祈使: leave/drop/share/give (me) your/us a <渠道词> + re.compile(r"(?:leave|drop|share|give)\s+(?:me\s+)?(?:your|us\s+your?|a)\s+" + r"(?:whatsapp|contact|number|e-?mail|联系方式)", re.I), + # 互留后缀 "Drop yours too..."(无渠道词,单列) + re.compile(r"(?:drop|leave)\s+yours\b", re.I), + # "WhatsApp or email?" 独立短问 + re.compile(r"(?:whatsapp|e-?mail|contact)\s+or\s+(?:whatsapp|e-?mail|contact)\s*\?", re.I), + # 裸碎片问句: "Your number, please?"/"Your WhatsApp?"(Ghana 案例穿透补) + # 后顾排除跟进确认句("reach/got/sent/did your X?" 是问进度不是要号) + re.compile(r"(? bool: + """输出句是否在索要联系方式(仅在客户已留资时用于丢弃)。""" + return any(p.search(sentence or "") for p in REASK_PATTERNS) diff --git a/app/services/contact_pipeline.py b/app/services/contact_pipeline.py new file mode 100644 index 0000000..a47d755 --- /dev/null +++ b/app/services/contact_pipeline.py @@ -0,0 +1,264 @@ +"""统一联系方式管道:联系方式检测的唯一事实源。 + +三个动作: +- 归一化:中英文 key 别名统一为 phone/whatsapp/email/wechat +- 深合并:同 key 新值覆盖旧值(客户改号场景),空值不覆盖 +- 校验分级:confirmed(格式通过,可触发邮件/结束)/ suspect(疑似, + 仅用于提醒 AI 礼貌确认,防止重复索要——D6/S1) + +上游约定:raw_info 中 "_" 前缀 key 为内部状态,禁止进入提示词渲染。 +""" +import re + +KEY_ALIASES = { + "phone": "phone", "手机": "phone", "电话": "phone", "电话号码": "phone", + "联系电话": "phone", "手机号": "phone", "手机号码": "phone", + "tel": "phone", "telephone": "phone", "phone_number": "phone", + "phone number": "phone", "mobile": "phone", "mobile_number": "phone", + "mobile number": "phone", + "whatsapp": "whatsapp", "wa": "whatsapp", "wsp": "whatsapp", + "whats app": "whatsapp", "whatsapp账号": "whatsapp", + "whatsapp号码": "whatsapp", "whatsapp number": "whatsapp", + "email": "email", "邮箱": "email", "e-mail": "email", "mail": "email", + "电子邮件": "email", + "wechat": "wechat", "微信": "wechat", "wx": "wechat", "weixin": "wechat", +} + + +def normalize_contacts(raw: dict | None) -> dict: + """key 归一化 + 去空值。精确别名优先;未命中再做 contains 兜底 + (extractor 即兴变体 key 不静默丢值)。contains 按 KEY_ALIASES 声明序 + 取第一个命中——复合 key(电话/WhatsApp)结果确定。""" + out: dict[str, str] = {} + for k, v in (raw or {}).items(): + key = str(k).strip().lower() + alias = KEY_ALIASES.get(key) + if alias is None: + for name, target in KEY_ALIASES.items(): + if name in key: + alias = target + break + val = str(v or "").strip() + if alias and val: + out[alias] = val + return out + + +def merge_contacts(old: dict | None, new: dict | None) -> dict: + """同 key 新值覆盖旧值;空/缺省不覆盖(堵浅合并抹数据 bug)。""" + return {**normalize_contacts(old), **normalize_contacts(new)} + + +def digits_only(s: str) -> str: + """提取纯数字(split_validate 位数判断与后续 canon 复用)。""" + return re.sub(r"\D", "", s) + + +def split_validate(contacts: dict) -> tuple[dict, dict]: + """返回 (confirmed, suspect)。电话/WhatsApp 取数字 7-15 位为合格;微信取非空 ≥4 字符。""" + confirmed: dict[str, str] = {} + suspect: dict[str, str] = {} + for k, v in normalize_contacts(contacts).items(): + if k == "email": + ok = re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", v) is not None + elif k == "wechat": + ok = len(v.strip()) >= 4 # 微信号无数字格式,非空短串即可 + else: + ok = 7 <= len(digits_only(v)) <= 15 + (confirmed if ok else suspect)[k] = v + return confirmed, suspect + + +EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") +INTL_PHONE_RE = re.compile(r"\+\d{6,15}") +# 邻接绑定:上下文词与数字必须在同一匹配内(仅空白衔接)——禁止"消息任意位置 +# 有上下文词 + 任意位置有 7 位数字"的消息级误绑(S1:车架号/订单号/先令价格) +WHATSAPP_CTX_RE = re.compile( + r"(?:whats\s*app|wa\.?me|(?:^|\b)wa\s*[::])\D{0,12}(\+?\d[\d\s\-().]{5,17}\d)", + re.IGNORECASE, +) +PHONE_CTX_RE = re.compile( + r"(?:my\s+(?:(?:new|other|another)\s+)?(?:phone\s+)?number\s+is|" + r"my\s+(?:(?:new|other|another)\s+)?phone\s+is|" + r"(?:msg|message|text|ping)\s+me\s+(?:at|on)?\s*|" + r"call\s+me\s+(?:at|on)|reach\s+me\s+(?:at|on)|" + r"phone\s*[::]|number\s*[::]|号码\s*[::]|手机号?\s*[::]|电话\s*[::])\s*" + r"(\+?\d[\d\s\-().]{5,17}\d)", + re.IGNORECASE, +) +# 仅 scan_user_messages 使用的裸邻接形态: "my number 0712345678"(无 is/冒号)。 +# ⚠️ 不得进 latest_stated_contacts——裸邻接无自述确定性,授覆盖权会让首轮旧值 +# 长期压制 extractor 后续改号(实证: test_correction_up_to_three 更正邮件全灭)。 +# 已知覆盖边界: "my phone 0712345678"(phone 变体,无 is)不在本形态内。 +PHONE_ADJACENCY_SCAN_RE = re.compile( + r"my\s+(?:(?:new|other|another)\s+)?number\s+(\+?\d[\d\s\-().]{5,17}\d)", + re.IGNORECASE, +) +# 倒装自述: "0712 345 678 is my whatsapp / 13800138000 我的微信" +# ⚠️ 只收显式渠道词(whatsapp/wa/wechat/微信),不收 number/phone/号码—— +# 自查实证: "2500000 is my number one choice" 这类排位表述会把价格误绑成 +# 号码,且本正则进入 latest_stated_contacts 即获覆盖权,误捕获会翻新掉 +# 已存真实号码(正是 S1 系测试在防的事故类)。倒装电话表述罕见,不值得 +# 为其承担该风险;电话主通路由 PHONE_CTX_RE 前缀形态/standalone/INTL 覆盖。 +# (?:my|我的)后用 \s* 而非 \s+: 中文"我的微信"连写无空白,\s+ 永不命中(实证)。 +NUMBER_FIRST_RE = re.compile( + r"(\+?\d[\d\s\-().]{5,17}\d)\s*(?:is|这是)?\s*" + r"(?:my|我的)\s*(whats\s*app|wa\b|wechat|微信)", + re.IGNORECASE, +) + +_NUMBER_FIRST_CHANNEL = { + "whatsapp": "whatsapp", "whats app": "whatsapp", "wa": "whatsapp", + "wechat": "wechat", "微信": "wechat", +} + + +def _number_first_channel(hit: re.Match) -> str: + # whats\s*app 可捕到任意空白变体(双空格/NBSP/换行),查表前先归一成 + # 单空格——否则 KeyError 会沿着 scan 的每消息热路径直接断流整轮对话。 + return _NUMBER_FIRST_CHANNEL[" ".join(hit.group(2).lower().split())] + + +# 更正语境(与裸国际号同消息出现时才授予覆盖权) +CORRECTION_CTX_RE = re.compile( + r"wrong\s+(?:number|phone)|typo|打错|写错|输错|错了|换成了?|改为|" + r"use\s+(?:this|new|the\s+new)|instead|correct\s+(?:number|phone|it)", + re.IGNORECASE, +) + + +def scan_user_messages(messages: list[dict]) -> dict: + """S4: 扫描全部客户消息,返回高置信捕获(email / +国际号 / whatsapp·wa.me / + 显式电话上下文邻接数字)。裸数字(价格/年份/里程/车架号)一律不收。""" + found: dict[str, str] = {} + for msg in messages: + if msg.get("role") != "user": + continue + text = msg.get("content", "") + if "email" not in found: + hit = EMAIL_RE.search(text) + if hit: + found["email"] = hit.group(0) + if "phone" not in found: + sn = standalone_number(text) + if sn: + found["phone"] = sn + if "phone" not in found: + hit = INTL_PHONE_RE.search(text) + if hit: + found["phone"] = hit.group(0) + if "whatsapp" not in found: + hit = WHATSAPP_CTX_RE.search(text) + if hit: + found["whatsapp"] = hit.group(1) + if "phone" not in found: + hit = PHONE_CTX_RE.search(text) + if hit: + found["phone"] = hit.group(1) + if "phone" not in found: + hit = PHONE_ADJACENCY_SCAN_RE.search(text) + if hit: + found["phone"] = hit.group(1) + # 倒装属最低置信层: whatsapp/phone 任一已被更高置信源捕获即跳过, + # 不与之竞争(跨 key 门控故含 phone)。 + if "whatsapp" not in found and "phone" not in found: + hit = NUMBER_FIRST_RE.search(text) + if hit: + ch = _number_first_channel(hit) + found[ch] = hit.group(1) + return found + + +def canon(value: str) -> str: + """去重比对用的规范形:邮箱小写,其余取纯数字(S3/R3 best-effort)。""" + value = str(value or "").strip() + if "@" in value: + return value.lower() + return digits_only(value) + + +def standalone_number(text: str) -> str | None: + """整条消息仅为一个号码(Task 12d): 客户"直接甩号"的最显式表达,授予覆盖权。 + ≥9 位下限把年份(4)/美元价(5)/先令价(5-7)全部排除在外; + 允许 +/-/空格/横线/点/括号包裹,其余任何字符(字母/逗号)即非独立号码条。""" + t = (text or "").strip() + if not re.fullmatch(r"\+?[\d\s\-().·]{9,25}", t): + return None + return t if 9 <= len(digits_only(t)) <= 15 else None + + +def latest_stated_contacts(messages: list[dict]) -> dict: + """最新显式自述(唯一可覆盖 extractor 回传的来源,Task 12): + + 同 key 取全历史最后一次显式自述。覆盖权仅授予: + - 上下文门控捕获(whatsapp/wa.me/wa:、my (new/other) number is、msg/text/ping me、colon 形式、email) + - 倒装渠道自述(NUMBER_FIRST_RE: "0712 345 678 is my whatsapp"、 + "13800138000 我的微信"——只认显式渠道词) + - "更正语境 ∧ 同消息裸国际号"(wrong number, use +86...) + 纯裸数字无覆盖权(S1): 防价格/年份/订单号误翻新。 + 例外(Task 12d): 整条消息仅为一个号码(≥9位) = 最显式自述,授予覆盖权。 + 通则(权威契约): 正则接进本循环即授覆盖权——新增低精度形态时只进 + 扫描辅源 scan_user_messages,不进这里(先例: PHONE_ADJACENCY_SCAN_RE, + 裸 my number 邻接曾致更正邮件全灭)。 + """ + latest: dict[str, str] = {} + for msg in messages: + if msg.get("role") != "user": + continue + text = msg.get("content") or "" + hit = EMAIL_RE.search(text) + if hit: + latest["email"] = hit.group(0) + hit = WHATSAPP_CTX_RE.search(text) + if hit: + latest["whatsapp"] = hit.group(1) + sn = standalone_number(text) + if sn: + latest["phone"] = sn + hit = PHONE_CTX_RE.search(text) + if hit: + latest["phone"] = hit.group(1) + elif CORRECTION_CTX_RE.search(text): + hit = INTL_PHONE_RE.search(text) + if hit: + latest["phone"] = hit.group(0) + hit = NUMBER_FIRST_RE.search(text) + if hit: + ch = _number_first_channel(hit) + latest[ch] = hit.group(1) + return latest + + +def resolve_by_recency(before: dict, after: dict, messages: list[dict]) -> dict: + """最近提及裁决(Task 12c): before 中同 canon 的非 email key 构成一个镜像身份组。 + 组内分歧时,取"出现在最晚客户消息里"的 canon 代表值—— + - 杀掉 extractor 在历史号码间的振荡(旧号总在更早的消息里,天然落败) + - 压制幻觉值(从未出现在任何消息里,last_idx=-1) + - 客户最新消息里的新号(extractor 抓到的裸号)天然胜出 + 组内一致或不同组(本就不同的号)时原样返回。""" + out = dict(after) + groups: dict[str, set[str]] = {} + for k, v in (before or {}).items(): + if k != "email" and v: + groups.setdefault(canon(str(v)), set()).add(k) + user_digits = [ + digits_only(m.get("content") or "") for m in messages + if m.get("role") == "user" + ] + + def last_idx(c: str) -> int: + for i in range(len(user_digits) - 1, -1, -1): + if c and c in user_digits[i]: + return i + return -1 + + for keys in groups.values(): + vals = {k: out[k] for k in keys if out.get(k)} + distinct = {canon(str(v)) for v in vals.values()} + if len(distinct) > 1: + best = max(distinct, key=last_idx) + if last_idx(best) >= 0: + rep = next(v for v in vals.values() if canon(str(v)) == best) + for k in keys: + out[k] = rep + return out diff --git a/app/services/conversation.py b/app/services/conversation.py new file mode 100644 index 0000000..25bfc5f --- /dev/null +++ b/app/services/conversation.py @@ -0,0 +1,645 @@ +import logging +import re +import uuid +from collections.abc import AsyncGenerator +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.database import ( + Conversation, ConversationStatus, ConversationSource, + Message, MessageRole, CustomerProfile, Evaluation, IntentLevel, + LeadPushLog, +) +from app.services import email_service, lead_push_service +from app.services.ai_service import AIService +from app.services.boundary_filter import ( + NO_CONTACT_ALREADY, NO_CONTACT_APP, NO_CONTACT_WEB, asks_contact, + deflection_for, no_contact_value, pivot_for, split_sentences, + violates_boundary, +) +from app.services.contact_pipeline import ( + canon, latest_stated_contacts, merge_contacts, normalize_contacts, + resolve_by_recency, scan_user_messages, split_validate, +) + +logger = logging.getLogger(__name__) + +# 职能边界触发词(Task 13.2, 评审#1/#4 修订): 命中即注入红线提醒。精修说明: +# - 裸 "how long"/"多久" 不收(续航类问题常用),英文要求 take/arrive/deliver/ship 邻接, +# 中文只收 到货/多久能到/交货 等明确交付语境 +# - 【评审#1】施压话术本身也触发: 磨价轮("just roughly"/"说个大概")没有价格词, +# 却是最易破防的时刻——压力词单列一组 +# - 【评审#4】v1 补齐漏词: expensive/fee/贵/价钱/总价/落地/划算/免费(短语式)等; +# cost/fee 用 \b 边界,costa rica 不再误命中("costa"含cost但无词边界) +# - 原则: 误触发低害(多一条提醒),漏触发高危(红线失守),词表偏宽;上线后漏词案例回流补词 +BOUNDARY_TRIGGER_RE = re.compile( + r"price|pricing|how\s+much|\bcost\b|discount|cheaper|expensive|\bfee\b|" + r"quote|quotation|shipping\s+(fee|cost|time)|freight|tariff|customs\s+fee|" + r"best\s+offer|any\s+offer|free\s+shipping|" + r"test\s+drive|book(ing)?\s|reserv|arrange|" + r"delivery|lead\s+time|arriv|" + r"how\s+long\s+(does|will|would|to).*(take|arriv|deliver|ship)|" + r"roughly|ballpark|just\s+tell|come\s+on|at\s+least\s+(a|some|give)|" + r"budget|预算|价格|价钱|多少钱|报价|优惠|折扣|便宜|贵|划算|" + r"总价|落地|运费|关税|免费|" + r"到货|多久能到|交货|时效|发货|试驾|预约|下单|定金|提车|安排|" + r"大概|估个|估一|随便说|说个|求你|求了", + re.IGNORECASE, +) + +# 预算路径路由(评审#9): 命中 BOUNDARY 触发词且含预算词 → 走预算 playbook +BUDGET_TRIGGER_RE = re.compile(r"budget|预算", re.IGNORECASE) + +# 索要我方联系方式触发(15.2, doctrine v2): 客户问"你们怎么联系/你的微信多少" +# = 最高意向信号。提醒层按 source 分支: web 指按钮, App 无按钮走反向留资 +# - 排除第一人称将来时: "I'll call you later" 是日常告别语,不是索要联系方式。 +# 必须用定宽 negative lookbehind——lookahead 在动词位置求值,永远看不到 +# 前面的 let me / i'll,不生效。四个 lookbehind 全部定宽合法: i'll(5)、 +# i’ll(5, 弯撇 U+2019 手机常见)、i will(7)、let me(7);IGNORECASE 兼容大写 +CONTACT_ASK_TRIGGER_RE = re.compile( + r"(your|our)\s+(whatsapp|number|phone|email|contact)|" + r"(? int: + global _boundary_seq + _boundary_seq += 1 + return _boundary_seq + + +class ConversationManager: + def __init__(self, db: AsyncSession, ai_service: AIService | None = None): + self.db = db + self.ai = ai_service or AIService() + + async def create_conversation( + self, + source: ConversationSource = ConversationSource.standalone, + client_info: dict | None = None, + page_token: str | None = None, + context: dict | None = None, + ) -> Conversation: + session_id = uuid.uuid4().hex + conv = Conversation( + session_id=session_id, + source=source, + client_info=client_info, + page_token=page_token, + ) + self.db.add(conv) + await self.db.commit() + await self.db.refresh(conv) + + # Conditional whitelist seed of app context into raw_info (red line): + # only when `context` is provided, and only vehicle_name/vehicle_id + # mapped to app_* keys. WS path passes no context -> raw_info stays {}. + raw_info: dict = {} + if context: + if context.get("vehicle_name"): + raw_info["app_vehicle_name"] = context["vehicle_name"] + if context.get("vehicle_id"): + raw_info["app_vehicle_id"] = context["vehicle_id"] + profile = CustomerProfile(conversation_id=conv.id, raw_info=raw_info) + self.db.add(profile) + await self.db.commit() + + return conv + + async def get_by_session(self, session_id: str) -> Conversation | None: + result = await self.db.execute( + select(Conversation).where(Conversation.session_id == session_id) + ) + return result.scalar_one_or_none() + + async def get_by_page_token(self, page_token: str) -> Conversation | None: + result = await self.db.execute( + select(Conversation).where( + Conversation.page_token == page_token, + Conversation.status == ConversationStatus.active, + ) + ) + return result.scalar_one_or_none() + + async def stream_process_message( + self, session_id: str, content: str + ) -> AsyncGenerator[dict, None]: + """Stream AI reply token by token, then do extract/evaluate.""" + conv = await self.get_by_session(session_id) + if not conv: + yield {"type": "error", "message": "session not found"} + return + + if conv.status != ConversationStatus.active: + yield {"type": "error", "message": "conversation ended"} + return + + # Save user message + user_msg = Message( + conversation_id=conv.id, role=MessageRole.user, content=content + ) + self.db.add(user_msg) + await self.db.commit() + + history = await self._load_history(conv.id) + + # Build context reminder (merged into system prompt) + profile = await self._get_profile(conv.id) + + # P0 同轮竞态消除: 当前消息即时正则扫描——仅并入本轮内存视图 + # (提醒/话术路由),不落库;持久化仍由轮末 extractor+scanner 负责, + # 邮件触发时序不变。 + current_scan = scan_user_messages([{"role": "user", "content": content}]) + effective_raw = dict(profile.raw_info or {}) + if current_scan: + effective_raw["联系方式"] = merge_contacts( + current_scan, effective_raw.get("联系方式")) + + context_reminder = self._build_context_reminder( + effective_raw, content, history, source=conv.source.value) + if CONTACT_ASK_TRIGGER_RE.search(content or ""): + logger.info("Contact-ask reminder fired (source=%s)", conv.source.value) + + # Enrich short user messages with context so AI doesn't lose track + history_for_ai = list(history) + enriched = self._enrich_short_message(content, profile.raw_info or {}, history) + if history_for_ai and history_for_ai[-1]["role"] == "user" and enriched != content: + history_for_ai[-1] = {**history_for_ai[-1], "content": enriched} + logger.info("Enriched short message: %r -> %r", content[:50], enriched[:100]) + + # 方案B'(13.7): 句子粒度门控——干净句子逐句流出(恢复打字机), + # 违规句永不流出:已流出内容全部干净,违规时接过渡话术收尾并掐断生成。 + # F6(D6联动): 客户已留联系方式时,收尾话术不得再索要——路由 contact 版 + has_contact = bool(split_validate( + normalize_contacts(effective_raw.get("联系方式")))[0]) + + yield {"type": "stream_start"} + emitted = "" + pending = "" + violated = False + dropped_ask = False # Task 5b: 本轮是否有索要句被再索要墙丢弃 + dirty = "" + # 15.3: 双墙裁决——价格墙检查在前: 同句双违规时价格路径话术优先 + # (职能边界话术更具体,且自带留资引导);仅纯号码/邮箱违规时 + # violation=="no_contact",用零号码墙替换句 + violation = "" + async for token in self.ai.stream_chat(history_for_ai, context=context_reminder): + pending += token + complete, pending = split_sentences(pending) + for sentence in complete: + if violates_boundary(sentence): + violated = True + dirty = sentence + violation = "boundary" + break + if no_contact_value(sentence, history_for_ai): + violated = True + dirty = sentence + violation = "no_contact" + break + # Task 5b 再索要墙: 已留资时索要句静默丢弃(丢句不掐断, + # 其余句子照常流出——软于两墙的掐断+替换) + if has_contact and asks_contact(sentence): + logger.warning("Re-ask wall dropped sentence (conv=%s): %r", + conv.id, sentence[:60]) + dropped_ask = True + continue + for i in range(0, len(sentence), 40): + yield {"type": "stream_chunk", "content": sentence[i:i + 40]} + emitted += sentence + if violated: + break # 提前掐断生成,省 token + if not violated and pending: + if violates_boundary(pending): + violated = True + dirty = pending + violation = "boundary" + elif no_contact_value(pending, history_for_ai): + violated = True + dirty = pending + violation = "no_contact" + elif has_contact and asks_contact(pending): + # Task 5b 再索要墙: 尾巴是索要句 → 静默丢弃(不流出不掐断) + logger.warning("Re-ask wall dropped tail (conv=%s): %r", + conv.id, pending[:60]) + dropped_ask = True + else: + for i in range(0, len(pending), 40): + yield {"type": "stream_chunk", "content": pending[i:i + 40]} + emitted += pending + if violated: + if violation == "no_contact": + # 零号码墙替换句: 已留资→确认话术(不再索要);未留资按 source 分支 + # (App 无按钮 → 反向留资; web/widget/standalone → 指顶部按钮) + logger.warning( + "No-contact wall replaced sentence (conv=%s): %r", + conv.id, dirty[:60]) + closing = (NO_CONTACT_ALREADY if has_contact + else (NO_CONTACT_APP if conv.source.value == "app" + else NO_CONTACT_WEB)) + else: + logger.warning( + "Boundary filter intercepted sentence (conv=%s): %r | tail=%r", + conv.id, dirty[:80], (pending or "")[:40]) + closing = (pivot_for(content, has_contact) if emitted + else deflection_for(content, _boundary_rotate(), + has_contact=has_contact)) + for i in range(0, len(closing), 40): + yield {"type": "stream_chunk", "content": closing[i:i + 40]} + emitted += closing + # Task 5b 评审修正: 整条回复只有索要句(全被丢)→ 兜底流出"经理主动 + # 联系"句,绝不空回复。与上方墙收尾互斥——violated 路径已自带收尾, + # 此处仅在无任何实义内容流出时接管;已流出的纯空白前缀一并作废, + # 落库恒为完整兜底句(客户看到的比一个空格更糟的是: 什么都没有)。 + if not violated and dropped_ask and not emitted.strip(): + logger.warning("Re-ask wall dropped entire reply (conv=%s), " + "falling back to closing", conv.id) + for i in range(0, len(NO_CONTACT_ALREADY), 40): + yield {"type": "stream_chunk", + "content": NO_CONTACT_ALREADY[i:i + 40]} + emitted = NO_CONTACT_ALREADY + yield {"type": "stream_end"} + ai_reply = emitted # 落库与流出统一为已流出文本(下方持久化) + + # Save assistant message (skip if empty) + if ai_reply.strip(): + assistant_msg = Message( + conversation_id=conv.id, role=MessageRole.assistant, content=ai_reply + ) + self.db.add(assistant_msg) + await self.db.commit() + + # Round 2: Extract info (background) + full_history = history + [{"role": "assistant", "content": ai_reply}] + profile = await self._get_profile(conv.id) + + # Task 12b: 轮前联系方式快照(extractor 深合并 + 覆盖 + 同号同步的比对基线) + before_contacts = normalize_contacts((profile.raw_info or {}).get("联系方式")) + + new_info = await self.ai.extract_info( + full_history, existing_info=profile.raw_info or {} + ) + logger.info("Extractor result: %s", new_info) + if new_info: + raw = dict(profile.raw_info or {}) + for k, v in new_info.items(): + # 联系方式深合并(同key新值覆盖),其余顶层覆盖 —— 堵浅合并抹数据 + if k == "联系方式": + raw[k] = merge_contacts(raw.get(k), v) + else: + raw[k] = v + profile.raw_info = raw + self.db.add(profile) + + # Round 2.5: 联系方式正则辅源(S4: 扫全量客户消息历史) + # 🔴 合并方向: 扫描器只填空,extractor 已有值优先——否则扫描器的 + # 首个历史值会压掉 extractor 后续捕获的改号, D4 更正邮件失效 + scanned = scan_user_messages(full_history) + if scanned: + raw = dict(profile.raw_info or {}) + raw["联系方式"] = merge_contacts(scanned, raw.get("联系方式")) + profile.raw_info = raw + self.db.add(profile) + + # Task 12: 最新显式自述优先 —— 唯一允许文本压过 extractor 回传的位置 + # (extractor 受"保留已有信息"倾向会滞留旧值,实测 wrong number 场景命中) + latest_stated = latest_stated_contacts(full_history) + if latest_stated: + current = normalize_contacts((profile.raw_info or {}).get("联系方式")) + changed = False + for k, v in latest_stated.items(): + if v and canon(str(v)) != canon(str(current.get(k, ""))): + current[k] = v + changed = True + if changed: + raw = dict(profile.raw_info or {}) + raw["联系方式"] = current + profile.raw_info = raw + self.db.add(profile) + + # Task 12c 最近提及裁决: 镜像组分歧时以客户最晚提及的号码为准 + # (杀 extractor 历史振荡 + 压制幻觉;Task 12b 同号同步场景被涵盖) + after_contacts = normalize_contacts((profile.raw_info or {}).get("联系方式")) + resolved = resolve_by_recency(before_contacts, after_contacts, full_history) + if resolved != after_contacts: + raw = dict(profile.raw_info or {}) + raw["联系方式"] = resolved + profile.raw_info = raw + self.db.add(profile) + + # Round 3: Evaluate (仅 ≥3 条客户消息才有意义, 前两轮必然 pending —— D7) + user_msg_count = sum(1 for m in full_history if m["role"] == "user") + evaluation_ran = user_msg_count >= 3 + if evaluation_ran: + evaluation = await self.ai.evaluate( + profile=profile.raw_info or {}, + recent_messages=full_history, + ) + else: + # 客户端 evaluated 事件语义保持 pending;邮件标签由 evaluation_ran 决定(D2) + evaluation = {"intent_level": "pending", "reasoning": "", + "contact_info": {}, "info_sufficient": False} + logger.info("Evaluation (stream): profile=%s | eval=%s", + profile.raw_info, evaluation) + + # 邮件触发(D1): 与会话结束彻底解耦,联系方式到手即发 + pending_pushes: list[LeadPushLog] = [] + await self._maybe_send_lead_email( + conv, profile, evaluation, evaluated=evaluation_ran, + pending_pushes=pending_pushes, + ) + + # 联系方式判定(供 D8 评估落库): 有意向但没联系方式 → 卡单供 admin 人工兜底 + confirmed_now, _ = split_validate( + normalize_contacts((profile.raw_info or {}).get("联系方式"))) + + # 评估落库(D8 泛化): 每轮评估后刷新意向;email_sent/correction_count + # 永不触碰。有意向无联系方式 = 卡单(admin 可筛);已发过邮件的 + # contact_info 由触发器更正分支管理,这里不覆盖。 + if user_msg_count >= 3: + ev = await self._get_or_init_evaluation(conv.id) + ev.intent_level = IntentLevel(evaluation.get("intent_level", "pending")) + ev.reasoning = evaluation.get("reasoning", "") + if not confirmed_now: + ev.contact_info = {} + elif not ev.email_sent: + ev.contact_info = confirmed_now + self.db.add(ev) + + # 决策A(2026-08-18): 自动结束移除——会话永不因评估结果结束; + # evaluated 事件保留但 ended 恒为 False(客户端协议不变)。 + await self.db.commit() + + # 询盘推送(设计 8.1): 主事务提交后才派生后台任务——payload 内存 + # 传递,任务不读库只写结果;fire 为同步函数,纳秒级返回不阻塞 + lead_push_service.fire_pending_pushes(pending_pushes) + + yield { + "type": "evaluated", + "ended": False, + "intent_level": evaluation.get("intent_level", "pending"), + } + + async def _get_or_init_evaluation(self, conv_id: str) -> Evaluation: + result = await self.db.execute( + select(Evaluation).where(Evaluation.conversation_id == conv_id) + ) + ev = result.scalar_one_or_none() + if not ev: + ev = Evaluation( + conversation_id=conv_id, + intent_level=IntentLevel.pending, + contact_info={}, + ) + self.db.add(ev) + await self.db.flush() + return ev + + async def _recently_emailed_canons(self) -> set[str]: + cutoff = datetime.now(timezone.utc) - timedelta(hours=24) + # 只取需要的列;🔴 禁止加 LIMIT——完整 24h 窗口才正确 + result = await self.db.execute( + select(Evaluation.contact_info).where( + Evaluation.email_sent.is_(True), + Evaluation.evaluated_at >= cutoff, + ) + ) + seen: set[str] = set() + for (contact_info,) in result: + for v in (contact_info or {}).values(): + if v: + seen.add(canon(str(v))) + return seen + + async def _maybe_send_lead_email( + self, conv: Conversation, profile: CustomerProfile, evaluation: dict, + evaluated: bool = True, + pending_pushes: list[LeadPushLog] | None = None, + ) -> None: + """D1: 联系方式是发邮件的唯一条件。confirmed 才触发;suspect 只进提醒。 + + evaluated=False 表示本轮跳过了 GLM 评估(<3条客户消息)——此时邮件 + intent_level 必须传空串(→[未评估]),不得把客户端事件用的 "pending" + 透传成 [待确认](D2 两者语义不同)。 + """ + contacts_all = normalize_contacts((profile.raw_info or {}).get("联系方式")) + confirmed, _suspect = split_validate(contacts_all) + if not confirmed: + return + ev = await self._get_or_init_evaluation(conv.id) + + if not ev.email_sent: + # S3: 任一值已见于24h内其他会话 → 轻提醒;否则全量 + seen = await self._recently_emailed_canons() + if any(canon(str(v)) in seen for v in confirmed.values()): + await self._collect_push( + pending_pushes, ev, conv, profile, confirmed, + "returning", evaluation, evaluated) + sent = await email_service.send_returning_lead_email(confirmed) + else: + await self._collect_push( + pending_pushes, ev, conv, profile, confirmed, + "lead", evaluation, evaluated) + messages = await self._load_history(conv.id) + sent = await email_service.send_lead_email( + customer_profile=profile.raw_info or {}, + contact_info=confirmed, + reasoning=evaluation.get("reasoning", ""), + conversation_messages=messages, + intent_level=evaluation.get("intent_level", "") if evaluated else "", + ) + if sent: + ev.email_sent = True + ev.contact_info = confirmed + self.db.add(ev) + else: + old_canons = {canon(str(v)) for v in (ev.contact_info or {}).values()} + if any(canon(str(v)) not in old_canons for v in confirmed.values()): + if ev.correction_count < 3: + count = ev.correction_count + 1 + await self._collect_push( + pending_pushes, ev, conv, profile, confirmed, + "correction", evaluation, evaluated) + if await email_service.send_correction_email(confirmed, count=count): + ev.correction_count = count + ev.contact_info = confirmed + self.db.add(ev) + else: + # D4'(2026-08-18): 锁邮件不锁快照 —— 上限已到, + # admin 仍要看到客户最新号码 + ev.contact_info = confirmed + self.db.add(ev) + + async def _collect_push( + self, pending, ev, conv, profile, confirmed, + push_type, evaluation, evaluated, + ) -> None: + """询盘推送派发(设计 §8): 触发条件=进入发信分支,与 SMTP 成败解耦。 + + dispatch 内部吞异常(失败只记日志),这里无需再兜。 + """ + log = await lead_push_service.dispatch_lead_push( + self.db, ev, conv, profile, confirmed, push_type, + evaluation, evaluated, + ) + if log is not None and pending is not None: + pending.append(log) + + async def _load_history(self, conv_id: str) -> list[dict]: + result = await self.db.execute( + select(Message) + .where(Message.conversation_id == conv_id) + .order_by(Message.created_at) + ) + messages = result.scalars().all() + return [{"role": m.role.value, "content": m.content} for m in messages] + + async def _get_profile(self, conv_id: str) -> CustomerProfile: + result = await self.db.execute( + select(CustomerProfile).where(CustomerProfile.conversation_id == conv_id) + ) + return result.scalar_one() + + @staticmethod + def _enrich_short_message(content: str, raw_info: dict, history: list[dict] | None = None) -> str: + """Enrich short user messages with conversation context. + + Two sources: extractor output (primary), last substantive user + message (fallback when extractor hasn't run yet). + """ + if len(content.strip()) >= 30: + return content + + facts = [] + + # Primary: extractor output + if raw_info: + facts = [f"{k}: {v}" for k, v in raw_info.items() + if v and not str(k).startswith("_") and k != "联系方式"] + + # Fallback: use last substantive user message as context + if not facts and history: + for msg in reversed(history[:-1]): + if msg.get("role") == "user" and len(msg.get("content", "").strip()) >= 5: + facts.append("客户之前说: " + msg["content"][:80]) + break + + if not facts: + return content + + return ( + content + + "\n\n【系统上下文 - 系统标注,不要向客户提及】已知: " + + " | ".join(facts) + + "。不要重复询问这些已确认的信息!" + ) + + @staticmethod + def _build_context_reminder(raw_info: dict, latest_msg: str, + history: list[dict] | None = None, + source: str = "") -> str: + """Build a context reminder from extracted profile info.""" + # 职能边界提醒(评审#7: 前置到早退之前,首条消息画像为空也要生效) + boundary_note = "" + if BOUNDARY_TRIGGER_RE.search(latest_msg or ""): + if BUDGET_TRIGGER_RE.search(latest_msg or ""): + # 评审#9: 预算求推荐 → 预算路径(推荐+反问+引导),不一刀切要号 + boundary_note = ( + "⚠️ 职能边界提醒:客户报预算求推荐。推荐车型但绝不复述预算数字、" + "不报车价 → 反问意向车型 → 顺势引导留联系方式。") + else: + boundary_note = ( + "⚠️ 职能边界提醒:客户正在询问价格/优惠/交付时效/实质安排。绝对禁止" + "给出任何价格数字(包括复述客户自己的预算)、交付时间或任何承诺," + "按职能边界对应话术处理。") + + # 联系方式状态前移(供 boundary/contact 提醒共用判定) + contacts_all = normalize_contacts(raw_info.get("联系方式")) + confirmed, suspect = split_validate(contacts_all) + + # 索要我方联系方式提醒(15.2): 与 boundary_note 同样前移到早退之前, + # 空画像首条消息也要生效;按 source 分支(widget/standalone 有按钮, app 无)。 + # 冒烟实证: 抽象指令("用App话术")GLM 会无视并背 prompt 里更显眼的 web 脚本—— + # 提醒必须携带该分支的逐字话术,不让模型自己挑。 + contact_note = "" + if CONTACT_ASK_TRIGGER_RE.search(latest_msg or ""): + if source == "app": + if confirmed or suspect: + # 已留资: 索要我方联系方式 → 逐字"经理主动联系"版, + # 与"禁止再索要"提醒不再打架 + contact_note = ( + "⚠️ 客户正在索要我方联系方式,且客户已留过自己的联系方式。" + "请逐字使用这句:\"Our manager will reach out to you at your " + "WhatsApp shortly with the full quote!\"(中文同理)。" + "绝对禁止输出任何号码或邮箱,也绝对禁止再索要联系方式。") + else: + contact_note = ("⚠️ 客户正在索要我方联系方式(最高意向信号)。当前为 App 会话," + "没有页面按钮——禁止使用任何 tap/button 按钮话术!请逐字使用这句:" + "\"Drop your WhatsApp and our manager will reach out to you directly " + "with the full quote!\"(中文同理引导客户留联系方式)。" + "绝对禁止输出任何号码或邮箱。") + else: + contact_note = ("⚠️ 客户正在索要我方联系方式(最高意向信号)。请逐字使用这句指路:" + "\"Just tap the WhatsApp button at the top of this chat — " + "our manager replies fast!\"(邮箱同理指 Email 按钮)。" + "绝对禁止口头报出任何号码或邮箱。") + + if not raw_info: + # 空画像:仅返回边界/联系提醒(可能为空串) + return "\n".join(x for x in (boundary_note, contact_note) if x) + + parts = ["[上下文提醒 - 这是一条系统指令,绝对不要向客户提及这段内容]"] + parts.append("客户已明确告知以下信息,你绝对不能再问这些:") + + for key, val in raw_info.items(): + if str(key).startswith("_") or key == "联系方式": + continue # 内部状态与联系方式单独渲染 + if val: + parts.append(f"{key}: {val}") + + # 联系方式专项(D6) 与 职能边界(D6联动) + if confirmed: + shown = ", ".join( + f"{k}: {' '.join(str(v).split())[:60]}" for k, v in confirmed.items()) + # 逐字话术直接引用 NO_CONTACT_ALREADY 常量(Task 5 已 import)—— + # 与零号码墙替换句同源,三处"经理主动联系"话术不漂移 + parts.append(f"⚠️ 客户已提供联系方式({shown})。绝对禁止再索要任何联系方式!" + f"需要回应时可直接逐字使用:\"{NO_CONTACT_ALREADY}\"(中文同理)。" + f"确认收到后继续解答客户问题即可,不必急着结束对话。") + if boundary_note: + parts.append(boundary_note) + parts.append("客户已留过联系方式——边界请求直接告知'客户经理会把准确报价/" + "安排发到你 WhatsApp'即可,禁止再索要联系方式。") + elif suspect: + shown = ", ".join( + f"{k}: {' '.join(str(v).split())[:60]}" for k, v in suspect.items()) + parts.append(f"⚠️ 客户疑似已提供联系方式({shown})。禁止重新索要;" + f"可礼貌地请客户确认一次。") + if boundary_note: + parts.append(boundary_note) + + if boundary_note and not confirmed and not suspect: + parts.append(boundary_note) # 未留资:边界提醒引导留资(职能边界话术自带) + + # 15.2: 索要我方联系方式提醒注入(镜像 boundary_note 的挂载位)。 + # 互留提示仅限 web 来源且客户未留资(confirmed/suspect 都算"已留"); + # App 的反向留资话术本身就是主话术,不再叠加 + if contact_note: + note = contact_note + if source != "app" and not confirmed and not suspect: + note += "客户尚未留过联系方式,可顺势轻问一句(每会话最多一次)。" + parts.append(note) + + if len(latest_msg.strip()) < 25: + parts.append("⚠️ 客户发的是短消息!必须结合上面已知信息来理解,绝对不能当作独立问题!") + + return "\n".join(parts) diff --git a/app/services/email_service.py b/app/services/email_service.py new file mode 100644 index 0000000..f7ee35d --- /dev/null +++ b/app/services/email_service.py @@ -0,0 +1,129 @@ +import html +import logging +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import aiosmtplib + +from app.config import settings + +logger = logging.getLogger(__name__) + +TAG_LABELS = { + "interested": "意向强", "pending": "待确认", + "not_interested": "无意向", "": "未评估", +} +CONTACT_LABELS = {"phone": "手机", "whatsapp": "WhatsApp", "email": "邮箱", "wechat": "微信"} + + +def _esc(v) -> str: + return html.escape(str(v)) if v else "" + + +def _flat(v) -> str: + """主题行用:折叠空白(堵换行注入头部的通道)+ 限长。""" + return " ".join(str(v).split())[:60] + + +def _contact_rows(contacts: dict) -> str: + return "".join( + f"{html.escape(CONTACT_LABELS.get(k, k))}" + f"{_esc(v)}" + for k, v in (contacts or {}).items() if v + ) + + +async def _send(msg: MIMEMultipart) -> bool: + if not all([settings.SMTP_HOST, settings.SMTP_USER, settings.NOTIFY_EMAIL]): + logger.warning("SMTP not configured, skipping email") + return False + try: + await aiosmtplib.send( + msg, hostname=settings.SMTP_HOST, port=settings.SMTP_PORT, + username=settings.SMTP_USER, password=settings.SMTP_PASS, + use_tls=True, + ) + logger.info("Lead email sent to %s", settings.NOTIFY_EMAIL) + return True + except Exception: + logger.exception("Failed to send lead email") + return False + + +async def send_lead_email( + customer_profile: dict, + contact_info: dict, + reasoning: str, + conversation_messages: list[dict], + intent_level: str = "", +) -> bool: + tag = TAG_LABELS.get(intent_level, "未评估") + contacts = dict(contact_info or {}) + country = (customer_profile or {}).get("目标国家", "") + if not country: + country = (customer_profile or {}).get("target_country", "未知") + + msg = MIMEMultipart("alternative") + contact_parts = [ + f"{CONTACT_LABELS.get(k, k)}: {_flat(v)}" for k, v in contacts.items() if v + ] + subject = f"[新询盘·{tag}] {_flat(country)}" + if contact_parts: + subject += " | " + ", ".join(contact_parts) + msg["Subject"] = subject + msg["From"] = f"【XYH】 <{settings.SMTP_USER}>" + msg["To"] = settings.NOTIFY_EMAIL + + info_rows = "".join( + f"{_esc(k)}" + f"{_esc(v)}" + for k, v in (customer_profile or {}).items() if not str(k).startswith("_") + ) + chat_lines = "".join( + f"
{'客户' if m.get('role') == 'user' else 'AI'}: {_esc(m.get('content', ''))}
" + for m in conversation_messages or [] + ) + + html_body = f""" + +

🎉 新询盘({html.escape(tag)})

+

联系方式

+ {_contact_rows(contacts)}
+

客户画像

+ {info_rows}
+

评估理由

{_esc(reasoning)}

+

完整对话记录

+
{chat_lines}
+

注:邮件为触发时快照,对话可能仍在进行,最新记录请以后台为准。

+ + """ + msg.attach(MIMEText(html_body, "html", "utf-8")) + return await _send(msg) + + +async def send_returning_lead_email(contact_info: dict) -> bool: + """D5: 24h 内同联系方式再访,只发一行轻提醒。""" + contact_parts = [ + f"{CONTACT_LABELS.get(k, k)}: {_flat(v)}" for k, v in (contact_info or {}).items() if v + ] + msg = MIMEMultipart("alternative") + msg["Subject"] = f"[线索回访] {' | '.join(contact_parts) or '已知客户'}再次来访" + msg["From"] = f"【XYH】 <{settings.SMTP_USER}>" + msg["To"] = settings.NOTIFY_EMAIL + msg.attach(MIMEText( + f"

该联系方式 24h 内已发过完整询盘,客户又开启了新对话," + f"详情请查后台。

", "html", "utf-8")) + return await _send(msg) + + +async def send_correction_email(contact_info: dict, count: int = 1) -> bool: + """D4: 联系方式变化(改号/新增/无提示换值)后的轻量更正邮件,每会话上限3封。""" + msg = MIMEMultipart("alternative") + msg["Subject"] = f"[询盘·更正{count}] 客户联系方式已更新(以本封为准)" + msg["From"] = f"【XYH】 <{settings.SMTP_USER}>" + msg["To"] = settings.NOTIFY_EMAIL + msg.attach(MIMEText( + f"

这是本会话第 {count} 次更正(上限 3 次),请以本封为准:

" + f"{_contact_rows(contact_info)}
" + f"", "html", "utf-8")) + return await _send(msg) diff --git a/app/services/ip_service.py b/app/services/ip_service.py new file mode 100644 index 0000000..79ec787 --- /dev/null +++ b/app/services/ip_service.py @@ -0,0 +1,28 @@ +from iptocc import country_code + +# ISO 3166-1 alpha-2 to country name (common ones) +COUNTRY_NAMES = { + "CN": "中国", "US": "美国", "NG": "尼日利亚", "GH": "加纳", + "KE": "肯尼亚", "TZ": "坦桑尼亚", "ZA": "南非", "EG": "埃及", + "AE": "阿联酋", "SA": "沙特阿拉伯", "IQ": "伊拉克", "JO": "约旦", + "PK": "巴基斯坦", "BD": "孟加拉国", "IN": "印度", "TH": "泰国", + "VN": "越南", "ID": "印尼", "PH": "菲律宾", "MY": "马来西亚", + "JP": "日本", "KR": "韩国", "GB": "英国", "DE": "德国", + "FR": "法国", "RU": "俄罗斯", "BR": "巴西", "MX": "墨西哥", + "AU": "加拿大", "AU": "澳大利亚", "TR": "土耳其", "IR": "伊朗", + "MM": "缅甸", "KH": "柬埔寨", "LA": "老挝", "LK": "斯里兰卡", + "NP": "尼泊尔", "UG": "乌干达", "ET": "埃塞俄比亚", "SN": "塞内加尔", + "CI": "科特迪瓦", "CM": "喀麦隆", "CD": "刚果(金)", "MZ": "莫桑比克", +} + + +def resolve_country(ip: str) -> str: + if not ip or ip in ("127.0.0.1", "::1", "localhost"): + return "本地" + try: + code = country_code(ip) + if code and code != "--": + return COUNTRY_NAMES.get(code, code) + return "未知" + except Exception: + return "未知" diff --git a/app/services/lead_push_service.py b/app/services/lead_push_service.py new file mode 100644 index 0000000..25a9006 --- /dev/null +++ b/app/services/lead_push_service.py @@ -0,0 +1,298 @@ +"""询盘推送 UniService 服务(设计 2026-09-28-lead-push-to-uniservice)。 + +三个入口: +- dispatch_lead_push: 触发点调用(发信分支进入处),写 pending/skipped 日志。 + 红线: 整体吞异常,绝不影响邮件主流程。 +- fire_pending_pushes: 主事务 commit 之后调用,create_task 派生后台任务。 +- sweep_stale_pending: lifespan 启动清扫(崩溃遗留的 pending → failed)。 + +后台任务红线(设计 8.1): 不读库只写结果,payload 内存传递; +结果写入用独立 session(from app.main import async_session,照 chat_rest 模式)。 +""" +import asyncio +import logging +from datetime import datetime, timedelta, timezone + +import httpx +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.main import async_session +from app.models.database import ( + Conversation, CustomerProfile, Evaluation, IntentLevel, LeadPushLog, + Message, MessageRole, +) +from app.services.ip_service import resolve_country + +logger = logging.getLogger(__name__) + +# 对方契约(docs/AI_INQUIRY_PUSH_API.md): phone/whatsapp/email 至少一个有效 +_TRIO = ("phone", "whatsapp", "email") +_CST = timezone(timedelta(hours=8)) +_INTENT_MAP = { + "interested": "high", + "pending": "medium", + "not_interested": "low", +} + + +def _fmt_cst(dt: datetime) -> str: + """UTC(-aware 或 naive)→ +08:00 ISO 串。对方明确拒收 UTC(契约 §2)。""" + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + dt = dt.replace(microsecond=0) # 归一整秒: 契约示例为整秒,亚秒精度无益且冒严格解析器拒收风险 + return dt.astimezone(_CST).isoformat() + + +def map_intent(level: str | None, evaluated: bool) -> str: + """我方 IntentLevel → 对方 high/medium/low/unknown。 + + evaluated=False(<3 条客户消息,D2)→ unknown: 未评估不是"待确认", + 与邮件标签 [未评估]/[待确认] 的区分同源。 + """ + if not evaluated or not level: + return "unknown" + return _INTENT_MAP.get(level, "unknown") + + +_sleep = asyncio.sleep # 模块级绑定,测试 monkeypatch 记录退避序列用 + + +async def _build_payload( + db: AsyncSession, ev: Evaluation, conv: Conversation, + profile: CustomerProfile, confirmed: dict, push_type: str, + evaluation: dict, evaluated: bool, +) -> dict: + """组装契约报文(设计 7.2 映射表)。仅读当前事务,不产生副作用。""" + # 7.1 event 规则: 仅更正可能 update,且必须同会话此前推送成功过 + # (不依赖对方"update 未知键"的未定义行为) + event = "new" + if push_type == "correction": + prior = await db.execute( + select(func.count()).select_from(LeadPushLog).where( + LeadPushLog.conversation_id == conv.id, + LeadPushLog.status == "success", + )) + if (prior.scalar() or 0) > 0: + event = "update" + + result = await db.execute( + select(Message) + .where(Message.conversation_id == conv.id) + .order_by(Message.created_at) + ) + msgs = result.scalars().all() + + raw_info = profile.raw_info or {} + client_info = conv.client_info or {} + profile_out = dict(raw_info) + profile_out["country"] = resolve_country(client_info.get("ip", "")) + profile_out["source"] = conv.source.value + if client_info.get("lang"): + profile_out["语言"] = client_info["lang"] + + reasoning = (evaluation.get("reasoning") or "") if evaluated else "" + + return { + "event": event, + "external_key": f"{settings.PUSH_EXTERNAL_KEY_PREFIX}-{conv.id}", + "occurred_at": _fmt_cst(datetime.now(timezone.utc)), + "contact": { + "name": None, # 我方 contact_info 快照无称呼字段,契约可空 + "phone": confirmed.get("phone"), + "whatsapp": confirmed.get("whatsapp"), + "email": confirmed.get("email"), + "wechat": confirmed.get("wechat"), + "skype": None, + }, + "intent_level": map_intent(evaluation.get("intent_level"), evaluated), + "intent_reason": reasoning or None, + "profile": profile_out, + "conversation": [ + { + "role": "customer" if m.role == MessageRole.user else "ai", + "text": m.content, + "at": _fmt_cst(m.created_at), + } + for m in msgs if m.role in (MessageRole.user, MessageRole.assistant) + ], + } + + +async def dispatch_lead_push( + db: AsyncSession, ev: Evaluation, conv: Conversation, + profile: CustomerProfile, confirmed: dict, push_type: str, + evaluation: dict, evaluated: bool, +) -> LeadPushLog | None: + """触发点唯一入口: 返回待派发的 pending 行(加入当前事务,随主事务提交)。 + + 红线(设计 6.1): 整体吞异常——任何失败只记日志,绝不影响邮件主流程; + 触发条件 = 调用方进入发信分支,与 SMTP 成败解耦。 + DB 层失败用 SAVEPOINT 隔离: 只回滚推送写入,不毒化调用方事务 + (否则 flush 失败 → PendingRollbackError → email_sent 落不了库 → 重复发邮件)。 + """ + if not settings.PUSH_ENABLED: + return None + try: + async with db.begin_nested(): + # 三选一预检(设计 §4 skipped 语义): 对方必拒,不发 HTTP 只留审计痕迹 + if not any(confirmed.get(k) for k in _TRIO): + db.add(LeadPushLog( + evaluation_id=ev.id, conversation_id=conv.id, + push_type=push_type, status="skipped", attempts=0, + error_message="no valid contact (phone/whatsapp/email)", + )) + return None + payload = await _build_payload( + db, ev, conv, profile, confirmed, push_type, evaluation, evaluated) + log = LeadPushLog( + evaluation_id=ev.id, conversation_id=conv.id, + push_type=push_type, status="pending", attempts=0, + request_payload=payload, + ) + db.add(log) + await db.flush() # 取 id 供后台任务写结果 + return log + except Exception: + logger.exception("dispatch_lead_push failed (type=%s, conv=%s)", + push_type, conv.id) + return None + + +async def _do_post(payload: dict) -> tuple[httpx.Response | None, str | None]: + """实际 HTTP 调用(独立函数便于测试 monkeypatch)。 + + 返回 (resp, transport_error): 传输层异常不抛出,转成错误串由重试矩阵消化。 + """ + try: + async with httpx.AsyncClient( + timeout=settings.PUSH_TIMEOUT_SECONDS, + follow_redirects=True) as client: # 307/308 不落终态失败 + resp = await client.post( + settings.PUSH_API_URL, + json={"messages": [payload]}, # 契约 §2: 批量结构,我方单条 + headers={"X-Api-Key": settings.PUSH_API_KEY}, + ) + return resp, None + except httpx.HTTPError as exc: + return None, repr(exc) + + +def _retryable(resp: httpx.Response | None) -> bool: + """契约 §3: 5xx/超时可重试;401/400/其他 4xx 重试无益。200 不在此判定。""" + return resp is None or resp.status_code >= 500 + + +async def _update_log(push_log_id: str, **fields) -> None: + """后台任务写结果——独立 session + 小事务(设计 6.3)。""" + async with async_session() as session: + await session.execute( + update(LeadPushLog) + .where(LeadPushLog.id == push_log_id) + .values(**fields) + ) + await session.commit() + + +async def _run_push(push_log_id: str, payload: dict) -> None: + """后台任务体: 分类重试 + 200 逐条结果解析(设计 6.3/§10)。 + + 红线: 全套 try/except,异常只记日志绝不逃逸(fire-and-forget)。 + """ + try: + attempts = 0 + while True: + attempts += 1 + resp, transport_error = await _do_post(payload) + if _retryable(resp) and attempts < settings.PUSH_MAX_ATTEMPTS: + await _update_log( + push_log_id, attempts=attempts, + error_message=transport_error + or (f"http {resp.status_code}" if resp is not None else "unknown"), + ) + await _sleep( + settings.PUSH_RETRY_BACKOFF_BASE * 2 ** (attempts - 1)) + continue + break + + if resp is not None and resp.status_code == 200: + try: + raw = resp.json() + except ValueError: + # 非 JSON 的 200(如网关重定向到 HTML 登录页)—— + # 无法确认对方已接受,诚实落 failed(幂等键保证补推安全) + await _update_log(push_log_id, attempts=attempts, status="failed", + response_status=200, + error_message="non-JSON 200 body") + return + data = raw.get("data") if isinstance(raw, dict) else None + data = data if isinstance(data, dict) else {} + failed_mine = [ + f for f in (data.get("failed") or []) + if f.get("external_key") == payload["external_key"] + ] + if failed_mine: + await _update_log( + push_log_id, attempts=attempts, status="failed", + response_status=200, + error_message=str(failed_mine[0].get("reason") or "rejected"), + ) + else: + # 单条推送: accepted/duplicated 计数即本条结论 + outcome = ("duplicated" + if (data.get("duplicated") or 0) > 0 else "accepted") + await _update_log( + push_log_id, attempts=attempts, status="success", + response_status=200, outcome=outcome, + ) + return + + # 终态失败: 重试耗尽(5xx/传输)或不可重试(401/400/其他) + if resp is not None and resp.status_code in (400, 401): + logger.error("lead push rejected (log_id=%s, http %s) — " + "密钥/报文问题需人工介入,不重试", + push_log_id, resp.status_code) + await _update_log( + push_log_id, attempts=attempts, status="failed", + response_status=resp.status_code if resp is not None else None, + error_message=transport_error + or (f"http {resp.status_code}" if resp is not None else "unknown"), + ) + except Exception: + logger.exception("lead push task crashed (log_id=%s)", push_log_id) + + +# 后台任务引用集: 防 GC(asyncio 官方要求),done 即移除 +_tasks: set[asyncio.Task] = set() + + +def fire_pending_pushes(pushes: list[LeadPushLog]) -> None: + """主事务 commit 之后调用(设计 8.1): payload 内存传递,任务不读库。 + + 同步函数、纳秒级返回——这就是"不阻塞"的派发点。 + """ + for log in pushes: + if log.status != "pending": + continue + task = asyncio.create_task(_run_push(log.id, log.request_payload)) + _tasks.add(task) + task.add_done_callback(_tasks.discard) + + +async def sweep_stale_pending(stale_minutes: int = 30) -> int: + """启动清扫(设计 8.2): 崩溃遗留的 pending → failed。 + + 已知限制(设计 §14): 推送成功但结果写回失败也会被标 failed—— + 幂等键保证将来补推安全(对方返回 duplicated),概率极低,接受。 + """ + cutoff = datetime.now(timezone.utc) - timedelta(minutes=stale_minutes) + async with async_session() as session: + result = await session.execute( + update(LeadPushLog).where( + LeadPushLog.status == "pending", + LeadPushLog.updated_at < cutoff, + ).values(status="failed", error_message="interrupted by restart") + ) + await session.commit() + return result.rowcount or 0 diff --git a/app/services/rate_limit.py b/app/services/rate_limit.py new file mode 100644 index 0000000..60c0430 --- /dev/null +++ b/app/services/rate_limit.py @@ -0,0 +1,39 @@ +"""Minimal in-memory rate limiter (per (ip, device_id), rolling 1h window). + +Mirrors hesu's rate_limit.py shape. Only the chat message route uses this; +session/history endpoints are cheap and not throttled. State is process-local +and resets on restart, which is acceptable for a single-worker ai-kefu deploy. + +Caveats: +- Single-process, in-memory. Under `uvicorn --workers >1` each worker keeps + its own `_buckets`, so the effective cap is `max_requests * workers`. +- Keyed on (ip, device_id); a client rotating the `X-Device-Id` header per + request bypasses it. This is a light safeguard against accidental GLM-cost + burn, NOT abuse-proof. +""" +import time +from collections import defaultdict + +_buckets: dict[tuple[str, str], list[float]] = defaultdict(list) +WINDOW = 3600 +DEFAULT_MAX = 30 + + +def check( + ip: str | None, + device_id: str | None, + *, + max_requests: int = DEFAULT_MAX, +) -> bool: + """Return True if the request is under the cap (and record it), else False.""" + key = (ip or "", device_id or "") + now = time.time() + _buckets[key] = [t for t in _buckets[key] if t > now - WINDOW] + if len(_buckets[key]) >= max_requests: + return False + _buckets[key].append(now) + return True + + +def _reset_for_test() -> None: + _buckets.clear() diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..b277e5d --- /dev/null +++ b/deploy.sh @@ -0,0 +1,40 @@ +#!/bin/bash +set -e + +# 兼容 docker compose 和 docker-compose 两种命令 +if docker compose version &>/dev/null; then + DC="docker compose" +elif command -v docker-compose &>/dev/null; then + DC="docker-compose" +else + echo "✗ 未找到 docker compose,请先安装" + exit 1 +fi + +echo ">>> 使用: $DC" + +echo ">>> 停止旧服务" +$DC down 2>/dev/null || true + +echo ">>> 构建镜像" +$DC build + +echo ">>> 启动服务" +$DC up -d + +echo ">>> 等待服务就绪..." +sleep 3 + +# 检查服务状态 +if curl -s http://127.0.0.1:8016/health | grep -q "ok"; then + echo "✓ 服务启动成功 (http://127.0.0.1:8016)" +else + echo "✗ 服务启动失败,请检查日志: $DC logs" + exit 1 +fi + +echo "" +echo "部署完成!" +echo " 聊天页面: http://你的域名/" +echo " 管理后台: http://你的域名/admin" +echo " 健康检查: http://你的域名/health" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7ebb74b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + app: + build: . + container_name: cc-ai-kefu + restart: always + env_file: + - .env + ports: + - "8016:8000" + volumes: + - ./data:/app/data + environment: + - APP_DEBUG=false diff --git a/docs/2026-08-18-contact-driven-lead-email-changelog.md b/docs/2026-08-18-contact-driven-lead-email-changelog.md new file mode 100644 index 0000000..dd24e6a --- /dev/null +++ b/docs/2026-08-18-contact-driven-lead-email-changelog.md @@ -0,0 +1,141 @@ +# 改动文档:联系方式驱动的询盘邮件(批次 1) + +- **日期**:2026-08-17 ~ 2026-08-18 +- **分支**:`chany_dev_dev`(基于 `ee03e8d`,全部改动在工作区,尚未提交) +- **状态**:77 项测试全绿(基线 17 项),本地真实 GLM + SMTP 冒烟通过,用户实测验收通过 +- **实施档案**:[superpowers/plans/2026-08-17-contact-driven-lead-email.md](superpowers/plans/2026-08-17-contact-driven-lead-email.md)(决策 D1-D13、12 个任务的全过程记录) + +--- + +## 1. 背景与解决的问题 + +| # | 线上问题 | 根因(代码实证) | +|---|---|---| +| 1 | 大量**无联系方式**的询盘邮件推送至销售邮箱 | widget/embedded/standalone 来源的会话结束条件没有联系方式守卫(仅 app 来源有);GLM 评估不遵守"无联系方式不给过"的 prompt 规则 | +| 2 | 有意向客户没留联系方式,**无法触达** | 一判定 interested 就结束会话并锁死客户(ended 后输入框禁用),连补联系方式的机会都没有 | +| 3 | 客户**已给的联系方式被 AI 丢失/重复索要** | extractor 某轮返回空联系方式时浅合并整层覆盖(数据丢失);上下文提醒不含"禁止再索要"指令 | + +**修复后的一句话**:联系方式是发询盘邮件的唯一条件——管道确定性检测到即发;没有就永不发、永不结束会话;已给的绝不丢失、绝不重复索要。 + +--- + +## 2. 核心架构 + +``` +客户每条消息 + ├─ AI 回复(system.md 话术:价值交换要号 → 先聊后引 WhatsApp 兜底) + ├─ 统一联系方式管道(app/services/contact_pipeline.py,纯函数) + │ 源1: extractor 提取(多语言 LLM) + │ 源2: 高置信正则(邻接绑定:裸数字一律不收,防价格/年份/车架号误捕) + │ 源3: 独立裸号(整条消息仅 ≥9 位数字 = 最显式自述) + │ → key 归一化 → 深合并(新值覆盖) → 最新显式自述覆盖 → 最近提及裁决 + │ → 格式校验分级:confirmed(可触发)/ suspect(仅用于提醒) + ├─ 邮件触发器(ConversationManager 内聚,路由层零邮件逻辑) + │ confirmed 首次出现 → [新询盘·标签];24h 跨会话同值 → [线索回访]轻提醒 + │ 联系方式变化 → [询盘·更正N](每会话≤3封);达上限后锁邮件不锁快照 + ├─ 定向提醒:confirmed→"绝对禁止再索要";suspect→"礼貌确认一次"(值经消毒防提示词注入) + ├─ 评估前置:<3 条客户消息跳过 GLM 评估(省 1/3 调用),意向仅作邮件标签 + ├─ 每轮评估落库:interested+无联系方式 = 卡单记录(admin 可筛人工兜底) + └─ 会话永不自动结束(决策 A)——关闭留给后续超时清理 +``` + +--- + +## 3. 文件清单 + +### 新增(5 个文件,1010 行) + +| 文件 | 行数 | 职责 | +|---|---|---| +| `app/services/contact_pipeline.py` | 200 | 统一联系方式管道:`normalize_contacts`(中英文 key 别名归一)、`merge_contacts`(深合并,堵浅合并抹数据)、`split_validate`(confirmed/suspect 分级;微信 ≥4 字符)、`scan_user_messages`(高置信正则扫描,全量客户消息历史)、`latest_stated_contacts`(最新显式自述,唯一可覆盖 extractor 回传的来源)、`standalone_number`(独立裸号 ≥9 位)、`resolve_by_recency`(最近提及裁决:杀 extractor 历史振荡、压幻觉值)、`canon`(去重规范形) | +| `tests/test_contact_pipeline.py` | 253 | 管道 34 项单测(含误报反例:价格/年份/里程/VIN/订单号) | +| `tests/test_lead_email_trigger.py` | 417 | 触发器/结束守卫/提醒/更正链 25 项集成测试(FakeAI 桩,含用户实测 bug 的完整复刻用例) | +| `tests/test_email_rendering.py` | 66 | 邮件渲染 3 项(标签、HTML 转义、内部 key 过滤、RFC2047 扁平化) | +| `scripts/baseline_stats.py` | 74 | 上线前后对比基线统计(只读单次脚本) | + +### 修改(9 个文件,+323 / −329) + +| 文件 | 改动摘要 | +|---|---| +| `app/services/conversation.py` | 核心重构。**新增**:管道接线(深合并修复、正则辅源、覆盖、裁决)、`_maybe_send_lead_email` 触发器、`_get_or_init_evaluation`/`_recently_emailed_canons` 辅助、评估前置、每轮评估落库(卡单标记)、定向提醒与短消息增强的过滤/消毒。**删除**:自动结束逻辑与 `_finalize`(决策 A)、`process_message` 死代码(83 行)、`_has_real_contact`、旧 `ended&&interested→发邮件` 路径的全部依赖 | +| `app/services/email_service.py` | 全量重写。全字段 `html.escape`(D12 防注入);主题意向标签 `[新询盘·意向强/待确认/无意向/未评估]` + 空白折叠加固;中文标签联系方式列;新增 `send_returning_lead_email`([线索回访])与 `send_correction_email(contact_info, count)`([询盘·更正N]);"邮件为触发时快照"提示 | +| `app/api/routes/chat.py` | 拆除 `_try_send_email` 整个函数、email 相关导入与 ended 触发块;WS 连接生命周期保持 HEAD 语义(ended 后关闭连接) | +| `app/api/routes/chat_rest.py` | 拆除 lazy-import 触发块;docstring 更新;App 响应契约字段不变(`ended` 恒为 `false`,见 §8 知会事项) | +| `app/models/database.py` | Evaluation 表新增 `correction_count INT DEFAULT 0` | +| `app/main.py` | `_migrate` 新增 PRAGMA 幂等迁移(实测二次运行 no-op) | +| `app/prompts/system.md` | ①价值交换固定模板(只承诺"完整报价单",禁具体价格/折扣/时限);②先聊后引(聊天内最多要两次,才推 WhatsApp 按钮);③拿到联系方式后确认一句继续解答,不急着收尾;④系统提醒配合(已提供→禁再要;疑似→礼貌确认一次) | +| `app/prompts/extractor.md` | 客户最后提供的号码覆盖旧值;单独数字串也是号码 | +| `tests/test_chat_rest_message.py` | 删除 2 个钉死旧触发路径的测试(被 manager 层新测试取代),保留 reply/404/429 | + +--- + +## 4. 行为对照 + +| 场景 | 改前 | 改后 | +|---|---|---| +| widget 无联系方式 + GLM 误判 interested | 结束会话锁死客户 + 发无联系方式邮件 | 零邮件;会话永不结束,AI 按话术继续争取 | +| 第 1 条消息就带联系方式 | 等判定结束才发 | 立即发 `[新询盘·未评估]` | +| 联系方式被 extractor 空轮抹掉 | 数据丢失 | 深合并保留 | +| AI 重复索要已给的联系方式 | 常发生 | 定向提醒禁止;suspect 值礼貌确认一次 | +| 换号(说/不说"更正") | 依赖 extractor(实测常丢) | 最新显式自述 / 独立裸号 / 最近提及,确定性翻新 | +| 同号镜像两个 key(手机+WhatsApp) | 更正后新旧混排 | 同步翻新,邮件/快照均为最新 | +| extractor 在历史号码间振荡 | 每次翻转烧一封更正邮件 | 最近提及裁决熄灭(实测复刻用例锁死) | +| 同号码 24h 内再开新会话 | 每会话一封全量 | 一行 `[线索回访]` 轻提醒 | +| 更正次数 | —(新能力) | 每会话 ≤3 封 `[更正N]`;达上限锁邮件不锁快照(admin 永远最新) | +| 会话结束 | contact+interested+≥3轮即结束锁死 | **永不自动结束**(决策 A) | +| GLM 评估调用 | 每条消息 | <3 条客户消息跳过 | +| 邮件 HTML | 客户可控文本不转义 | 全转义 + 主题行加固 | + +--- + +## 5. 关键决策记录(产品拍板存档) + +| 决策 | 内容 | +|---|---| +| D1 | 联系方式 = 发邮件唯一条件(不看消息数、不看意向评级) | +| D2 | 意向降级为邮件标签;评估跳过时标签为 [未评估](与客户端事件的 pending 语义分离) | +| D4 | 同会话全量邮件 1 封 + 更正邮件 ≤3 封;锁邮件不锁快照 | +| D5 | 24h 跨会话同值去重 → 轻提醒 | +| D6 | confirmed→禁止再索要 / suspect→礼貌确认(含提示词注入消毒) | +| D7 | 评估前置(<3 轮跳过) | +| D8 | interested+无联系方式 → 卡单记录(admin 人工兜底数据基础) | +| D9 | 旧路由层触发路径整体拆除(与新触发器同批部署,杜绝双发窗口) | +| 决策 A(2026-08-18) | 会话永不自动结束(邮件解耦后自动结束只剩伤害:锁死没问完的客户) | +| 更正上限(2026-08-18) | 1 → 3 次(防轰炸与漏最终号的折中) | + +## 6. 实测驱动的修复链(2026-08-18 用户实测发现的 4 个 bug) + +| 用户反馈 | 定位 | 修复(任务号) | +|---|---|---| +| "wrong number, use X" 不触发更正 | extractor 回传旧值 + 扫描器首中/填空无法翻新 | 最新显式自述覆盖(Task 12) | +| 更正邮件里手机/WhatsApp 新旧混排 | extractor 镜像同号进两个 key,只翻新命中 key | 同号同步(12b)→ 升级为最近提及裁决(12c) | +| 更正配额被烧光、末封邮件显示旧号 | extractor 在历史号码间振荡,每次翻转触发虚假更正 | 最近提及裁决(12c) | +| 直接发裸号不触发更正 | 裸号唯一捕获源是概率性的 extractor | 独立裸号确定性捕获,≥9 位下限排除价格/年份(12d) | + +--- + +## 7. 部署注意事项 ⚠️ + +1. **整体上线,禁止部署中间态**:新触发器与旧路径拆除必须同一批次(D9,中间态存在双发邮件窗口)。 +2. **迁移自动执行**:启动时 `_migrate` 自动为旧库追加 `correction_count` 列,无需手工 SQL。 +3. **跑生产基线**:部署机上执行 `PYTHONIOENCODING=utf-8 python scripts/baseline_stats.py`,覆盖 `docs/superpowers/plans/2026-08-17-baseline.txt`(当前为开发库占位数据)。 +4. **SMTP 必须配置**:首触邮件即时发送;未配置时每条消息都会重试并记日志。 +5. **上线后观测**(对照基线):①新邮件 100% 带联系方式;②全量邮件量 vs 基线上界;③[更正]/[回访] 占比;④admin 卡单数量。 + +## 8. 已知限制与残余风险(台账摘录) + +| 项 | 状态 | +|---|---| +| App 端 `ended` 恒为 false(决策 A) | App 若有"对话结束"闭环 UI 需调整;widget/独立页零影响 | +| 会话只生不死(无超时清理) | 数据卫生问题,功能无害;超时清理排健康轨,**上线后尽早做** | +| 第 1 轮 extractor 幻觉出从未提及的号码 → 首封邮件混排 | 双低概率条件叠加;whatsapp 侧仍真实可联;出现再做 12e | +| 中文无冒号给号句式("我手机号是X")无确定性捕获 | extractor 兜底;实测命中率待观察,低则补中文语境模式 | +| 号码变体击穿 24h 去重 / 撤回已给联系方式 / 客户离开页面后无法找回 | best-effort / 接受 / 物理死局(火力已前置到"人还在"时) | +| ADMIN_PASSWORD 仍为默认 admin123 | H1 暂缓(用户决策),**强烈建议尽快在 .env 修改** | +| 发送成功后落库失败的毫秒级窗口 | at-least-once 有意选择(漏线索比重复邮件贵) | + +## 9. 未包含(后续批次,见实施档案 §6) + +- **批次 2**:widget 会话持久化(localStorage+48h 过期,解决刷新丢会话/重复线索)、前端历史渲染(独立页 loadHistory 空函数)、WS 限流 + 消息长度上限 +- **批次 3**:admin"缺联系方式线索"筛选(基于卡单记录)、UI 留资卡片 +- **健康轨**:删 Redis、SQLite 备份+WAL、GLM 超时重试、admin 分页修复、国家映射(加拿大笔误)、超时清理会话、删除无调用方的 `AIService.chat`、ADMIN_PASSWORD diff --git a/docs/2026-08-19-ai-role-boundary-changelog.md b/docs/2026-08-19-ai-role-boundary-changelog.md new file mode 100644 index 0000000..a028478 --- /dev/null +++ b/docs/2026-08-19-ai-role-boundary-changelog.md @@ -0,0 +1,126 @@ +# 改动文档:AI 职能边界(价格红线 + 转介机制) + +- **日期**:2026-08-18 ~ 2026-08-19 +- **分支**:`chany_dev_dev`,提交 `3028685`(三层防线)+ `9433f96`(句子粒度门控) +- **状态**:106 项测试全绿(基线 77),本地真实 GLM 冒烟 7 路径通过,WS 探针实证打字机恢复 +- **实施档案**:[superpowers/plans/2026-08-18-ai-role-boundary.md](superpowers/plans/2026-08-18-ai-role-boundary.md)(两轮对抗评审 + 13.1-13.7 全过程) + +--- + +## 1. 背景与解决的问题 + +**定位反转**:改造前 system.md 明文要求 AI 报价("宁可给一个不完美的价格估计",且报价是好回复示例)——用户实测 AI 张口就是 "$25k"。改造后 AI 定位为: + +> **信息型助手 + 线索收集器**——介绍车、挖意向、收联系方式。不承担销售职能: +> 不做任何承诺(价格/时间/库存)、不说绝对化的话、不代表公司做决定。 + +价格/优惠/交付时效/实质请求一律迂回引导留资,由客户经理承接——与询盘邮件体系闭环(AI 收集意向+联系方式 → 邮件 → 客户经理报价/安排)。 + +## 2. 核心架构:三层防线(冒烟实测逐层验证) + +``` +客户消息(问价/磨价/问时效/要安排/报预算) + │ + ├─ 第1层 prompt:system.md 职能边界章节 + │ 三类请求处理(信息直答 / 价格时效迂回 / 实质转介) + │ 预算路径(推荐车型但零数字复现→反问意向→引导留资) + │ 节奏豁免(价格红线立即生效;零上下文询价按钮首触先挖一轮需求) + │ 红线五条(无价格数字/无占位符/无交付时间/无承诺/安排主体是客户经理) + │ 追问坚持策略 + 六种磨法应对手册(态度软立场硬) + │ + ├─ 第2层 提醒:当轮红线注入(BOUNDARY_TRIGGER_RE) + │ 价格/时效/安排词 + 施压话术(roughly/说个大概/求你了——磨价轮不裸奔) + │ 预算路由(BUDGET_TRIGGER_RE → 推荐+反问 playbook,不一刀切要号) + │ 前置到空画像早退之前(Get Quotation 首条消息即有防护) + │ 与 D6 联动(已留资→"经理会发给你",禁止再索要) + │ + └─ 第3层 过滤墙:句子粒度门控流式(确定性保证) + token 攒到句子边界 → 逐句检测价格形态 + ├─ 干净句子 → 立即流出(打字机效果保留,WS 探针实证渐进到达) + └─ 违规句子 → 一个字不流:掐断生成(省token)、接过渡话术收尾 + (有干净前缀→自然转折"Actually, for the exact price…"; + 无前缀→整段标准迂回话术;已留资→不重复要号变体) +``` + +**为什么三层**:GLM 有不守 prompt 的前科(本项目已实测两次),价格又是最强诱导——冒烟数据证明单靠第 1+2 层守不住(预算复述 6/6 全破、压力轮 1/3 报出真实价格),按方案预设的 >10% 破防率阈值激活第 3 层;第 3 层最初为整段缓冲(牺牲打字机),后升级为句子门控(两全)。 + +## 3. 文件清单 + +### 新增 + +| 文件 | 内容 | +|---|---| +| `app/services/boundary_filter.py` | 价格形态检测($/货币单位/k形式/动词邻接/交付时间/预算复述/金融裸数),白名单排除(年份/车型编号L7/里程30k km/3万公里/质保期/kWh);无损句子切分(标点边界/小数保护/CJK免空格);迂回话术与过渡话术(中英路由/已留资变体/轮换) | +| `scripts/breach_rate.py` | 破防率观测(只读,锚定式匹配)——改造前基线 **18%** 存档于 `2026-08-18-breach-baseline.txt` | +| `tests/test_boundary_filter.py` | 17 项单测(五模式命中/白名单/切分/话术路由) | +| `docs/superpowers/plans/2026-08-18-ai-role-boundary.md` | 完整实施档案(含两轮评审 12 条 + 最终评审 8 条发现的处置) | + +### 修改 + +| 文件 | 改动 | +|---|---| +| `app/prompts/system.md` | 插入职能边界章节(+58行,位于语言规则后最高优先级);**删除/反转 9 处旧价格规则**("宁可给价格估计"规则、3 个含价好示例、"报价范围"要求、推进成交定义、3轮节奏两处加豁免);价值交换章节并入职能边界;术语统一"客户经理/manager" | +| `app/prompts/evaluator.md` | 问价/优惠/运费/试驾 = 意向积极信号(标签更准) | +| `app/services/conversation.py` | `BOUNDARY_TRIGGER_RE`/`BUDGET_TRIGGER_RE` 触发词表;`_build_context_reminder` 重构(边界检查前置到空画像早退前 + 预算路由 + D6 联动分支);流式区重构为句子门控(缓冲按句→判定→流出/拦截);拦截记 `Boundary filter replaced` warning | +| `tests/test_lead_email_trigger.py` | +10 项(边界提醒 8 例 + 流式门控集成 2 例) | + +**测试 77 → 106**;前端零改动(事件协议不变);无 schema 变更;邮件体系零影响。 + +## 4. 行为对照 + +| 场景 | 改前 | 改后 | +|---|---|---| +| "Tang 多少钱 / 有优惠吗" | 直接报价 "$25k 起步" | 迂回:先问意向车型,同回复引导留资,零数字 | +| "运费/关税多少" | 视作报价的一部分回答 | 同上迂回 | +| "多久能到货" | 给时间估计 | 一律不给时间,引导留资 | +| "我预算2万有什么推荐" | 报价推荐 | **推荐车型但零数字复现**(不说"2万/20K")→ 反问意向 → 引导留资 | +| "just tell me roughly"(磨价) | 磨两下就报区间 | 坚持:换理由不重复原话→第三次推 WhatsApp 按钮;**过滤器兜底** | +| "能约试驾吗 / 帮我留一台" | 自行应承 | 转介:"对接客户经理安排,留个联系方式" | +| "Get Quotation" 快捷按钮首触 | 立即报价 | 价格红线立即生效 + **先一轮了解需求**再引导留资 | +| 已留联系方式后问价 | (无此状态) | "经理会把报价发你 WhatsApp",不再索要 | +| 正常问续航/里程/配置 | 正常回答 | **照常回答**(30k km / 3万公里 / 85kWh 白名单保护,实测未误杀) | +| 回复呈现 | 逐 token 打字机 | **保留**:逐句渐进流出(实测首句 t=2.3s,第二句 t=5.5s) | + +## 5. 关键决策记录(全部用户拍板存档) + +| 决策 | 内容 | +|---|---| +| 职能定位 | 需求了解+留资;介绍车、挖意向、收联系方式;不是销售、无承诺权(价格/时间/库存)、无绝对化表述、不代表公司做决定 | +| 预算处理 | 可推荐车型,**不复述预算数字**(含 20K 等改写形式),反问意向后引导留资 | +| 交付时效 | 一律不直接回答,同样引导留资 | +| 询价按钮首触 | 价格红线立即生效,但**先用一轮了解需求**再要号(低质量线索 vs 转化节奏的取舍) | +| 方案 B 激活 | 按预设计划的 >10% 破防率阈值(冒烟 6/6+1/3 击穿)→ 输出过滤上墙 | +| 打字机恢复 | 句子粒度门控方案(用户确认)——干净句子流出、违规句不流,两全 | + +## 6. 实测驱动的演进链 + +| 阶段 | 发现 | 处置 | +|---|---|---| +| 冒烟第1轮 | 预算复述 6/6 全破("budget of 20K")、压力轮 1/3 报真实价、50% 输出"[价格区间]"占位符 | prompt 加固(具体替代措辞) | +| 冒烟第2轮(加固后) | **依旧 6/6 破**——GLM 把复述预算当礼貌确认 | 方案 B 激活:整段缓冲过滤,违规归零 | +| 最终评审 | 里程误杀(30k km)、裸预算复述漏检(恰是旗舰形态且观测不可见)、已留资重复要号 | 过滤器收严(F1-F6:白名单/预算模式/金融/质保/联系变体) | +| 用户反馈 | 打字机效果没了 | 句子粒度门控(13.7)——渐进流出恢复,红线不降级 | + +## 7. 部署注意事项 ⚠️ + +1. **重启即生效**:prompt 在启动时加载、触发词表在代码中——所谓"词表热调"实为重新部署。 +2. **无 schema 变更、邮件体系零影响**;询盘邮件正文里的 AI 回复将全是过滤后文本(破防原文只在日志)。 +3. **体验变化预告**:回复从逐字打字机变为**逐句出现**(首句 ~1s,人格回复 1-2 句所以观感接近);AI 从此不报任何价格——**提前告知销售团队**,报价承接节奏要跟上。 +4. **上线后观测**:`docker compose logs app | grep "Boundary filter"` = 真实破防率(GLM 想报但被墙拦的频率);改造前基线 18% 已存档,以此对比第 1+2 层的自律率。 +5. **触发词调参**:漏词案例(新问法/多语言)回流补 `BOUNDARY_TRIGGER_RE`;误触发低害(多一条提醒)。 +6. **回滚**:`git revert 9433f96 3028685` 两提交即完整回滚。 + +## 8. 已知限制与残余(台账) + +| 项 | 状态 | +|---|---| +| 单句回复仍需等该句生成完(~1s)——那句本身可能含价,无更快解 | 接受(物理约束) | +| 句子切分为启发式(小数已保护;切歪只影响节奏不影响安全) | 接受 | +| 多语言问价(法/斯瓦希里/阿拉伯语)触发词盲区 | 第1层全局红线 + 第3层过滤器兜底(价格形态与语言无关) | +| 极端假阳性:"这车跑了两万"(无"公里"后缀的里程表述)会被当价格拦截 | 罕见,方向安全(多拦不泄露) | +| 破防率脚本与过滤器模式不再严格同步(过滤器更严) | 上线后以日志行为准,脚本留作历史基线 | +| 过渡话术单一池(不轮换) | 观察重复感,需要时扩池 | + +## 9. 未包含(后续批次) + +与之前清单一致:批次 2(widget 会话持久化/前端历史渲染/WS 限流)、批次 3(admin 卡单筛选/UI 留资卡片)、健康轨(超时清理会话、SQLite 备份、删 Redis、ADMIN_PASSWORD、GLM 超时重试)。可基于 `Boundary filter` 日志做 admin 侧"破防监控面板"(若需要)。 diff --git a/docs/2026-08-19-system-audit-and-optimization.md b/docs/2026-08-19-system-audit-and-optimization.md new file mode 100644 index 0000000..de14b1a --- /dev/null +++ b/docs/2026-08-19-system-audit-and-optimization.md @@ -0,0 +1,155 @@ +# AI 客服系统全面分析与优化方案 + +- **日期**:2026-08-19 +- **基线**:`e02be47`(两大特性已落地:联系方式驱动询盘邮件 `6860e88`;AI 职能边界三层防线 `3028685`+`9433f96`) +- **质量状态**:106 项测试全绿(原始基线 17);真实 GLM 冒烟通过 +- **性质**:只分析不调整。问题按七层组织,每项含现象/根因/影响;优化方案按优先级排期;最后是需要拍板的决策点。 + +--- + +## 一、当前系统状态总览 + +``` +已落地的两大能力(互相咬合的闭环): + 联系方式驱动邮件: 管道确定性检测联系方式 → 秒发询盘邮件 → 更正/去重/永不锁死客户 + 职能边界三层防线: prompt 职能定位 → 当轮红线提醒 → 句子粒度过滤墙(打字机保留) + +链路: 客户 ↔ 前端(widget/独立页/App REST) → WS/REST → ConversationManager + (GLM对话+提取+评估 | 联系方式管道 | 邮件触发器 | 边界过滤) → SQLite → SMTP询盘邮件 + → 销售人工承接(报价/安排) → admin后台辅助 +``` + +**系统当前最大的结构性事实**:核心转化链路(聊车→挖意向→收联系方式→邮件→人工)已经完整且被测试覆盖;**剩余问题集中在四个区域——安全基线、数据可靠性、前端体验、以及"上线后才知道"的观测盲区。** + +--- + +## 二、问题清单(七层,共 30 项) + +### A. 安全层(3 项 —— 上线公网前必须处理) + +| # | 问题 | 现象/根因 | 影响 | +|---|---|---|---| +| A1 | **管理后台默认密码** | `.env` 无 `ADMIN_PASSWORD` → `admin123` 生效中(本次复核确认);登录无限流无锁定;会话存内存 | 任何扫到域名的人可看全部客户对话/联系方式并删库 | +| A2 | **明文 HTTP** | openresty 的 HTTPS 配置整段被注释;admin 密码/数据明文传输 | 公网嗅探风险 | +| A3 | **WS 零限流 + 消息无长度上限** | 限流只挂在 REST `/api/chat/message`(30/h);`/ws/chat` 与独立页无任何限制;消息 content 不截断 | 每条消息 3 次 GLM 调用——脚本可无限烧钱;超长消息直接怼给 GLM | + +### B. 数据与可靠性层(5 项) + +| # | 问题 | 现象/根因 | 影响 | +|---|---|---|---| +| B1 | **SQLite 零备份** | 全部客户线索/画像/对话就 `data/cc_ai_kefu.db` 一份文件,无备份任务、无 WAL | 磁盘坏 = 线索全灭,业务级风险 | +| B2 | **GLM 调用无超时无重试** | 所有 GLM 调用不限时;挂起则该轮永远 typing(前端只重连不报错) | 客户体验死等 + 无告警 | +| B3 | **SMTP 无重试队列** | 发送失败靠"下一条消息自然重试"(有意的 at-least-once);给号后沉默+首发失败=线索丢失窗口 | 低概率但真实 | +| B4 | **邮件发送在请求内同步** | SMTP 握手挤占响应时间;触发频率上升后影响面变大 | 延迟抖动 | +| B5 | **会话只生不死** | 决策 A 后会话永不自动结束、无超时清理;active 行无限增长 | 数据卫生(admin 列表污染、查询变慢),功能无害但持续恶化 | + +### C. 前端体验层(4 项 —— 用户实测亲历) + +| # | 问题 | 现象/根因 | 影响 | +|---|---|---|---| +| C1 | **widget 刷新/翻页丢会话** | 会话只存 JS 内存、`page_token` 每次加载重新生成(widget.js:288) | 客户重聊一遍(AI 重复提问)+ 同一客户重复产生新会话(重复线索邮件放大器) | +| C2 | **会话恢复了但界面空白** | 独立页 `loadHistory()` 是空函数(chat.js:182);后端历史接口存在但页面不调用 | 客户看不到自己说过什么 | +| C3 | **widget 中途重连丢视觉历史** | WS 断线重连后消息列表只在内存 | 体验断裂 | +| C4 | **ended 后无重开入口**(低) | 永不结束后基本不触发;但历史 completed 会话刷新即新会话 | 极低 | + +### D. AI 行为质量层(5 项 —— 两大特性引入的新观测点) + +| # | 问题 | 现象/根因 | 影响 | +|---|---|---|---| +| D1 | **破防率长期趋势未知** | 第1+2层自律率、第3层拦截频率只有日志行,无聚合视图 | 无法判断防线配置是否过严/过松,也无法评估 GLM 升级收益 | +| D2 | **迂回话术同质化** | 过滤器过渡话术单一池不轮换;客户一小时内反复问价会看到重复句子 | 机器人感;聪明客户可感知到"触发了什么" | +| D3 | **转化流失未量化** | 价格拦截是强业务取舍——拿不到价的客户流失率、留资率变化没有任何埋点 | 无法回答"红线是否伤害转化"这个关键问题 | +| D4 | **[未评估] 邮件占比未知** | 即时触发让"第1条消息就留号"的邮件带着薄上下文+未评估标签发出 | 销售对这类邮件的跟进策略缺数据支撑 | +| D5 | **glm-4-flash 能力天花板** | 复杂询价理解/话术遵循/多语言均偏弱(本项目多次实测其不守规则) | 三层防线里第1+2层形同虚设的部分原因;模型升级可能显著降低第3层压力 | + +### E. 转化漏斗层(5 项 —— 计划内未做) + +| # | 问题 | 说明 | +|---|---|---| +| E1 | **UI 留资卡片未做** | 表单留资转化率 > 聊天索要;判定"有意向缺联系方式"时弹卡片是转化质变手段(批次3) | +| E2 | **admin 卡单筛选未做** | interested+无联系方式的人工兜底视图;卡单数据已在落库,只差筛选 UI | +| E3 | **线索无生命周期** | 邮件发出后无"未跟进提醒/二次触达/关闭"管理;24h 轻提醒只覆盖再来访 | +| E4 | **邮件单收件人** | `NOTIFY_EMAIL` 单一邮箱;无按国家/语种分诊(肯尼亚线和中东线分开?) | +| E5 | **无数据看板** | 会话量/留资率/意向分布/破防率/邮件量——全靠 SQL 手查 | + +### F. 运维观测层(4 项) + +| # | 问题 | 说明 | +|---|---|---| +| F1 | **无监控告警** | 服务挂/GLM 连续失败/SMTP 失败/磁盘满——只有日志行,无人知道 | +| F2 | **无 CI** | 106 项测试只在本地跑;push 无门禁 | +| F3 | **Redis 容器白跑** | compose 里运行但代码零引用;弱默认密码写在仓库 | +| F4 | **杂项债务** | admin 内存会话重启掉线、admin 筛选分页 total 错误、国家映射"加拿大"笔误、IP 可被 client_info 覆盖、`AIService.chat` 死代码、邮件 From 品牌【XYH】与站点不符、openresty 静态双拷贝同步陷阱、deploy.sh 停机窗口 | + +### G. 架构扩展层(3 项 —— 流量上来之前不用动) + +| # | 问题 | 说明 | +|---|---|---| +| G1 | 单进程 + SQLite | 并发写锁风险与流量成正比;当前量级完全够 | +| G2 | 限流/会话存内存 | 多 worker 部署即失效(当前单 worker 无碍) | +| G3 | PII 永久存储无保留策略 | 外贸业务合规盲区(低优先,知晓即可) + +--- + +## 三、优化方案(按优先级四批) + +### P0 · 上线公网前必须(约 1 天) + +| 序 | 动作 | 对应 | 成本 | +|---|---|---|---| +| 1 | .env 配置强 `ADMIN_PASSWORD` + admin 登录限流 | A1 | 0.5h | +| 2 | 启用 openresty HTTPS(证书+301) | A2 | 1h | +| 3 | WS 限流(session 严 60/h、IP 宽、消息级拒绝防重连风暴)+ 消息长度上限(复用 429) | A3 | 0.5 天,前后端同批 | +| 4 | SQLite 定时备份(`sqlite3 .backup` cron + 保留策略 + WAL) | B1 | 1h | +| 5 | GLM 调用超时(30s)+ 单次重试 + 前端明确报错 | B2 | 0.5 天 | + +### P1 · 上线第一周(观测+卫生,约 2 天) + +| 序 | 动作 | 对应 | +|---|---|---| +| 1 | 超时清理任务(lifespan 周期扫描:24h 无消息的 active → abandoned) | B5 | +| 2 | **破防率聚合**:每日统计 `Boundary filter` 日志行数 → 简单接口/脚本输出趋势 | D1 | +| 3 | 留资率/流失埋点:admin 加统计接口(会话数、留联系方式数、[未评估]占比) | D3/D4 | +| 4 | 简单告警:健康检查失败/GLM 连续失败/SMTP 失败 → 邮件或 webhook 通知 | F1 | +| 5 | 邮件发送移出请求路径(asyncio.create_task + 短超时) | B4 | +| 6 | F4 杂项打包:admin 分页/国家映射/删 AIService.chat/删 Redis/From 品牌改 | F4 | + +### P2 · 批次 2:前端体验(约 3 天,解决你亲历的痛点) + +| 序 | 动作 | 对应 | +|---|---|---| +| 1 | widget 会话持久化(独立前缀 localStorage + 48h 过期) | C1 | +| 2 | 独立页/widget 历史渲染(loadHistory 接现有历史接口) | C2/C3 | +| 3 | WS 限流前端配合(P0-3 的前端侧) | A3 | + +### P3 · 批次 3 + 长期:转化与质量 + +| 序 | 动作 | 对应 | +|---|---|---| +| 1 | UI 留资卡片(端点限流+校验+只弹一次) | E1 | +| 2 | admin 卡单筛选 + 线索列表视图(含破防监控标签) | E2/D1 | +| 3 | 过渡话术扩池轮换 | D2 | +| 4 | 评估 glm-4-air/plus 对自律率的提升(换模型跑冒烟对比破防率) | D5 | +| 5 | 邮件分诊(按国家路由收件人) | E4 | +| 6 | 线索生命周期(跟进状态字段+超时提醒) | E3 | +| 7 | 数据看板(E5)、CI(F2)、多 worker 改造(G1/G2,流量触发) | 长期 | + +--- + +## 四、需要拍板的决策点 + +| # | 决策 | 选项与建议 | +|---|---|---| +| 1 | **上线节奏** | 两大特性现在就上(建议:核心价值已闭环,P0 做完就走),还是等 P1 观测一起? | +| 2 | **超时时长**(B5) | active 会话多久算 abandoned?建议 24h(与 24h 去重窗口对齐) | +| 3 | **是否保留生产基线任务** | 上线前跑一次 `baseline_stats.py` + `breach_rate.py` 存档(强烈建议,一次性机会) | +| 4 | **模型升级评估要不要排期** | 如果第1+2层自律率长期很低(全靠墙),升模型可能比维护三层更省心 | +| 5 | **邮件分诊需求** | 销售团队实际几人?按国家分箱是否真实需要? | + +--- + +## 五、结论 + +**系统核心链路健康**:两大特性互相咬合、106 项测试护栏、真实流量冒烟通过。剩余问题没有一个是核心链路缺陷——全部集中在**安全基线(A 层 3 项)、数据可靠性(B 层 2 项)、前端体验(C 层 3 项)和观测盲区(D/F 层)**。 + +最短路径建议:**P0 五项(约一天)做完即可上线**,第一周做 P1 建立观测,之后按 P2/P3 消化体验与转化。A1(改密码)是零成本零风险的一项,无论如何先做。 diff --git a/docs/2026-08-20-company-contact-handoff-changelog.md b/docs/2026-08-20-company-contact-handoff-changelog.md new file mode 100644 index 0000000..7e38344 --- /dev/null +++ b/docs/2026-08-20-company-contact-handoff-changelog.md @@ -0,0 +1,87 @@ +# 改动文档:我方联系方式外发(按钮指路 + 零号码红线) + +- **日期**:2026-08-20 +- **分支**:`chany_dev_dev` +- **状态**:117 项测试全绿(基线 106),真实 GLM 冒烟通过(web/App 双来源) +- **实施档案**:[superpowers/plans/2026-08-20-company-contact-handoff.md](superpowers/plans/2026-08-20-company-contact-handoff.md)(v1 四轮评审后因"站点动态配置"事实推翻重构为 v2,v2 再经四轮评审收敛) + +--- + +## 1. 背景与需求 + +客户主动索要我方联系方式("how can I contact you / 你们的WhatsApp多少")时:**web 客户引导点击窗口顶部的 WhatsApp/邮箱按钮;App 客户(无按钮)反向留资由客户经理主动联系**。AI 在任何情况下不口头输出号码或邮箱。 + +**修复的隐患**:此前 prompt 不含任何我方联系方式——GLM 会**编造假号**(客户加错号=线索彻底丢失)。 + +## 2. 方案演进(v1 → v2,关键决策记录) + +| | v1(已废弃) | v2(实施) | +|---|---|---| +| 思路 | .env 全局真值注入 prompt,AI 报号 | **AI 永不报号**——web 指按钮、App 反向留资 | +| 动态配置难题(每站点 data-* 配不同按钮值) | 需三选一解决(前端上报/域名映射/统一单号) | **自动消失**(AI 不报值,与站点配置零冲突) | +| 防幻觉墙 | 真值比对(allowed 集合+国家码尾缀+回显豁免) | **零号码墙**:任何 ≥10 位数字串/邮箱一律拦,无值比对 | +| 配置/前端/管道改动 | 配置注入+毒值三入口过滤 | **全部零改动** | + +v1 四轮评审仍适用的发现全部继承(提醒前移防早退、独立叠加、互留条件排除 suspect、节奏豁免补索要场景、未提供渠道引导);v2 溶解的复杂度(真值比对/毒值过滤/配置兜底)在档案中注明原因。 + +## 3. 三层实现 + +### 3.1 prompt 层(system.md) + +- 新增"客户索要我方联系方式"话术节:web 指按钮 / App 反向留资 / 互留(每会话一次,已留或疑似留过不加)/ 未提供渠道(微信/Telegram 等)一律引导回 WhatsApp 按钮,绝不编造 +- 红线新增:**回复中不能出现任何电话号码或邮箱地址——客户自己的号码也不回显** +- 节奏豁免触发清单补"索要我方联系方式" + +### 3.2 提醒层(conversation.py) + +- `CONTACT_ASK_TRIGGER_RE`(含"你的微信"单数形态) +- `_build_context_reminder` 加 `source` 参数:**contact_note 前移到空画像早退之前**(首条消息索要也有提醒)、与价格红线提醒独立叠加、按 source 分支 +- **冒烟实证加固**:抽象指令("用 App 话术")GLM 会无视并背 prompt 里更显眼的 web 脚本——提醒必须**携带该分支的逐字话术**,不让模型自己挑(与预算复述同类失败模式的同款解法) +- 索要率观测日志 `Contact-ask reminder fired (source=...)` + +### 3.3 零号码墙(boundary_filter.py + 句子门控接线) + +- `no_contact_value(sentence, user_history)`:≥10 位连续数字串(允许分隔形态)或邮箱 → 拦截;唯一豁免=该串出现在近期客户消息中(客户自己号码的礼貌回显) +- **无值比对、无公司值依赖**——年份(4位)/里程(逗号断开)/VIN(字母混合)/续航(600km)/电池(85kWh) 天然安全,合法聊车内容零误杀(已验证) +- 替换句按 source 分支(web→按钮指路 / App→反向留资),与价格墙并列检查、同一拦截管道 +- 附带收益:任意场景幻觉出的热线号/银行账号/邮箱一律被拦 + +### 3.4 evaluator 联动 + +客户主动索要我方联系方式 = 最强意向信号之一。 + +## 4. 文件清单 + +| 文件 | 动作 | +|---|---| +| `app/prompts/system.md` | +话术节/+零号码红线/节奏豁免扩展(3 hunks, +15/−1) | +| `app/services/conversation.py` | 触发词表 + 提醒(source 参数/前移/逐字话术)+ 墙接线 | +| `app/services/boundary_filter.py` | `LONG_NUMBER_RE`/`ANY_EMAIL_RE`/`no_contact_value`/双来源替换句 | +| `app/prompts/evaluator.md` +1 行 | 最强意向信号 | +| 测试两文件 | +11(提醒 5/墙 4/集成 2),106→117 | + +前端零改动、无 schema、无新配置。 + +## 5. 真机冒烟结果 + +| 路径 | 结果 | +|---|---| +| web 索要联系方式 | ✅ 按钮指路 + 零号码零邮箱 + 顺带问车型(线索质量) | +| 索要微信(未提供渠道) | ✅ 引导回 WhatsApp,零编造 | +| 双重请求(联系方式+价格同句) | ✅ 指按钮 + 不报价(两提醒叠加共存) | +| 连续索要 ×3 | ✅ 全程合规,无重复骚扰 | +| **App 会话索要** | ✅ 加固后 3/3 逐字反向留资话术、零按钮话术(加固前 0/3——抽象指令被无视,逐字话术修复) | +| 正常问车(续航/年份) | ✅ 数字正常回答,零误杀 | + +## 6. 已知限制与残余 + +| 项 | 处置 | +|---|---| +| 客户坚持要"号码本身" | web:点按钮即加号(wa.me 直达);App:反向留资后客户经理主动联系 | +| 纯字母渠道 ID(微信号)编造 | 零号码墙拦不住字母 ID——prompt"绝不提供未配置渠道"兜底 | +| 客户从按钮链接复制号码回显 | AI 从不报号后主通路消失;极罕见,出现再补毒值 belt | +| GLM 无视抽象指令选错话术分支 | 已用逐字话术注入修复 App 分支;观察期 | + +## 7. 部署与回滚 + +重启生效(prompt/代码启动加载);无迁移、无配置、无前端;回滚 = revert 单提交。 diff --git a/docs/2026-08-31-dynamic-theme-changelog.md b/docs/2026-08-31-dynamic-theme-changelog.md new file mode 100644 index 0000000..5a87234 --- /dev/null +++ b/docs/2026-08-31-dynamic-theme-changelog.md @@ -0,0 +1,17 @@ +# 2026-08-31 动态主题色 + 头像图片化 + +## 嵌入方用法 +- 品牌色:` + + +``` + +- [ ] **Step 2: 测试基本功能** + +打开测试页面,验证: +1. ✅ 页面加载 1.8 秒后邀请卡片自动弹出 +2. ✅ 邀请卡片样式正确(橙色头部、两个话题按钮) +3. ✅ 点击 "Get Quotation" → 邀请关闭 → 聊天打开 → 消息自动发送 +4. ✅ 点击 "Distributor Cooperation" → 邀请关闭 → 聊天打开 → 消息自动发送 +5. ✅ 点击 "Start Chat" → 邀请关闭 → 聊天打开 +6. ✅ 点击 "No Thanks" → 邀请永久关闭 +7. ✅ 点击悬浮按钮(邀请可见时)→ 邀请关闭 → 聊天打开 +8. ✅ 点击悬浮按钮(邀请隐藏时)→ 聊天打开 + +- [ ] **Step 3: 测试页面独立性** + +在多个标签页/页面中测试: +1. ✅ 页面 A 关闭邀请后,页面 B 仍能弹出邀请 +2. ✅ 页面 A 刷新后,已关闭状态保持 +3. ✅ 关闭整个网站重新打开,所有状态重置 + +- [ ] **Step 4: 测试热门话题** + +1. ✅ 聊天面板显示 "POPULAR TOPICS" 区域 +2. ✅ 点击话题按钮 → 消息自动发送 +3. ✅ 发送第一条消息后,热门话题隐藏 + +- [ ] **Step 5: 测试响应式** + +在不同屏幕尺寸下测试: +1. ✅ 移动端(<450px)样式正确 +2. ✅ 话题按钮在小屏幕上正确排列 + +- [ ] **Step 6: 测试边界情况** + +1. ✅ 快速连续点击悬浮按钮 +2. ✅ 在邀请弹出前打开聊天 +3. ✅ 在消息发送过程中关闭邀请 + +- [ ] **Step 7: Commit** + +```bash +# 如果创建了测试文件 +git add static/widget-test.html +git commit -m "test: add widget invite card test page" +``` + +--- + +## 任务 8: 最终清理和文档 + +- [ ] **Step 1: 代码审查** + +检查代码: +1. ✅ 无 console.log 调试语句 +2. ✅ 变量命名一致 +3. ✅ 注释清晰 +4. ✅ 无重复代码 + +- [ ] **Step 2: 浏览器兼容性测试** + +在不同浏览器中测试: +1. ✅ Chrome/Edge (Chromium) +2. ✅ Firefox +3. ✅ Safari (如果可用) + +- [ ] **Step 3: 性能检查** + +1. ✅ 无内存泄漏(定时器清理) +2. ✅ 无不必要的 DOM 操作 +3. ✅ 存储操作高效 + +- [ ] **Step 4: 最终 Commit** + +```bash +git add static/widget.js +git commit -m "chore: final cleanup and polish invite card feature" +``` + +--- + +## 验收标准 + +### 功能要求 +- ✅ 页面加载 1.8 秒后自动弹出邀请卡片 +- ✅ 包含两个话题按钮:Get Quotation、Distributor Cooperation +- ✅ 点击话题按钮 → 关闭邀请 → 打开聊天 → 自动发送消息 +- ✅ 点击 "Start Chat" → 关闭邀请 → 打开聊天 +- ✅ 点击 "No Thanks" → 永久关闭邀请 +- ✅ 每个页面独立管理状态 +- ✅ 聊天面板显示热门话题区域 + +### 样式要求 +- ✅ 橙色渐变头部:`linear-gradient(135deg, #F08A34 0%, #EC8C2F 100%)` +- ✅ 圆角 26px +- ✅ 阴影效果正确 +- ✅ 淡入 + 滑入动画 +- ✅ 响应式设计 + +### 技术要求 +- ✅ 使用 sessionStorage 存储状态 +- ✅ 使用 Shadow DOM 隔离样式 +- ✅ 定时器正确清理 +- ✅ 无内存泄漏 + +--- + +**预计工作量**: 约 2-3 小时 +**文件修改**: 1 个文件(static/widget.js) +**新增代码**: 约 250-300 行 diff --git a/docs/superpowers/plans/2026-06-18-dynamic-contact-config.md b/docs/superpowers/plans/2026-06-18-dynamic-contact-config.md new file mode 100644 index 0000000..b119b70 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-dynamic-contact-config.md @@ -0,0 +1,292 @@ +# WhatsApp / Email 动态配置 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:** 将写死的 WhatsApp / Email 联系方式改为通过 ` +``` +刷新页面,再次执行 Step 6 的 Console 命令。 +Expected: +- `whatsapp-btn` → `https://wa.me/8613800138000` +- `email-btn` → `mailto:demo@hesu-auto.com` + +覆盖路径验证通过后,**先不要回退**——这个改动正好是 Task 3 的成果,直接进入提交。 +> 注:即使这里手滑回退了也无妨——Task 3 Step 1 会重新把它加回来。本 Task 的提交只包含 `widget.js`,不含 `widget-test.html`。 + +- [ ] **Step 8: 提交** + +```bash +git add static/widget.js +git commit -m "$(cat <<'EOF' +feat(widget): WhatsApp/Email 改为 data-whatsapp/data-email 动态配置 + +组件读取自身 +``` +替换为(用与 Task 1 Step 7 一致的自定义演示值): +```html + +``` + +> `chat.html:35` 与 `:39` 原有的静态 `href` **保留不动**——作为 `chat.js` 未执行时的 fallback(默认值),`chat.js` 启动后会覆盖为配置值。 + +- [ ] **Step 3: 手动验证——自定义值生效** + +打开 `static/chat.html`(方式 A 或 B),DevTools Console 执行: +```js +[...document.querySelectorAll('a.whatsapp-btn, a.email-btn')].forEach(a => console.log(a.className, '->', a.href)); +``` +Expected: +- `call-btn whatsapp-btn` → `https://wa.me/8613800138000` +- `call-btn email-btn` → `mailto:demo@hesu-auto.com` + +(chat.html 是 light DOM,不需要 shadowRoot。) + +- [ ] **Step 4: 手动验证——回退默认值(临时)** + +临时把 `chat.html:80` 的两个 data 属性删掉(恢复成 ``),刷新页面,再次执行 Step 3 的 Console 命令。 +Expected: +- `whatsapp-btn` → `https://wa.me/+8619037179250` +- `email-btn` → `mailto:info@borderlesscar.com` + +回退验证通过后,**把 data 属性加回去**(恢复 Step 2 的状态)。 + +- [ ] **Step 5: 提交** + +```bash +git add static/js/chat.js static/chat.html +git commit -m "$(cat <<'EOF' +feat(chat): 独立页 WhatsApp/Email 改为 data-* 动态配置 + +chat.js 读取自身 +``` +若 Step 7 之后被改动过,恢复成上面这样。 + +- [ ] **Step 2: 全局终检——确认无残留写死联系方式(前端)** + +Run: `grep -rn "wa.me/+8619037179250\|mailto:info@borderlesscar.com" static/widget.js static/js/chat.js` +Expected: **无输出**(两处运行时文件都已参数化)。 + +注:`static/chat.html:35` 和 `:39` 仍含默认值字符串,这是**有意保留的 fallback**,属正常。 + +- [ ] **Step 3: 更新测试清单文档** + +在 `docs/test-checklist.md` 末尾追加一节(若文件不存在则新建并加标题 `# 测试清单`): + +```markdown +## WhatsApp / Email 动态配置(2026-06-18) + +- [ ] **默认值(未传属性)**:组件(widget-test.html 去掉 data 属性时)与独立页(chat.html 去掉 data 属性时)的 WhatsApp / Email 按钮分别指向 `https://wa.me/+8619037179250` 与 `mailto:info@borderlesscar.com`,与历史行为一致。 +- [ ] **自定义值**:widget-test.html 传 `data-whatsapp` / `data-email` 后,组件内三处链接(面板 help-block 的 WhatsApp、面板 help-block 的 Email、邀请卡 WhatsApp、邀请卡 Email)均指向自定义地址。 +- [ ] **独立页自定义值**:chat.html 传 `data-whatsapp` / `data-email` 后,两个按钮指向自定义地址。 +- [ ] **空字符串**:`data-whatsapp=""` 等价于未传,回退默认值。 +- [ ] **JS 未执行**:chat.html 在 JS 禁用 / 未执行时,静态 `href` 仍为默认值,不出现空链接。 +``` + +- [ ] **Step 4: 提交** + +```bash +git add static/widget-test.html docs/test-checklist.md +git commit -m "$(cat <<'EOF' +docs(test): widget-test 演示动态配置属性 + 补充测试清单 + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## 完成标准(Definition of Done) + +- [ ] `static/widget.js` 与 `static/js/chat.js` 中不再有任何写死的 `wa.me/+8619037179250` / `mailto:info@borderlesscar.com`(grep 无输出)。 +- [ ] 不传任何新属性时,所有联系方式链接与改动前完全一致(向后兼容)。 +- [ ] 传入自定义 `data-whatsapp` / `data-email` 时,组件 3 处 + 独立页 2 处链接均指向自定义值。 +- [ ] 3 个 commit 已提交,每个 commit 自包含、可独立验证。 +- [ ] `docs/test-checklist.md` 已补充对应手动验证条目。 + +## 风险与回滚 + +- 风险极低:纯前端字符串替换 + 新增属性读取,不触碰 WebSocket、后端、样式。 +- 回滚:`git revert` 对应 commit 即可;未传属性的部署本来就无任何行为变化。 diff --git a/docs/superpowers/plans/2026-06-23-auto-invite-toggle-and-mobile-button.md b/docs/superpowers/plans/2026-06-23-auto-invite-toggle-and-mobile-button.md new file mode 100644 index 0000000..eab79b3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-auto-invite-toggle-and-mobile-button.md @@ -0,0 +1,365 @@ +# 邀请卡自动弹出开关 + 移动端悬浮按钮 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:** 为嵌入式组件 `widget.js` 新增两个 `data-*` 配置开关——`data-auto-invite`(控制邀请卡是否 1.8s 自动弹出)与 `data-mobile`(切换到移动端竖排悬浮按钮 D2),默认行为 100% 向后兼容。 + +**Architecture:** 复用 `widget.js` 既有的 `script.getAttribute("data-*")` 配置范式。`data-auto-invite` 在 `startInviteTimer()` 开头加一句短路;`data-mobile` 走「独立移动端 DOM 模板」分支(桌面模板逐字不动),并配套一份贴角的位置表 `positionStylesMobile` 与移动端 CSS。 + +**Tech Stack:** 原生 JavaScript(ES5 风格 IIFE + Shadow DOM)、静态 HTML。无构建步骤、无打包器。 + +**Spec:** [docs/superpowers/specs/2026-06-23-auto-invite-toggle-and-mobile-button-design.md](../specs/2026-06-23-auto-invite-toggle-and-mobile-button-design.md) + +--- + +## ⚠️ 关于测试方式(重要) + +本仓库为**纯静态 JS 前端**,**没有任何 JS 测试框架**(无 `package.json`,`tests/` 只有空的 `__init__.py`)。为本改动引入 JS 测试框架违反 YAGNI。 + +因此本计划**不使用自动化 TDD**,而是采用**手动浏览器验证**:每个 Task 末尾用浏览器打开页面、通过 DevTools 检查实际 DOM / 行为。验证标准写明「期望看到的结果」,与自动化断言等价。 + +**验证通用方法:** +- 方式 A(推荐,贴近真实部署):启动后端服务(`bash deploy.sh` 或 `docker compose up`),浏览器访问页面。 +- 方式 B(最快,免启动服务):直接用浏览器打开本地 HTML 文件(`file://`)。组件的 WebSocket 会连不上,但**不影响本特性的验证**——邀请卡弹出与按钮渲染都在初始化时完成,与 WS 无关。 +- 检查 Shadow DOM 内部:DevTools → Elements → 展开组件 Shadow Root,或在 Console 执行下文给出的命令。 + +--- + +## 文件结构(File Structure) + +| 文件 | 职责 | 本次改动 | +|------|------|----------| +| `static/widget.js` | 嵌入式组件(第三方站点通过 ` +``` +刷新页面,等待 5 秒以上。 +Expected:**邀请卡不再自动弹出**;悬浮按钮照常显示,点击仍能打开聊天面板。 + +验证通过后,**把 `data-auto-invite="false"` 从该标签删掉**(恢复成 Step 3 的状态,保持测试页的邀请卡回归用途)。Task 3 不会重新加它。 + +- [ ] **Step 5: 提交** + +```bash +git add static/widget.js +git commit -m "$(cat <<'EOF' +feat(widget): 新增 data-auto-invite 控制邀请卡自动弹出 + +默认(true)保持 1.8s 自动弹出;设 data-auto-invite="false" 时 +startInviteTimer 直接返回,邀请卡不再自动弹,悬浮按钮照常可用。 + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 2: `data-mobile` —— 移动端竖排悬浮按钮(D2)+ 贴角位置 + +**Files:** +- Modify: `static/widget.js`(IIFE 顶部新增 `isMobile` 读取 + `positionStylesMobile` + `pos` 选择) +- Modify: `static/widget.js`(样式数组新增移动端 CSS) +- Modify: `static/widget.js`(`toggleBtn` 改为按 `isMobile` 分两套模板) + +- [ ] **Step 1: 新增 `isMobile` 配置读取** + +在 Task 1 新增的 `var autoInvite = ...` 之后、`var wsBase = ...` 之前,插入: +```js + var isMobile = script.getAttribute("data-mobile") === "true"; +``` + +插入后该区域应为: +```js + var autoInvite = script.getAttribute("data-auto-invite") !== "false"; + var isMobile = script.getAttribute("data-mobile") === "true"; + var wsBase = new URL(script.src).origin; +``` + +- [ ] **Step 2: 新增 `positionStylesMobile` 并切换 `pos`** + +定位 `positionStyles` 定义(约第 16-21 行): +```js + var positionStyles = { + "bottom-right": { bottom: "80px", right: "10px" }, + "bottom-left": { bottom: "20px", left: "20px" }, + "top-right": { top: "20px", right: "20px" }, + "top-left": { top: "20px", left: "20px" }, + }; +``` +在其**下方**紧接着新增一份移动端位置表: +```js + var positionStylesMobile = { + "bottom-right": { bottom: "20px", right: "16px" }, + "bottom-left": { bottom: "20px", left: "16px" }, + "top-right": { top: "20px", right: "16px" }, + "top-left": { top: "20px", left: "16px" }, + }; +``` + +然后定位 `pos` 计算(约第 29 行,紧接 `var c = colors[theme] || colors.light;` 之后): +```js + var pos = positionStyles[position] || positionStyles["bottom-right"]; +``` +改为按 `isMobile` 选择表: +```js + var pos = (isMobile ? positionStylesMobile : positionStyles)[position] + || (isMobile ? positionStylesMobile : positionStyles)["bottom-right"]; +``` + +> 目的:移动端圆钮仅 56px,沿用桌面 `bottom-right` 的 `bottom:80px` 会离底边偏高;移动端改用贴角的 ~20px。桌面端 `positionStyles` 逐字不动。 + +- [ ] **Step 3: 样式数组新增移动端 CSS** + +定位样式数组中 `.toggle-btn:hover` 那一行(约第 47 行): +```js + ".toggle-btn:hover { transform:scale(1.1); }", +``` +在其**下方**紧接着插入 5 行移动端按钮样式: +```js + ".toggle-btn.mobile { flex-direction:column; gap:5px; padding:0; background:transparent; box-shadow:none; border-radius:0; }", + ".toggle-btn.mobile:hover { transform:scale(1.05); }", + ".toggle-btn.mobile .mobile-circle { position:relative; width:56px; height:56px; border-radius:50%; background:#f08a31; display:flex; align-items:center; justify-content:center; font-size:28px; box-shadow:0 4px 16px rgba(240,138,49,0.45); }", + ".toggle-btn.mobile .mobile-dot { position:absolute; bottom:2px; right:2px; width:13px; height:13px; background:#22c55e; border-radius:50%; border:2px solid #f08a31; }", + ".toggle-btn.mobile .mobile-label { color:#8C4D1D; font-size:10px; font-weight:600; line-height:1.1; }", +``` + +- [ ] **Step 4: `toggleBtn` 改为按 `isMobile` 分两套模板** + +定位 `toggleBtn` 创建处(约第 233-235 行): +```js + var toggleBtn = document.createElement("button"); + toggleBtn.className = "toggle-btn"; + toggleBtn.innerHTML = ` +``` + +**改动一**:把 className 改为带 mobile 修饰: +```js + toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : ""); +``` + +**改动二**:把紧跟其后的单行 `toggleBtn.innerHTML = \`...桌面模板...\`;` 改成 `if/else` 分支——在它**前面**插入移动端分支、把它本身包进 `else`。 + +具体:在 `toggleBtn.className = ...` 这一行之后、原 `toggleBtn.innerHTML = \`` 这一行之前,插入: +```js + if (isMobile) { + toggleBtn.innerHTML = ` +
🤖
+ Chat with us +`; + } else { +``` +然后在原桌面模板赋值的结束反引号 `` `; `` **之后**,补上闭合的 `}`: +```js + } +``` + +改动后该区域结构应为(桌面模板内容逐字不变,仅被包进 `else { ... }`): +```js + var toggleBtn = document.createElement("button"); + toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : ""); + if (isMobile) { + toggleBtn.innerHTML = ` +
🤖
+ Chat with us +`; + } else { + toggleBtn.innerHTML = ` +
+ ... (现有桌面模板,原样不动) ... +
+`; + } +``` + +> `toggleBtn` 仍是接收点击的元素,[widget.js:367](../../../static/widget.js#L367) 的 `toggleBtn.addEventListener("click", toggle)` 无需改动,点击圆钮或文字均触发 `toggle()`。 + +- [ ] **Step 5: 手动验证——默认值(向后兼容,桌面款按钮不变)** + +`widget-test.html` 此刻未加 `data-mobile` → `isMobile=false` → 走桌面分支。 + +打开 `static/widget-test.html`(方式 A 或 B),DevTools Console 执行: +```js +const host = document.querySelector('#cc-widget-host'); +const btn = host.shadowRoot.querySelector('.toggle-btn'); +console.log('class:', btn.className); +console.log('html:', btn.innerHTML.slice(0, 60)); +``` +Expected: +- `class:` → `toggle-btn`(**不含** `mobile`) +- `html:` → 以桌面模板开头(含 `position:relative` 的 div) + +视觉上:右下角是**现有的宽药丸按钮**(🤖 图标 + "Chat with us" / "Usually replies quickly" 两行文字),与改动前一致 → **向后兼容验证通过**。 + +- [ ] **Step 6: 手动验证——`data-mobile="true"` 切到 D2(临时)** + +临时编辑 `static/widget-test.html:69`,给组件 script 标签加上 `data-mobile="true"`(**保留** Step 1 Task 1 回退后去掉 `data-auto-invite` 的状态,即当前标签不含 auto-invite): +```html + +``` +刷新页面,DevTools Console 执行: +```js +const host = document.querySelector('#cc-widget-host'); +const btn = host.shadowRoot.querySelector('.toggle-btn'); +const container = host.shadowRoot.querySelector('.widget-container'); +console.log('class:', btn.className); +console.log('circle:', !!btn.querySelector('.mobile-circle'), 'label:', btn.querySelector('.mobile-label')?.textContent); +console.log('position bottom/right:', getComputedStyle(container).bottom, getComputedStyle(container).right); +``` +Expected: +- `class:` → `toggle-btn mobile` +- `circle:` → `true`,`label:` → `Chat with us` +- `position bottom/right:` → `20px` / `16px`(贴角,**非** 桌面的 `80px`) + +视觉上:右下角是 **D2 竖排按钮**——56px 橙色圆钮 🤖 在上、"Chat with us" 文字外置在下(非橙色底),且**更贴近屏幕右下角**。点击圆钮或文字均能开关聊天面板。 + +验证通过后,**保留** `data-mobile="true"` 不要回退——这正是 Task 3 要定稿的演示状态。 + +- [ ] **Step 7: 手动验证——`data-position` 其它取值不报错(临时)** + +把 Step 6 标签里的 `data-position` 分别临时改成 `bottom-left`、`top-right`、`top-left`,各刷新一次。 +Expected:D2 按钮出现在对应角,位置贴角(~20px),无 JS 报错(DevTools Console 无红色)。验证完**恢复** `data-position="bottom-right"`。 + +- [ ] **Step 8: 提交** + +```bash +git add static/widget.js +git commit -m "$(cat <<'EOF' +feat(widget): 新增 data-mobile 切换移动端竖排悬浮按钮(D2) + +data-mobile="true" 时悬浮按钮改为竖排样式(56px 圆钮在上、"Chat with us" +文字外置在下),并切换到贴角位置表 positionStylesMobile。桌面款模板与 +位置逻辑逐字不动,默认行为完全向后兼容。 + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 3: `widget-test.html` 演示属性 + 更新测试清单 + +**Files:** +- Modify: `static/widget-test.html:69`(确认 `data-mobile="true"` 演示属性到位,Task 2 Step 6 已加) +- Modify: `docs/test-checklist.md`(补充手动验证条目) + +- [ ] **Step 1: 确认 `widget-test.html` 演示属性到位** + +确认 `static/widget-test.html:69` 已是(Task 2 Step 6 改成的): +```html + +``` +若之后被改动过,恢复成上面这样。 + +> 故意**不加** `data-auto-invite="false"`:widget-test.html 的核心用途就是测试邀请卡自动弹出(页面标题即 "Widget Invite Card Test Page"),加 false 会让邀请卡不弹、破坏其回归用途。`data-auto-invite` 的关闭路径靠临时编辑验证(见测试清单)。 + +- [ ] **Step 2: 更新测试清单文档** + +在 `docs/test-checklist.md` 末尾追加一节: + +```markdown +## 邀请卡自动弹出开关 + 移动端悬浮按钮(2026-06-23) + +### data-auto-invite +- [ ] **默认(未传)**:组件邀请卡在 ~1.8s 后自动弹出,与历史一致。 +- [ ] **`data-auto-invite="false"`**:邀请卡不再自动弹出;悬浮按钮照常显示,点击仍能打开聊天面板。 +- [ ] **空值/非法值**:`data-auto-invite=""` 或 `data-auto-invite="0"` 等价于未传,仍自动弹。 +- [ ] **与 data-mobile 独立**:`data-mobile="true" data-auto-invite="false"` 时竖排按钮 + 不弹邀请卡,互不影响。 + +### data-mobile +- [ ] **默认(未传)**:悬浮按钮为桌面款宽药丸(`toggle-btn`,不含 `mobile`),与历史一致。 +- [ ] **`data-mobile="true"`**:悬浮按钮为 D2 竖排(`toggle-btn mobile`,含 `.mobile-circle` + `.mobile-label`="Chat with us"),点击圆钮或文字均能开关聊天面板。 +- [ ] **移动端位置贴角**:`data-mobile="true"` + `data-position="bottom-right"` 时容器 `bottom/right` 为 `20px`/`16px`(非桌面 `80px`)。 +- [ ] **位置覆盖各角**:`data-mobile="true"` 配合 `data-position` 取 `bottom-left`/`top-right`/`top-left`,按钮出现在对应角、贴角、无 JS 报错。 +- [ ] **空值/非法值**:`data-mobile=""` 或 `data-mobile="yes"` 等价于未传,回退桌面款。 +``` + +- [ ] **Step 3: 提交** + +```bash +git add static/widget-test.html docs/test-checklist.md +git commit -m "$(cat <<'EOF' +docs(test): widget-test 演示 data-mobile + 补充两个开关测试清单 + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## 完成标准(Definition of Done) + +- [ ] `static/widget.js` 读取 `data-auto-invite` 与 `data-mobile` 两个新属性;不传时默认行为与改动前**完全一致**(桌面款按钮 + 邀请卡自动弹)。 +- [ ] `data-auto-invite="false"` 时邀请卡不再自动弹出,悬浮按钮仍可用。 +- [ ] `data-mobile="true"` 时悬浮按钮为 D2 竖排样式,且贴角位置生效。 +- [ ] 桌面分支(按钮模板、`positionStyles`)逐字未改,`grep` 确认无意外改动。 +- [ ] 3 个 commit 已提交,每个自包含、可独立验证。 +- [ ] `docs/test-checklist.md` 已补充两个开关的手动验证条目。 + +## 风险与回滚 + +- **风险点**:悬浮按钮是所有嵌入共享的代码路径,Task 2 改动它时若手滑可能影响桌面款。规避:桌面模板逐字包进 `else`、`positionStyles` 不动;Task 2 Step 5 专门做桌面款回归验证。 +- **回滚**:`git revert` 对应 commit 即可;未传新属性的部署本来就无任何行为变化(零破坏)。 diff --git a/docs/superpowers/plans/2026-08-17-baseline.txt b/docs/superpowers/plans/2026-08-17-baseline.txt new file mode 100644 index 0000000..10fc90f --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-baseline.txt @@ -0,0 +1,7 @@ +# 注意: 本文件为开发库占位数据, 生产基线需在部署机覆盖重跑 +== 已发邮件联系方式缺失(按来源)== + (无记录) +== 新策略邮件量预估 == + 有用户消息的会话: 3 + 消息中含联系方式样式: 0 (0%) ← 样式上界 + 联系方式出现在前2条消息内: 0 (0%) ← 即时触发增量 diff --git a/docs/superpowers/plans/2026-08-17-contact-driven-lead-email.md b/docs/superpowers/plans/2026-08-17-contact-driven-lead-email.md new file mode 100644 index 0000000..bbe386f --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-contact-driven-lead-email.md @@ -0,0 +1,1535 @@ +# 联系方式驱动的询盘邮件(Contact-Driven Lead Email)实施计划 v1.1 + +> **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:** 联系方式成为发询盘邮件的唯一条件——统一管道检测到联系方式立刻发邮件(不看消息数、不看意向评级);无联系方式永不发邮件、永不结束会话;客户已给的联系方式绝不丢失、绝不重复索要。 + +**Architecture:** 新建确定性"统一联系方式管道"(`app/services/contact_pipeline.py`):extractor 提取(多语言 LLM 源)+ 正则高置信扫描(确定性辅源)→ key 归一化 → 深合并(同 key 新值覆盖)→ 格式校验(confirmed/suspect 分级)。管道结果作为唯一事实源,同时供邮件触发器(去重 + 更正 + 24h 跨会话去重)、定向提醒(禁止重复索要)、会话结束守卫(全来源)三处消费。邮件触发从路由层(ended && interested)整体迁移到 `ConversationManager` 内部,与结束逻辑彻底解耦。 + +**Tech Stack:** Python 3.13 / FastAPI / SQLAlchemy 2.0 async + SQLite(aiosqlite) / pytest(asyncio_mode=auto) + +--- + +## 1. 背景与问题(为什么做) + +线上痛点:大量**无联系方式**的询盘邮件推送至销售邮箱(无效询盘 + 有意向但无法联系的客户)。根因链(已代码验证): + +1. widget/embedded/standalone 来源的 `should_end` 没有联系方式守卫(`_has_real_contact` 仅 app 生效,`conversation.py:230`); +2. evaluator.md 要求"无联系方式 info_sufficient 必须 false",但 GLM 不遵守(`conversation.py:253` 注释自证); +3. 判定 interested 即结束会话锁死客户(`conversation.py:350`),连补联系方式的机会都没有; +4. extractor 某轮返回空 `联系方式` 时浅合并整层覆盖,已提取的联系方式被静默抹掉(`conversation.py:119`)。 + +## 2. 已定决策(本计划的 spec,评审以此为准) + +| # | 决策 | 内容 | +|---|---|---| +| D1 | **邮件唯一条件** | 管道 confirmed 联系方式 → 立即发邮件。≥3轮、interested、info_sufficient 一律不参与邮件门槛。第 1 条消息含联系方式也发 | +| D2 | **意向降级为标签** | 邮件主题打标签:interested→[意向强]、pending→[待确认]、not_interested→[无意向]、未评估(<3轮未跑评估)→[未评估]。**评估被跳过时邮件 intent_level 参数传空串**(→[未评估]),但发给客户端的 evaluated 事件仍报 `pending`(客户端语义不变)——两者不得混用 | +| D3 | **会话永不自动结束(2026-08-18 决策 A,取代原"全来源守卫")** | 邮件与结束解耦后,自动结束只剩收尾UX/停止计费两个弱价值,却会把还没问完的客户锁死(刷新丢上下文+销售响应空窗)。**任何情况都不自动结束**:会话保持 active 直到客户自然沉默,关闭交给超时清理(健康轨)。evaluated 事件 `ended` 恒 False(App 端需知晓);`_finalize` 删除,D10 的 upsert 语义由"每轮评估落库"承担 | +| D4 | **防重发** | 同会话 email_sent 标记,全量只发一次;联系方式变化(改号/新增key/无提示换值)→轻量[更正]邮件,**每会话≤3封(2026-08-18 用户测试反馈放宽)**;达到上限后"锁邮件不锁快照"——Evaluation.contact_info 仍刷新为最新(admin 永远看到客户最新号码) | +| D5 | **24h 跨会话去重** | 同一联系方式值(规范化比对)24h 内已发过全量 → 新会话只发一行"该客户又来聊了"轻提醒 | +| D6 | **不重复索要** | 管道 confirmed → 系统提醒注入"禁止再索要,确认收到后继续解答";suspect(校验不过) → "礼貌确认,禁止重新索要"。suspect 不触发邮件、不参与结束 | +| D7 | **评估前置** | 用户消息 <3 条跳过 GLM evaluate 调用(结果必然 pending),纯省成本;邮件标签用[未评估] | +| D8 | **卡单标记** | interested + 无联系方式 → upsert Evaluation(admin 后续可筛,人工兜底的数据基础) | +| D9 | **旧触发路径拆除** | `chat.py:94-97` 与 `chat_rest.py:140-142` 的 ended&&interested→`_try_send_email` 整体删除,与 D1 触发器**同一个 PR**(两套并存=双发邮件)。**批次 1 全部提交在同一分支、合并后一次部署——严禁部署分支中间提交**:Task 6(新触发器生效)与 Task 7(旧路径拆除)之间存在新旧双触发窗口,该窗口只允许存在于开发分支上 | +| D10 | **Evaluation upsert 语义** | `_get_or_init_evaluation` 是唯一建行入口(绝不 INSERT 已存在记录,唯一约束会 IntegrityError);每轮评估落库只更新 intent/reasoning/快照,**永不触碰 email_sent/correction_sent**(原 `_finalize` 已随决策 A 删除) | +| D11 | **话术**(system.md) | ①价值交换固定模板:只承诺"完整报价单",禁止自由发挥价格/时限;②先聊后引:聊天内要两次未果才引导点 WhatsApp 按钮;③拿到联系方式后确认一句、继续解答,不急着收尾 | +| D12 | **邮件转义(S2 提前)** | email_service 全量 `html.escape`(邮件触发频率上升,注入面同步放大,不等健康轨) | +| D13 | **行为变化已批准** | 本计划推翻"不改 widget 行为"红线(2026-08-17 产品决策)。上线时同步清理 `conversation.py:131-134、226-229` 的 app-only 注释与 `chat_rest.py` 模块 docstring 第 12-13 行 | + +**多值/晋升细则(S1/S3/S4 钉死):** +- S1 suspect 只用于"防重复索要"提醒,永不自动晋升;客户后续重发或改发新值时由 extractor/正则重新捕获、重新校验(新值覆盖)。 +- S3 24h 去重多值规则:confirmed 中**任一**值在 24h 内已发过 → 轻提醒(新值会显示在提醒主题里,信息不丢;全部值都未见 → 全量)。 +- S4 正则每轮扫描**全部客户消息历史**(非仅当轮),兜住 GLM 失败轮次漏掉的联系方式。 + +**明确不做(防扩散):** 范式全量重构、超时清理、UI 留资卡片、WS 限流、会话持久化、admin 筛选视图 → 批次 2/3 或健康轨(见 §6)。`process_message`(`conversation.py:70`,grep 确认零调用方的死代码)在 Task 7 随守卫改造**一并删除**(若保留,其对 `_has_real_contact` 的调用会成为悬空引用);Task 7 之前不碰它。 + +## 3. 文件结构 + +| 文件 | 动作 | 职责 | +|---|---|---| +| `app/services/contact_pipeline.py` | 新建 | 纯函数管道:归一化/深合并/校验分级/正则扫描/规范化比对 | +| `app/services/conversation.py` | 修改 | 接线管道、邮件触发器、全来源结束守卫、卡单标记、评估前置、提醒注入、finalize UPDATE、深合并修复 | +| `app/services/email_service.py` | 修改 | 转义、标签、中文标签列、轻提醒/更正邮件、"对话进行中"提示 | +| `app/models/database.py` | 修改 | Evaluation 加 `correction_sent` 列 | +| `app/main.py` | 修改 | `_migrate` 加 correction_sent 的 PRAGMA 迁移 | +| `app/api/routes/chat.py` | 修改 | 删除 `_try_send_email` 与触发块 | +| `app/api/routes/chat_rest.py` | 修改 | 删除触发块与 lazy import,更新 docstring | +| `app/prompts/system.md` | 修改 | D11 话术 | +| `scripts/baseline_stats.py` | 新建 | 批次 0.5 基线统计(只读) | +| `tests/test_contact_pipeline.py` | 新建 | 管道单元测试 | +| `tests/test_lead_email_trigger.py` | 新建 | 触发器/结束守卫/卡单集成测试(FakeAI + async_db) | +| `tests/test_chat_rest_message.py` | 修改 | 删除钉死旧触发路径的 3 个测试(被新测试取代) | + +> ⚠️ **行号锚点使用规则**:文中行号是计划撰写时(当前 HEAD)的定位辅助。Task 5/6 会向 `conversation.py` 插入代码,**后续任务(Task 7/8)引用的行号会漂移约 20 行**——实施时一律以任务中引用的**代码内容**定位(如 `should_end`、`_has_real_contact` 方法名),禁止盲改行号区域。 + +--- + +## 批次 0.5:基线统计 + +### Task 0: 基线脚本 + +**Files:** +- Create: `scripts/baseline_stats.py` + +- [ ] **Step 0.1: 写脚本(只读,不写库)** + +```python +"""批次0.5 基线统计:上线前存档,上线后同口径对比。只读。""" +import re +import sqlite3 +import sys + +DB = sys.argv[1] if len(sys.argv) > 1 else "./data/cc_ai_kefu.db" + +EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") +INTL_RE = re.compile(r"\+\d{6,15}") + + +def main() -> None: + conn = sqlite3.connect(DB) + cur = conn.cursor() + + # 1) 历史已发邮件中无联系方式占比(按 source) + rows = cur.execute( + """SELECT c.source, + COUNT(*) AS sent, + SUM(CASE WHEN COALESCE(e.contact_info, '{}') IN ('{}', 'null', '') + OR e.contact_info IS NULL THEN 1 ELSE 0 END) AS no_contact + FROM evaluations e JOIN conversations c ON c.id = e.conversation_id + WHERE e.email_sent = 1 GROUP BY c.source""" + ).fetchall() + print("== 已发邮件联系方式缺失(按来源)==") + for source, sent, no_contact in rows: + pct = (no_contact / sent * 100) if sent else 0 + print(f" {source}: 发送 {sent}, 缺失 {no_contact} ({pct:.0f}%)") + + # 2) 用户消息中含联系方式样式的会话占比(新策略邮件量上界预估) + convs = cur.execute("SELECT id FROM conversations").fetchall() + with_contact = early_contact = total = 0 + for (cid,) in convs: + msgs = cur.execute( + "SELECT role, content FROM messages WHERE conversation_id=? ORDER BY created_at", + (cid,), + ).fetchall() + user_msgs = [m for m in msgs if m[0] == "user"] + if not user_msgs: + continue + total += 1 + hit_idx = next( + (i for i, m in enumerate(user_msgs) + if EMAIL_RE.search(m[1]) or INTL_RE.search(m[1])), + None, + ) + if hit_idx is not None: + with_contact += 1 + if hit_idx < 2: + early_contact += 1 + print("== 新策略邮件量预估 ==") + print(f" 有用户消息的会话: {total}") + print(f" 消息中含联系方式样式: {with_contact}" + f" ({with_contact / total * 100 if total else 0:.0f}%) ← 全量邮件上界") + print(f" 联系方式出现在前2条消息内: {early_contact}" + f" ({early_contact / total * 100 if total else 0:.0f}%) ← 即时触发增量") + + conn.close() + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 0.2: 运行并存档** + +Run: `python scripts/baseline_stats.py | tee docs/superpowers/plans/2026-08-17-baseline.txt` +Expected: 打印三组数字(存档文件用于上线后对比) + +- [ ] **Step 0.3: Commit** + +```bash +git add scripts/baseline_stats.py docs/superpowers/plans/2026-08-17-baseline.txt +git commit -m "chore(baseline): 询盘邮件改造前基线统计" +``` + +--- + +## 批次 1:核心实现 + +### Task 1: contact_pipeline — 归一化 / 深合并 / 校验分级 + +**Files:** +- Create: `app/services/contact_pipeline.py` +- Test: `tests/test_contact_pipeline.py` + +- [ ] **Step 1.1: 写失败测试** + +```python +from app.services.contact_pipeline import ( + merge_contacts, normalize_contacts, split_validate, +) + + +def test_normalize_chinese_and_english_keys(): + raw = {"手机": "+8613800138000", "邮箱": "a@b.com", "微信": "wxid_1", + "WhatsApp号码": "+254712345678", "空": ""} + out = normalize_contacts(raw) + assert out == { + "phone": "+8613800138000", "email": "a@b.com", + "wechat": "wxid_1", "whatsapp": "+254712345678", + } + + +def test_normalize_later_alias_wins_on_same_key(): + # 同 key 别名冲突时后写者覆盖(上游 extractor 单轮内一般不会同时给 手机 与 tel, + # 但语义必须被钉死并被测试) + assert normalize_contacts({"手机": "1111111", "tel": "2222222"}) == {"phone": "2222222"} +``` + +```python +def test_merge_new_value_wins_same_key(): + old = {"phone": "+254700000000"} + new = {"手机": "+254711111111"} # 客户改号 + assert merge_contacts(old, new) == {"phone": "+254711111111"} + + +def test_merge_empty_new_keeps_old(): + # T1 场景: extractor 某轮返回空联系方式,不得抹掉已有值 + old = {"phone": "+254700000000"} + assert merge_contacts(old, {}) == {"phone": "+254700000000"} + assert merge_contacts(old, None) == {"phone": "+254700000000"} + + +def test_split_validate_confirmed_and_suspect(): + confirmed, suspect = split_validate({ + "email": "a@b.com", # 合法邮箱 + "phone": "+254 712 345 678", # 数字 12 位 → confirmed + "whatsapp": "12345", # 数字 5 位 → suspect + }) + assert confirmed == {"email": "a@b.com", "phone": "+254 712 345 678"} + assert suspect == {"whatsapp": "12345"} + + +def test_split_validate_bad_email_is_suspect(): + confirmed, suspect = split_validate({"email": "not-an-email"}) + assert confirmed == {} and suspect == {"email": "not-an-email"} + + +def test_split_validate_wechat_id_is_confirmed(): + # 微信号无数字格式(wxid_x 等),非空≥4字符即 confirmed——否则只留微信的 + # 客户永远无法触发邮件 + confirmed, suspect = split_validate({"wechat": "wxid_1"}) + assert confirmed == {"wechat": "wxid_1"} and suspect == {} + + +def test_split_validate_wechat_short_is_suspect(): + confirmed, suspect = split_validate({"wechat": "abc"}) + assert confirmed == {} and suspect == {"wechat": "abc"} + + +def test_normalize_coerces_int_values(): + # extractor 偶尔会把号码输出成 int,不得崩溃且要能归一化 + assert normalize_contacts({"手机": 254712345678}) == {"phone": "254712345678"} + + +def test_merge_none_old_keeps_new(): + assert merge_contacts(None, {"email": "a@b.com"}) == {"email": "a@b.com"} +``` + +- [ ] **Step 1.2: 运行确认失败** + +Run: `pytest tests/test_contact_pipeline.py -v` +Expected: FAIL(ModuleNotFoundError) + +- [ ] **Step 1.3: 实现 contact_pipeline.py(归一化/合并/校验部分)** + +```python +"""统一联系方式管道:联系方式检测的唯一事实源。 + +三个动作: +- 归一化:中英文 key 别名统一为 phone/whatsapp/email/wechat +- 深合并:同 key 新值覆盖旧值(客户改号场景),空值不覆盖 +- 校验分级:confirmed(格式通过,可触发邮件/结束)/ suspect(疑似, + 仅用于提醒 AI 礼貌确认,防止重复索要——D6/S1) + +上游约定:raw_info 中 "_" 前缀 key 为内部状态,禁止进入提示词渲染。 +""" +import re + +KEY_ALIASES = { + "phone": "phone", "手机": "phone", "电话": "phone", "电话号码": "phone", + "tel": "phone", "telephone": "phone", "phone_number": "phone", + "phone number": "phone", "mobile": "phone", "mobile_number": "phone", + "mobile number": "phone", + "whatsapp": "whatsapp", "wa": "whatsapp", "whats app": "whatsapp", + "whatsapp号码": "whatsapp", "whatsapp number": "whatsapp", + "email": "email", "邮箱": "email", "e-mail": "email", "mail": "email", + "电子邮件": "email", + "wechat": "wechat", "微信": "wechat", "wx": "wechat", "weixin": "wechat", +} + + +def normalize_contacts(raw: dict | None) -> dict: + """key 归一化 + 去空值。同 key 别名冲突时后写者覆盖。""" + out: dict[str, str] = {} + for k, v in (raw or {}).items(): + key = KEY_ALIASES.get(str(k).strip().lower()) + val = str(v or "").strip() + if key and val: + out[key] = val + return out + + +def merge_contacts(old: dict | None, new: dict | None) -> dict: + """同 key 新值覆盖旧值;空/缺省不覆盖(堵浅合并抹数据 bug)。""" + return {**normalize_contacts(old), **normalize_contacts(new)} + + +def digits_only(s: str) -> str: + """提取纯数字(split_validate 位数判断与后续 canon 复用)。""" + return re.sub(r"\D", "", s) + + +def split_validate(contacts: dict) -> tuple[dict, dict]: + """返回 (confirmed, suspect)。电话/WhatsApp 取数字 7-15 位为合格; + 微信号无数字格式,取非空 ≥4 字符为合格(否则 wxid_x 类真实微信号 + 会永远滞留 suspect,只留微信的客户无法触发邮件——违背 D1)。""" + confirmed: dict[str, str] = {} + suspect: dict[str, str] = {} + for k, v in normalize_contacts(contacts).items(): + if k == "email": + ok = re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", v) is not None + elif k == "wechat": + ok = len(v.strip()) >= 4 + else: + ok = 7 <= len(digits_only(v)) <= 15 + (confirmed if ok else suspect)[k] = v + return confirmed, suspect +``` + +(`canon()` 的定义已移至 Task 6——它只被去重逻辑消费,与本任务的纯函数无关。) + +- [ ] **Step 1.4: 运行确认通过** + +Run: `pytest tests/test_contact_pipeline.py -v` +Expected: 7 PASS(6 项基础用例 + wechat 修正用例) + +- [ ] **Step 1.5: Commit** + +```bash +git add app/services/contact_pipeline.py tests/test_contact_pipeline.py +git commit -m "feat(pipeline): 联系方式归一化/深合并/校验分级" +``` + +### Task 2: contact_pipeline — 正则扫描(高置信,S4) + +**Files:** +- Modify: `app/services/contact_pipeline.py` +- Test: `tests/test_contact_pipeline.py` + +- [ ] **Step 2.1: 写失败测试(含误报反例——二手车语境全是数字)** + +```python +from app.services.contact_pipeline import scan_user_messages + + +def m(*texts): + return [{"role": "user", "content": t} for t in texts] + [ + {"role": "assistant", "content": "reply"}] + + +def test_scan_email(): + found = scan_user_messages(m("my email is john.doe@x.co send quotes")) + assert found == {"email": "john.doe@x.co"} + + +def test_scan_intl_phone(): + found = scan_user_messages(m("you can reach me +254712345678")) + assert found == {"phone": "+254712345678"} + + +def test_scan_whatsapp_context_word(): + found = scan_user_messages(m("whatsapp: 0712 345 678")) + assert found.get("whatsapp") == "0712 345 678" + + +def test_scan_wa_me_link(): + found = scan_user_messages(m("chat https://wa.me/254712345678")) + assert found == {"whatsapp": "254712345678"} + + +def test_scan_ignores_prices_years_and_ai_messages(): + texts = m( + "budget $25,000 for 2024 BYD Tang", # 价格+年份 + "50,000 km mileage", # 里程 + "I want 5 units", # 数量 + ) + # 上一条含 assistant 消息,确保只扫 user + found = scan_user_messages(texts) + assert found == {} + + +def test_scan_full_history_not_only_last(): + # S4: 扫全量历史,早轮的联系方式也要能捞到 + msgs = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hey"}, + {"role": "user", "content": "mail me a@b.com"}, + {"role": "assistant", "content": "got it"}, + {"role": "user", "content": "how much is tang"}, + ] + assert scan_user_messages(msgs) == {"email": "a@b.com"} + + +def test_scan_context_word_does_not_grab_unrelated_numbers(): + # S1: 上下文词必须与数字邻接(同一匹配内),消息任意位置的7位数字不得被抓 + assert scan_user_messages(m("call me on delivery, order 1234567")) == {} + assert scan_user_messages(m("车架号码是 LFV3A23C8K3123456 吗")) == {} + assert scan_user_messages(m("engine number 2ZRFE1234567?")) == {} + assert scan_user_messages(m("我手机坏了 budget 1500000 ksh")) == {} + + +def test_scan_adjacent_context_phone(): + assert scan_user_messages(m("my phone number is 0712 345 678")) == { + "phone": "0712 345 678"} + assert scan_user_messages(m("call me at +86 138 0013 8000")) == { + "phone": "+86 138 0013 8000"} +``` + +- [ ] **Step 2.2: 运行确认失败** + +Run: `pytest tests/test_contact_pipeline.py -v` +Expected: 新增用例 FAIL(scan_user_messages 未定义) + +- [ ] **Step 2.3: 实现扫描器(追加到 contact_pipeline.py)** + +```python +EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") +INTL_PHONE_RE = re.compile(r"\+\d{6,15}") +# 邻接绑定:上下文词与数字必须在同一匹配内(仅空白衔接)——禁止"消息任意位置 +# 有上下文词 + 任意位置有 7 位数字"的消息级误绑(S1:车架号/订单号/先令价格) +WHATSAPP_CTX_RE = re.compile( + r"(?:whats\s*app|wa\.?me)\D{0,12}(\+?\d[\d\s\-().]{5,17}\d)", + re.IGNORECASE, +) +PHONE_CTX_RE = re.compile( + r"(?:my\s+phone\s+(?:number\s+)?is|my\s+number\s+is|" + r"call\s+me\s+(?:at|on)|reach\s+me\s+(?:at|on)|" + r"phone\s*[::]|number\s*[::]|号码\s*[::]|手机号?\s*[::]|电话\s*[::])\s*" + r"(\+?\d[\d\s\-().]{5,17}\d)", + re.IGNORECASE, +) + + +def scan_user_messages(messages: list[dict]) -> dict: + """S4: 扫描全部客户消息,返回高置信捕获(email / +国际号 / whatsapp·wa.me / + 显式电话上下文邻接数字)。裸数字(价格/年份/里程/车架号)一律不收。""" + found: dict[str, str] = {} + for msg in messages: + if msg.get("role") != "user": + continue + text = msg.get("content", "") + if "email" not in found: + hit = EMAIL_RE.search(text) + if hit: + found["email"] = hit.group(0) + if "phone" not in found: + hit = INTL_PHONE_RE.search(text) + if hit: + found["phone"] = hit.group(0) + if "whatsapp" not in found: + hit = WHATSAPP_CTX_RE.search(text) + if hit: + found["whatsapp"] = hit.group(1) + if "phone" not in found: + hit = PHONE_CTX_RE.search(text) + if hit: + found["phone"] = hit.group(1) + return found +``` + +- [ ] **Step 2.4: 运行确认通过** + +Run: `pytest tests/test_contact_pipeline.py -v` +Expected: 19 PASS(11 存量 + 8 本任务,含 S1 邻接反例与邻接正向用例) + +- [ ] **Step 2.5: Commit** + +```bash +git add app/services/contact_pipeline.py tests/test_contact_pipeline.py +git commit -m "feat(pipeline): 高置信正则扫描(仅客户消息/全量历史/价格年份不误捕)" +``` + +### Task 3: Evaluation 加 correction_sent 列 + 迁移(D4) + +**Files:** +- Modify: `app/models/database.py:109-126` +- Modify: `app/main.py:16-22` + +- [ ] **Step 3.1: database.py 的 Evaluation 类加列**(`email_sent` 行后) + +```python + correction_sent: Mapped[bool] = mapped_column(Boolean, default=False) +``` + +- [ ] **Step 3.2: main.py 的 `_migrate` 追加迁移**(page_token 块之后) + +```python + result = await conn.execute(text("PRAGMA table_info(evaluations)")) + columns = {row[1] for row in result} + if "correction_sent" not in columns: + await conn.execute(text( + "ALTER TABLE evaluations ADD COLUMN correction_sent BOOLEAN DEFAULT 0" + )) +``` + +- [ ] **Step 3.3: 验证未破坏** + +Run: `pytest tests/test_smoke.py -v` +Expected: PASS(create_all 在全新库上含新列;PRAGMA ALTER 分支与既有 page_token 迁移一样无测试覆盖,属既有模式,接受——不要把本步当作 ALTER 分支的验证) + +- [ ] **Step 3.4: Commit** + +```bash +git add app/models/database.py app/main.py +git commit -m "feat(db): evaluations.correction_sent 列 + PRAGMA 迁移" +``` + +### Task 4: email_service — 转义 / 标签 / 轻提醒 / 更正邮件(D2/D4/D12) + +**Files:** +- Modify: `app/services/email_service.py` +- Test: `tests/test_lead_email_trigger.py`(本 Task 只改实现,集成验证在 Task 7) + +- [ ] **Step 4.0: 写失败测试(先红)** + +Create `tests/test_email_rendering.py`(monkeypatch 模块内 `_send`,不触网;断言主题标签、HTML 转义、内部 key 过滤): + +```python +"""邮件渲染单测: 标签主题/HTML转义/内部key过滤(D2/D12)。不触网。""" +import asyncio + +from app.services import email_service + + +def _capture_send(monkeypatch): + captured = {} + + async def fake_send(msg): + captured["msg"] = msg + return True + + monkeypatch.setattr(email_service, "_send", fake_send) + return captured + + +def _html(msg) -> str: + return msg.get_payload()[0].get_payload(decode=True).decode("utf-8") + + +def test_lead_email_subject_tag_and_escaped_body(monkeypatch): + captured = _capture_send(monkeypatch) + ok = asyncio.run(email_service.send_lead_email( + customer_profile={"目标国家": "肯尼亚", "_internal": "x"}, + contact_info={"phone": "+254712345678"}, + reasoning="强意向", + conversation_messages=[ + {"role": "user", "content": ""}, + {"role": "assistant", "content": "ok"}, + ], + intent_level="interested", + )) + assert ok is True + subject = str(captured["msg"]["Subject"]) + assert "[新询盘·意向强]" in subject and "肯尼亚" in subject + body = _html(captured["msg"]) + assert " +``` + +L12:`
🤖
` → `
🤖
` +L36/L40:`stroke="#8C4D1D"` → `stroke="currentColor"` +L50:删除整行(``) + +- [ ] **Step 2: chat.js——文件顶部追加 bootstrap(在现有逻辑之前)** + +```javascript +// ---- 动态主题色 bootstrap(spec §3):?color= → 派生 → :root 变量 ---- +(function () { + if (typeof ChatTheme === "undefined") return; // theme.js 加载失败→style.css :root 默认橙兜底 + var m = /(?:^|[?&])color=([^&]*)/.exec(location.search); + var palette = ChatTheme.derivePalette(m ? decodeURIComponent(m[1]) : ChatTheme.DEFAULT_COLOR); + var root = document.documentElement.style; + root.setProperty("--w-primary", palette.primary); + root.setProperty("--w-primary-rgb", palette.primaryRgb); + root.setProperty("--w-gradient-end", palette.gradientEnd); + root.setProperty("--w-deep-text", palette.deepText); + root.setProperty("--w-caption-text", palette.captionText); + root.setProperty("--w-soft-bg", palette.softBg); + root.setProperty("--w-border-soft", palette.borderSoft); + + // 头像兜底(与挂件同机制) + Array.prototype.forEach.call(document.querySelectorAll(".avatar-img"), function (img) { + function hideEmoji() { + var s = img.parentElement && img.parentElement.querySelector(".avatar-emoji"); + if (s) s.style.display = "none"; + } + if (img.complete && img.naturalWidth > 0) { hideEmoji(); return; } + img.addEventListener("load", hideEmoji); + img.addEventListener("error", function () { img.remove(); }); + }); +})(); +``` + +- [ ] **Step 3: style.css 追加头像规则(若 Task 5 未含)** + +```css +.avatar-emoji { line-height: 1; } +.avatar-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; border-radius: 50%; } +``` + +- [ ] **Step 4: 全量 pytest + Commit** + +Run: `python -m pytest tests/ -q` → Expected: 全 PASS + +```bash +git add static/chat.html static/js/chat.js static/css/style.css +git commit -m "feat(theme): 独立页接入——?color=参数+theme.js+头像图片+SVG currentColor" +``` + +--- + +### Task 10: widget-test.html 重写(预览 + 断言面板) + +**Files:** +- Modify: `static/widget-test.html`(整页重写) + +- [ ] **Step 1: 重写页面——保留原说明,新增控制区与断言区** + +结构:原 info 卡片保留;`` 中**必须**加 ``(断言面板依赖 `window.ChatTheme`,漏掉则面板永远显示钩子未就绪);新增: + +```html +
+

Theme Color Preview

+

+ + + + + +

+
+
+

Assertion Panel

+
+
+``` + +- [ ] **Step 2: teardown/重建逻辑(spec §7.1 陷阱;基础属性对象 + extra 合并,缓存戳防缓存)** + +```javascript + var BASE_ATTRS = { + "data-position": "bottom-right", + "data-theme": "light", + "data-mobile": "true", + "data-debug": "true", + "data-whatsapp": "https://wa.me/8613800138000", + "data-email": "mailto:demo@hesu-auto.com" + }; + + function rebuildWidget(extra) { + var host = document.getElementById("cc-widget-host"); + if (host) host.remove(); + document.querySelectorAll("script[src*='widget.js']").forEach(function (s) { s.remove(); }); + try { sessionStorage.removeItem("widget_invite_dismissed_" + window.location.pathname); } catch (e) {} + var s = document.createElement("script"); + s.src = "/static/widget.js?" + Date.now(); + var attrs = Object.assign({}, BASE_ATTRS, extra || {}); + Object.keys(attrs).forEach(function (k) { s.setAttribute(k, attrs[k]); }); + s.onload = runAssertions; + document.body.appendChild(s); + } + function applyColor(c) { + rebuildWidget({ "data-color": c || document.getElementById("colorPicker").value }); + } + function applyBrokenAvatar() { + rebuildWidget({ "data-color": "#1a73e8", "data-avatar": "/static/nope.png" }); + } +``` + +- [ ] **Step 3: 断言面板(spec §8.2;页面加载后自动跑一次)** + +```javascript + var CURRENT_HARDCODED = { // 现状代表值(spec §4),Δ ≤ 8/通道 + primary: "#f08a31", gradientEnd: "#F08A34", deepText: "#8C4D1D", + captionText: "#C16C2E", softBg: "#FFF8F3", borderSoft: "#F0D5BF" + }; + function hexToRgb6(h) { /* 复用 theme.js 的 hexToRgb 逻辑或简化实现 */ } + function channelDelta(a, b) { /* 返回 max 通道差 */ } + function runAssertions() { + var rows = []; + function check(name, ok, detail) { rows.push((ok ? "✅ " : "❌ ") + name + (detail ? " — " + detail : "")); } + var t = window.ChatTheme, d = window.__chatWidgetDebug; + if (!t || !d) { rows.push("❌ theme.js / widget debug 钩子未就绪"); } + else { + var p = t.derivePalette("#f08a31"); + Object.keys(CURRENT_HARDCODED).forEach(function (k) { + var delta = channelDelta(p[k], CURRENT_HARDCODED[k]); + check("default " + k + " Δ≤8 (" + p[k] + " vs " + CURRENT_HARDCODED[k] + ")", delta <= 8, "Δ=" + delta); + }); + check("derive(default) === DEFAULT_PALETTE", + JSON.stringify(p) === JSON.stringify(t.DEFAULT_PALETTE)); + check("inline copy === theme.js copy", + d.derivePalette.toString().replace(/\s+/g, "") === t.derivePalette.toString().replace(/\s+/g, "")); + check("invalid color → DEFAULT_PALETTE", + JSON.stringify(t.derivePalette("not-a-color")) === JSON.stringify(t.DEFAULT_PALETTE)); + } + document.getElementById("assertions").innerHTML = rows.map(function (r) { return "
" + r + "
"; }).join(""); + } +``` + +页面初始嵌入(替代原 L69 的静态 script 标签)同样带 `data-debug="true"` 并挂 `onload="runAssertions()"`,属性与 BASE_ATTRS 一致。 + +- [ ] **Step 4: 手动验收(浏览器,无自动化,如实记录结果)** + +1. 打开 `/static/widget-test.html`:断言面板全 ✅ +2. 默认橙:邀请卡(1.8s)+ 聊天面板 + 头像 5 处中挂件侧 4 处,与改造前截图对比无可见差异 +3. Blue/Green/Purple:全套配色跟随,无橙色残留 +4. Test Broken Avatar:头像回退 🤖 +5. `/`(独立页)与 `/?color=1a73e8`:独立页跟随;头像替换;SVG 图标为深主题色 +6. `?color=red`(非法):回退默认橙,console 有 warn + +- [ ] **Step 5: Commit** + +```bash +git add static/widget-test.html +git commit -m "feat(test): widget-test重写——换色预览+teardown重建+浏览器断言面板" +``` + +--- + +### Task 11: changelog 文档 + spec 校准 + 终验 + +**Files:** +- Create: `docs/2026-08-31-dynamic-theme-changelog.md` +- Modify: `docs/superpowers/specs/2026-08-31-dynamic-theme-color-design.md`(三处校准) + +- [ ] **Step 1: 写 changelog(按仓库惯例,含嵌入方用法)** + +```markdown +# 2026-08-31 动态主题色 + 头像图片化 + +## 嵌入方用法 +- 品牌色:` +``` + +### Widget技术实现 + +Widget JS加载后:创建 Shadow DOM(样式隔离) → 注入悬浮气泡按钮 → 点击后通过WebSocket连接后端 → 渲染聊天界面。 + +## 5. AI服务设计 + +### 模型适配层 + +```python +class AIProvider(ABC): + async def chat(messages, system_prompt) -> str + async def chat_with_extraction(messages, system_prompt, extract_fields) -> tuple[str, dict] + +class GLMProvider(AIProvider) # 智谱GLM(初期) +class OpenAIProvider(AIProvider) # 预留 +class DeepSeekProvider(AIProvider) # 预留 +``` + +### 三轮调用机制 + +每轮客户消息到达后,后端做三件事(对客户透明): + +1. **主对话调用** → AI回复客户(自然对话,引导收集信息) +2. **信息提取调用**(后台静默)→ 从对话历史中提取已获得的信息,更新raw_info +3. **意向判断调用**(后台静默)→ 评估当前信息是否足够,决定是否结束对话 + +第2、3步使用同一个GLM模型和完整prompt,确保提取和判断质量。第3步判断信息足够时,主对话引导收尾并提取联系方式。 + +### Prompt策略 + +- **System Prompt** — AI角色为「二手车顾问」,对话风格专业但亲切,发问策略自然引导 +- **信息提取Prompt** — 从对话历史中提取结构化信息,输出JSON +- **评估Prompt** — 根据已有信息判断意向等级,输出intent_level + 理由 + +### 会话上下文管理 + +- 短期上下文:Redis缓存最近N轮对话(实时对话用) +- 完整历史:每轮对话后持久化到数据库 +- 上下文窗口控制:对话过长时自动摘要早期对话,避免token超限 + +### 防流失机制 + +- 客户关闭页面后,Redis会话保留24小时 +- 同一浏览器再次打开,自动恢复上次对话 +- 对话中断但已收集部分信息,标记为`pending`,不发邮件但记录归档 + +## 6. 邮件推送 + +### 流程 + +``` +AI判定意向 = interested + │ + ▼ + 组装邮件内容 + ├── 客户画像摘要(AI生成) + ├── 关键需求提取(预算、偏好、用途等) + ├── 联系方式(手机/微信/邮箱) + ├── 完整对话记录(可折叠) + └── 评估理由 + │ + ▼ + SMTP发送 → 目标邮箱 + │ + ▼ + 记录发送状态(成功/失败/重试) +``` + +### 配置(环境变量) + +- `SMTP_HOST` / `SMTP_PORT` — SMTP服务器 +- `SMTP_USER` / `SMTP_PASS` — 认证信息 +- `NOTIFY_EMAIL` — 目标通知邮箱 + +## 7. 项目结构 + +``` +cc_ai_kefu/ +├── docker-compose.yml +├── Dockerfile +├── .env.example +├── app/ +│ ├── main.py # FastAPI入口 +│ ├── config.py # 配置管理 +│ ├── api/ +│ │ ├── routes/ +│ │ │ ├── chat.py # WebSocket聊天端点 +│ │ │ └── webhook.py # 回调/状态查询API +│ │ └── deps.py # 依赖注入 +│ ├── services/ +│ │ ├── ai_service.py # AI模型适配层 +│ │ ├── conversation.py # 对话管理(三轮调用) +│ │ ├── extractor.py # 信息提取 +│ │ ├── evaluator.py # 意向评估 +│ │ └── email_service.py # 邮件发送 +│ ├── models/ +│ │ ├── conversation.py # SQLAlchemy模型 +│ │ └── schemas.py # Pydantic schemas +│ ├── providers/ +│ │ ├── base.py # AIProvider基类 +│ │ └── glm.py # 智谱GLM实现 +│ └── prompts/ +│ ├── system.md # 主对话system prompt +│ ├── extractor.md # 信息提取prompt +│ └── evaluator.md # 评估prompt +├── static/ # 前端静态文件 +│ ├── chat.html # 独立聊天页面 +│ ├── css/ +│ ├── js/ +│ │ └── chat.js # 聊天核心逻辑 +│ └── widget.js # 外部嵌入Widget脚本 +├── migrations/ # 数据库迁移 +└── tests/ +``` + +## 8. 技术栈 + +| 层 | 选型 | +|---|---| +| 后端框架 | FastAPI + Uvicorn | +| 数据库 | SQLite(初版)/ PostgreSQL(生产) | +| 缓存 | Redis | +| ORM | SQLAlchemy + Alembic | +| AI SDK | 智谱GLM SDK(zhipuai) | +| 邮件 | aiosmtplib(异步SMTP) | +| 前端 | 原生HTML/CSS/JS,无框架 | +| 部署 | Docker Compose(FastAPI + Redis) | diff --git a/docs/superpowers/specs/2026-04-20-prompt-refinement-design.md b/docs/superpowers/specs/2026-04-20-prompt-refinement-design.md new file mode 100644 index 0000000..e4bb6eb --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-prompt-refinement-design.md @@ -0,0 +1,40 @@ +# AI对话风格优化 + 联系方式引导 + +## 背景 +用户反馈AI客服回复"AI味重",需要更自然;同时需要在对话中拿到客户联系方式才算有效客户。 + +## 问题 +1. **客服套话多** — "感谢咨询""很高兴为您服务"等模板话术 +2. **回复太长** — 经常写2-3个长句,不像即时聊天 +3. **要联系方式不够主动** — 只有一句模糊的"自然引导",没有具体策略 + +## 方案 +只改 `app/prompts/system.md`,不动代码。 + +### 改动内容 + +#### 1. 加禁止套话规则 +明确列出禁止使用的句式: +- "感谢/谢谢您的咨询/关注/提问" +- "我很高兴/荣幸为您服务/帮助" +- "请随时/不要犹豫联系我" +- "希望以上信息对您有帮助" +- "如果您有任何其他问题" +- 任何以"您好"开头的句子(开场白除外) + +#### 2. 加联系方式引导策略 +- 3-4轮对话后,以"转接专业销售"为理由要联系方式 +- 给具体话术示例,让模型知道怎么自然地说 +- 拿到联系方式后不再重复要 + +#### 3. 加强长度控制 +- 从"2-3句话"改为"1-2个短句" +- 明确要求像 WhatsApp/微信聊天的长度 + +#### 4. 加正例/反例 +让模型直观理解什么是自然、什么是套话。 + +## 不改动的部分 +- `extractor.md` — 提取逻辑不变 +- `evaluator.md` — 评估逻辑不变(已有联系方式作为interested必要条件) +- 任何代码文件 diff --git a/docs/superpowers/specs/2026-04-22-chat-widget-redesign.md b/docs/superpowers/specs/2026-04-22-chat-widget-redesign.md new file mode 100644 index 0000000..886201c --- /dev/null +++ b/docs/superpowers/specs/2026-04-22-chat-widget-redesign.md @@ -0,0 +1,458 @@ +# 聊天窗口和入口按钮重新设计 + +**日期**: 2026-04-22 +**项目**: AI 客服系统 - VOLT/HESU +**目标**: 重新设计聊天窗口入口按钮和聊天界面样式 + +--- + +## 一、设计概述 + +### 1.1 项目背景 +当前 AI 客服系统的聊天窗口样式需要更新,以提供更专业、现代的用户体验。主要涉及: +- 悬浮入口按钮样式优化 +- 聊天窗口布局重新设计 +- 交互细节完善 + +### 1.2 设计目标 +- 提供专业、友好的客服入口体验 +- 优化聊天窗口的信息层级 +- 改善用户交互流程 +- 保持与品牌橙色主题的一致性 + +--- + +## 二、入口按钮设计 + +### 2.1 样式规范 + +| 属性 | 值 | +|------|-----| +| 类型 | 横条卡片式 | +| 尺寸 | 宽度约 180px,高度自动适应内容 | +| 圆角 | 30px | +| 背景 | #f08a31(品牌橙色) | +| 阴影 | 0 4px 16px rgba(240,138,49,0.4) | +| 悬停效果 | 放大 1.1 倍 | + +### 2.2 内容构成 + +``` +┌─────────────────────────────┐ +│ [📞 图标] 在线咨询 │ +│ 即时回复 │ +└─────────────────────────────┘ +``` + +- **左侧**: 圆形图标区域(36x36px,半透明白色背景) +- **右侧**: + - 主标题:"在线咨询"(14px,加粗,白色) + - 副标题:"即时回复"(11px,白色,90% 透明度) + +### 2.3 位置规范 +- 默认位置:右下角(bottom: 20px, right: 20px) +- 可配置位置:bottom-left, top-right, top-left +- z-index: 99999(确保在最顶层) + +--- + +## 三、聊天窗口设计 + +### 3.1 窗口规格 + +| 属性 | 值 | +|------|-----| +| 宽度 | 400px | +| 高度 | 自适应,最小高度 480px | +| 圆角 | 16px | +| 阴影 | 0 10px 40px rgba(0,0,0,0.15) | +| 最大高度 | 80vh | +| 字体 | -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto | + +### 3.2 头部区域 + +#### 3.2.1 布局 +- 高度:约 68px(padding 14px 18px) +- 背景:#f08a31(品牌橙色渐变) +- 文字颜色:白色 + +#### 3.2.2 元素构成 + +``` +┌─────────────────────────────────────┐ +│ [🚗 头像+绿点] HESU Support [−] [✕] │ +│ Online now · Usually │ +│ replies in minutes │ +└─────────────────────────────────────┘ +``` + +| 元素 | 规格 | +|------|------| +| 头像 | 48x48px 圆形,汽车 emoji 🚗,半透明白色背景 | +| 在线状态点 | 14px,绿色 #22c55e,3px 橙色边框,右下角定位 | +| 标题 | 17px,加粗,"HESU Support" | +| 状态文字 | 13px,"Online now · Usually replies in minutes",95% 透明度 | +| 操作按钮 | 32x32px,圆角 8px,半透明白色背景 15% | + +### 3.3 消息区域 + +#### 3.3.1 基础规范 +- 内边距:20px +- 背景:#ffffff 白色 +- 最小高度:260px +- 圆角:0(与窗口顶部对齐) + +#### 3.3.2 "How can we help?" 区块 + +``` +┌─────────────────────────────────────┐ +│ How can we help? │ +│ Ask about vehicles, market support, │ +│ quotation, shipping and delivery. │ +│ [📞 Call us] │ +└─────────────────────────────────────┘ +``` + +| 属性 | 值 | +|------|-----| +| 背景 | #fef9f3(浅黄橙色) | +| 边框 | 1px,#fef3c7 | +| 圆角 | 12px | +| 内边距 | 16px | +| 外边距 | 底部 20px | +| 布局 | 左侧内容 + 右侧按钮 | + +| 元素 | 规格 | +|------|------| +| 标题 | 17px,加粗,#111827 | +| 描述 | 13px,#6b7280,行高 1.4 | +| Call us 按钮 | #f08a31 背景,9px 18px 内边距,10px 圆角,📞 图标 15px | + +**交互**: Call us 按钮点击跳转到 https://wa.me/+8619037179250 + +#### 3.3.3 欢迎消息 + +``` +┌─────────────────────────────────────┐ +│ [🚗] Hello. Welcome to HESU. I can │ +│ help with stock vehicles, │ +│ quotation, delivery timing │ +│ and distributor cooperation. │ +└─────────────────────────────────────┘ +``` + +| 属性 | 值 | +|------|-----| +| AI 头像 | 32x32px 圆形,#f08a31 背景 | +| 消息气泡 | #f3f4f6 背景,16px 圆角,左下角 4px 直角 | +| 文字 | 14px,#1f2937,行高 1.5 | +| 最大宽度 | 280px | +| 间距 | 头像与气泡 10px | + +### 3.4 分隔线 + +| 属性 | 值 | +|------|-----| +| 高度 | 1px | +| 颜色 | #f08a31(橙色) | +| 透明度 | 30% | +| 位置 | 消息区与输入区之间 | + +### 3.5 输入区域 + +#### 3.5.1 容器规范 + +| 属性 | 值 | +|------|-----| +| 外边距 | 16px 18px | +| 背景 | 白色 | +| 内边距 | 0(容器本身无内边距) | + +#### 3.5.2 输入容器 + +``` +┌─────────────────────────────────────┐ +│ [ 输入框... ] │ [➤ Send ] │ +└─────────────────────────────────────┘ +``` + +| 属性 | 值 | +|------|-----| +| 背景 | #fef9f3(浅黄橙色) | +| 圆角 | 28px | +| 内边距 | 12px 16px | +| 边框 | 无 | +| 阴影 | 0 1px 3px rgba(0,0,0,0.05) | +| 间距 | 输入框与分隔线 8px | + +#### 3.5.3 输入框 + +| 属性 | 值 | +|------|-----| +| 背景 | 透明 | +| 边框 | 无 | +| 内边距 | 0 | +| 字号 | 14px | +| 颜色 | #1f2937 | +| 占位符 | "Type a message...",#9ca3af | +| 最小高度 | 20px | +| outline | none(聚焦时无特殊样式) | + +#### 3.5.4 分隔线 + +| 属性 | 值 | +|------|-----| +| 宽度 | 1px | +| 高度 | 24px | +| 颜色 | #e5e7eb | +| 位置 | 输入框与按钮之间 | + +#### 3.5.5 发送按钮 + +| 属性 | 值 | +|------|-----| +| 文字 | "➤ Send" | +| 背景 | linear-gradient(135deg, #f08a31, #f59e0b) | +| 内边距 | 8px 16px | +| 圆角 | 12px | +| 字号 | 14px,字重 500 | +| 阴影 | 0 2px 8px rgba(240,138,49,0.25) | +| 箭头图标 | 16px | +| white-space | nowrap(防止文字换行) | + +### 3.6 底部信息栏 + +``` +┌─────────────────────────────────────┐ +│ By sending a message, you agree to │ +│ the privacy policy. AI + human support │ +└─────────────────────────────────────┘ +``` + +| 属性 | 值 | +|------|-----| +| 背景 | 白色 | +| 内边距 | 10px 18px | +| 布局 | justify-content: space-between | +| 间距 | gap: 20px | +| 字号 | 11px | +| 颜色 | #d97706(深橙色) | + +| 元素 | 规格 | +|------|------| +| 左侧 | "By sending a message, you agree to the privacy policy.",flex: 1 | +| 右侧 | "AI + human support",flex-shrink: 0 | + +--- + +## 四、配色方案 + +### 4.1 主色调 + +| 颜色名称 | HEX 用途 | 说明 | +|----------|---------|------| +| 品牌橙 | #f08a31 | 主色、头部背景、按钮背景 | +| 品牌橙深 | #e07a20 | 悬停状态 | +| 橙黄渐变 | #f08a31 → #f59e0b | 发送按钮渐变 | + +### 4.2 辅助色 + +| 颜色名称 | HEX 用途 | 说明 | +|----------|---------|------| +| 浅黄橙 | #fef9f3 | Help 区块、输入容器背景 | +| 浅黄边 | #fef3c7 | Help 区块边框 | +| 橙色文字 | #d97706 | 底部信息栏文字 | +| 浅橙分隔 | #fed7aa | 辅助橙色 | + +### 4.3 中性色 + +| 颜色名称 | HEX 用途 | 说明 | +|----------|---------|------| +| 纯白 | #ffffff | 聊天背景、输入容器 | +| 浅灰 | #f9fafb | 输入框背景、快捷按钮背景 | +| 消息气泡灰 | #f3f4f6 | AI 消息气泡 | +| 边框灰 | #e5e7eb | 分隔线、边框 | +| 分隔线灰 | #e5e7eb | 细线分隔 | + +### 4.4 文字色 + +| 颜色名称 | HEX 用途 | 说明 | +|----------|---------|------| +| 深黑 | #111827 | 标题 | +| 中灰 | #6b7280 | 描述文字 | +| 深灰 | #1f2937 | 正文 | +| 浅灰 | #9ca3af | 占位符 | + +### 4.5 功能色 + +| 颜色名称 | HEX 用途 | 说明 | +|----------|---------|------| +| 在线绿 | #22c55e | 在线状态点 | + +--- + +## 五、交互说明 + +### 5.1 悬浮入口按钮 + +| 交互 | 行为 | +|------|------| +| 悬停 | 放大 1.1 倍 | +| 点击 | 展开/收起聊天窗口 | + +### 5.2 聊天窗口头部 + +| 元素 | 行为 | +|------|------| +| 最小化按钮 (−) | 折叠聊天窗口,保留悬浮入口 | +| 关闭按钮 (✕) | 关闭聊天窗口 | + +### 5.3 Call us 按钮 + +| 交互 | 行为 | +|------|------| +| 点击 | 新标签页打开 WhatsApp 链接:https://wa.me/+8619037179250 | +| target | _blank | + +### 5.4 发送按钮 + +| 交互 | 行为 | +|------|------| +| 点击 | 发送消息到 AI 客服 | +| 悬停 | 可选:放大效果 | + +--- + +## 六、响应式设计 + +### 6.1 移动端适配 + +| 断点 | 调整 | +|------|------| +| 最大宽度 400px | 窗口宽度 | +| 高度 | calc(100vh - 100px) | +| 底部位置 | 距底部 20px | + +### 6.2 字体缩放 + +| 元素 | 移动端 | +|------|--------| +| 标题 | 保持不变 | +| 正文 | 保持不变 | +| 按钮 | 保持不变 | + +--- + +## 七、技术实现要点 + +### 7.1 CSS 关键样式 + +```css +/* 悬浮入口按钮 */ +.toggle-btn { + width: auto; + height: auto; + background: #f08a31; + border-radius: 30px; + padding: 8px 20px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 4px 16px rgba(240,138,49,0.4); + cursor: pointer; + transition: transform 0.2s; +} + +/* 聊天窗口 */ +.chat-window { + width: 400px; + background: white; + border-radius: 16px; + box-shadow: 0 10px 40px rgba(0,0,0,0.15); +} + +/* 输入容器 */ +.input-container { + background: #fef9f3; + border-radius: 28px; + padding: 12px 16px; + display: flex; + align-items: center; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); +} +``` + +### 7.2 需要修改的文件 + +1. **[static/widget.js](static/widget.js)** - 入口按钮样式 +2. **[static/css/style.css](static/css/style.css)** - 聊天窗口样式 +3. **[static/js/chat.js](static/js/chat.js)** - 交互逻辑(如需要) + +--- + +## 八、验收标准 + +### 8.1 视觉验收 + +- [ ] 入口按钮为横条卡片式,包含图标和双行文字 +- [ ] 聊天窗口宽度 400px,圆角 16px +- [ ] 头部橙色背景,包含头像+在线状态+标题+操作按钮 +- [ ] "How can we help?" 区块浅黄背景,左内容右按钮布局 +- [ ] 欢迎消息包含 AI 头像和问候语 +- [ ] 橙色分隔线(1px,30% 透明度) +- [ ] 输入容器浅黄背景,圆角 28px,无边框 +- [ ] 发送按钮为圆角长方形,包含箭头图标和"Send"文字 +- [ ] 底部信息栏橙色文字,左右分开布局 + +### 8.2 交互验收 + +- [ ] 入口按钮悬停放大 +- [ ] Call us 按钮跳转到 WhatsApp +- [ ] 最小化/关闭按钮功能正常 +- [ ] 发送按钮功能正常 + +### 8.3 响应式验收 + +- [ ] 移动端窗口正确显示 +- [ ] 文字大小适配合理 + +--- + +## 九、实施计划 + +### 9.1 实施步骤 + +1. 修改悬浮入口按钮样式(widget.js) +2. 更新聊天窗口 CSS 样式(style.css) +3. 调整输入区域 HTML 结构(chat.html) +4. 更新交互逻辑(chat.js) +5. 测试所有交互功能 +6. 移动端兼容性测试 + +### 9.2 兼容性 + +- 现代浏览器(Chrome, Firefox, Safari, Edge) +- 移动端浏览器(iOS Safari, Chrome Mobile) +- Shadow DOM 支持(widget 嵌入模式) + +--- + +## 十、附录 + +### 10.1 设计参考 + +- 入口按钮:现代客服系统的横条卡片样式 +- 聊天窗口:IM 应用(WhatsApp、Telegram)的对话界面 +- 配色方案:品牌橙色主题的延伸 + +### 10.2 设计资产 + +- 头像:汽车 emoji 🚗 +- 在线图标:绿点 + "Online" 文字 +- 操作图标:−(最小化)、✕(关闭) +- 功能图标:📞(电话)、➤(发送箭头) + +--- + +**文档版本**: 1.0 +**最后更新**: 2026-04-22 diff --git a/docs/superpowers/specs/2026-04-23-auto-popup-invite-card-design.md b/docs/superpowers/specs/2026-04-23-auto-popup-invite-card-design.md new file mode 100644 index 0000000..d1fc07f --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-auto-popup-invite-card-design.md @@ -0,0 +1,248 @@ +# 自动弹出邀请卡片设计规范 + +**日期:** 2026-04-23 +**状态:** 已批准 +**相关:** 2026-04-22-chat-widget-redesign.md + +--- + +## 概述 + +为 Widget 模式添加自动弹出邀请卡片,页面加载 1.8 秒后显示。邀请卡片提供快速访问常见话题的入口,提升用户参与度。 + +### 目标 + +- 通过主动邀请提升用户参与度 +- 提供热门话题的快速访问 +- 保持每页独立的状态管理 +- 与现有聊天组件无缝集成 + +--- + +## 组件设计 + +### 邀请卡片 + +**位置**: 右下角,悬浮按钮上方 + +**尺寸**: +- 宽度: 360px +- 移动端最大宽度: calc(100vw - 24px) + +**样式**: +- 圆角: 26px +- 头部: 橙色渐变 `linear-gradient(135deg, #F08A34 0%, #EC8C2F 100%)` +- 阴影: `0 28px 80px rgba(196,91,25,0.22)` +- 动画: 淡入 + 从底部滑入 (300ms) + +**结构**: + +``` +┌──────────────────────────────────────┐ +│ 头部 (橙色渐变) │ +│ 🤖 Get stock list or quotation now │ +│ Chat with us for available vehicles, │ +│ delivery timing and distributor... │ +├──────────────────────────────────────┤ +│ 话题按钮 (2x1 网格) │ +│ ┌─────────────┐ ┌─────────────────┐ │ +│ │Get Quotation│ │Distributor Coopt│ │ +│ └─────────────┘ └─────────────────┘ │ +├──────────────────────────────────────┤ +│ 操作按钮 │ +│ [Start Chat →] [ No Thanks ] │ +└──────────────────────────────────────┘ +``` + +**话题列表**: +1. Get Quotation +2. Distributor Cooperation + +--- + +## 状态管理 + +### 存储方案 + +**键名格式**: `widget_invite_dismissed_${pathname}` + +**示例**: +- 页面 `/home` → `widget_invite_dismissed_/home` +- 页面 `/products` → `widget_invite_dismissed_/products` + +**存储类型**: `sessionStorage` + +### 状态流程 + +``` +页面加载 + ↓ +启动 1.8 秒定时器 + ↓ +检查 sessionStorage + ↓ +┌─────────────┬─────────────┐ +│ 未关闭 │ 已关闭 │ +│ ↓ │ ↓ │ +│ 显示邀请 │ 跳过 │ +└─────────────┴─────────────┘ +``` + +### 关闭类型 + +| 类型 | 触发条件 | 存储行为 | 刷新后行为 | +|------|---------|---------|-----------| +| 暂时关闭 | 点击话题、Start Chat、悬浮按钮 | 设置 dismissed = true | 重置(再次显示) | +| 永久关闭 | 点击 No Thanks | 设置 dismissed = true | 持续(保持隐藏) | + +--- + +## 交互矩阵 + +| 用户操作 | 邀请卡片 | 聊天面板 | 自动发送消息 | +|---------|---------|---------|-------------| +| 点击 "Get Quotation" | 关闭(暂时) | 打开 | 是 | +| 点击 "Distributor Cooperation" | 关闭(暂时) | 打开 | 是 | +| 点击 "Start Chat" | 关闭(暂时) | 打开 | 否 | +| 点击 "No Thanks" | 关闭(永久) | - | 否 | +| 点击悬浮按钮(邀请可见时) | 关闭(暂时) | 打开 | 否 | +| 点击悬浮按钮(邀请隐藏时) | - | 打开 | 否 | + +### 显示逻辑 + +**互斥关系**: +- 邀请卡片可见 → 聊天面板隐藏 +- 聊天面板打开 → 邀请卡片隐藏 + +**层级 (Z-Index)**: +- 邀请卡片: 99998 +- 聊天面板: 99999 +- 悬浮按钮: 99997 + +--- + +## 聊天面板中的热门话题 + +### 位置 + +消息区域,初始消息上方。仅在对话初始阶段显示。 + +### 样式 + +``` +┌──────────────────────────────────────┐ +│ POPULAR TOPICS │ +│ ┌─────────────┐ ┌─────────────────┐ │ +│ │Get Quotation│ │Distributor Coopt│ │ +│ └─────────────┘ └─────────────────┘ │ +└──────────────────────────────────────┘ +``` + +### 行为 + +- 点击话题 → 自动发送消息到聊天 +- 用户发送第一条消息后隐藏 + +--- + +## 每页独立运行 + +### 规则 + +1. **独立定时器**: 每个页面加载时启动自己的 1.8 秒定时器 +2. **独立存储**: 每个页面单独存储关闭状态 +3. **无跨页面通信**: 状态不在页面间同步 +4. **会话重置**: 关闭网站清空所有状态 + +### 示例流程 + +``` +1. 用户打开 /home + → 1.8 秒 → 显示邀请 + → 用户点击 "Get Quotation" + → /home 标记为已关闭 + +2. 用户导航到 /products + → 1.8 秒 → 显示邀请(独立) + → 用户点击 "No Thanks" + → /products 标记为已关闭 + +3. 用户导航到 /about + → 1.8 秒 → 显示邀请(独立) + → 用户没有操作 + → /about 未标记为已关闭 + +4. 用户回到 /home + → 检查存储 → 已关闭 + → 不显示邀请 + +5. 用户回到 /about + → 检查存储 → 未关闭 + → 邀请保持显示 + +6. 用户刷新 /about + → 存储持续 → 邀请保持显示 + +7. 用户关闭并重新打开网站 + → 所有存储清空 + → 所有页面重新开始 +``` + +--- + +## 实现文件 + +### 修改 + +- `static/widget.js` - 添加邀请卡片组件和逻辑(约 250-300 行) + +### 新增代码段 + +1. **邀请 HTML 生成** - 创建邀请卡片 DOM 结构 +2. **邀请 CSS 样式** - 添加到 Shadow DOM 的样式 +3. **定时器逻辑** - 1.8 秒延迟机制 +4. **存储管理** - 每页状态管理 +5. **事件处理** - 话题按钮、操作按钮 +6. **自动发送消息** - 注入消息到聊天的函数 + +--- + +## 技术考虑 + +### Shadow DOM + +所有邀请卡片样式都在 Shadow DOM 内部作用域,防止与宿主页面样式冲突。 + +### 性能 + +- 定时器是简单的 `setTimeout()` - 开销极小 +- 存储检查是同步且快速的 +- 初始渲染后 DOM 操作最少 + +### 可访问性 + +- 邀请卡片可用 Escape 键关闭(未来增强) +- 所有按钮都有正确的 `aria-label` 属性 +- 支持键盘导航 + +### 浏览器兼容性 + +- `sessionStorage` 在所有现代浏览器中支持 +- Shadow DOM 在 Chrome 53+、Firefox 63+、Safari 10+ 中支持 + +--- + +## 未来增强 + +1. **可配置延迟**: 通过 data 属性自定义 1.8 秒延迟 +2. **A/B 测试**: 支持不同的邀请变体 +3. **分析**: 跟踪邀请关闭和转化率 +4. **Escape 键**: 用键盘关闭邀请 +5. **智能时机**: 用户主动滚动时延迟邀请 + +--- + +## 参考资料 + +- 父规范: `2026-04-22-chat-widget-redesign.md` +- 参考设计: `livechat_ui_widget (5).jsx` (StrongInvite 组件) diff --git a/docs/superpowers/specs/2026-06-18-dynamic-contact-config-design.md b/docs/superpowers/specs/2026-06-18-dynamic-contact-config-design.md new file mode 100644 index 0000000..b913611 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-dynamic-contact-config-design.md @@ -0,0 +1,160 @@ +# WhatsApp / Email 联系方式动态配置 + +**日期**: 2026-06-18 +**项目**: AI 客服系统 - VOLT/HESU +**目标**: 将写死的 WhatsApp 号码和 Email 地址改为通过 ` + + + +``` + +--- + +## 三、改动详情 + +### 3.1 `static/widget.js`(嵌入式组件) + +**读取配置**:在 [widget.js:8-12](../../../static/widget.js#L8-L12) 现有 `getAttribute` 调用旁,新增两行(沿用现有写法): + +```js +var whatsapp = script.getAttribute("data-whatsapp") || "https://wa.me/+8619037179250"; +var email = script.getAttribute("data-email") || "mailto:info@borderlesscar.com"; +``` + +**使用配置**:把以下 3 处写死的 `href` 替换为字符串拼接 `+ whatsapp +` / `+ email +`: + +| 位置 | 当前写死值 | 改为 | +|------|------------|------| +| [widget.js:170](../../../static/widget.js#L170)(面板 help-block,含 WhatsApp 与 Email 两个 `href`) | `https://wa.me/+8619037179250` / `mailto:info@borderlesscar.com` | `+ whatsapp +` / `+ email +` | +| [widget.js:199](../../../static/widget.js#L199)(邀请卡 WhatsApp) | `https://wa.me/+8619037179250` | `+ whatsapp +` | +| [widget.js:203](../../../static/widget.js#L203)(邀请卡 Email) | `mailto:info@borderlesscar.com` | `+ email +` | + +> 注意:第 170 行是单行内同时包含 WhatsApp 与 Email 两个 ``,需保证两处拼接都正确,不要漏改其中一个。 + +### 3.2 `static/js/chat.js` + `static/chat.html`(独立页,方案 A) + +`chat.html` 是独立整页,加载 `/static/js/chat.js`(**不**加载 widget.js)。为保持与组件**同一套**配置机制,让 `chat.js` 读取自身 `` 标签上添加 `data-whatsapp` / `data-email` 属性,使演示页展示配置能力。 +- 第 35、39 行原有的静态 `href` **保留为默认值不动**,作为 `chat.js` 执行前的 fallback(避免 JS 未执行时出现空链接),`chat.js` 启动后会按配置覆盖。 +- **执行时序安全**:`chat.js` 在 `` 末尾加载([chat.html:80](../../../static/chat.html#L80),晚于聊天容器 DOM),故 IIFE 顶部同步 `querySelector('.whatsapp-btn' / '.email-btn')` 时两个锚点已存在,同步赋值 `href` 安全。这两个 class 仅出现在 `chat.html` 的 light DOM 中(widget.js 里同名 class 在其 shadow DOM 内,互不干扰)。 + +### 3.3 `static/widget-test.html`(组件测试/演示页) + +- 在 [widget-test.html:69](../../../static/widget-test.html#L69) 的组件 ` + + + + + + +``` + +两个开关互相独立,可任意组合。 + +--- + +## 三、改动详情 + +全部改动集中在 [static/widget.js](../../../static/widget.js) 与测试页 [static/widget-test.html](../../../static/widget-test.html)。 + +### 3.1 `static/widget.js` — 读取两个新属性 + +在 [widget.js:8-14](../../../static/widget.js#L8-L14) 现有 `getAttribute` 调用旁,新增两行: + +```js +var autoInvite = script.getAttribute("data-auto-invite") !== "false"; +var isMobile = script.getAttribute("data-mobile") === "true"; +``` + +### 3.2 `static/widget.js` — 邀请卡自动弹出开关 + +在 [widget.js:306](../../../static/widget.js#L306) 的 `startInviteTimer()` 函数**最开头**加一句短路: + +```js +function startInviteTimer() { + if (!autoInvite) return; // ← 新增:配置关闭则不启动计时器 + if (checkInviteStorage()) { + return; // 已被永久关闭 + } + inviteTimer = window.setTimeout(function () { ... }, 1800); +} +``` + +效果:`data-auto-invite="false"` 时计时器根本不启动,邀请卡永不自动弹出。点悬浮按钮仍照常打开聊天面板;邀请卡内部的 "Start Chat" / "No Thanks" 等逻辑因卡片不出现而自然不触发,无需额外改动。 + +### 3.3 `static/widget.js` — 移动端悬浮按钮(D2) + +悬浮按钮当前在 [widget.js:233-244](../../../static/widget.js#L233-L244) 一处生成,`toggleBtn.innerHTML` 为桌面款模板。改动为**按 `isMobile` 分两套模板**,点击事件不变。 + +**实现路径选择(见第四节):采用「独立移动端 DOM 模板」**,桌面款模板逐字不动搬入 `else` 分支。 + +**DOM 结构(D2,竖排:圆钮在上、文字在外在下)**: + +```js +var toggleBtn = document.createElement("button"); +toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : ""); + +if (isMobile) { + toggleBtn.innerHTML = ` +
🤖
+ Chat with us + `; +} else { + toggleBtn.innerHTML = `/* 现有桌面款模板(widget.js:235-244),原样照搬、逐字不动 */`; +} +``` + +`toggleBtn` 仍是接收点击的元素([widget.js:367](../../../static/widget.js#L367) 的 `toggleBtn.addEventListener("click", toggle)` 不变),点击圆钮或文字均触发 `toggle()`。 + +**新增 CSS(追加到 [widget.js:40](../../../static/widget.js#L40) 注入的样式数组中)**: + +```js +// 移动端竖排按钮(D2):覆盖桌面款药丸背景,把橙色挪到圆钮上 +".toggle-btn.mobile { flex-direction:column; gap:5px; padding:0; background:transparent; box-shadow:none; border-radius:0; }", +".toggle-btn.mobile:hover { transform:scale(1.05); }", +".toggle-btn.mobile .mobile-circle { position:relative; width:56px; height:56px; border-radius:50%; background:#f08a31; display:flex; align-items:center; justify-content:center; font-size:28px; box-shadow:0 4px 16px rgba(240,138,49,0.45); }", +".toggle-btn.mobile .mobile-dot { position:absolute; bottom:2px; right:2px; width:13px; height:13px; background:#22c55e; border-radius:50%; border:2px solid #f08a31; }", +".toggle-btn.mobile .mobile-label { color:#8C4D1D; font-size:10px; font-weight:600; line-height:1.1; }", +``` + +> **位置**:桌面端位置逻辑不变——按钮仍位于由 `positionStyles` 决定的容器锚点(默认右下)。移动端采用更贴角的位置表(见下「位置偏移」)。 +> +> **颜色与主题**:移动端按钮颜色硬编码固定橙色(`#f08a31` 圆钮 + `#8C4D1D` 文字),**不随 `data-theme` 切换**——这与现有桌面款按钮(同样固定橙色、忽略 `data-theme`)行为一致,实现时无需特殊处理主题。 + +**位置偏移(移动端贴角)**: + +现有 `positionStyles`([widget.js:16-21](../../../static/widget.js#L16-L21))的 `bottom-right` 取 `bottom:80px`(为高药丸按钮预留)。移动端圆钮仅 56px,沿用 80px 会在手机上离底边偏高、显得"飘"。故 `isMobile` 时改用一份更贴角的位置表 `positionStylesMobile`: + +```js +var positionStylesMobile = { + "bottom-right": { bottom: "20px", right: "16px" }, + "bottom-left": { bottom: "20px", left: "16px" }, + "top-right": { top: "20px", right: "16px" }, + "top-left": { top: "20px", left: "16px" }, +}; +var pos = (isMobile ? positionStylesMobile : positionStyles)[position] + || (isMobile ? positionStylesMobile : positionStyles)["bottom-right"]; +``` + +桌面端 `positionStyles` 逐字不动;仅当 `data-mobile="true"` 时切换到 `positionStylesMobile`。聊天面板/邀请卡因相对容器定位(`bottom:80px`),随容器下移后仍开在按钮上方,不重叠。 + +### 3.4 `static/widget-test.html` — 演示 / 测试属性 + +在 [widget-test.html:69](../../../static/widget-test.html#L69) 的组件 ` + + diff --git a/static/chat.html b/static/chat.html new file mode 100644 index 0000000..e218b0e --- /dev/null +++ b/static/chat.html @@ -0,0 +1,82 @@ + + + + + + VOLT + + + + +
+
+
🤖
+
+

HESU Support

+
Online now - Usually replies in minutes
+
+ +
+ +
+ Chat ended. We'll be in touch soon! +
+
+
+
+
+
Hey there! Looking for a ride? Tell me what you need.
+
+
+ +
+ +
+ + + +
+ +
+
+ + +
+
+
+ + + + diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..554ee44 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,468 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --w-primary: #f08a31; + --w-primary-rgb: 240, 138, 49; + --w-gradient-end: #f08e2c; + --w-deep-text: #8a4c1d; + --w-caption-text: #c16d2d; + --w-soft-bg: #fef8f4; + --w-border-soft: #f0d3bd; + --bg: #f5f5f5; + --chat-bg: #ffffff; + --ai-bubble: #f0f0f0; + --text: #333333; + --text-light: #666666; + --text-white: #ffffff; + --border: #e0e0e0; + --success: #34a853; + --shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +html, body { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background: var(--bg); + color: var(--text); +} + +.chat-container { + display: flex; + flex-direction: column; + height: 100vh; + max-width: 800px; + margin: 0 auto; + background: var(--chat-bg); + box-shadow: var(--shadow); +} + +/* Header */ +.chat-header { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 18px; + background: var(--w-primary); + color: white; + flex-shrink: 0; +} + +.chat-header .avatar { + width: 48px; + height: 48px; + background: rgba(255, 255, 255, 0.2); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + position: relative; +} + +/* 头像通用:emoji 兜底层 + 品牌图片层 */ +.avatar-emoji { line-height: 1; } +.avatar-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; border-radius: 50%; } + +/* 在线状态点 */ +.chat-header .avatar::after { + content: ''; + position: absolute; + bottom: 1px; + right: 1px; + width: 14px; + height: 14px; + background: #22c55e; + border-radius: 50%; + border: 3px solid var(--w-primary); +} + +.chat-header .info h1 { + font-size: 17px; + font-weight: 600; + margin: 0; +} + +.chat-header .info .status { + font-size: 13px; + opacity: 0.95; + margin: 0; +} + +/* 头部操作按钮 */ +.chat-header .actions { + display: flex; + gap: 8px; +} + +.chat-header .action-btn { + width: 32px; + height: 32px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.25); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border: none; + transition: background 0.2s; + color: #fff; +} + +.chat-header .action-btn:hover { + background: rgba(255, 255, 255, 0.25); +} + +/* Messages area */ +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 20px; + padding-bottom: 8px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.message { + display: flex; + max-width: 80%; +} + +.message.user { + align-self: flex-end; +} + +.message.assistant { + align-self: flex-start; + display: flex; + align-items: flex-start; + gap: 10px; +} + +.message .bubble { + padding: 10px 16px; + border-radius: 16px; + line-height: 1.5; + font-size: 14px; + word-break: break-word; +} + +.message.user .bubble { + background: var(--w-primary); + color: var(--text-white); + border-bottom-right-radius: 4px; +} + +.message.assistant .avatar { + width: 32px; + height: 32px; + background: var(--w-primary); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + color: white; + flex-shrink: 0; + box-shadow: 0 2px 8px rgba(var(--w-primary-rgb),0.3); +} + +.message.assistant .bubble { + background: #f3f4f6; + color: #1f2937; + padding: 12px 16px; + border-bottom-left-radius: 4px; + max-width: 280px; +} + +.message .time { + font-size: 11px; + color: var(--text-light); + margin-top: 4px; + padding: 0 4px; +} + +.message.user .time { + text-align: right; +} + +/* Typing indicator */ +.typing-indicator { + display: none; + align-self: flex-start; + padding: 10px 16px; + background: var(--ai-bubble); + border-radius: 16px; + border-bottom-left-radius: 4px; +} + +.typing-indicator.active { + display: flex; + gap: 4px; + align-items: center; +} + +.typing-indicator span { + width: 8px; + height: 8px; + background: #999; + border-radius: 50%; + animation: typing 1.4s infinite; +} + +.typing-indicator span:nth-child(2) { animation-delay: 0.2s; } +.typing-indicator span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes typing { + 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } + 30% { transform: translateY(-6px); opacity: 1; } +} + +/* 消息区分隔线 */ +.chat-separator { + height: 1px; + background: var(--w-primary); + opacity: 0.3; + flex-shrink: 0; +} + +/* 输入区域容器 */ +.chat-input-container { + padding: 10px 10px; + background: white; + flex-shrink: 0; +} + +/* 输入容器 */ +.input-wrapper { + display: flex; + align-items: center; + gap: 0; + padding: 8px 16px; + background: var(--w-soft-bg); /* 主题浅底 */ + border-radius: 28px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +/* 输入框 */ +.chat-input-container input { + flex: 1; + padding: 0; + border: none; + font-size: 14px; + background: transparent; + outline: none; + min-height: 20px; + color: #1f2937; +} + +.chat-input-container input:focus { + box-shadow: inset 0 0 0 2px rgba(var(--w-primary-rgb),0.2); +} + +.chat-input-container input::placeholder { + color: #9ca3af; +} + +/* 分隔线 */ +.input-separator { + width: 1px; + height: 24px; + background: #e5e7eb; + margin: 0 8px; + flex-shrink: 0; +} + +/* 发送按钮 */ +.send-btn { + padding: 8px 16px; + border: none; + border-radius: 12px; + background: linear-gradient(135deg, var(--w-primary), var(--w-gradient-end)); + color: white; + font-size: 14px; + font-weight: 500; + cursor: pointer; + flex-shrink: 0; + box-shadow: 0 2px 8px rgba(var(--w-primary-rgb),0.25); + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.send-btn:hover { + box-shadow: 0 4px 12px rgba(var(--w-primary-rgb),0.35); +} + +.send-btn .send-icon { + font-size: 16px; +} + +.send-btn .send-text { + font-size: 14px; +} + +.send-btn:disabled { + background: #ccc; + cursor: not-allowed; + box-shadow: none; +} + +/* Ended banner */ +.chat-ended { + display: none; + text-align: center; + padding: 12px; + background: #e8f5e9; + color: var(--success); + font-size: 13px; + font-weight: 500; +} + +.chat-ended.active { + display: block; +} + +/* Help 区块 */ +.help-block { + background: var(--w-soft-bg); + border-radius: 12px; + padding: 10px 14px; + margin-bottom: 16px; + border: 1px solid var(--w-border-soft); +} +.panel-top { + flex-shrink: 0; + padding: 10px 16px; + background: var(--w-soft-bg); +} +.help-block .help-content { + flex: 1; + min-width: 0; +} + +.help-block .help-title { + font-size: 16px; + font-weight: 700; + color: #111827; + margin-bottom: 3px; +} + +.help-block .help-description { + font-size: 12px; + color: #6b7280; + line-height: 1.4; +} + +.help-block .help-actions { + display: grid; + gap: 8px; + flex-shrink: 0; +} + +.help-block .call-btn { + background: #fff; + color: var(--w-deep-text); + border: 1px solid var(--w-border-soft); + padding: 6px 10px; + border-radius: 20px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + text-decoration: none; + flex-shrink: 0; + transition: all 0.2s; +} + +.help-block .call-btn:hover { + background: var(--w-soft-bg); + transform: translateY(-1px); +} + +.help-block .call-btn svg { + flex-shrink: 0; +} + +/* POPULAR TOPICS 区块 */ +.popular-topics-fixed { + flex-shrink: 0; + padding: 12px 20px 8px; + background: white; +} + +.chat-separator-topics { + height: 1px; + background: var(--w-primary); + opacity: 0.15; + flex-shrink: 0; +} + +.popular-topics-label { + font-size: 11px; + font-weight: 600; + color: var(--w-caption-text); + letter-spacing: 0.14em; + margin-bottom: 8px; + text-transform: uppercase; +} + +.popular-topics-grid { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.popular-topic-btn { + border-radius: 20px; + border: 1px solid var(--w-border-soft); + background: var(--w-soft-bg); + padding: 8px 16px; + font-size: 12px; + font-weight: 500; + color: var(--w-deep-text); + cursor: pointer; + transition: background 0.2s; + white-space: nowrap; +} + +.popular-topic-btn:hover { + background: white; +} + +/* 底部信息栏 */ +.chat-footer { + padding: 10px 10px; + background: white; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + color: var(--w-caption-text); + gap: 20px; +} + +.chat-footer .footer-left { + flex: 1; +} + +.chat-footer .footer-right { + flex-shrink: 0; +} + +/* Mobile */ +@media (max-width: 600px) { + .chat-container { + max-width: 100%; + } + .message { + max-width: 88%; + } +} diff --git a/static/js/chat.js b/static/js/chat.js new file mode 100644 index 0000000..fd15015 --- /dev/null +++ b/static/js/chat.js @@ -0,0 +1,231 @@ +// ---- 动态主题色 bootstrap(spec §3):?color= → 派生 → :root 变量 ---- +(function () { + if (typeof ChatTheme === "undefined") return; // theme.js 加载失败→style.css :root 默认橙兜底 + var m = /(?:^|[?&])color=([^&]*)/.exec(location.search); + function safeDecode(s) { try { return decodeURIComponent(s); } catch (e) { return ""; } } + var color = m ? safeDecode(m[1]).trim() : ""; + var palette = ChatTheme.derivePalette(color || ChatTheme.DEFAULT_COLOR); + var root = document.documentElement.style; + root.setProperty("--w-primary", palette.primary); + root.setProperty("--w-primary-rgb", palette.primaryRgb); + root.setProperty("--w-gradient-end", palette.gradientEnd); + root.setProperty("--w-deep-text", palette.deepText); + root.setProperty("--w-caption-text", palette.captionText); + root.setProperty("--w-soft-bg", palette.softBg); + root.setProperty("--w-border-soft", palette.borderSoft); + + // 头像兜底(与挂件同机制) + Array.prototype.forEach.call(document.querySelectorAll(".avatar-img"), function (img) { + function hideEmoji() { + var s = img.parentElement && img.parentElement.querySelector(".avatar-emoji"); + if (s) s.style.display = "none"; + } + if (img.complete && img.naturalWidth > 0) { hideEmoji(); return; } + img.addEventListener("load", hideEmoji); + img.addEventListener("error", function () { img.remove(); }); + }); +})(); + +(function () { + // 联系方式配置(与 widget.js 的 data-* 机制一致) + var chatScript = document.currentScript || document.querySelector('script[src*="chat.js"]'); + var whatsapp = (chatScript && chatScript.getAttribute("data-whatsapp")) || "https://wa.me/+8619037179250"; + var email = (chatScript && chatScript.getAttribute("data-email")) || "mailto:info@borderlesscar.com"; + var waBtn = document.querySelector('.whatsapp-btn'); + var emBtn = document.querySelector('.email-btn'); + if (waBtn) waBtn.href = whatsapp; + if (emBtn) emBtn.href = email; + var sessionId = localStorage.getItem("cc_session_id"); + var messagesEl = document.getElementById("messages"); + var inputEl = document.getElementById("input"); + var sendBtn = document.getElementById("sendBtn"); + var typingEl = document.getElementById("typing"); + var endedEl = document.getElementById("chatEnded"); + var popularTopicsEl = document.getElementById("popular-topics"); + var ws = null; + var ended = false; + + // Current streaming message element + var streamBubble = null; + var statusTimer = null; + + function getWsUrl() { + var loc = window.location; + var proto = loc.protocol === "https:" ? "wss:" : "ws:"; + return proto + "//" + loc.host + "/ws/chat"; + } + + function connect() { + ws = new WebSocket(getWsUrl()); + + ws.onopen = function () { + if (sessionId) { + ws.send(JSON.stringify({ session_id: sessionId })); + } else { + ws.send(JSON.stringify({ source: "standalone" })); + } + }; + + ws.onmessage = function (e) { + var data = JSON.parse(e.data); + + if (data.type === "init" || data.type === "resumed") { + sessionId = data.session_id; + localStorage.setItem("cc_session_id", sessionId); + if (data.type === "resumed") { + loadHistory(); + } + } + + if (data.type === "stream_start") { + clearStatusTimer(); + hideTyping(); + streamBubble = addStreamBubble(); + } + + if (data.type === "stream_chunk") { + if (streamBubble) { + streamBubble.textContent += data.content; + messagesEl.scrollTop = messagesEl.scrollHeight; + } + } + + if (data.type === "stream_end") { + streamBubble = null; + } + + if (data.type === "evaluated") { + if (data.ended) { + ended = true; + endedEl.classList.add("active"); + inputEl.disabled = true; + sendBtn.disabled = true; + } + } + + if (data.type === "error") { + clearStatusTimer(); + hideTyping(); + streamBubble = null; + addMessage("assistant", "抱歉,出了点问题,请稍后再试。"); + } + }; + + ws.onclose = function () { + if (!ended) { + setTimeout(connect, 3000); + } + }; + + ws.onerror = function () { + ws.close(); + }; + } + + function send() { + var text = inputEl.value.trim(); + if (!text || ended || !ws || ws.readyState !== WebSocket.OPEN) return; + + addMessage("user", text); + inputEl.value = ""; + hidePopularTopics(); + + // Immediate feedback: show status then typing + showStatusThenTyping(); + + ws.send(JSON.stringify({ content: text })); + } + + function sendTopicMessage(topic) { + if (ended || !ws || ws.readyState !== WebSocket.OPEN) return; + + addMessage("user", topic); + hidePopularTopics(); + + // Immediate feedback: show status then typing + showStatusThenTyping(); + + ws.send(JSON.stringify({ content: topic })); + } + + function hidePopularTopics() { + if (popularTopicsEl) { + popularTopicsEl.style.display = "none"; + } + } + + function showStatusThenTyping() { + clearStatusTimer(); + showTyping(); + // Auto-hide typing is handled by stream_start replacing it + } + + function clearStatusTimer() { + if (statusTimer) { + clearTimeout(statusTimer); + statusTimer = null; + } + } + + function addMessage(role, content) { + var div = document.createElement("div"); + div.className = "message " + role; + + var wrapper = document.createElement("div"); + var bubble = document.createElement("div"); + bubble.className = "bubble"; + bubble.textContent = content; + + wrapper.appendChild(bubble); + div.appendChild(wrapper); + messagesEl.appendChild(div); + messagesEl.scrollTop = messagesEl.scrollHeight; + } + + function addStreamBubble() { + var div = document.createElement("div"); + div.className = "message assistant"; + + var wrapper = document.createElement("div"); + var bubble = document.createElement("div"); + bubble.className = "bubble"; + bubble.textContent = ""; + + wrapper.appendChild(bubble); + div.appendChild(wrapper); + messagesEl.appendChild(div); + messagesEl.scrollTop = messagesEl.scrollHeight; + return bubble; + } + + function showTyping() { + typingEl.classList.add("active"); + messagesEl.scrollTop = messagesEl.scrollHeight; + } + + function hideTyping() { + typingEl.classList.remove("active"); + } + + function loadHistory() { + } + + sendBtn.addEventListener("click", send); + inputEl.addEventListener("keydown", function (e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + send(); + } + }); + + // Popular topics buttons + var topicBtns = document.querySelectorAll(".popular-topic-btn"); + topicBtns.forEach(function (btn) { + btn.addEventListener("click", function () { + var topic = this.getAttribute("data-topic"); + sendTopicMessage(topic); + }); + }); + + connect(); +})(); diff --git a/static/js/theme.js b/static/js/theme.js new file mode 100644 index 0000000..90b64d0 --- /dev/null +++ b/static/js/theme.js @@ -0,0 +1,103 @@ +/* 客服主题派生——单品牌色 → 7 槽位色板(spec 2026-08-31 §5)。 + * 注意:此文件与 static/widget.js 内联拷贝必须逐字符一致(空白归一化后), + * 由 tests/test_theme_static.py 强制;修改务必两边同步。 */ +(function (global) { + "use strict"; + + var DEFAULT_COLOR = "#f08a31"; + + var DEFAULT_PALETTE = { + primary: "#f08a31", + primaryRgb: "240,138,49", + gradientEnd: "#f08e2c", + deepText: "#8a4c1d", + captionText: "#c16d2d", + softBg: "#fef8f4", + borderSoft: "#f0d3bd" + }; + + function clampNum(v, lo, hi) { + return Math.min(hi, Math.max(lo, v)); + } + + function hexToRgb(hex) { + hex = String(hex || "").replace(/^#/, ""); + if (hex.length === 3) { + hex = hex.split("").map(function (ch) { return ch + ch; }).join(""); + } + if (!/^[0-9a-fA-F]{6}$/.test(hex)) return null; + return { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16) + }; + } + + function rgbToHsl(r, g, b) { + r /= 255; g /= 255; b /= 255; + var max = Math.max(r, g, b), min = Math.min(r, g, b); + var l = (max + min) / 2; + var d = max - min; + var h = 0, s = 0; + if (d !== 0) { + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + if (max === r) h = ((g - b) / d) % 6; + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h *= 60; + if (h < 0) h += 360; + } + return { h: h, s: s * 100, l: l * 100 }; + } + + function toHex2(v) { + var s = Math.round(clampNum(v, 0, 255)).toString(16); + return s.length < 2 ? "0" + s : s; + } + + function hslToHex(h, s, l) { + h = ((h % 360) + 360) % 360; + s = clampNum(s, 0, 100) / 100; + l = clampNum(l, 0, 100) / 100; + var c = (1 - Math.abs(2 * l - 1)) * s; + var x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + var m = l - c / 2; + var rp = 0, gp = 0, bp = 0; + if (h < 60) { rp = c; gp = x; } + else if (h < 120) { rp = x; gp = c; } + else if (h < 180) { gp = c; bp = x; } + else if (h < 240) { gp = x; bp = c; } + else if (h < 300) { rp = x; bp = c; } + else { rp = c; bp = x; } + return "#" + toHex2((rp + m) * 255) + toHex2((gp + m) * 255) + toHex2((bp + m) * 255); + } + + function derivePalette(color) { + try { + var rgb = hexToRgb(color); + if (!rgb) { + if (typeof console !== "undefined" && console.warn) console.warn("[chat-theme] invalid color, fallback:", color); + return DEFAULT_PALETTE; + } + var hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + var h = hsl.h, s = hsl.s, l = hsl.l; + return { + primary: "#" + toHex2(rgb.r) + toHex2(rgb.g) + toHex2(rgb.b), + primaryRgb: rgb.r + "," + rgb.g + "," + rgb.b, + gradientEnd: hslToHex(h + 2, s, l - 1), + deepText: hslToHex(h - 2, s * 0.76, clampNum(l - 24, 28, 40)), + captionText: hslToHex(h - 2, s * 0.72, clampNum(l - 10, 40, 52)), + softBg: hslToHex(h, s * 0.9, 97.5), + borderSoft: hslToHex(h - 2, s * 0.72, 84) + }; + } catch (e) { + return DEFAULT_PALETTE; + } + } + + global.ChatTheme = { + derivePalette: derivePalette, + DEFAULT_COLOR: DEFAULT_COLOR, + DEFAULT_PALETTE: DEFAULT_PALETTE + }; +})(typeof window !== "undefined" ? window : this); diff --git a/static/kefu-small.png b/static/kefu-small.png new file mode 100644 index 0000000..2f234e3 Binary files /dev/null and b/static/kefu-small.png differ diff --git a/static/kefu.png b/static/kefu.png new file mode 100644 index 0000000..8bddbbf Binary files /dev/null and b/static/kefu.png differ diff --git a/static/widget-test.html b/static/widget-test.html new file mode 100644 index 0000000..31886e3 --- /dev/null +++ b/static/widget-test.html @@ -0,0 +1,183 @@ + + + + + + Widget Test — Invite Card + Theme Color + Assertions + + + + + +
+

Widget Invite Card Test Page

+

This page tests the auto-popup invite card feature.

+

Expected behavior:

+
    +
  • Invite card appears after 5 seconds
  • +
  • Invite card has orange gradient header with robot emoji
  • +
  • Two topic buttons: "Get Quotation" and "Distributor Cooperation"
  • +
  • Action buttons: "Start Chat" and "No Thanks"
  • +
  • Click "Get Quotation" → invite closes → chat opens → message auto-sends
  • +
  • Click "Distributor Cooperation" → invite closes → chat opens → message auto-sends
  • +
  • Click "Start Chat" → invite closes → chat opens
  • +
  • Click "No Thanks" → invite permanently closes
  • +
  • Click "No Thanks" or X → refresh → invite stays hidden AND the old card area stays clickable (no invisible overlay; regression 2026-09-01)
  • +
  • Click toggle button (while invite visible) → invite closes → chat opens
  • +
  • Refresh page → invite appears again after 5s (unless "No Thanks" was clicked)
  • +
+
+ +
+

Popular Topics in Chat Panel

+
    +
  • Chat panel shows "POPULAR TOPICS" section with two buttons
  • +
  • Click topic → message auto-sends → topics section hides
  • +
+
+ +
+

Theme Color Preview

+

Pick a color and Apply, or use a preset. Each click tears down the old widget (host + script + invite dismissal state) and re-embeds with data-color. "Test Broken Avatar" points data-avatar at a missing image to verify the 🤖 emoji fallback.

+

+ + + + + + + + +

+
+ +
+

Assertion Panel

+

Runs on every widget (re)embed. Checks: derived default palette vs current hardcoded values (max per-channel Δ ≤ 8), derive(default) === DEFAULT_PALETTE, inline widget.js copy === theme.js copy, invalid input falls back to DEFAULT_PALETTE.

+
+
+ +
+

Current Page:

+

Storage Key: widget_invite_dismissed_

+

+
+ + + + diff --git a/static/widget.js b/static/widget.js new file mode 100644 index 0000000..e5b2617 --- /dev/null +++ b/static/widget.js @@ -0,0 +1,687 @@ +(function () { + var script = document.currentScript || (function () { + var scripts = document.getElementsByTagName("script"); + return scripts[scripts.length - 1]; + })(); + + // Save config immediately (before DOM ready) + var position = script.getAttribute("data-position") || "bottom-right"; + var theme = script.getAttribute("data-theme") || "light"; + var title = script.getAttribute("data-title") || "HESU Support"; + var autoOpen = script.getAttribute("data-auto-open") === "true"; + var whatsapp = script.getAttribute("data-whatsapp") || "https://wa.me/+8619037179250"; + var email = script.getAttribute("data-email") || "mailto:info@borderlesscar.com"; + var autoInvite = script.getAttribute("data-auto-invite") !== "false"; + var isMobile = script.getAttribute("data-mobile") === "true"; + var wsBase = new URL(script.src).origin; + + /* ===== 主题派生单元:与 static/js/theme.js 逐字同步,tests/test_theme_static.py 强制;只可整块替换,不可单边修改 ===== */ + var DEFAULT_COLOR = "#f08a31"; + + var DEFAULT_PALETTE = { + primary: "#f08a31", + primaryRgb: "240,138,49", + gradientEnd: "#f08e2c", + deepText: "#8a4c1d", + captionText: "#c16d2d", + softBg: "#fef8f4", + borderSoft: "#f0d3bd" + }; + + function clampNum(v, lo, hi) { + return Math.min(hi, Math.max(lo, v)); + } + + function hexToRgb(hex) { + hex = String(hex || "").replace(/^#/, ""); + if (hex.length === 3) { + hex = hex.split("").map(function (ch) { return ch + ch; }).join(""); + } + if (!/^[0-9a-fA-F]{6}$/.test(hex)) return null; + return { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16) + }; + } + + function rgbToHsl(r, g, b) { + r /= 255; g /= 255; b /= 255; + var max = Math.max(r, g, b), min = Math.min(r, g, b); + var l = (max + min) / 2; + var d = max - min; + var h = 0, s = 0; + if (d !== 0) { + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + if (max === r) h = ((g - b) / d) % 6; + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h *= 60; + if (h < 0) h += 360; + } + return { h: h, s: s * 100, l: l * 100 }; + } + + function toHex2(v) { + var s = Math.round(clampNum(v, 0, 255)).toString(16); + return s.length < 2 ? "0" + s : s; + } + + function hslToHex(h, s, l) { + h = ((h % 360) + 360) % 360; + s = clampNum(s, 0, 100) / 100; + l = clampNum(l, 0, 100) / 100; + var c = (1 - Math.abs(2 * l - 1)) * s; + var x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + var m = l - c / 2; + var rp = 0, gp = 0, bp = 0; + if (h < 60) { rp = c; gp = x; } + else if (h < 120) { rp = x; gp = c; } + else if (h < 180) { gp = c; bp = x; } + else if (h < 240) { gp = x; bp = c; } + else if (h < 300) { rp = x; bp = c; } + else { rp = c; bp = x; } + return "#" + toHex2((rp + m) * 255) + toHex2((gp + m) * 255) + toHex2((bp + m) * 255); + } + + function derivePalette(color) { + try { + var rgb = hexToRgb(color); + if (!rgb) { + if (typeof console !== "undefined" && console.warn) console.warn("[chat-theme] invalid color, fallback:", color); + return DEFAULT_PALETTE; + } + var hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + var h = hsl.h, s = hsl.s, l = hsl.l; + return { + primary: "#" + toHex2(rgb.r) + toHex2(rgb.g) + toHex2(rgb.b), + primaryRgb: rgb.r + "," + rgb.g + "," + rgb.b, + gradientEnd: hslToHex(h + 2, s, l - 1), + deepText: hslToHex(h - 2, s * 0.76, clampNum(l - 24, 28, 40)), + captionText: hslToHex(h - 2, s * 0.72, clampNum(l - 10, 40, 52)), + softBg: hslToHex(h, s * 0.9, 97.5), + borderSoft: hslToHex(h - 2, s * 0.72, 84) + }; + } catch (e) { + return DEFAULT_PALETTE; + } + } + + /* ===== 主题派生单元结束 ===== */ + var DEFAULT_AVATAR_PATH = "/static/kefu-small.png"; + var avatarAttr = (script.getAttribute("data-avatar") || "").trim(); + var avatarUrl = avatarAttr + ? (/^https?:\/\//i.test(avatarAttr) ? avatarAttr : wsBase + "/" + avatarAttr.replace(/^\//, "")) + : wsBase + DEFAULT_AVATAR_PATH; + var colorAttr = (script.getAttribute("data-color") || "").trim(); + var palette = derivePalette(colorAttr || DEFAULT_COLOR); + if (script.getAttribute("data-debug") === "true") { + window.__chatWidgetDebug = { derivePalette: derivePalette, palette: palette }; + } + + var positionStyles = { + "bottom-right": { bottom: "80px", right: "10px" }, + "bottom-left": { bottom: "20px", left: "20px" }, + "top-right": { top: "20px", right: "20px" }, + "top-left": { top: "20px", left: "20px" }, + }; + var positionStylesMobile = { + "bottom-right": { bottom: "20px", right: "16px" }, + "bottom-left": { bottom: "20px", left: "16px" }, + "top-right": { top: "20px", right: "16px" }, + "top-left": { top: "20px", left: "16px" }, + }; + + var colors = { + light: { bg: "#ffffff", text: "#333", bubble: "#f0f0f0" }, + dark: { bg: "#1e1e1e", text: "#e0e0e0", bubble: "#2d2d2d" }, + }; + + var c = colors[theme] || colors.light; + var pos = (isMobile ? positionStylesMobile : positionStyles)[position] + || (isMobile ? positionStylesMobile : positionStyles)["bottom-right"]; + + function init() { + var host = document.createElement("div"); + host.id = "cc-widget-host"; + document.body.appendChild(host); + + var shadow = host.attachShadow({ mode: "open" }); + + // Inject styles + var style = document.createElement("style"); + style.textContent = [ + ":host{--w-primary:" + palette.primary + ";--w-primary-rgb:" + palette.primaryRgb + ";--w-gradient-end:" + palette.gradientEnd + ";--w-deep-text:" + palette.deepText + ";--w-caption-text:" + palette.captionText + ";--w-soft-bg:" + palette.softBg + ";--w-border-soft:" + palette.borderSoft + "}", + "* { margin:0; padding:0; box-sizing:border-box; }", + ".widget-container { position:fixed; z-index:99999; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }", + ".widget-container { " + Object.keys(pos).map(function (k) { return k + ":" + pos[k]; }).join(";") + "; }", + + // Toggle button + ".toggle-btn { width:auto; height:auto; background:var(--w-primary); border-radius:30px; padding:8px 20px; display:flex; align-items:center; gap:12px; box-shadow:0 4px 16px rgba(var(--w-primary-rgb),0.4); transition:transform 0.2s; cursor:pointer; border:none; }", + ".toggle-btn:hover { transform:scale(1.1); }", + ".toggle-btn.mobile { flex-direction:column; gap:5px; padding:0; background:transparent; box-shadow:none; border-radius:0; }", + ".toggle-btn.mobile:hover { transform:scale(1.05); }", + ".toggle-btn.mobile .mobile-circle { position:relative; width:56px; height:56px; border-radius:50%; background:var(--w-primary); display:flex; align-items:center; justify-content:center; font-size:28px; box-shadow:0 4px 16px rgba(var(--w-primary-rgb),0.45); }", + ".toggle-btn.mobile .mobile-dot { position:absolute; bottom:2px; right:2px; width:13px; height:13px; background:#22c55e; border-radius:50%; border:2px solid var(--w-primary); }", + ".toggle-btn.mobile .mobile-label { color:var(--w-deep-text); font-size:10px; font-weight:600; line-height:1.1; }", + ".toggle-avatar-wrap { position:relative; }", + ".toggle-avatar-circle { position:relative; width:36px; height:36px; background:rgba(255,255,255,0.2); border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:18px; }", + ".toggle-avatar-dot { position:absolute; top:0; right:0; width:10px; height:10px; background:#22c55e; border-radius:50%; border:2px solid var(--w-primary); }", + ".avatar-emoji { line-height:1; }", + ".avatar-img { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; border-radius:50%; }", + // 移动端 D2 按钮比桌面药丸高(72px vs 52px),抬高面板底边避免压住按钮(覆盖面板默认 80px 与 @media 60px) + ".widget-container.mobile-host .chat-panel { bottom:84px; }", + ".widget-container.mobile-host { bottom:70px; }", + + // Chat panel + ".chat-panel { position:absolute; bottom:80px; right:0; width:400px; height:540px; background:" + c.bg + "; border-radius:16px; box-shadow:0 10px 40px rgba(0,0,0,0.15); display:none; flex-direction:column; overflow:hidden; }", + ".chat-panel.open { display:flex; }", + + // Panel header + ".panel-header { display:flex; align-items:center; gap:12px; padding:14px 18px; background:var(--w-primary); color:white; }", + ".panel-header .avatar { width:48px; height:48px; background:rgba(255,255,255,0.2); border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:22px; position:relative; }", + ".panel-header .avatar::after { content:''; position:absolute; bottom:1px; right:1px; width:14px; height:14px; background:#22c55e; border-radius:50%; border:3px solid var(--w-primary); }", + ".panel-header .info { flex:1; }", + ".panel-header .info h3 { font-size:17px; font-weight:600; margin:0; }", + ".panel-header .info .status { font-size:13px; opacity:0.95; margin:0; }", + ".panel-header .actions { display:flex; gap:8px; }", + ".panel-header .action-btn { width:32px; height:32px; border-radius:8px; background:rgba(255,255,255,0.15); cursor:pointer; display:flex; align-items:center; justify-content:center; transition:background 0.2s; border:none; color:white; }", + ".panel-header .action-btn:hover { background:rgba(255,255,255,0.25); }", + + // Messages + ".panel-top { flex-shrink:0; padding:10px 16px; background:var(--w-soft-bg); }", + ".panel-messages { flex:1; overflow-y:auto; padding:16px; background:#ffffff; display:flex; flex-direction:column; gap:8px; padding-bottom:8px; }", + ".popular-topics-fixed { flex-shrink:0; padding:12px 16px 8px; background:#ffffff; }", + ".chat-separator-topics { height:1px; background:var(--w-primary); opacity:0.15; flex-shrink:0; }", + ".help-block { background:var(--w-soft-bg); border-radius:12px; padding:10px 14px; border:1px solid var(--w-border-soft); }", + ".help-block .help-content { flex:1; min-width:0; }", + ".help-block .help-title { font-size:16px; font-weight:700; color:#111827; margin-bottom:3px; }", + ".help-block .help-description { font-size:12px; color:#6b7280; line-height:1.4; }", + ".help-block .help-actions { display:grid; gap:8px; flex-shrink:0; }", + ".help-block .call-btn { justify-content: center;background: #fff; color: var(--w-deep-text);border: 1px solid var(--w-border-soft);padding: 6px 10px; border-radius:20px; font-size:13px; font-weight:600; cursor:pointer; display:flex; align-items:center; gap:5px; text-decoration:none; flex-shrink:0; transition:all 0.2s; }", + // ".help-block .whatsapp-btn:hover { box-shadow:0 4px 12px rgba(37,211,102,0.4); transform:translateY(-1px); }", + // ".help-block .email-btn { background:linear-gradient(135deg, #EA4335 0%, #C5221F 100%); }", + // ".help-block .email-btn:hover { box-shadow:0 4px 12px rgba(234,67,53,0.4); transform:translateY(-1px); }", + ".popular-topics-label { font-size:11px; font-weight:600; color:var(--w-caption-text); letter-spacing:0.14em; margin-bottom:8px; text-transform:uppercase; }", + ".popular-topics-grid { display:flex; gap:8px; flex-wrap:wrap; }", + ".popular-topic-btn { border-radius:20px; border:1px solid var(--w-border-soft); background:var(--w-soft-bg); padding:8px 16px; font-size:12px; font-weight:500; color:var(--w-deep-text); cursor:pointer; transition:background 0.2s; white-space:nowrap; }", + ".popular-topic-btn:hover { background:white; }", + ".msg-wrapper { display:flex; gap:10px; }", + ".msg-wrapper .avatar { width:32px; height:32px; background:var(--w-primary); border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:14px; color:white; flex-shrink:0; box-shadow:0 2px 8px rgba(var(--w-primary-rgb),0.3); }", + ".msg { max-width:280px; padding:12px 16px; border-radius:16px; font-size:14px; line-height:1.5; word-break:break-word; }", + ".msg.user { align-self:flex-end; background:var(--w-primary); color:#fff; border-bottom-right-radius:4px; }", + ".msg.ai { background:#f3f4f6; color:#1f2937; border-bottom-left-radius:4px; }", + + // Separator + ".chat-separator { height:1px; background:var(--w-primary); opacity:0.3; flex-shrink:0; }", + + // Footer + ".chat-footer { padding:10px 10px; background:white; display:flex; align-items:center; justify-content:space-between; font-size:11px; color:var(--w-caption-text); gap:20px; }", + ".chat-footer .footer-left { flex:1; }", + ".chat-footer .footer-right { flex-shrink:0; }", + + // Typing + ".typing { display:none; align-self:flex-start; padding:8px 12px; background:" + c.bubble + "; border-radius:12px; }", + ".typing.active { display:flex; gap:4px; align-items:center; }", + ".typing span { width:6px; height:6px; background:#999; border-radius:50%; animation:blink 1.4s infinite; }", + ".typing span:nth-child(2){animation-delay:0.2s} .typing span:nth-child(3){animation-delay:0.4s}", + "@keyframes blink { 0%,60%,100%{transform:translateY(0);opacity:0.4} 30%{transform:translateY(-4px);opacity:1} }", + + // Input + ".input-container { padding:10px 10px; background:white; }", + ".input-wrapper { display:flex; align-items:center; gap:0; padding:8px 16px; background:var(--w-soft-bg); border-radius:28px; box-shadow:0 1px 3px rgba(0,0,0,0.05); }", + ".input-wrapper input { flex:1; padding:0; border:none; font-size:14px; background:transparent; outline:none; min-height:20px; color:#1f2937; }", + ".input-wrapper input::placeholder { color:#9ca3af; }", + ".input-separator { width:1px; height:24px; background:#e5e7eb; margin:0 8px; flex-shrink:0; }", + ".input-wrapper button { padding:8px 16px; border:none; border-radius:12px; background:linear-gradient(135deg, var(--w-primary), var(--w-gradient-end)); color:#fff; font-size:14px; font-weight:500; cursor:pointer; display:flex; align-items:center; gap:6px; white-space:nowrap; box-shadow:0 2px 8px rgba(var(--w-primary-rgb),0.25); flex-shrink:0; }", + ".input-wrapper button:disabled { background:#ccc; box-shadow:none; }", + + // Ended + ".ended { display:none; text-align:center; padding:8px; background:#e8f5e9; color:#34a853; font-size:12px; }", + ".ended.active { display:block; }", + + // Invite card container + // pointer-events:none:容器永不拦截点击,展示态由 .invite-panel.show 的 auto 恢复交互 + ".invite-container { z-index:99998;position: absolute;bottom: 80px;right: 0;pointer-events:none; }", + ".invite-panel { width:360px; max-width:calc(100vw - 24px); overflow:hidden; border-radius:26px; border:1px solid var(--w-border-soft); background:white; box-shadow:0 28px 80px rgba(var(--w-primary-rgb),0.22); opacity:0; transform:translateY(20px); transition:opacity 0.3s, transform 0.3s; pointer-events:none; }", + ".invite-panel.show { opacity:1; transform:translateY(0); pointer-events:auto; }", + ".invite-panel.hide { display:none; }", + + // Invite header + ".invite-header { background:linear-gradient(135deg, var(--w-primary), var(--w-gradient-end)); padding:16px 20px; color:white; display:flex; gap:12px; }", + ".invite-icon-wrapper { flex-shrink:0; }", + ".invite-icon { position:relative;width:40px; height:40px; background:rgba(255,255,255,0.18); border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:18px; backdrop-filter:blur(10px); }", + ".invite-status-dot { position:absolute; bottom:-2px; right:-2px; width:12px; height:12px; background:#22c55e; border-radius:50%; border:3px solid var(--w-primary); }", + ".invite-header-content { flex:1; min-width:0; }", + ".invite-title { font-size:15px; font-weight:600; line-height:1.3; }", + ".invite-subtitle { font-size:12px; margin-top:2px; opacity:0.9; line-height:1.4; }", + ".invite-header-actions { display:flex; flex-direction:column; gap:6px; flex-shrink:0; margin-left:auto; }", + ".invite-close-btn { align-self: flex-end; width:28px; height:28px; border-radius:6px; background:rgba(255,255,255,0.1); border:none; color:white; cursor:pointer; display:flex; align-items:center; justify-content:center; transition:background 0.2s; flex-shrink:0; }", + ".invite-close-btn:hover { background:rgba(255,255,255,0.2); }", + ".invite-action-btn { justify-content: center; background:#fff; color:var(--w-deep-text); border:1px solid var(--w-border-soft); padding:5px 10px; border-radius:20px; font-size:12px; font-weight:600; cursor:pointer; display:flex; align-items:center; gap:5px; text-decoration:none; flex-shrink:0; transition:all 0.2s; white-space:nowrap; }", + ".invite-action-btn:hover { background:var(--w-soft-bg); }", + + // Invite body + ".invite-body { padding:16px 8px; background:var(--w-soft-bg); }", + + // Topic buttons + ".invite-topics { display:grid; grid-template-columns:1fr 1fr; gap:5px; margin-bottom:16px; }", + ".invite-topic-btn { border-radius:22px; border:1px solid var(--w-border-soft); background:white; padding:14px 10px; text-align:center; font-size:13px; font-weight:500; color:var(--w-deep-text); cursor:pointer; transition:background 0.2s; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }", + ".invite-topic-btn:hover { background:var(--w-soft-bg); }", + + // Action buttons + ".invite-actions { display:flex; gap:10px; }", + ".invite-start-btn { flex:1; border-radius:20px; border:none; background:linear-gradient(135deg, var(--w-primary), var(--w-gradient-end)); padding:14px 20px; color:white; font-size:14px; font-weight:600; cursor:pointer; display:flex; align-items:center; justify-content:center; gap:8px; box-shadow:0 2px 8px rgba(var(--w-primary-rgb),0.25); transition:opacity 0.2s; }", + ".invite-start-btn:hover { opacity:0.95; }", + ".invite-dismiss-btn { border-radius:20px; border:1px solid var(--w-border-soft); background:white; padding:14px 20px; color:var(--w-deep-text); font-size:14px; font-weight:500; cursor:pointer; transition:background 0.2s; }", + ".invite-dismiss-btn:hover { background:#FEFEFE; }", + + // Responsive + "@media(max-width:450px){ .chat-panel{width:calc(100vw - 20px);height:calc(100vh - 200px);bottom:60px;right:0px;} .invite-panel{width:calc(100vw - 24px);} .invite-topics{grid-template-columns:1fr;} }", + ].join("\n"); + shadow.appendChild(style); + + // Build DOM + var container = document.createElement("div"); + container.className = "widget-container" + (isMobile ? " mobile-host" : ""); + + var panel = document.createElement("div"); + panel.className = "chat-panel"; + panel.innerHTML = [ + '
', + '
🤖
', + '

' + title + '

Online now · Usually replies in minutes
', + '
', + '', + // '', + '
', + '
Chat ended. We\'ll be in touch!
', + '
', + '
How can we help?
Ask about vehicles, market support, quotation, shipping and delivery.
', + '
', + '
', + '
', + '
Hey! What kind of car are you looking for?
', + '
', + '
', + '', + '
', + '
', + '', + '', + '
', + ].join(""); + + // Invite card HTML template + var inviteTemplate = [ + '
', + '
', + '
', + '
🤖
', + '
', + '
', + '
Chat with us for available vehicles, delivery timing and distributor cooperation.
', + '
', + '
', + '', + '', + '', + 'WhatsApp', + '', + '', + '
', + '
', + '
', + '
', + '', + '', + '
', + '
', + '
', + '
', + '
', + '', + '', + '
', + '
', + '
' + ].join(""); + + var inviteEl = document.createElement("div"); + inviteEl.className = "invite-container"; + inviteEl.innerHTML = inviteTemplate; + // 不变量:面板出厂即 hide(display:none)—— 任何不经过 showInvite 的路径都零占位。 + // 2026-09-01 修复:此前 permanent 关闭后刷新时 showInvite 不运行、invitePanel 恒为 + // null、hideInvite 提前返回,隐形 invite-panel(display:block + opacity:0)在 + // z-index:99998 容器里拦截右下角点击。出厂 hide 取代原 !autoInvite 特判。 + + var toggleBtn = document.createElement("button"); + toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : ""); + if (isMobile) { + toggleBtn.innerHTML = ` +
🤖
+ Chat with us +`; + } else { + toggleBtn.innerHTML = ` +
+
🤖
+
+
+
+
Chat with us
+
Usually replies quickly
+
+`; + } + + container.appendChild(panel); + container.appendChild(inviteEl); + container.appendChild(toggleBtn); + shadow.appendChild(container); + + // 头像兜底:load 成功隐藏底层 emoji;error 移除 img 露出 emoji(spec §12.4) + Array.prototype.forEach.call(shadow.querySelectorAll(".avatar-img"), function (img) { + function hideEmoji() { + var s = img.parentElement && img.parentElement.querySelector(".avatar-emoji"); + if (s) s.style.display = "none"; + } + if (img.complete && img.naturalWidth > 0) { hideEmoji(); return; } + img.addEventListener("load", hideEmoji); + img.addEventListener("error", function () { img.remove(); }); + }); + + // Widget state + var isOpen = autoOpen; + var wSessionId = null; + var wWs = null; + var wEnded = false; + var streamBubble = null; + var pageToken = crypto.randomUUID(); + var pendingMessage = null; // Queue message to send after WebSocket opens + + // Invite card state management + var inviteTimer = null; + var invitePanel = null; + var inviteShown = false; + var inviteStorageKey = "widget_invite_dismissed_" + window.location.pathname; + var isPermanentlyClosed = false; + + function checkInviteStorage() { + try { + var dismissed = sessionStorage.getItem(inviteStorageKey); + if (dismissed === "permanent") { + isPermanentlyClosed = true; + return true; + } + return false; + } catch (e) { + return false; // Assume not dismissed on error + } + } + + function setInviteDismissed(type) { + try { + if (type !== "permanent" && type !== "temporary") { + type = "temporary"; // Default fallback + } + if (type === "permanent") { + sessionStorage.setItem(inviteStorageKey, "permanent"); + isPermanentlyClosed = true; + } else { + sessionStorage.setItem(inviteStorageKey, "temporary"); + } + } catch (e) { + // Silently fail - storage unavailable + } + } + + function clearInviteDismissed() { + try { + sessionStorage.removeItem(inviteStorageKey); + isPermanentlyClosed = false; + inviteShown = false; // Reset shown flag + } catch (e) { + // Silently fail - storage unavailable + } + } + + function startInviteTimer() { + if (!autoInvite) return; // 配置关闭则不启动计时器,邀请卡永不自动弹 + if (checkInviteStorage()) { + return; // Already dismissed, don't show + } + + inviteTimer = window.setTimeout(function() { + if (!isOpen) { + showInvite(); + } + }, 5000); // 5 seconds + } + + function showInvite() { + if (inviteShown || isPermanentlyClosed) return; + inviteShown = true; + invitePanel = shadow.querySelector(".invite-panel"); + if (invitePanel) { + invitePanel.classList.remove("hide"); + void invitePanel.offsetWidth; // 强制回流:remove 与 add 同帧会跳过 0.3s 淡入过渡 + invitePanel.classList.add("show"); + } + } + + function hideInvite(type) { + if (!invitePanel) return; + invitePanel.classList.remove("show"); + invitePanel.classList.add("hide"); + setInviteDismissed(type || "temporary"); + } + + var msgsEl = shadow.getElementById("w-msgs"); + var inputEl = shadow.getElementById("w-input"); + var sendEl = shadow.getElementById("w-send"); + var typingEl = shadow.getElementById("w-typing"); + var endedEl = shadow.getElementById("w-ended"); + var inviteCloseBtn = shadow.querySelector(".invite-close-btn"); + var inviteStartBtn = shadow.querySelector(".invite-start-btn"); + var inviteDismissBtn = shadow.querySelector(".invite-dismiss-btn"); + var inviteTopicBtns = shadow.querySelectorAll(".invite-topic-btn"); + + if (isOpen) panel.classList.add("open"); + + function toggle() { + isOpen = !isOpen; + panel.classList.toggle("open", isOpen); + + if (isOpen) { + if (inviteTimer) { + window.clearTimeout(inviteTimer); + inviteTimer = null; + } + hideInvite("temporary"); + if (!wWs) wConnect(); + } else { + // Only restart timer if not permanently closed + if (!isPermanentlyClosed && !checkInviteStorage()) { + inviteShown = false; + startInviteTimer(); + } + } + } + + toggleBtn.addEventListener("click", toggle); + // panel.querySelector(".close-btn").addEventListener("click", toggle); + panel.querySelector(".action-btn:not(.close-btn)")?.addEventListener("click", toggle); + + // Invite close button + // 2026-08-20 用户决策: 点 X 与点 No Thanks 同效(permanent)——本标签页会话+本页面 + // 路径内不再自动弹(含关面板后/刷新后);temporary 写了从未被 checkInviteStorage + // 消费,等于无抑制,原行为即"关面板5s后又弹"。 + inviteCloseBtn.addEventListener("click", function() { + hideInvite("permanent"); + }); + + // Invite Start Chat button + inviteStartBtn.addEventListener("click", function() { + hideInvite("temporary"); + if (!isOpen) { + toggle(); + } + }); + + // Invite No Thanks button + inviteDismissBtn.addEventListener("click", function() { + hideInvite("permanent"); + }); + + // Invite topic buttons - auto send message + Array.prototype.forEach.call(inviteTopicBtns, function(btn) { + btn.addEventListener("click", function() { + var topic = this.getAttribute("data-topic"); + hideInvite("temporary"); + + if (!isOpen) { + toggle(); + } + + // Queue message to send after WebSocket opens + pendingMessage = topic; + }); + }); + + // Popular topics buttons + var popularTopicBtns = shadow.querySelectorAll(".popular-topic-btn"); + Array.prototype.forEach.call(popularTopicBtns, function(btn) { + btn.addEventListener("click", function() { + var topic = this.getAttribute("data-topic"); + + // Hide popular topics after first interaction + var popularTopicsEl = shadow.getElementById("w-popular-topics"); + if (popularTopicsEl) { + popularTopicsEl.style.display = "none"; + } + + // Send message + autoSendMessage(topic); + }); + }); + + function wConnect() { + var proto = wsBase.indexOf("https") === 0 ? "wss" : "ws"; + var wsHost = wsBase.replace(/^https?/, proto); + wWs = new WebSocket(wsHost + "/ws/chat"); + + wWs.onopen = function () { + if (wSessionId) { + wWs.send(JSON.stringify({ session_id: wSessionId, page_token: pageToken })); + } else { + wWs.send(JSON.stringify({ source: "widget", page_token: pageToken })); + } + // Send any pending message after WebSocket opens + if (pendingMessage) { + wWs.send(JSON.stringify({ content: pendingMessage })); + addMsg("user", pendingMessage); + typingEl.classList.add("active"); + pendingMessage = null; + } + }; + + wWs.onmessage = function (e) { + var data = JSON.parse(e.data); + if (data.type === "init") { + wSessionId = data.session_id; + } + + if (data.type === "stream_start") { + typingEl.classList.remove("active"); + streamBubble = addStreamBubble(); + } + + if (data.type === "stream_chunk") { + if (streamBubble) { + streamBubble.textContent += data.content; + msgsEl.scrollTop = msgsEl.scrollHeight; + } + } + + if (data.type === "stream_end") { + streamBubble = null; + } + + if (data.type === "evaluated") { + if (data.ended) { + wEnded = true; + endedEl.classList.add("active"); + inputEl.disabled = true; + sendEl.disabled = true; + } + } + + if (data.type === "error") { + typingEl.classList.remove("active"); + streamBubble = null; + addMsg("ai", "Sorry, something went wrong."); + } + }; + + wWs.onclose = function () { + if (!wEnded && host.isConnected) setTimeout(wConnect, 3000); + }; + } + + function addMsg(role, text) { + var div = document.createElement("div"); + div.className = "msg " + role; + div.textContent = text; + msgsEl.appendChild(div); + msgsEl.scrollTop = msgsEl.scrollHeight; + } + + function addStreamBubble() { + var div = document.createElement("div"); + div.className = "msg ai"; + div.textContent = ""; + msgsEl.appendChild(div); + msgsEl.scrollTop = msgsEl.scrollHeight; + return div; + } + + function autoSendMessage(text) { + if (!text || !wWs || wWs.readyState !== WebSocket.OPEN) return; + addMsg("user", text); + typingEl.classList.add("active"); + wWs.send(JSON.stringify({ content: text })); + } + + function wSend() { + var text = inputEl.value.trim(); + if (!text || wEnded || !wWs || wWs.readyState !== WebSocket.OPEN) return; + addMsg("user", text); + inputEl.value = ""; + typingEl.classList.add("active"); + wWs.send(JSON.stringify({ content: text })); + } + + sendEl.addEventListener("click", wSend); + inputEl.addEventListener("keydown", function (e) { + if (e.key === "Enter") { e.preventDefault(); wSend(); } + }); + + // Start invite timer + startInviteTimer(); + } + + // Wait for body if script is in + if (document.body) { + init(); + } else { + document.addEventListener("DOMContentLoaded", init); + } +})(); diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..26d3079 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,120 @@ +import os +import tempfile +import asyncio + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from sqlalchemy.pool import NullPool + +from app.models.database import Base + +# `chat_rest` is created in Task A4. Tolerate its absence so earlier tasks +# (e.g. A0's /health smoke) can use the `client` fixture without it. +# Import `app.main` FIRST so the natural main -> chat_rest load order resolves +# (chat_rest does `from app.main import async_session`; if conftest imports +# chat_rest before main is loaded, main's `from chat_rest import router` hits +# a partially-initialized module -> circular ImportError). +_HAS_CHAT_REST = False +chat_rest_mod = None +try: + import app.main # noqa: F401 + import app.api.routes.chat_rest as chat_rest_mod + _HAS_CHAT_REST = True +except ModuleNotFoundError: + pass + + +async def _create_all(engine): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +@pytest.fixture(scope="session", autouse=True) +def _dispose_main_engine(): + """Dispose `app.main.engine` at session end. + + Best-effort cleanup: explicitly disposing the app-level engine before + interpreter shutdown reduces (but does NOT eliminate) the noisy + `Windows fatal exception: access violation` traces that aiosqlite worker + threads emit when interrupted mid-cleanup at interpreter exit. + + Reality check on Windows: + - All tests pass and pytest exits 0; the traces are written to stderr + AFTER the run completes and do not affect pass/fail or the exit code. + - They originate from aiosqlite background threads being torn down by the + interpreter and are harmless. + - They do not occur on Linux/CI. + """ + yield + from app.main import engine as main_engine + try: + asyncio.run(main_engine.dispose()) + except Exception: + pass + + +@pytest.fixture +def async_db(): + """Fresh temp-file SQLite sessionmaker for direct async-DB tests + (e.g. testing ConversationManager without HTTP). Isolated per test. + """ + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + engine = create_async_engine( + f"sqlite+aiosqlite:///{path}", poolclass=NullPool + ) + asyncio.run(_create_all(engine)) + sm = async_sessionmaker(engine, expire_on_commit=False) + yield sm + asyncio.run(engine.dispose()) + try: + os.remove(path) + except PermissionError: + pass + + +@pytest.fixture(autouse=True) +def _reset_rate_limit(): + """Reset the in-memory rate limiter before and after every test so that + module-global bucket state can never leak across tests.""" + from app.services.rate_limit import _reset_for_test + _reset_for_test() + yield + _reset_for_test() + + +@pytest.fixture +def client(monkeypatch): + """Temporary file-SQLite TestClient with `chat_rest.async_session` overridden. + + Design notes: + - File DB (not :memory:) avoids the StaticPool cross-loop reuse pitfall. + - `NullPool` closes each connection when the session returns it, so no + aiosqlite worker thread outlives the event loop that created it. + Together with `_dispose_main_engine`, this REDUCES but does NOT + eliminate the harmless Windows access-violation stderr traces described + above; it has no effect on test correctness or on Linux/CI. + - Monkeypatching the module-level `async_session` binding in `chat_rest` + works because the route handlers resolve it as a module global at call. + """ + from fastapi.testclient import TestClient + from app.main import app + + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + engine = create_async_engine( + f"sqlite+aiosqlite:///{path}", poolclass=NullPool + ) + asyncio.run(_create_all(engine)) + sm = async_sessionmaker(engine, expire_on_commit=False) + if _HAS_CHAT_REST: + monkeypatch.setattr(chat_rest_mod, "async_session", sm) + + with TestClient(app) as c: + yield c + + asyncio.run(engine.dispose()) + try: + os.remove(path) + except PermissionError: + pass diff --git a/tests/test_admin_push_logs.py b/tests/test_admin_push_logs.py new file mode 100644 index 0000000..83dea31 --- /dev/null +++ b/tests/test_admin_push_logs.py @@ -0,0 +1,149 @@ +"""admin 推送日志查询接口(设计 §9): 鉴权/筛选/分页/不含 payload。""" +import asyncio +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from sqlalchemy.pool import NullPool + +from app.models.database import ( + Base, Conversation, CustomerProfile, Evaluation, IntentLevel, LeadPushLog, +) + +# admin 路由用 `from app.main import async_session` 的模块级绑定—— +# 测试必须 patch admin 模块自己的绑定(client fixture 只 patch 了 chat_rest) +import app.api.routes.admin as admin_mod + + +@pytest.fixture +def admin_client(monkeypatch): + from app.main import app + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + engine = create_async_engine( + f"sqlite+aiosqlite:///{path}", poolclass=NullPool) + + async def create_all(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + asyncio.run(create_all()) + sm = async_sessionmaker(engine, expire_on_commit=False) + monkeypatch.setattr(admin_mod, "async_session", sm) + with TestClient(app) as c: + yield c, sm + asyncio.run(engine.dispose()) + try: + os.remove(path) + except PermissionError: + pass + + +def _seed(sm): + async def run(): + async with sm() as db: + conv = Conversation(session_id="s-admin") + db.add(conv) + await db.flush() + ev = Evaluation(conversation_id=conv.id, + intent_level=IntentLevel.pending, contact_info={}) + db.add(ev) + await db.flush() + db.add_all([ + LeadPushLog(evaluation_id=ev.id, conversation_id=conv.id, + push_type="lead", status="success", attempts=1, + outcome="accepted", response_status=200, + request_payload={"big": "x" * 1000}), + LeadPushLog(evaluation_id=ev.id, conversation_id=conv.id, + push_type="correction", status="failed", attempts=3, + response_status=500, error_message="http 500", + request_payload={"big": "y" * 1000}), + LeadPushLog(evaluation_id=ev.id, conversation_id=conv.id, + push_type="lead", status="skipped", attempts=0, + error_message="no valid contact", + request_payload=None), + ]) + await db.commit() + asyncio.run(run()) + + +def test_push_logs_requires_auth(admin_client): + c, _ = admin_client + resp = c.get("/api/admin/push-logs") + assert resp.json() == {"error": "unauthorized"} + + +def test_push_logs_list_filter_pagination(admin_client): + c, sm = admin_client + _seed(sm) + assert c.post("/api/admin/login", + json={"password": "admin123"}).json()["ok"] is True + + body = c.get("/api/admin/push-logs").json() + assert body["total"] == 3 and len(body["items"]) == 3 + item = body["items"][0] + assert "request_payload" not in item # 设计 §9: 列表不回 payload + assert {i["status"] for i in body["items"]} == {"success", "failed", "skipped"} + + only_failed = c.get("/api/admin/push-logs", params={"status": "failed"}).json() + assert only_failed["total"] == 1 + assert only_failed["items"][0]["push_type"] == "correction" + assert only_failed["items"][0]["error_message"] == "http 500" + assert only_failed["items"][0]["attempts"] == 3 + + page2 = c.get("/api/admin/push-logs", + params={"page": 2, "size": 2}).json() + assert page2["total"] == 3 and len(page2["items"]) == 1 + + by_conv = c.get("/api/admin/push-logs", + params={"conversation_id": "no-such"}).json() + assert by_conv["total"] == 0 + + +def test_push_logs_invalid_status_ignored(admin_client): + """非法 status 值当未筛(与 conversations 的 intent 白名单风格一致)。""" + c, sm = admin_client + _seed(sm) + c.post("/api/admin/login", json={"password": "admin123"}) + body = c.get("/api/admin/push-logs", params={"status": "bogus"}).json() + assert body["total"] == 3 + + +def test_admin_page_has_push_logs_entry(): + """后台页静态回归:推送日志入口/视图容器/接口引用三件套在场。""" + from pathlib import Path + html = (Path(__file__).resolve().parent.parent / "static" / "admin.html" + ).read_text(encoding="utf-8") + assert 'onclick="toggleLogs()"' in html # header 入口按钮 + assert 'id="logsView"' in html # 日志视图容器 + assert "/api/admin/push-logs" in html # 数据接口引用 + + +def test_push_logs_pending_filter_and_page_overflow(admin_client): + """pending 筛选分支 + 超界页码(items 空、total 不变)。""" + c, sm = admin_client + _seed(sm) # 3 条: success/failed/skipped + + async def add_pending(): + async with sm() as db: + ev = (await db.execute(select(Evaluation))).scalar_one() + conv_id = ev.conversation_id + db.add(LeadPushLog(evaluation_id=ev.id, conversation_id=conv_id, + push_type="lead", status="pending", attempts=0, + request_payload={"external_key": "k"})) + await db.commit() + asyncio.run(add_pending()) + + assert c.post("/api/admin/login", + json={"password": "admin123"}).json()["ok"] is True + + only_pending = c.get("/api/admin/push-logs", + params={"status": "pending"}).json() + assert only_pending["total"] == 1 + assert only_pending["items"][0]["status"] == "pending" + + # 超界页码: items 空而 total 仍是全量(4) + overflow = c.get("/api/admin/push-logs", params={"page": 99}).json() + assert overflow["total"] == 4 and overflow["items"] == [] diff --git a/tests/test_boundary_filter.py b/tests/test_boundary_filter.py new file mode 100644 index 0000000..c2a8979 --- /dev/null +++ b/tests/test_boundary_filter.py @@ -0,0 +1,225 @@ +from app.services.boundary_filter import ( + deflection_for, pivot_for, split_sentences, violates_boundary, +) + + +def test_dollar_form_violates(): + assert violates_boundary("the Tang starts at $25k") + assert violates_boundary("around $ 25,000 for the EV") + + +def test_currency_unit_violates(): + assert violates_boundary("that's 25000 usd") + assert violates_boundary("大概是2万左右") + assert violates_boundary("20万块") + + +def test_k_form_violates(): + assert violates_boundary("a budget of 20K sounds good") + assert violates_boundary("around 25k") + + +def test_verb_adjacency_violates(): + assert violates_boundary("the price is 25 for that one") + + +def test_time_promise_violates(): + assert violates_boundary("shipping takes about 3 weeks") + assert violates_boundary("2-3 days to Mombasa") + + +def test_whitelist_clean(): + assert not violates_boundary("the 2024 model is our best seller") + assert not violates_boundary("I'll send you a tailored quote for the L7 SUV") + assert not violates_boundary("600km range on the Tang EV") + assert not violates_boundary("85kWh battery, 7-seater layout") + assert not violates_boundary("we got tons of them, any preference on year?") + + +def test_deflection_language_routing(): + assert deflection_for("多少钱") in __import__( + "app.services.boundary_filter", fromlist=["DEFLECTIONS_ZH"]).DEFLECTIONS_ZH + assert deflection_for("how much?").endswith("?") + + +def test_deflection_rotates(): + a = deflection_for("how much?", 0) + b = deflection_for("how much?", 1) + assert a != b + + +def test_mileage_forms_clean(): + assert not violates_boundary("under 30k km on the clock") + assert not violates_boundary("45k miles, mostly highway") + assert not violates_boundary("这车表显3万公里") + + +def test_bare_budget_echo_violates(): + assert violates_boundary("with your budget of 20000 you have options") + assert violates_boundary("你的预算是20000,可以看看这几款") + assert violates_boundary("预算两万的话选择不少") + + +def test_financing_violates(): + assert violates_boundary("down payment is 5,000") + assert violates_boundary("monthly payment of 450") + + +def test_warranty_clean(): + # 政策变更(2026-09-10): 编造的保修期限承诺改为墙级违规(原先白名单放行) + assert violates_boundary("6 months warranty on the engine") + + +def test_deflection_contact_aware(): + from app.services.boundary_filter import DEFLECTIONS_EN_CONTACT + d = deflection_for("how much?", 0, has_contact=True) + assert d in DEFLECTIONS_EN_CONTACT + assert "WhatsApp?" not in d # 不再索要 + + +# ---- 13.7: 句子粒度门控 ---- +def test_split_sentences_en_and_tail(): + complete, tail = split_sentences("Nice pick! The Tang comes in EV and DM-i.") + assert complete == ["Nice pick!"] + # 句间空白随下句保留(无损): 干净回复逐句流出后逐字还原,不多字不少字 + assert tail == " The Tang comes in EV and DM-i." + assert "".join(complete) + tail == "Nice pick! The Tang comes in EV and DM-i." + + +def test_split_sentences_cjk_no_space_needed(): + complete, tail = split_sentences("好的。下句") + assert complete == ["好的。"] + assert tail == "下句" + + +def test_split_sentences_decimal_not_split(): + # 意图锚定: 小数点不得把 "2.5..." 碎成 "2" + "5 engine..."(碎片会 + # 误触发价格动词邻接模式)。整句保持完整即达标;尾巴必然是 "Next one"。 + complete, tail = split_sentences("2.5 engine is strong. Next one") + assert complete == ["2.5 engine is strong."] + assert tail.strip() == "Next one" + + +def test_pivot_contact_aware(): + assert "WhatsApp?" in pivot_for("how much?") + assert "WhatsApp?" not in pivot_for("how much?", has_contact=True) + assert "客户经理" in pivot_for("多少钱") + + +# ---- 15.3: 零号码墙(doctrine v2) ---- +from app.services.boundary_filter import ( + NO_CONTACT_APP, NO_CONTACT_WEB, no_contact_value, +) + + +def test_no_contact_wall_blocks_any_number_or_email(): + assert no_contact_value("call us at +86 138 0013 8000") + assert no_contact_value("our email is sales@example.com") + + +def test_no_contact_wall_customer_echo_exempt(): + history = [{"role": "user", "content": "my number is 07012345678"}] + assert not no_contact_value("Got it, 07012345678!", history) + + +def test_no_contact_wall_whitelist_safe(): + assert not no_contact_value("the 2024 model with 45,000 km, VIN LFV3A23C8K3123456") + assert not no_contact_value("85kWh battery, 600km range") + + +def test_no_contact_templates_contain_no_numbers(): + # 替换句自身不得含号码/邮箱(防递归与自破) + assert not no_contact_value(NO_CONTACT_WEB) + assert not no_contact_value(NO_CONTACT_APP) + + +def test_no_contact_already_template_clean(): + from app.services.boundary_filter import NO_CONTACT_ALREADY + assert not no_contact_value(NO_CONTACT_ALREADY) # 模板自身无号码/邮箱 + assert "WhatsApp?" not in NO_CONTACT_ALREADY # 不索要 + + +# ---- Task 5b: 再索要墙 ---- +from app.services.boundary_filter import asks_contact + + +def test_asks_contact_detects_production_log_phrasings(): + # 2026-09 生产日志 4 次违规索要原句 + system.md 全部留资模板同型句,逐字钉死 + # (⚠️ 首版模式曾漏 "I'll need your WhatsApp"/"Tell me your WhatsApp" 两句 + # system.md 模板原句——GLM 背的就是这些,必须全收;已实证 13/13) + assert asks_contact("What's your WhatsApp or email?") + assert asks_contact("Could you leave your WhatsApp?") + assert asks_contact("Got your WhatsApp so our manager can send the details?") + assert asks_contact("Got your WhatsApp?") + assert asks_contact("Drop your WhatsApp and our manager will reach out to you directly with the full quote!") + assert asks_contact("Want me to get you a proper quote? I'll need your WhatsApp so our manager can reach you.") + assert asks_contact("Tell me your WhatsApp and our manager will send you an accurate quote for your budget.") + assert asks_contact("leave your contact and our manager will give you an exact schedule") + assert asks_contact("Drop yours too and our manager will send you the full quote!") + assert asks_contact("留个WhatsApp或者邮箱吧?") + assert asks_contact("你WhatsApp多少?") + assert asks_contact("你给我个联系方式,我让客户经理给你推几个车型。") + + +def test_asks_contact_clean_phrasings_pass(): + assert not asks_contact("Got your number, thanks!") # 确认,非问句 + assert not asks_contact("Just tap the WhatsApp button at the top of this chat — our manager replies fast!") + assert not asks_contact("Which color do you prefer?") + assert not asks_contact("What's your budget range for this one?") + assert not asks_contact("Can you send me more details about the Tang?") # 泛请求无渠道词 + assert not asks_contact("Our manager will send you all the details.") + assert not asks_contact("I want your advice on the Song L EV.") # want+非渠道词 + assert not asks_contact("Could you drop it here once more?") # 核对恢复话术(suspect场景) + assert not asks_contact("Our manager needs your config and destination.") # 合法迂回句 + + +def test_asks_contact_bare_fragment_asks(): + # Ghana 生产案例: "Your number, please?" 曾穿透(只覆盖动词/疑问词形态) + assert asks_contact("Your number, please?") + assert asks_contact("Your WhatsApp?") + assert asks_contact("So... your number?") + assert asks_contact("And your contact?") + # 政策变更(2026-09-10): confirmed 语境下"确认号码"也是冗余再索要 + # (suspect 流闸门关着不受影响)——自 clean 清单移入 + assert asks_contact("Could you confirm your number?") + + +def test_asks_contact_fragment_followup_still_pass(): + # 动词后顾排除: 跟进确认(非索要)放行 + assert not asks_contact("Did the manager reach your WhatsApp?") + assert not asks_contact("Got your number, thanks!") + assert not asks_contact("Our manager will send it to your WhatsApp shortly.") + # "to your <渠道>" 是送达/核对语境,不是索要(评审修正: 补 (?" 曾从门控模式掉落(extractor 漏时彻底失联) + found = scan_user_messages(m("my phone is 07123456789")) + assert found == {"phone": "07123456789"} + + +def test_latest_stated_my_phone_is_form(): + msgs = m("hi", "my phone is 07123456789") + assert latest_stated_contacts(msgs) == {"phone": "07123456789"} + + +from app.services.contact_pipeline import resolve_by_recency + + +def _u(*texts): + return [{"role": "user", "content": t} for t in texts] + + +def test_recency_mirror_lag_newest_wins(): + # Task 12b 场景: extractor 只翻新 phone,镜像 whatsapp 滞后 → 最新提及统一 + before = {"phone": "+8613800138000", "whatsapp": "+8613800138000"} + after = {"phone": "+8613999999999", "whatsapp": "+8613800138000"} + msgs = _u("my phone is +8613800138000", "wrong number, use +8613999999999") + assert resolve_by_recency(before, after, msgs) == { + "phone": "+8613999999999", "whatsapp": "+8613999999999"} + + +def test_recency_kills_extractor_oscillation(): + # 实测bug场景: extractor 翻回更早消息里的旧号 → 裁决回最新的 ...004 + before = {"phone": "+8613800138004", "whatsapp": "+8613800138004"} + after = {"phone": "+8613800138000", "whatsapp": "+8613800138004"} # extractor 振荡 + msgs = _u("my whatsapp is +8613800138000", "my whatsapp is +8613800138004", + "any car talk") + assert resolve_by_recency(before, after, msgs) == before # 无变化→不触发更正 + + +def test_recency_suppresses_hallucinated_value(): + # 幻觉值从未出现在消息里(last_idx=-1) → 真实陈述胜 + before = {"phone": "+8613800138004", "whatsapp": "+8613800138004"} + after = {"phone": "+8613999999999", "whatsapp": "+8613800138004"} + msgs = _u("my whatsapp is +8613800138004") + assert resolve_by_recency(before, after, msgs) == before + + +def test_recency_bare_newest_number_wins(): + # 用户实测终局: 最晚消息里的裸新号(extractor 抓到)胜过更早的陈述 + before = {"phone": "+8613800138004", "whatsapp": "+8613800138004"} + after = {"phone": "18325698745", "whatsapp": "+8613800138004"} + msgs = _u("my whatsapp is +8613800138004", "chat", "18325698745") + assert resolve_by_recency(before, after, msgs) == { + "phone": "18325698745", "whatsapp": "18325698745"} + + +def test_recency_genuinely_different_untouched(): + before = {"phone": "+8613800138000", "whatsapp": "+254712345678"} + after = {"phone": "+8613999999999", "whatsapp": "+254712345678"} + msgs = _u("my phone is +8613800138000", "my whatsapp is +254712345678", + "wrong number, use +8613999999999") + assert resolve_by_recency(before, after, msgs) == after # 不同组不动 + + +from app.services.contact_pipeline import standalone_number + + +def test_standalone_number_captures_cn_and_ke_mobiles(): + assert standalone_number("15236541128") == "15236541128" + assert standalone_number("0712345678") == "0712345678" + assert standalone_number("+86 138 0013 8000") == "+86 138 0013 8000" + + +def test_standalone_number_rejects_prices_years_and_prose(): + assert standalone_number("2024") is None # 年份 + assert standalone_number("25000") is None # 美元价 + assert standalone_number("1500000") is None # 先令价(7位) + assert standalone_number("50,000 km") is None # 含字母逗号 + assert standalone_number("I want 5 units") is None + + +def test_latest_stated_standalone_overrides(): + msgs = m("my number is +254712345678", "chat", "15236541128") + assert latest_stated_contacts(msgs) == {"phone": "15236541128"} + + +# ---- P0 捕获缺口扩充: 号码在前/无is/msg me/wa冒号 ---- +# 全部经 scan_user_messages 断言,不直接 import 新正则符号 + + +def test_scan_number_before_whatsapp(): + # 倒装: "0712 345 678 is my whatsapp" + assert scan_user_messages(m("0712 345 678 is my whatsapp")) == { + "whatsapp": "0712 345 678"} + + +def test_scan_number_before_wechat_cn(): + # 中文倒装(我的后无空格——正则必须用 \s* 而非 \s+,实证 \s+ 永不命中) + assert scan_user_messages(m("13800138000 我的微信")) == { + "wechat": "13800138000"} + assert scan_user_messages(m("13800138000 这是我的微信")) == { + "wechat": "13800138000"} + + +def test_scan_my_number_without_is(): + # "my number 0712345678"(无 is/冒号) + assert scan_user_messages(m("my number 0712345678")) == { + "phone": "0712345678"} + + +def test_scan_text_me_forms(): + assert scan_user_messages(m("text me at 07123456789")) == { + "phone": "07123456789"} + assert scan_user_messages(m("msg me 07123456789")) == { + "phone": "07123456789"} + + +def test_scan_wa_colon_local_number(): + assert scan_user_messages(m("wa: 07123456789")) == { + "whatsapp": "07123456789"} + + +def test_scan_new_forms_do_not_grab_prices(): + # 误报防护: 价格/单号即使与 whatsapp 词同现也不得捕获(无 my 指称/无邻接) + assert scan_user_messages(m("paid 2500000 via whatsapp transfer")) == {} + assert scan_user_messages(m("my budget 25000 number one priority")) == {} + assert scan_user_messages(m("order 1234567 is my order number")) == {} + # 排位表述(实证: 若倒装正则收 number/phone,"2500000 is my number" 被误捕, + # 且 latest_stated_contacts 覆盖权会把真实号码翻新成价格)——倒装只收显式渠道词 + assert scan_user_messages(m("2500000 is my number one choice")) == {} + # wa 词界守卫: "my watch/way" 的 wa 后无词边界,不得命中 + assert scan_user_messages(m("0712345678 my watch")) == {} + assert scan_user_messages(m("0712345678 my way home")) == {} + + +def test_bare_my_number_has_no_latest_authority(): + # 回归钉死: 裸 "my number X"(无 is/冒号)只进扫描辅源,不得进 + # latest_stated_contacts 获覆盖权——否则首轮旧值长期压制 extractor + # 后续改号(实证: test_correction_up_to_three 两封更正邮件全灭)。 + # 预期 {} 依赖 "wrong,"(逗号后无 number/phone)不命中 CORRECTION_CTX_RE。 + msgs = m("my number +8613800138000", "wrong, use +8613800139999", + "again +8613800122222") + assert latest_stated_contacts(msgs) == {} + + +def test_number_first_whatsapp_whitespace_variants(): + # 回归(评审): whats\s*app 捕获的任意空白变体(双空格/NBSP)不得让 + # _NUMBER_FIRST_CHANNEL 查表 KeyError——scan 在每条用户消息的热路径上 + # 运行(conversation.py 无 try/except),崩溃即断流整轮对话。 + assert scan_user_messages(m("0712345678 is my whats app")) == { + "whatsapp": "0712345678"} + assert scan_user_messages(m("0712345678 is my whats\xa0app")) == { + "whatsapp": "0712345678"} + # 两个接线位(scan + latest)都要走空白归一,任一崩都算回归 + assert latest_stated_contacts(m("0712345678 is my whats app")) == { + "whatsapp": "0712345678"} diff --git a/tests/test_context_seeding.py b/tests/test_context_seeding.py new file mode 100644 index 0000000..24c7fae --- /dev/null +++ b/tests/test_context_seeding.py @@ -0,0 +1,45 @@ +"""Red-line: `context` injection must be CONDITIONAL + WHITELIST only. + +- No `context` passed -> `raw_info` stays `{}` (widget WS path unchanged). +- `context` passed -> only `vehicle_name`/`vehicle_id` map to `app_*`. +""" +from sqlalchemy import select + +from app.models.database import CustomerProfile +from app.services.conversation import ConversationManager + + +async def _raw_info_for(async_db, conv_id) -> dict: + async with async_db() as db: + result = await db.execute( + select(CustomerProfile).where( + CustomerProfile.conversation_id == conv_id + ) + ) + profile = result.scalar_one() + return profile.raw_info or {} + + +async def test_no_context_keeps_raw_info_empty(async_db): + async with async_db() as db: + mgr = ConversationManager(db) + conv = await mgr.create_conversation(context=None) + raw = await _raw_info_for(async_db, conv.id) + assert raw == {} + + +async def test_context_seeds_whitelist_only(async_db): + async with async_db() as db: + mgr = ConversationManager(db) + conv = await mgr.create_conversation( + context={ + "vehicle_name": "Tesla Model Y", + "vehicle_id": "v123", + "evil_key": "should_be_ignored", + } + ) + raw = await _raw_info_for(async_db, conv.id) + assert raw == { + "app_vehicle_name": "Tesla Model Y", + "app_vehicle_id": "v123", + } diff --git a/tests/test_email_rendering.py b/tests/test_email_rendering.py new file mode 100644 index 0000000..e14f818 --- /dev/null +++ b/tests/test_email_rendering.py @@ -0,0 +1,66 @@ +"""邮件渲染单测: 标签主题/HTML转义/内部key过滤(D2/D12)。不触网。""" +import asyncio + +from app.services import email_service + + +def _capture_send(monkeypatch): + captured = {} + + async def fake_send(msg): + captured["msg"] = msg + return True + + monkeypatch.setattr(email_service, "_send", fake_send) + return captured + + +def _html(msg) -> str: + return msg.get_payload()[0].get_payload(decode=True).decode("utf-8") + + +def test_lead_email_subject_tag_and_escaped_body(monkeypatch): + captured = _capture_send(monkeypatch) + ok = asyncio.run(email_service.send_lead_email( + customer_profile={"目标国家": "肯尼亚", "_internal": "x", "备注": "bold"}, + contact_info={"phone": "+254712345678", "wechat": "wx"}, + reasoning="强意向", + conversation_messages=[ + {"role": "user", "content": ""}, + {"role": "assistant", "content": "ok"}, + ], + intent_level="interested", + )) + assert ok is True + subject = str(captured["msg"]["Subject"]) + assert "[新询盘·意向强]" in subject and "肯尼亚" in subject + body = _html(captured["msg"]) + assert "