feat: chat integration, tenant admin spec, backend chat service, miniprogram updates, DEMO moved to tmp, XCX-TEST removed, migrations & docs
This commit is contained in:
@@ -1,149 +0,0 @@
|
||||
"""
|
||||
DB 文档全量对账脚本(审计用,一次性)。
|
||||
连接测试库,查询 information_schema,与 docs/database/ 现有文档对比。
|
||||
输出 JSON 摘要到 stdout。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 加载根 .env
|
||||
load_dotenv(Path(__file__).resolve().parents[2] / ".env")
|
||||
|
||||
TEST_ETL_DSN = os.environ.get("TEST_DB_DSN")
|
||||
TEST_APP_DSN = os.environ.get("TEST_APP_DB_DSN")
|
||||
|
||||
if not TEST_ETL_DSN or not TEST_APP_DSN:
|
||||
print("ERROR: TEST_DB_DSN or TEST_APP_DB_DSN not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
import psycopg2 # noqa: E402
|
||||
|
||||
|
||||
def query_tables_and_columns(dsn: str, schemas: list[str]) -> dict:
|
||||
"""查询指定 schema 下所有表和字段。"""
|
||||
conn = psycopg2.connect(dsn)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
placeholders = ",".join(["%s"] * len(schemas))
|
||||
# 查询表
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT table_schema, table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema IN ({placeholders})
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_schema, table_name
|
||||
""",
|
||||
schemas,
|
||||
)
|
||||
tables = cur.fetchall()
|
||||
|
||||
# 查询字段
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT table_schema, table_name, column_name,
|
||||
data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema IN ({placeholders})
|
||||
ORDER BY table_schema, table_name, ordinal_position
|
||||
""",
|
||||
schemas,
|
||||
)
|
||||
columns = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
result = {}
|
||||
for schema, table in tables:
|
||||
key = f"{schema}.{table}"
|
||||
result[key] = {"schema": schema, "table": table, "columns": []}
|
||||
|
||||
for schema, table, col_name, data_type, nullable, default in columns:
|
||||
key = f"{schema}.{table}"
|
||||
if key in result:
|
||||
result[key]["columns"].append({
|
||||
"name": col_name,
|
||||
"type": data_type,
|
||||
"nullable": nullable,
|
||||
"default": default,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def scan_existing_docs(docs_dir: Path) -> set[str]:
|
||||
"""扫描 docs/database/ 下的 BD_Manual_*.md,提取已文档化的表名关键词。"""
|
||||
documented = set()
|
||||
for f in docs_dir.glob("BD_Manual_*.md"):
|
||||
# 从文件名提取表名关键词
|
||||
stem = f.stem.replace("BD_Manual_", "")
|
||||
documented.add(stem.lower())
|
||||
# 也从文件内容提取 schema.table 引用
|
||||
try:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
# 匹配 schema.table_name 模式
|
||||
for m in re.finditer(r"(\w+)\.(\w+)", content):
|
||||
schema, table = m.group(1), m.group(2)
|
||||
if schema in (
|
||||
"ods", "dwd", "dws", "meta", "core", "app",
|
||||
"public", "auth",
|
||||
):
|
||||
documented.add(f"{schema}.{table}".lower())
|
||||
except Exception:
|
||||
pass
|
||||
return documented
|
||||
|
||||
|
||||
def reconcile(db_tables: dict, documented: set[str]) -> dict:
|
||||
"""对账:找出缺失文档的表。"""
|
||||
missing = []
|
||||
for key, info in sorted(db_tables.items()):
|
||||
key_lower = key.lower()
|
||||
table_lower = info["table"].lower()
|
||||
# 检查是否有文档覆盖
|
||||
if key_lower not in documented and table_lower not in documented:
|
||||
missing.append({
|
||||
"schema_table": key,
|
||||
"column_count": len(info["columns"]),
|
||||
})
|
||||
return {
|
||||
"total_db_tables": len(db_tables),
|
||||
"documented_refs": len(documented),
|
||||
"missing_docs": missing,
|
||||
"missing_count": len(missing),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
docs_dir = Path(__file__).resolve().parents[2] / "docs" / "database"
|
||||
|
||||
# ETL 库(六层 schema)
|
||||
etl_schemas = ["ods", "dwd", "dws", "meta", "core", "app"]
|
||||
etl_tables = query_tables_and_columns(TEST_ETL_DSN, etl_schemas)
|
||||
|
||||
# 业务库
|
||||
app_schemas = ["public", "auth"]
|
||||
app_tables = query_tables_and_columns(TEST_APP_DSN, app_schemas)
|
||||
|
||||
# 合并
|
||||
all_tables = {**etl_tables, **app_tables}
|
||||
|
||||
# 扫描现有文档
|
||||
documented = scan_existing_docs(docs_dir)
|
||||
|
||||
# 对账
|
||||
result = reconcile(all_tables, documented)
|
||||
|
||||
# 输出 JSON
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
345
scripts/ops/test_chat_ai_quality.py
Normal file
345
scripts/ops/test_chat_ai_quality.py
Normal file
@@ -0,0 +1,345 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
RNS1.4 CHAT 模块 AI 返回质量评估脚本。
|
||||
|
||||
直接调用百炼 API(OpenAI 兼容协议),模拟 4 种入口场景的对话,
|
||||
评估 AI 回复的质量(语义相关性、中文正确性、上下文理解能力)。
|
||||
|
||||
输出:Markdown 评估报告 → docs/reports/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 加载根 .env
|
||||
_root = Path(__file__).resolve().parents[2]
|
||||
load_dotenv(_root / ".env")
|
||||
|
||||
BAILIAN_API_KEY = os.environ.get("BAILIAN_API_KEY")
|
||||
BAILIAN_BASE_URL = os.environ.get("BAILIAN_BASE_URL")
|
||||
BAILIAN_MODEL = os.environ.get("BAILIAN_MODEL")
|
||||
|
||||
if not all([BAILIAN_API_KEY, BAILIAN_BASE_URL, BAILIAN_MODEL]):
|
||||
print("ERROR: 缺少 BAILIAN_API_KEY / BAILIAN_BASE_URL / BAILIAN_MODEL 环境变量")
|
||||
sys.exit(1)
|
||||
|
||||
import openai
|
||||
|
||||
client = openai.AsyncOpenAI(api_key=BAILIAN_API_KEY, base_url=BAILIAN_BASE_URL)
|
||||
|
||||
SYSTEM_PROMPT = json.dumps(
|
||||
{"task": "你是台球门店的 AI 助手,根据用户的问题和当前页面上下文提供帮助。"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# ── 4 个测试场景 ──────────────────────────────────────────────
|
||||
|
||||
SCENARIOS: list[dict] = [
|
||||
{
|
||||
"name": "场景1: task 入口 — 维客任务咨询",
|
||||
"context_type": "task",
|
||||
"description": "助教从任务详情页进入,询问如何完成一个维客任务",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(
|
||||
{
|
||||
"current_time": datetime.now().isoformat(),
|
||||
"source_page": "task-detail",
|
||||
"page_context": {
|
||||
"task_type": "retention",
|
||||
"member_name": "张三",
|
||||
"priority_score": 85,
|
||||
"expires_at": "2026-03-25",
|
||||
},
|
||||
"screen_content": "维客任务:张三,优先级85分,3月25日到期",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "这个客户最近消费频率下降了,我应该怎么跟他沟通比较好?有什么话术建议吗?",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "场景2: customer 入口 — 客户详情咨询",
|
||||
"context_type": "customer",
|
||||
"description": "助教从客户详情页进入,询问客户消费情况分析",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(
|
||||
{
|
||||
"current_time": datetime.now().isoformat(),
|
||||
"source_page": "customer-detail",
|
||||
"page_context": {
|
||||
"member_id": 12345,
|
||||
"member_name": "李四",
|
||||
"member_level": "VIP",
|
||||
"last_visit": "2026-03-15",
|
||||
"total_consumption": "¥8,500",
|
||||
},
|
||||
"screen_content": "客户:李四,VIP会员,累计消费¥8,500,最近到店3月15日",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "帮我分析一下这个客户的消费习惯,他适合推荐什么课程?",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "场景3: coach 入口 — 助教业绩咨询",
|
||||
"context_type": "coach",
|
||||
"description": "助教从自己的详情页进入,询问业绩提升建议",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(
|
||||
{
|
||||
"current_time": datetime.now().isoformat(),
|
||||
"source_page": "coach-detail",
|
||||
"page_context": {
|
||||
"coach_name": "王教练",
|
||||
"monthly_lessons": 45,
|
||||
"monthly_revenue": "¥12,000",
|
||||
"customer_count": 28,
|
||||
},
|
||||
"screen_content": "助教:王教练,本月课时45节,收入¥12,000,服务客户28人",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "我这个月业绩一般,有什么方法可以提升客户续课率?",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "场景4: general 入口 — 通用对话",
|
||||
"context_type": "general",
|
||||
"description": "助教从首页直接进入聊天,无特定上下文",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "台球馆周末客流量大的时候,怎么合理安排台位和助教排班?",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# ── 多轮追问(场景1 追加) ────────────────────────────────────
|
||||
|
||||
FOLLOWUP_MESSAGES = [
|
||||
"如果他说最近比较忙没时间来,我该怎么回应?",
|
||||
"好的,那如果他愿意来,我应该推荐什么样的课程套餐?",
|
||||
]
|
||||
|
||||
|
||||
async def call_ai(messages: list[dict]) -> tuple[str, float, int | None]:
|
||||
"""调用百炼 API,返回 (回复内容, 耗时秒, tokens_used)。"""
|
||||
t0 = time.time()
|
||||
response = await client.chat.completions.create(
|
||||
model=BAILIAN_MODEL,
|
||||
messages=messages,
|
||||
temperature=0.7,
|
||||
max_tokens=2000,
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
content = response.choices[0].message.content or ""
|
||||
tokens = response.usage.total_tokens if response.usage else None
|
||||
return content, elapsed, tokens
|
||||
|
||||
|
||||
async def call_ai_stream(messages: list[dict]) -> tuple[str, float, int]:
|
||||
"""流式调用百炼 API,返回 (完整回复, 耗时秒, chunk数)。"""
|
||||
t0 = time.time()
|
||||
chunks: list[str] = []
|
||||
chunk_count = 0
|
||||
response = await client.chat.completions.create(
|
||||
model=BAILIAN_MODEL,
|
||||
messages=messages,
|
||||
temperature=0.7,
|
||||
max_tokens=2000,
|
||||
stream=True,
|
||||
)
|
||||
async for chunk in response:
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
chunks.append(chunk.choices[0].delta.content)
|
||||
chunk_count += 1
|
||||
elapsed = time.time() - t0
|
||||
return "".join(chunks), elapsed, chunk_count
|
||||
|
||||
|
||||
async def run_scenario(scenario: dict) -> dict:
|
||||
"""执行单个场景,返回结果字典。"""
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {scenario['name']}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
results = {"name": scenario["name"], "description": scenario["description"], "rounds": []}
|
||||
|
||||
messages = list(scenario["messages"])
|
||||
|
||||
# 第一轮:非流式
|
||||
user_msg = messages[-1]["content"]
|
||||
print(f"\n[用户] {user_msg[:80]}...")
|
||||
reply, elapsed, tokens = await call_ai(messages)
|
||||
print(f"[AI] ({elapsed:.1f}s, {tokens} tokens) {reply[:100]}...")
|
||||
results["rounds"].append({
|
||||
"round": 1,
|
||||
"mode": "非流式",
|
||||
"user_message": user_msg,
|
||||
"ai_reply": reply,
|
||||
"elapsed_seconds": round(elapsed, 2),
|
||||
"tokens_used": tokens,
|
||||
})
|
||||
messages.append({"role": "assistant", "content": reply})
|
||||
|
||||
# 第二轮:流式(仅场景1)
|
||||
if scenario["context_type"] == "task":
|
||||
for i, followup in enumerate(FOLLOWUP_MESSAGES):
|
||||
messages.append({"role": "user", "content": followup})
|
||||
print(f"\n[用户] {followup}")
|
||||
reply_s, elapsed_s, chunk_count = await call_ai_stream(messages)
|
||||
print(f"[AI-Stream] ({elapsed_s:.1f}s, {chunk_count} chunks) {reply_s[:100]}...")
|
||||
results["rounds"].append({
|
||||
"round": i + 2,
|
||||
"mode": "流式",
|
||||
"user_message": followup,
|
||||
"ai_reply": reply_s,
|
||||
"elapsed_seconds": round(elapsed_s, 2),
|
||||
"chunk_count": chunk_count,
|
||||
})
|
||||
messages.append({"role": "assistant", "content": reply_s})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def main():
|
||||
print("RNS1.4 CHAT AI 质量评估 — 开始")
|
||||
print(f"模型: {BAILIAN_MODEL}")
|
||||
print(f"端点: {BAILIAN_BASE_URL}")
|
||||
print(f"时间: {datetime.now().isoformat()}")
|
||||
|
||||
all_results: list[dict] = []
|
||||
for scenario in SCENARIOS:
|
||||
try:
|
||||
result = await run_scenario(scenario)
|
||||
all_results.append(result)
|
||||
except Exception as e:
|
||||
print(f"\n ❌ 场景失败: {e}")
|
||||
all_results.append({
|
||||
"name": scenario["name"],
|
||||
"description": scenario["description"],
|
||||
"error": str(e),
|
||||
})
|
||||
|
||||
# 生成 Markdown 报告
|
||||
report = generate_report(all_results)
|
||||
output_path = _root / "docs" / "reports" / "2026-03-20__rns14_chat_ai_quality_eval.md"
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(report, encoding="utf-8")
|
||||
print(f"\n✅ 报告已输出: {output_path}")
|
||||
|
||||
|
||||
def generate_report(results: list[dict]) -> str:
|
||||
"""生成 Markdown 评估报告。"""
|
||||
lines: list[str] = []
|
||||
lines.append("# RNS1.4 CHAT 模块 AI 返回质量评估报告")
|
||||
lines.append("")
|
||||
lines.append(f"- 评估时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
||||
lines.append(f"- 模型: {BAILIAN_MODEL}")
|
||||
lines.append(f"- 端点: {BAILIAN_BASE_URL}")
|
||||
lines.append(f"- 场景数: {len(results)}")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
for r in results:
|
||||
lines.append(f"## {r['name']}")
|
||||
lines.append("")
|
||||
lines.append(f"**场景描述**: {r['description']}")
|
||||
lines.append("")
|
||||
|
||||
if "error" in r:
|
||||
lines.append(f"**❌ 执行失败**: {r['error']}")
|
||||
lines.append("")
|
||||
continue
|
||||
|
||||
for rd in r.get("rounds", []):
|
||||
lines.append(f"### 第 {rd['round']} 轮({rd['mode']})")
|
||||
lines.append("")
|
||||
lines.append(f"**用户发送**:")
|
||||
lines.append("")
|
||||
lines.append(f"```")
|
||||
lines.append(rd["user_message"])
|
||||
lines.append(f"```")
|
||||
lines.append("")
|
||||
lines.append(f"**AI 回复**:")
|
||||
lines.append("")
|
||||
lines.append(f"```")
|
||||
lines.append(rd["ai_reply"])
|
||||
lines.append(f"```")
|
||||
lines.append("")
|
||||
|
||||
meta_parts = [f"耗时 {rd['elapsed_seconds']}s"]
|
||||
if rd.get("tokens_used"):
|
||||
meta_parts.append(f"tokens: {rd['tokens_used']}")
|
||||
if rd.get("chunk_count"):
|
||||
meta_parts.append(f"chunks: {rd['chunk_count']}")
|
||||
lines.append(f"**性能**: {' | '.join(meta_parts)}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
# AI 评价占位(由执行者填写)
|
||||
lines.append("## 综合评价")
|
||||
lines.append("")
|
||||
lines.append("| 维度 | 评分 | 说明 |")
|
||||
lines.append("|------|------|------|")
|
||||
lines.append("| 语义相关性 | — | — |")
|
||||
lines.append("| 中文表达质量 | — | — |")
|
||||
lines.append("| 上下文理解 | — | — |")
|
||||
lines.append("| 多轮连贯性 | — | — |")
|
||||
lines.append("| 响应速度 | — | — |")
|
||||
lines.append("| 流式输出稳定性 | — | — |")
|
||||
lines.append("")
|
||||
lines.append("> 评分标准: ✅ 优秀 / ⚠️ 可接受 / ❌ 不合格")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
225
scripts/ops/test_chat_e2e.py
Normal file
225
scripts/ops/test_chat_e2e.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
RNS1.4 CHAT 模块端到端测试脚本
|
||||
用法: python scripts/ops/test_chat_e2e.py
|
||||
|
||||
前置条件:
|
||||
- 后端服务已启动 (uvicorn app.main:app)
|
||||
- .env 中配置了 TEST_USER_TOKEN 和 APP_DB_DSN
|
||||
- test_zqyy_app 数据库可访问
|
||||
|
||||
环境变量:
|
||||
BACKEND_URL — 后端地址,默认 http://localhost:8000
|
||||
TEST_USER_TOKEN — 测试用户 JWT token
|
||||
APP_DB_DSN — 业务数据库连接串(指向 test_zqyy_app)
|
||||
"""
|
||||
# CHANGE 2026-03-20 | RNS1.4 T13.1: CHAT 端到端测试脚本
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# 加载根 .env
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(Path(__file__).resolve().parents[2] / ".env")
|
||||
|
||||
BACKEND_URL = os.environ.get("BACKEND_URL", "http://localhost:8000").rstrip("/")
|
||||
TOKEN = os.environ.get("TEST_USER_TOKEN", "")
|
||||
DB_DSN = os.environ.get("APP_DB_DSN", "")
|
||||
|
||||
if not TOKEN:
|
||||
print("❌ 缺少 TEST_USER_TOKEN 环境变量")
|
||||
sys.exit(1)
|
||||
if not DB_DSN:
|
||||
print("❌ 缺少 APP_DB_DSN 环境变量")
|
||||
sys.exit(1)
|
||||
|
||||
import httpx
|
||||
import psycopg2
|
||||
|
||||
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
|
||||
results: list[dict] = []
|
||||
|
||||
|
||||
def record(name: str, passed: bool, detail: str = ""):
|
||||
status = "✅ PASS" if passed else "❌ FAIL"
|
||||
print(f" {status} — {name}" + (f" ({detail})" if detail else ""))
|
||||
results.append({"test": name, "passed": passed, "detail": detail})
|
||||
|
||||
|
||||
def main():
|
||||
print(f"\n🔗 后端: {BACKEND_URL}")
|
||||
print(f"🗄️ 数据库: {DB_DSN[:40]}...\n")
|
||||
|
||||
chat_id = None
|
||||
|
||||
# ── CHAT-1: 对话历史列表 ──
|
||||
print("── CHAT-1: GET /api/xcx/chat/history ──")
|
||||
try:
|
||||
r = httpx.get(f"{BACKEND_URL}/api/xcx/chat/history", headers=HEADERS, timeout=10)
|
||||
record("CHAT-1 状态码 200", r.status_code == 200, f"got {r.status_code}")
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
record("CHAT-1 返回 items 数组", isinstance(data.get("items"), list))
|
||||
except Exception as e:
|
||||
record("CHAT-1 请求", False, str(e))
|
||||
|
||||
# ── 创建/获取对话(通过 general 入口)──
|
||||
print("\n── CHAT-2b: GET /api/xcx/chat/messages?contextType=general ──")
|
||||
try:
|
||||
r = httpx.get(
|
||||
f"{BACKEND_URL}/api/xcx/chat/messages",
|
||||
params={"contextType": "general", "contextId": ""},
|
||||
headers=HEADERS, timeout=10,
|
||||
)
|
||||
record("CHAT-2b 状态码 200", r.status_code == 200, f"got {r.status_code}")
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
chat_id = data.get("chatId")
|
||||
record("CHAT-2b 返回 chatId", chat_id is not None, f"chatId={chat_id}")
|
||||
except Exception as e:
|
||||
record("CHAT-2b 请求", False, str(e))
|
||||
|
||||
if not chat_id:
|
||||
print("\n⚠️ 无法获取 chatId,跳过后续测试")
|
||||
_print_summary()
|
||||
return
|
||||
|
||||
# ── CHAT-2a: 通过 chatId 查询消息 ──
|
||||
print(f"\n── CHAT-2a: GET /api/xcx/chat/{chat_id}/messages ──")
|
||||
try:
|
||||
r = httpx.get(
|
||||
f"{BACKEND_URL}/api/xcx/chat/{chat_id}/messages",
|
||||
headers=HEADERS, timeout=10,
|
||||
)
|
||||
record("CHAT-2a 状态码 200", r.status_code == 200, f"got {r.status_code}")
|
||||
except Exception as e:
|
||||
record("CHAT-2a 请求", False, str(e))
|
||||
|
||||
# ── CHAT-3: 发送消息(同步) ──
|
||||
print(f"\n── CHAT-3: POST /api/xcx/chat/{chat_id}/messages ──")
|
||||
test_content = "你好,这是一条端到端测试消息,请简短回复。"
|
||||
ai_reply_id = None
|
||||
try:
|
||||
r = httpx.post(
|
||||
f"{BACKEND_URL}/api/xcx/chat/{chat_id}/messages",
|
||||
json={"content": test_content},
|
||||
headers=HEADERS, timeout=30,
|
||||
)
|
||||
record("CHAT-3 状态码 200", r.status_code == 200, f"got {r.status_code}")
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
user_msg = data.get("userMessage", {})
|
||||
ai_msg = data.get("aiReply", {})
|
||||
record("CHAT-3 用户消息已返回", bool(user_msg.get("id")))
|
||||
record("CHAT-3 AI 回复已返回", bool(ai_msg.get("id")))
|
||||
record("CHAT-3 AI 回复非空", bool(ai_msg.get("content")))
|
||||
ai_reply_id = ai_msg.get("id")
|
||||
if ai_msg.get("content"):
|
||||
print(f" AI 回复: {ai_msg['content'][:80]}...")
|
||||
except Exception as e:
|
||||
record("CHAT-3 请求", False, str(e))
|
||||
|
||||
# ── CHAT-4: SSE 流式 ──
|
||||
print(f"\n── CHAT-4: POST /api/xcx/chat/stream (SSE) ──")
|
||||
sse_content = "请用一句话介绍台球运动。"
|
||||
sse_tokens: list[str] = []
|
||||
sse_done = False
|
||||
sse_message_id = None
|
||||
try:
|
||||
with httpx.stream(
|
||||
"POST",
|
||||
f"{BACKEND_URL}/api/xcx/chat/stream",
|
||||
json={"chatId": int(chat_id), "content": sse_content},
|
||||
headers=HEADERS,
|
||||
timeout=60,
|
||||
) as resp:
|
||||
record("CHAT-4 状态码 200", resp.status_code == 200, f"got {resp.status_code}")
|
||||
current_event = ""
|
||||
for line in resp.iter_lines():
|
||||
if line.startswith("event:"):
|
||||
current_event = line[6:].strip()
|
||||
elif line.startswith("data:"):
|
||||
raw = line[5:].strip()
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if current_event == "message" and "token" in d:
|
||||
sse_tokens.append(d["token"])
|
||||
elif current_event == "done":
|
||||
sse_done = True
|
||||
sse_message_id = d.get("messageId")
|
||||
elif current_event == "error":
|
||||
record("CHAT-4 收到 error 事件", False, d.get("message", ""))
|
||||
|
||||
full_reply = "".join(sse_tokens)
|
||||
record("CHAT-4 收到 token 事件", len(sse_tokens) > 0, f"{len(sse_tokens)} tokens")
|
||||
record("CHAT-4 收到 done 事件", sse_done)
|
||||
record("CHAT-4 回复语义通顺", len(full_reply) > 5, f"len={len(full_reply)}")
|
||||
if full_reply:
|
||||
print(f" SSE 回复: {full_reply[:80]}...")
|
||||
except Exception as e:
|
||||
record("CHAT-4 请求", False, str(e))
|
||||
|
||||
# ── 数据库验证 ──
|
||||
print("\n── 数据库持久化验证 ──")
|
||||
try:
|
||||
conn = psycopg2.connect(DB_DSN)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 验证 ai_messages 包含用户消息和 AI 回复
|
||||
cur.execute(
|
||||
"SELECT id, role, tokens_used FROM biz.ai_messages "
|
||||
"WHERE conversation_id = %s ORDER BY created_at DESC LIMIT 4",
|
||||
(int(chat_id),),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
roles = [r[1] for r in rows]
|
||||
record("DB: ai_messages 有 user 消息", "user" in roles)
|
||||
record("DB: ai_messages 有 assistant 回复", "assistant" in roles)
|
||||
|
||||
# 验证 tokens_used
|
||||
assistant_rows = [r for r in rows if r[1] == "assistant"]
|
||||
if assistant_rows:
|
||||
tokens = assistant_rows[0][2]
|
||||
record("DB: tokens_used 已记录", tokens is not None and tokens > 0,
|
||||
f"tokens_used={tokens}")
|
||||
else:
|
||||
record("DB: tokens_used 已记录", False, "无 assistant 行")
|
||||
|
||||
# 验证 ai_conversations 元数据更新
|
||||
cur.execute(
|
||||
"SELECT last_message, last_message_at FROM biz.ai_conversations WHERE id = %s",
|
||||
(int(chat_id),),
|
||||
)
|
||||
conv = cur.fetchone()
|
||||
if conv:
|
||||
record("DB: last_message 已更新", bool(conv[0]))
|
||||
record("DB: last_message_at 已更新", conv[1] is not None)
|
||||
else:
|
||||
record("DB: ai_conversations 记录存在", False)
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
record("DB 验证", False, str(e))
|
||||
|
||||
_print_summary()
|
||||
|
||||
|
||||
def _print_summary():
|
||||
print("\n" + "=" * 50)
|
||||
passed = sum(1 for r in results if r["passed"])
|
||||
failed = sum(1 for r in results if not r["passed"])
|
||||
print(f"总计: {len(results)} 项 | ✅ {passed} 通过 | ❌ {failed} 失败")
|
||||
|
||||
# 输出 JSON 报告
|
||||
report_path = Path(__file__).parent / "chat_e2e_report.json"
|
||||
report_path.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"📄 报告已保存: {report_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -59,7 +59,6 @@ EXPECTED_RLS_VIEWS: list[str] = [
|
||||
"v_dws_member_visit_detail",
|
||||
"v_dws_member_winback_index",
|
||||
"v_dws_member_newconv_index",
|
||||
"v_dws_member_recall_index",
|
||||
"v_dws_member_assistant_relation_index",
|
||||
"v_dws_member_assistant_intimacy",
|
||||
"v_dws_assistant_daily_detail",
|
||||
|
||||
Reference in New Issue
Block a user