PMFlow/scripts/ai_steward_pressure_coach.py

916 lines
34 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""AI 管家并发压测 + 纠错调教脚本。
目标:
1. 对 /rag/ask 做并发多轮压测。
2. 覆盖“纠错追问 + AI 引擎调用链”高风险问法,检测模板化误答泄漏。
3. 可选自动提交反馈(positive / negative + correct_answer)用于线上调教数据积累。
4. 输出 JSON 报告并按阈值给出 pass/fail 退出码。
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import random
import statistics
import sys
import time
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
import httpx
from backend.app.utils.timezone import now_cn_aware
DEFAULT_TEMPLATE_LEAK_PATTERNS = [
"当前系统采用前后端分层架构",
"fastapi + sqlalchemy",
"postgresql (pgvector-ready)",
"后端模块规模",
]
ENGINE_QUERY_MARKERS = [
"ai引擎",
"openclaw",
"调用链",
"模型网关",
"路由",
]
CORRECTION_MARKERS = [
"不是",
"不对",
"错了",
"我说的是",
"纠正",
]
ARCHITECTURE_MARKERS = [
"系统架构",
"技术架构",
"前后端",
"整体架构",
"架构",
]
@dataclass(frozen=True)
class TurnCase:
question: str
thinking_mode: str = "normal"
expect_deep: bool = False
engine_sensitive: bool = False
expect_requirement_sync: bool = False
@dataclass(frozen=True)
class Scenario:
name: str
turns: list[TurnCase]
@dataclass
class TurnSample:
worker_id: int
round_id: int
scenario: str
turn_index: int
question: str
requested_thinking_mode: str
expected_deep: bool
endpoint_latency_ms: int
backend_response_time_ms: int
status_code: int
ok: bool
error: str | None
session_id: int | None
qa_id: int | None
query_type: str
intent: str
returned_thinking_mode: str
confidence: float
evidence_guard_hit: bool
template_leak: bool
direct_reply_required: bool
requirement_sync_expected: bool
requirement_sync_hit: bool
answer_preview: str
feedback_sent: str | None = None
@dataclass
class Metrics:
samples: list[TurnSample] = field(default_factory=list)
session_mismatch: int = 0
feedback_positive: int = 0
feedback_negative: int = 0
feedback_failed: int = 0
def add(self, sample: TurnSample) -> None:
self.samples.append(sample)
@property
def total(self) -> int:
return len(self.samples)
@property
def ok_count(self) -> int:
return sum(1 for x in self.samples if x.ok)
@property
def fail_count(self) -> int:
return self.total - self.ok_count
@property
def success_rate(self) -> float:
if self.total == 0:
return 0.0
return self.ok_count / self.total
@property
def template_leak_count(self) -> int:
return sum(1 for x in self.samples if x.template_leak)
@property
def template_leak_rate(self) -> float:
if self.total == 0:
return 0.0
return self.template_leak_count / self.total
@property
def deep_expected_count(self) -> int:
return sum(1 for x in self.samples if x.expected_deep)
@property
def deep_hit_count(self) -> int:
return sum(1 for x in self.samples if x.expected_deep and x.returned_thinking_mode == "deep")
@property
def deep_hit_rate(self) -> float:
if self.deep_expected_count == 0:
return 1.0
return self.deep_hit_count / self.deep_expected_count
@property
def requirement_sync_expected_count(self) -> int:
return sum(1 for x in self.samples if x.requirement_sync_expected)
@property
def requirement_sync_hit_count(self) -> int:
return sum(1 for x in self.samples if x.requirement_sync_expected and x.requirement_sync_hit)
@property
def requirement_sync_hit_rate(self) -> float:
if self.requirement_sync_expected_count == 0:
return 0.0
return self.requirement_sync_hit_count / self.requirement_sync_expected_count
@property
def evidence_guard_hits(self) -> int:
return sum(1 for x in self.samples if x.evidence_guard_hit)
@property
def endpoint_latencies(self) -> list[int]:
return [x.endpoint_latency_ms for x in self.samples if x.endpoint_latency_ms > 0]
@property
def backend_latencies(self) -> list[int]:
return [x.backend_response_time_ms for x in self.samples if x.backend_response_time_ms > 0]
def _percentile(values: Iterable[int], q: float) -> int:
data = sorted(int(v) for v in values if v >= 0)
if not data:
return 0
if len(data) == 1:
return data[0]
pos = max(0.0, min(1.0, q)) * (len(data) - 1)
lo = int(pos)
hi = min(lo + 1, len(data) - 1)
frac = pos - lo
return int(round(data[lo] * (1.0 - frac) + data[hi] * frac))
def _build_rag_scenarios() -> list[Scenario]:
return [
Scenario(
name="engine_correction_chain",
turns=[
TurnCase(
question="给我说下当前系统架构",
thinking_mode="normal",
expect_deep=False,
engine_sensitive=False,
),
TurnCase(
question="不是啊,我说的是ai引擎驱动的方式",
thinking_mode="normal",
expect_deep=True,
engine_sensitive=True,
),
TurnCase(
question="内置的ai管家第一层也是去调用openclaw吗?",
thinking_mode="normal",
expect_deep=True,
engine_sensitive=True,
),
],
),
Scenario(
name="manual_deep_engine_chain",
turns=[
TurnCase(
question="请深入分析本项目AI问答引擎调用链,按入口->路由->模型网关列证据。",
thinking_mode="deep",
expect_deep=True,
engine_sensitive=True,
),
TurnCase(
question="如果证据不足请直接说不确定,不要泛化成系统架构模板。",
thinking_mode="deep",
expect_deep=True,
engine_sensitive=True,
),
],
),
Scenario(
name="quick_correction_probe",
turns=[
TurnCase(
question="给我说下当前系统的ai架构",
thinking_mode="normal",
expect_deep=False,
engine_sensitive=True,
),
TurnCase(
question="不是啊。",
thinking_mode="normal",
expect_deep=True,
engine_sensitive=True,
),
TurnCase(
question="草泥马(这是鲁棒性测试,应该礼貌回应并要求明确目标)",
thinking_mode="normal",
expect_deep=False,
engine_sensitive=False,
),
],
),
]
def _build_feishu_capture_scenarios() -> list[Scenario]:
return [
Scenario(
name="question_requirement_sync",
turns=[
TurnCase(
question="这个需求要不要支持飞书同步?",
thinking_mode="normal",
expect_deep=False,
engine_sensitive=False,
expect_requirement_sync=True,
),
TurnCase(
question="再加一个导出按钮可以吗?",
thinking_mode="normal",
expect_deep=False,
engine_sensitive=False,
expect_requirement_sync=True,
),
TurnCase(
question="好的,按这个需求推进。",
thinking_mode="normal",
expect_deep=False,
engine_sensitive=False,
expect_requirement_sync=False,
),
],
),
Scenario(
name="feishu_explicit_deep_chain",
turns=[
TurnCase(
question="请深入分析内置AI管家到飞书需求同步链路,按入口->路由->写库给证据。",
thinking_mode="normal",
expect_deep=True,
engine_sensitive=True,
expect_requirement_sync=False,
),
TurnCase(
question="不是系统架构,我要你继续深挖飞书链路里具体是哪个服务落库。",
thinking_mode="normal",
expect_deep=True,
engine_sensitive=True,
expect_requirement_sync=False,
),
],
),
]
def _build_scenarios(mode: str) -> list[Scenario]:
if str(mode or "").strip().lower() == "feishu_capture":
return _build_feishu_capture_scenarios()
return _build_rag_scenarios()
def _is_engine_or_correction_question(question: str) -> bool:
q = str(question or "").strip().lower()
if not q:
return False
return any(k in q for k in ENGINE_QUERY_MARKERS) or any(k in q for k in CORRECTION_MARKERS)
def _is_plain_architecture_question(question: str) -> bool:
q = str(question or "").strip().lower()
if not q:
return False
has_arch = any(k in q for k in ARCHITECTURE_MARKERS)
has_engine = any(k in q for k in ENGINE_QUERY_MARKERS)
has_correction = any(k in q for k in CORRECTION_MARKERS)
if not has_arch:
return False
# “纠错 + 架构词”不算纯架构问法,比如:不是系统架构,我要AI引擎路由。
return (not has_engine) and (not has_correction)
def _contains_template_leak(answer: str) -> bool:
text = str(answer or "").strip().lower()
if not text:
return False
return any(pattern.lower() in text for pattern in DEFAULT_TEMPLATE_LEAK_PATTERNS)
def _is_requirement_like_question(question: str) -> bool:
q = str(question or "").strip().lower()
if not q:
return False
has_question = ("?" in q) or ("?" in q) or any(k in q for k in ("吗", "是否", "要不要", "能不能", "可不可以"))
if not has_question:
return False
verb_hits = ("新增", "增加", "加一个", "加个", "支持", "接入", "实现", "优化", "修复", "改进", "同步")
object_hits = ("功能", "能力", "接口", "页面", "按钮", "流程", "模块", "需求", "项目", "队列", "信号")
return any(v in q for v in verb_hits) and any(o in q for o in object_hits)
def _pick_negative_reason(question: str) -> tuple[list[str], str]:
q = str(question or "").strip()
if _is_engine_or_correction_question(q):
return (
["答非所问", "答案错误", "来源错误"],
"请基于项目AI引擎调用链回答:入口、路由、模型网关分别是什么;证据不足就明确写“不确定”。",
)
return (["答案错误"], "请结合证据回答,并避免使用通用模板。")
async def _login_token(
*,
base_url: str,
username: str,
password: str,
timeout_seconds: float,
) -> str:
async with httpx.AsyncClient(timeout=timeout_seconds, trust_env=False) as client:
resp = await client.post(
f"{base_url}/auth/login",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={"username": username, "password": password},
)
resp.raise_for_status()
payload = resp.json()
token = str(payload.get("access_token") or "").strip()
if not token:
raise RuntimeError("login success but access_token missing")
return token
async def _send_feedback(
*,
client: httpx.AsyncClient,
base_url: str,
token: str,
qa_id: int,
payload: dict[str, Any],
) -> tuple[bool, int]:
try:
resp = await client.post(
f"{base_url}/rag/qa-records/{qa_id}/feedback",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json=payload,
)
return (resp.status_code < 300, int(resp.status_code))
except Exception:
return (False, 0)
async def _ask_once(
*,
client: httpx.AsyncClient,
base_url: str,
endpoint: str,
token: str,
mode: str,
question: str,
thinking_mode: str,
session_id: int | None,
project_id: int | None,
group_chat_id: str,
sender: str,
feishu_message_id: str,
) -> tuple[int, int, dict[str, Any] | None, str | None]:
mode_key = str(mode or "").strip().lower()
if mode_key == "feishu_capture":
payload = {
"group_chat_id": group_chat_id,
"message_content": question,
"sender": sender,
"feishu_message_id": feishu_message_id,
}
else:
payload = {
"question": question,
"asked_via": "pmflow",
"thinking_mode": "deep" if thinking_mode == "deep" else "normal",
}
if session_id is not None:
payload["session_id"] = int(session_id)
if project_id is not None:
payload["project_id"] = int(project_id)
started = time.perf_counter()
try:
resp = await client.post(
f"{base_url}{endpoint}",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json=payload,
)
elapsed = int((time.perf_counter() - started) * 1000)
if resp.status_code >= 400:
text = resp.text[:300]
return (resp.status_code, elapsed, None, text)
body = resp.json()
data = body.get("data") if isinstance(body, dict) and isinstance(body.get("data"), dict) else body
if not isinstance(data, dict):
return (resp.status_code, elapsed, None, "response_data_not_dict")
return (resp.status_code, elapsed, data, None)
except Exception as exc:
elapsed = int((time.perf_counter() - started) * 1000)
return (0, elapsed, None, str(exc))
async def _run_worker(
*,
worker_id: int,
rounds: int,
base_url: str,
endpoint: str,
token: str,
mode: str,
timeout_seconds: float,
project_id: int | None,
scenarios: list[Scenario],
rng_seed: int,
auto_feedback: bool,
sleep_ms: int,
chat_id_prefix: str,
sender_prefix: str,
) -> Metrics:
mode_key = str(mode or "").strip().lower()
rng = random.Random(rng_seed + worker_id)
metrics = Metrics()
async with httpx.AsyncClient(timeout=timeout_seconds, trust_env=False) as client:
for round_id in range(1, rounds + 1):
scenario = scenarios[rng.randrange(0, len(scenarios))]
active_session_id: int | None = None
previous_session_id: int | None = None
group_chat_id = f"{chat_id_prefix}-{worker_id}-{round_id}"
sender = f"{sender_prefix}{worker_id:02d}"
for turn_idx, turn in enumerate(scenario.turns, start=1):
feishu_message_id = f"pc-{worker_id}-{round_id}-{turn_idx}-{rng.randrange(100000, 999999)}"
status_code, endpoint_latency, data, err = await _ask_once(
client=client,
base_url=base_url,
endpoint=endpoint,
token=token,
mode=mode_key,
question=turn.question,
thinking_mode=turn.thinking_mode,
session_id=active_session_id,
project_id=project_id,
group_chat_id=group_chat_id,
sender=sender,
feishu_message_id=feishu_message_id,
)
payload = data if isinstance(data, dict) else {}
ok = bool(status_code and status_code < 300 and isinstance(payload, dict))
if mode_key == "feishu_capture":
if str(payload.get("status") or "").strip().lower() in {"error", "failed"}:
ok = False
rag_payload = payload.get("rag_result") if isinstance(payload.get("rag_result"), dict) else {}
answer = str(payload.get("direct_reply_text") or rag_payload.get("answer") or "")
returned_mode = str(rag_payload.get("thinking_mode") or "normal").strip().lower()
query_type = str(rag_payload.get("query_type") or "")
qa_id_raw = rag_payload.get("qa_id")
sess_raw = rag_payload.get("session_id")
confidence_raw = rag_payload.get("confidence")
backend_response_time_raw = rag_payload.get("response_time_ms")
sources = rag_payload.get("sources") if isinstance(rag_payload.get("sources"), list) else []
intent = str(payload.get("intent") or "").strip().lower()
direct_reply_required = bool(payload.get("direct_reply_required"))
requirement_sync_expected = bool(
turn.expect_requirement_sync or _is_requirement_like_question(turn.question)
)
has_sync_artifact = bool(payload.get("results")) or (payload.get("signal_id") is not None)
requirement_sync_hit = bool(
requirement_sync_expected
and intent == "question_requirement"
and str(payload.get("status") or "").strip().lower() == "captured"
and has_sync_artifact
)
else:
answer = str(payload.get("answer") or "")
returned_mode = str(payload.get("thinking_mode") or "normal").strip().lower()
query_type = str(payload.get("query_type") or "")
qa_id_raw = payload.get("qa_id")
sess_raw = payload.get("session_id")
confidence_raw = payload.get("confidence")
backend_response_time_raw = payload.get("response_time_ms")
sources = payload.get("sources") if isinstance(payload.get("sources"), list) else []
intent = ""
direct_reply_required = False
requirement_sync_expected = bool(turn.expect_requirement_sync)
requirement_sync_hit = False
try:
qa_id = int(qa_id_raw) if qa_id_raw is not None else None
except Exception:
qa_id = None
try:
current_session_id = int(sess_raw) if sess_raw is not None else None
except Exception:
current_session_id = None
try:
confidence = float(confidence_raw) if confidence_raw is not None else 0.0
except Exception:
confidence = 0.0
try:
backend_response_time_ms = int(backend_response_time_raw) if backend_response_time_raw is not None else 0
except Exception:
backend_response_time_ms = 0
evidence_guard_hit = False
for src in sources:
if not isinstance(src, dict):
continue
if str(src.get("label") or "").strip().lower() == "evidence guard":
evidence_guard_hit = True
break
template_leak = False
if (turn.engine_sensitive or _is_engine_or_correction_question(turn.question)) and (
not _is_plain_architecture_question(turn.question)
):
template_leak = _contains_template_leak(answer)
if (
mode_key != "feishu_capture"
and previous_session_id is not None
and current_session_id is not None
and previous_session_id != current_session_id
):
metrics.session_mismatch += 1
if current_session_id is not None:
active_session_id = current_session_id
previous_session_id = current_session_id
sample = TurnSample(
worker_id=worker_id,
round_id=round_id,
scenario=scenario.name,
turn_index=turn_idx,
question=turn.question,
requested_thinking_mode=turn.thinking_mode,
expected_deep=turn.expect_deep,
endpoint_latency_ms=endpoint_latency,
backend_response_time_ms=backend_response_time_ms,
status_code=status_code,
ok=ok,
error=err,
session_id=current_session_id,
qa_id=qa_id,
query_type=query_type,
intent=intent,
returned_thinking_mode=returned_mode if returned_mode in {"normal", "deep"} else "normal",
confidence=round(max(0.0, min(1.0, confidence)), 4),
evidence_guard_hit=evidence_guard_hit,
template_leak=template_leak,
direct_reply_required=direct_reply_required,
requirement_sync_expected=requirement_sync_expected,
requirement_sync_hit=requirement_sync_hit,
answer_preview=answer.replace("\n", " ")[:180],
)
if auto_feedback and ok and qa_id is not None:
if template_leak:
error_types, correct_answer = _pick_negative_reason(turn.question)
fb_payload = {
"rating": "negative",
"error_types": error_types,
"correct_answer": correct_answer,
}
fb_ok, _ = await _send_feedback(
client=client,
base_url=base_url,
token=token,
qa_id=qa_id,
payload=fb_payload,
)
if fb_ok:
metrics.feedback_negative += 1
sample.feedback_sent = "negative"
else:
metrics.feedback_failed += 1
else:
fb_payload = {
"rating": "positive",
"helpfulness": "fully",
}
fb_ok, _ = await _send_feedback(
client=client,
base_url=base_url,
token=token,
qa_id=qa_id,
payload=fb_payload,
)
if fb_ok:
metrics.feedback_positive += 1
sample.feedback_sent = "positive"
else:
metrics.feedback_failed += 1
metrics.add(sample)
if sleep_ms > 0:
await asyncio.sleep(max(0.0, sleep_ms / 1000.0))
return metrics
def _merge_metrics(items: list[Metrics]) -> Metrics:
merged = Metrics()
for m in items:
merged.samples.extend(m.samples)
merged.session_mismatch += m.session_mismatch
merged.feedback_positive += m.feedback_positive
merged.feedback_negative += m.feedback_negative
merged.feedback_failed += m.feedback_failed
return merged
def _build_report(
*,
started_at: str,
finished_at: str,
args: argparse.Namespace,
metrics: Metrics,
) -> dict[str, Any]:
endpoint_lat = metrics.endpoint_latencies
backend_lat = metrics.backend_latencies
errors = [x for x in metrics.samples if not x.ok]
template_hits = [x for x in metrics.samples if x.template_leak]
deep_miss = [x for x in metrics.samples if x.expected_deep and x.returned_thinking_mode != "deep"]
summary = {
"started_at": started_at,
"finished_at": finished_at,
"mode": args.mode,
"base_url": args.base_url,
"endpoint": args.endpoint,
"workers": args.workers,
"rounds": args.rounds,
"total_turns": metrics.total,
"ok_turns": metrics.ok_count,
"failed_turns": metrics.fail_count,
"success_rate": round(metrics.success_rate, 4),
"template_leak_count": metrics.template_leak_count,
"template_leak_rate": round(metrics.template_leak_rate, 4),
"deep_expected_count": metrics.deep_expected_count,
"deep_hit_count": metrics.deep_hit_count,
"deep_hit_rate": round(metrics.deep_hit_rate, 4),
"question_requirement_expected_count": metrics.requirement_sync_expected_count,
"question_requirement_hit_count": metrics.requirement_sync_hit_count,
"question_requirement_hit_rate": round(metrics.requirement_sync_hit_rate, 4),
"evidence_guard_hits": metrics.evidence_guard_hits,
"session_mismatch": metrics.session_mismatch,
"feedback_positive": metrics.feedback_positive,
"feedback_negative": metrics.feedback_negative,
"feedback_failed": metrics.feedback_failed,
"endpoint_latency_ms": {
"p50": _percentile(endpoint_lat, 0.50),
"p95": _percentile(endpoint_lat, 0.95),
"p99": _percentile(endpoint_lat, 0.99),
"avg": round(statistics.mean(endpoint_lat), 2) if endpoint_lat else 0.0,
},
"backend_response_time_ms": {
"p50": _percentile(backend_lat, 0.50),
"p95": _percentile(backend_lat, 0.95),
"p99": _percentile(backend_lat, 0.99),
"avg": round(statistics.mean(backend_lat), 2) if backend_lat else 0.0,
},
}
return {
"summary": summary,
"thresholds": {
"min_success_rate": args.min_success_rate,
"max_template_leak_rate": args.max_template_leak_rate,
"min_deep_hit_rate": args.min_deep_hit_rate,
"min_requirement_sync_hit_rate": args.min_requirement_sync_hit_rate,
"max_session_mismatch": args.max_session_mismatch,
},
"samples_head": [asdict(x) for x in metrics.samples[:30]],
"error_samples": [asdict(x) for x in errors[:20]],
"template_leak_samples": [asdict(x) for x in template_hits[:20]],
"deep_miss_samples": [asdict(x) for x in deep_miss[:20]],
}
def _print_console_summary(report: dict[str, Any]) -> None:
s = report.get("summary") or {}
print("==== AI Steward Pressure Coach Summary ====")
print(f"mode={s.get('mode') or ''} base_url={s.get('base_url') or ''} endpoint={s.get('endpoint') or ''}")
print(
f"turns={s.get('total_turns')} ok={s.get('ok_turns')} fail={s.get('failed_turns')} "
f"success_rate={s.get('success_rate')}"
)
print(
f"template_leak={s.get('template_leak_count')}({s.get('template_leak_rate')}) "
f"deep_hit={s.get('deep_hit_count')}/{s.get('deep_expected_count')}({s.get('deep_hit_rate')}) "
f"question_requirement_hit={s.get('question_requirement_hit_count')}/{s.get('question_requirement_expected_count')}({s.get('question_requirement_hit_rate')}) "
f"session_mismatch={s.get('session_mismatch')}"
)
el = s.get("endpoint_latency_ms") or {}
bl = s.get("backend_response_time_ms") or {}
print(
f"latency endpoint(p50/p95/p99/avg)={el.get('p50')}/{el.get('p95')}/{el.get('p99')}/{el.get('avg')} ms"
)
print(
f"latency backend(p50/p95/p99/avg)={bl.get('p50')}/{bl.get('p95')}/{bl.get('p99')}/{bl.get('avg')} ms"
)
print(
f"feedback +={s.get('feedback_positive')} -={s.get('feedback_negative')} failed={s.get('feedback_failed')}"
)
def _parse_args() -> argparse.Namespace:
now_tag = datetime.now().strftime("%Y%m%d_%H%M%S")
parser = argparse.ArgumentParser(description="AI 管家并发压测 + 调教脚本")
parser.add_argument("--mode", choices=["rag", "feishu_capture"], default=os.getenv("STRESS_MODE", "rag"))
parser.add_argument("--base-url", default=os.getenv("STRESS_BASE_URL", "http://127.0.0.1:8000"))
parser.add_argument("--endpoint", default=os.getenv("STRESS_ENDPOINT", ""))
parser.add_argument("--user", default=os.getenv("STRESS_USER", "boss"))
parser.add_argument("--password", default=os.getenv("STRESS_PASSWORD", "boss123"))
parser.add_argument("--workers", type=int, default=int(os.getenv("STRESS_WORKERS", "8")))
parser.add_argument("--rounds", type=int, default=int(os.getenv("STRESS_ROUNDS", "6")))
parser.add_argument("--project-id", type=int, default=None)
parser.add_argument("--timeout-seconds", type=float, default=float(os.getenv("STRESS_TIMEOUT", "60")))
parser.add_argument("--sleep-ms", type=int, default=int(os.getenv("STRESS_SLEEP_MS", "80")))
parser.add_argument("--seed", type=int, default=int(os.getenv("STRESS_SEED", "20260306")))
parser.add_argument("--auto-feedback", action="store_true", default=False)
parser.add_argument("--report-file", default=f"logs/ai_steward_pressure_{now_tag}.json")
parser.add_argument("--min-success-rate", type=float, default=0.95)
parser.add_argument("--max-template-leak-rate", type=float, default=0.08)
parser.add_argument("--min-deep-hit-rate", type=float, default=0.90)
parser.add_argument("--min-requirement-sync-hit-rate", type=float, default=0.90)
parser.add_argument("--max-session-mismatch", type=int, default=0)
parser.add_argument("--chat-id-prefix", default=os.getenv("STRESS_CHAT_PREFIX", "oc_pressure_chat"))
parser.add_argument("--sender-prefix", default=os.getenv("STRESS_SENDER_PREFIX", "压测用户"))
return parser.parse_args()
def _normalize_base_url(raw: str) -> str:
base = str(raw or "").strip().rstrip("/")
if not base:
return "http://127.0.0.1:8000"
if not (base.startswith("http://") or base.startswith("https://")):
return f"http://{base}"
return base
def _normalize_endpoint(raw: str) -> str:
ep = str(raw or "").strip()
if not ep:
return "/rag/ask"
if not ep.startswith("/"):
ep = f"/{ep}"
return ep
async def _amain() -> int:
args = _parse_args()
args.mode = str(args.mode or "rag").strip().lower()
args.base_url = _normalize_base_url(args.base_url)
if not str(args.endpoint or "").strip():
args.endpoint = "/ai-steward/signal/capture-feishu" if args.mode == "feishu_capture" else "/rag/ask"
args.endpoint = _normalize_endpoint(args.endpoint)
args.workers = max(1, min(args.workers, 256))
args.rounds = max(1, min(args.rounds, 10000))
args.sleep_ms = max(0, min(args.sleep_ms, 10000))
args.timeout_seconds = max(5.0, min(args.timeout_seconds, 300.0))
started_at = now_cn_aware().isoformat()
scenarios = _build_scenarios(args.mode)
token = await _login_token(
base_url=args.base_url,
username=args.user,
password=args.password,
timeout_seconds=args.timeout_seconds,
)
tasks = [
_run_worker(
worker_id=i + 1,
rounds=args.rounds,
base_url=args.base_url,
endpoint=args.endpoint,
token=token,
mode=args.mode,
timeout_seconds=args.timeout_seconds,
project_id=args.project_id,
scenarios=scenarios,
rng_seed=args.seed,
auto_feedback=bool(args.auto_feedback),
sleep_ms=args.sleep_ms,
chat_id_prefix=args.chat_id_prefix,
sender_prefix=args.sender_prefix,
)
for i in range(args.workers)
]
results = await asyncio.gather(*tasks)
metrics = _merge_metrics(results)
finished_at = now_cn_aware().isoformat()
report = _build_report(
started_at=started_at,
finished_at=finished_at,
args=args,
metrics=metrics,
)
_print_console_summary(report)
report_path = Path(args.report_file)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"report_file={report_path}")
summary = report.get("summary") or {}
success_rate = float(summary.get("success_rate") or 0.0)
template_leak_rate = float(summary.get("template_leak_rate") or 0.0)
deep_hit_rate = float(summary.get("deep_hit_rate") or 0.0)
requirement_sync_expected_count = int(summary.get("question_requirement_expected_count") or 0)
requirement_sync_hit_rate = float(summary.get("question_requirement_hit_rate") or 0.0)
session_mismatch = int(summary.get("session_mismatch") or 0)
failed_checks: list[str] = []
if success_rate < float(args.min_success_rate):
failed_checks.append(f"success_rate<{args.min_success_rate}")
if template_leak_rate > float(args.max_template_leak_rate):
failed_checks.append(f"template_leak_rate>{args.max_template_leak_rate}")
if deep_hit_rate < float(args.min_deep_hit_rate):
failed_checks.append(f"deep_hit_rate<{args.min_deep_hit_rate}")
if (
args.mode == "feishu_capture"
and requirement_sync_expected_count > 0
and requirement_sync_hit_rate < float(args.min_requirement_sync_hit_rate)
):
failed_checks.append(f"question_requirement_hit_rate<{args.min_requirement_sync_hit_rate}")
if session_mismatch > int(args.max_session_mismatch):
failed_checks.append(f"session_mismatch>{args.max_session_mismatch}")
if failed_checks:
print(f"RESULT=FAIL checks={','.join(failed_checks)}")
return 2
print("RESULT=PASS")
return 0
def main() -> int:
try:
return asyncio.run(_amain())
except KeyboardInterrupt:
print("interrupted")
return 130
except Exception as exc:
print(f"fatal_error={exc}")
return 2
if __name__ == "__main__":
raise SystemExit(main())