529 lines
24 KiB
Diff
529 lines
24 KiB
Diff
--- server/model_gateway.py
|
|
|
|
+++ local/model_gateway.py
|
|
|
|
@@ -45,4 +45,5 @@
|
|
|
|
import model_protocol
|
|
import admin_backend
|
|
+from model_usage import UsageRecorder
|
|
from knowledge_store import KnowledgeStore
|
|
from knowledge_retriever import KnowledgeRetriever, inject_references
|
|
@@ -186,5 +187,5 @@
|
|
|
|
self.refresh()
|
|
|
|
- def plan(self) -> tuple[list[Outlet], Outlet | None, str, int]:
|
|
+ def plan(self, *, purpose: str = "chat") -> tuple[list[Outlet], Outlet | None, str, int]:
|
|
"""本轮问谁、谁当裁判、什么模式、编排版本号。"""
|
|
roles = self.roles or {}
|
|
@@ -202,4 +203,11 @@
|
|
|
|
if outlet is not None
|
|
]
|
|
+ if purpose == "guard":
|
|
+ # Internal screenshot classification uses the configured vision role.
|
|
+ # Older installations without that role keep their primary outlet;
|
|
+ # customer-answer fanout and reply scoring cannot judge UI state.
|
|
+ vision = pick(roles.get("vision_id") or "")
|
|
+ return ([vision] if vision is not None else answers[:1], None,
|
|
+ "shadow", int(roles.get("version") or 0))
|
|
judge = pick(roles.get("judge_id") or "")
|
|
mode = str(roles.get("judge_mode") or "shadow").lower()
|
|
@@ -210,7 +218,8 @@
|
|
|
|
|
|
class GatewayError(Exception):
|
|
- def __init__(self, message: str, *, retriable: bool = False):
|
|
+ def __init__(self, message: str, *, retriable: bool = False, response_data=None):
|
|
super().__init__(message)
|
|
self.retriable = retriable
|
|
+ self.response_data = response_data
|
|
|
|
|
|
@@ -229,16 +238,19 @@
|
|
|
|
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
|
raise GatewayError(f"{type(exc).__name__}: {exc}", retriable=True) from exc
|
|
+ try:
|
|
+ data = response.json()
|
|
+ except ValueError:
|
|
+ data = None
|
|
if response.status_code in RETRIABLE_STATUS:
|
|
raise GatewayError(
|
|
- f"上游返回 {response.status_code}", retriable=True
|
|
+ f"上游返回 {response.status_code}", retriable=True, response_data=data
|
|
)
|
|
if response.status_code >= 400:
|
|
raise GatewayError(
|
|
- f"上游返回 {response.status_code}: {response.text[:200]}", retriable=False
|
|
+ f"上游返回 {response.status_code}: {response.text[:200]}", retriable=False, response_data=data
|
|
)
|
|
- try:
|
|
- return response.json()
|
|
- except ValueError as exc:
|
|
- raise GatewayError("上游响应不是合法 JSON", retriable=False) from exc
|
|
+ if data is None:
|
|
+ raise GatewayError("上游响应不是合法 JSON", retriable=False)
|
|
+ return data
|
|
|
|
|
|
@@ -253,4 +265,6 @@
|
|
|
|
tools: list | None = None,
|
|
deadline: float = SINGLE_CALL_TIMEOUT,
|
|
+ usage_recorder: UsageRecorder | None = None,
|
|
+ usage_role: str = "answer",
|
|
) -> dict:
|
|
"""向一个出口要一次回复。并发闸 + 熔断 + 有界重试都在这里。
|
|
@@ -284,6 +298,6 @@
|
|
|
|
payload_messages = list(messages)
|
|
dify_files = None
|
|
- if image_b64 and kind == "dify":
|
|
- dify_files = [await _dify_upload(client, outlet, image_b64, timeout)]
|
|
+ if kind == "dify":
|
|
+ dify_files = await _dify_message_files(client, outlet, messages, image_b64, timeout)
|
|
elif image_b64:
|
|
payload_messages = payload_messages + [
|
|
@@ -320,4 +334,7 @@
|
|
|
|
last: Exception | None = None
|
|
for attempt in range(1, MAX_ATTEMPTS + 1):
|
|
+ data = None
|
|
+ status = "error"
|
|
+ attempt_started = time.monotonic()
|
|
try:
|
|
data = await _post_once(
|
|
@@ -327,4 +344,5 @@
|
|
|
|
kind, data, config.get("base_url") or ""
|
|
)
|
|
+ status = "success"
|
|
outlet.breaker.record(True)
|
|
return {
|
|
@@ -336,11 +354,18 @@
|
|
|
|
}
|
|
except GatewayError as exc:
|
|
+ data = exc.response_data
|
|
last = exc
|
|
if not exc.retriable or attempt >= MAX_ATTEMPTS:
|
|
break
|
|
- await asyncio.sleep(0.35 * attempt + secrets.randbelow(200) / 1000.0)
|
|
except ValueError as exc: # 解析失败,确定性故障
|
|
last = exc
|
|
break
|
|
+ finally:
|
|
+ if usage_recorder is not None:
|
|
+ usage_recorder.capture(
|
|
+ outlet, data, attempt=attempt, role=usage_role, status=status,
|
|
+ latency_ms=int((time.monotonic() - attempt_started) * 1000),
|
|
+ )
|
|
+ await asyncio.sleep(0.35 * attempt + secrets.randbelow(200) / 1000.0)
|
|
outlet.breaker.record(False)
|
|
return {
|
|
@@ -359,10 +384,53 @@
|
|
|
|
}
|
|
|
|
+ except (GatewayError, httpx.HTTPError, ValueError) as exc:
|
|
+ # Attachment preparation is part of this outlet, not a failure of every
|
|
+ # concurrent model. Keep healthy candidates available to the caller.
|
|
+ outlet.breaker.record(False)
|
|
+ return {"provider": outlet.name, "text": "", "latency_ms": int((time.monotonic() - started) * 1000),
|
|
+ "error": str(exc)[:200] or type(exc).__name__}
|
|
+
|
|
+
|
|
+async def _dify_message_files(client, outlet, messages, image_b64, timeout):
|
|
+ """Translate current-turn attachments without leaking image bytes into query."""
|
|
+ urls = []
|
|
+ for message in reversed(messages or []):
|
|
+ if not isinstance(message, dict) or message.get("role") != "user":
|
|
+ continue
|
|
+ content = message.get("content")
|
|
+ if isinstance(content, list):
|
|
+ for part in content:
|
|
+ if isinstance(part, dict) and part.get("type") == "image_url":
|
|
+ image = part.get("image_url") or {}
|
|
+ url = image.get("url", "") if isinstance(image, dict) else image
|
|
+ if isinstance(url, str) and url:
|
|
+ urls.append(url)
|
|
+ break
|
|
+ if image_b64:
|
|
+ urls.append("data:image/png;base64," + image_b64)
|
|
+ files = []
|
|
+ seen = set()
|
|
+ for url in urls:
|
|
+ if url in seen:
|
|
+ continue
|
|
+ seen.add(url)
|
|
+ if url.startswith("data:"):
|
|
+ header, separator, data = url.partition(",")
|
|
+ mime = header[5:].split(";")[0].lower()
|
|
+ if not separator or ";base64" not in header or mime not in {"image/png", "image/jpeg", "image/webp", "image/gif"}:
|
|
+ raise GatewayError("Dify 图片数据格式不受支持", retriable=False)
|
|
+ files.append(await _dify_upload(client, outlet, data, timeout, mime=mime))
|
|
+ elif url.startswith(("https://", "http://")):
|
|
+ files.append({"type": "image", "transfer_method": "remote_url", "url": url})
|
|
+ else:
|
|
+ raise GatewayError("Dify 图片地址格式不受支持", retriable=False)
|
|
+ return files or None
|
|
+
|
|
|
|
async def _dify_upload(
|
|
- client: httpx.AsyncClient, outlet: Outlet, image_b64: str, timeout: float
|
|
+ client: httpx.AsyncClient, outlet: Outlet, image_b64: str, timeout: float, *, mime: str = "image/png"
|
|
) -> dict:
|
|
"""Dify 的图片要先 upload 拿 id 再在 chat-messages 里引用。"""
|
|
- root = model_protocol.dify_api_root(outlet.config.get("base_url") or "")
|
|
+ root = model_protocol.dify_api_root(outlet.config.get("base_url") or "", outlet.config.get("endpoint_mode") or "auto")
|
|
response = await client.post(
|
|
f"{root}/files/upload",
|
|
@@ -371,6 +439,6 @@
|
|
|
|
"Accept": "application/json",
|
|
},
|
|
- data={"user": "wechat-rpa-vision"},
|
|
- files={"file": ("chat.png", base64.b64decode(image_b64), "image/png")},
|
|
+ data={"user": "wechat-rpa"},
|
|
+ files={"file": ("chat." + {"image/png":"png", "image/jpeg":"jpg", "image/webp":"webp", "image/gif":"gif"}[mime], base64.b64decode(image_b64, validate=True), mime)},
|
|
timeout=timeout,
|
|
)
|
|
@@ -379,7 +447,14 @@
|
|
|
|
f"Dify 截图上传失败 {response.status_code}", retriable=response.status_code in RETRIABLE_STATUS
|
|
)
|
|
- upload_id = str((response.json() or {}).get("id") or "").strip()
|
|
- if not upload_id:
|
|
- raise GatewayError("Dify 上传成功但没返回文件 ID", retriable=False)
|
|
+ try:
|
|
+ uploaded = response.json()
|
|
+ except ValueError as exc:
|
|
+ raise GatewayError("Dify 上传响应不是合法 JSON", retriable=False) from exc
|
|
+ if not isinstance(uploaded, dict):
|
|
+ raise GatewayError("Dify 上传响应不是 JSON 对象", retriable=False)
|
|
+ upload_id = uploaded.get("id")
|
|
+ if not isinstance(upload_id, str) or not upload_id.strip():
|
|
+ raise GatewayError("Dify 上传成功但没返回有效文件 ID", retriable=False)
|
|
+ upload_id = upload_id.strip()
|
|
return {
|
|
"type": "image",
|
|
@@ -450,4 +525,5 @@
|
|
|
|
first: str,
|
|
second: str = "",
|
|
+ *, usage_recorder: UsageRecorder | None = None,
|
|
) -> dict:
|
|
if judge is None or not str(first or "").strip():
|
|
@@ -469,4 +545,5 @@
|
|
|
|
temperature=0.0,
|
|
deadline=JUDGE_DEADLINE,
|
|
+ usage_recorder=usage_recorder, usage_role="judge",
|
|
)
|
|
latency = int((time.monotonic() - started) * 1000)
|
|
@@ -607,5 +684,6 @@
|
|
|
|
|
|
body = await request.json()
|
|
- answers, judge, mode, version = catalog.plan()
|
|
+ purpose = str(body.get("purpose") or "chat")
|
|
+ answers, judge, mode, version = catalog.plan(purpose=purpose)
|
|
if not answers:
|
|
raise HTTPException(status_code=503, detail="没有可用的答题出口")
|
|
@@ -625,151 +703,162 @@
|
|
|
|
tools = body.get("tools") or None
|
|
|
|
- started = time.monotonic()
|
|
- knowledge_result = None
|
|
- knowledge_trace = {"enabled": False, "hits": [], "mode": "disabled", "elapsed_ms": 0}
|
|
- tenant = str(account["tenant_id"])
|
|
- if str(body.get("purpose") or "chat") == "chat":
|
|
- settings = await asyncio.to_thread(knowledge_store.settings, tenant)
|
|
- if settings["enabled"]:
|
|
- from knowledge_processing import latest_query
|
|
- query = str(body.get("customer_text") or "")
|
|
- if not query:
|
|
+ async with UsageRecorder(
|
|
+ database, tenant_id=str(account["tenant_id"]), desktop_account_id=int(account["id"]),
|
|
+ task_id=str(body.get("task_id") or ""), purpose=purpose,
|
|
+ ) as usage_recorder:
|
|
+ started = time.monotonic()
|
|
+ knowledge_result = None
|
|
+ knowledge_trace = {"enabled": False, "hits": [], "mode": "disabled", "elapsed_ms": 0}
|
|
+ tenant = str(account["tenant_id"])
|
|
+ if str(body.get("purpose") or "chat") == "chat":
|
|
+ settings = await asyncio.to_thread(knowledge_store.settings, tenant)
|
|
+ if settings["enabled"]:
|
|
+ from knowledge_processing import latest_query
|
|
+ query = ""
|
|
for turn in reversed(messages):
|
|
- if turn.get("role") == "user" and isinstance(turn.get("content"), str):
|
|
- query = turn["content"]
|
|
+ if isinstance(turn, dict) and turn.get("role") == "user":
|
|
+ query = model_protocol.message_text(turn.get("content"))
|
|
break
|
|
- query = latest_query(query) or query
|
|
+ if not query:
|
|
+ query = str(body.get("customer_text") or "")
|
|
+ query = latest_query(query) or query
|
|
+ try:
|
|
+ knowledge_result = await asyncio.wait_for(
|
|
+ asyncio.to_thread(knowledge_retriever.search, tenant, query), timeout=4.0)
|
|
+ knowledge_trace = {"enabled": True, "mode": knowledge_result["mode"],
|
|
+ "elapsed_ms": knowledge_result["elapsed_ms"], "hits": [
|
|
+ {k: h[k] for k in ("id", "revision", "score")} for h in knowledge_result["hits"]]}
|
|
+ messages = inject_references(messages, knowledge_result["hits"])
|
|
+ except Exception:
|
|
+ knowledge_trace = {"enabled": True, "hits": [], "mode": "unavailable", "elapsed_ms": 4000}
|
|
+ from knowledge_processing import redact
|
|
+ knowledge_result = {**knowledge_trace, "query": redact(query)[:1000]}
|
|
+ messages = inject_references(messages, [])
|
|
+
|
|
+ if tools:
|
|
+ # 工具轮:只问主出口,不并发也不评审。
|
|
+ #
|
|
+ # 并发问多个模型在这里是错的——它们会给出不同的 tool_calls,而工具
|
|
+ # 跑在桌面端本机、有副作用(挂号登记会真的建一条记录)。执行哪一份?
|
|
+ # 都执行就是重复下单,选一份就是把另一份的上下文丢了,接下来那一路
|
|
+ # 的对话直接对不上。
|
|
+ #
|
|
+ # 评审同理:一句"我要查客户资料"没什么好打分的。等工具跑完、模型给
|
|
+ # 出真正的答复(那一轮不带 tools),best-of-N 和裁判照常生效。
|
|
+ primary = answers[0]
|
|
+ single = await call_outlet(
|
|
+ request.app.state.client,
|
|
+ primary,
|
|
+ messages,
|
|
+ image_b64=image_b64,
|
|
+ tools=tools,
|
|
+ deadline=ANSWER_DEADLINE,
|
|
+ usage_recorder=usage_recorder,
|
|
+ )
|
|
+ payload = {
|
|
+ "reply": single.get("text") or "",
|
|
+ "tool_calls": single.get("tool_calls") or [],
|
|
+ "chosen": single.get("provider") or "",
|
|
+ "candidates": [single],
|
|
+ "judge": dict(_EMPTY_VERDICT),
|
|
+ "judge_mode": mode,
|
|
+ "roles_version": version,
|
|
+ "knowledge": knowledge_trace,
|
|
+ "total_ms": int((time.monotonic() - started) * 1000),
|
|
+ }
|
|
+ if single.get("error") and not payload["reply"] and not payload["tool_calls"]:
|
|
+ return JSONResponse(payload, status_code=502)
|
|
+ if idempotency_key:
|
|
+ idempotency[idempotency_key] = (now, payload)
|
|
try:
|
|
- knowledge_result = await asyncio.wait_for(
|
|
- asyncio.to_thread(knowledge_retriever.search, tenant, query), timeout=4.0)
|
|
- knowledge_trace = {"enabled": True, "mode": knowledge_result["mode"],
|
|
- "elapsed_ms": knowledge_result["elapsed_ms"], "hits": [
|
|
- {k: h[k] for k in ("id", "revision", "score")} for h in knowledge_result["hits"]]}
|
|
- messages = inject_references(messages, knowledge_result["hits"])
|
|
- except Exception:
|
|
- knowledge_trace = {"enabled": True, "hits": [], "mode": "unavailable", "elapsed_ms": 4000}
|
|
- from knowledge_processing import redact
|
|
- knowledge_result = {**knowledge_trace, "query": redact(query)[:1000]}
|
|
- messages = inject_references(messages, [])
|
|
-
|
|
- if tools:
|
|
- # 工具轮:只问主出口,不并发也不评审。
|
|
- #
|
|
- # 并发问多个模型在这里是错的——它们会给出不同的 tool_calls,而工具
|
|
- # 跑在桌面端本机、有副作用(挂号登记会真的建一条记录)。执行哪一份?
|
|
- # 都执行就是重复下单,选一份就是把另一份的上下文丢了,接下来那一路
|
|
- # 的对话直接对不上。
|
|
- #
|
|
- # 评审同理:一句"我要查客户资料"没什么好打分的。等工具跑完、模型给
|
|
- # 出真正的答复(那一轮不带 tools),best-of-N 和裁判照常生效。
|
|
- primary = answers[0]
|
|
- single = await call_outlet(
|
|
- request.app.state.client,
|
|
- primary,
|
|
- messages,
|
|
- image_b64=image_b64,
|
|
- tools=tools,
|
|
- deadline=ANSWER_DEADLINE,
|
|
+ log_queue.put_nowait({
|
|
+ "desktop_account_id": int(account["id"]), "tenant_id": tenant,
|
|
+ "device_id": x_device_id, "task_id": usage_recorder.context["task_id"],
|
|
+ "roles_version": version, "judge_mode": mode, "chosen": payload["chosen"],
|
|
+ "judge": payload["judge"], "candidates": [single], "total_ms": payload["total_ms"],
|
|
+ "customer_text": str(body.get("customer_text") or ""), "reply_text": payload["reply"],
|
|
+ "purpose": str(body.get("purpose") or "chat"), "knowledge_retrieval": knowledge_result,
|
|
+ })
|
|
+ except asyncio.QueueFull:
|
|
+ pass
|
|
+ return JSONResponse(payload)
|
|
+
|
|
+ results = await asyncio.gather(
|
|
+ *[
|
|
+ call_outlet(
|
|
+ request.app.state.client,
|
|
+ outlet,
|
|
+ messages,
|
|
+ image_b64=image_b64,
|
|
+ deadline=ANSWER_DEADLINE,
|
|
+ usage_recorder=usage_recorder,
|
|
+ )
|
|
+ for outlet in answers
|
|
+ ]
|
|
)
|
|
+ usable = [item for item in results if item.get("text")]
|
|
+ if not usable:
|
|
+ payload = {
|
|
+ "reply": "", "chosen": "", "candidates": results,
|
|
+ "judge": dict(_EMPTY_VERDICT), "judge_mode": mode,
|
|
+ "roles_version": version,
|
|
+ "total_ms": int((time.monotonic() - started) * 1000),
|
|
+ }
|
|
+ return JSONResponse(payload, status_code=502)
|
|
+
|
|
+ if purpose == "guard":
|
|
+ verdict = dict(_EMPTY_VERDICT)
|
|
+ else:
|
|
+ verdict = await run_judge(
|
|
+ request.app.state.client,
|
|
+ judge,
|
|
+ str(body.get("customer_text") or ""),
|
|
+ usable[0]["text"],
|
|
+ usable[1]["text"] if len(usable) > 1 else "",
|
|
+ usage_recorder=usage_recorder,
|
|
+ )
|
|
+
|
|
+ chosen = usable[0]
|
|
+ if (
|
|
+ mode == "arbitrate"
|
|
+ and verdict.get("participated")
|
|
+ and verdict.get("winner") == "B"
|
|
+ and len(usable) > 1
|
|
+ ):
|
|
+ chosen = usable[1]
|
|
+
|
|
payload = {
|
|
- "reply": single.get("text") or "",
|
|
- "tool_calls": single.get("tool_calls") or [],
|
|
- "chosen": single.get("provider") or "",
|
|
- "candidates": [single],
|
|
- "judge": dict(_EMPTY_VERDICT),
|
|
+ "reply": chosen["text"],
|
|
+ "knowledge": knowledge_trace,
|
|
+ "tool_calls": [],
|
|
+ "chosen": chosen["provider"],
|
|
+ "candidates": results,
|
|
+ "judge": verdict,
|
|
"judge_mode": mode,
|
|
"roles_version": version,
|
|
- "knowledge": knowledge_trace,
|
|
"total_ms": int((time.monotonic() - started) * 1000),
|
|
}
|
|
- if single.get("error") and not payload["reply"] and not payload["tool_calls"]:
|
|
- return JSONResponse(payload, status_code=502)
|
|
if idempotency_key:
|
|
idempotency[idempotency_key] = (now, payload)
|
|
try:
|
|
log_queue.put_nowait({
|
|
- "desktop_account_id": int(account["id"]), "tenant_id": tenant,
|
|
- "device_id": x_device_id, "task_id": str(body.get("task_id") or ""),
|
|
- "roles_version": version, "judge_mode": mode, "chosen": payload["chosen"],
|
|
- "judge": payload["judge"], "candidates": [single], "total_ms": payload["total_ms"],
|
|
- "customer_text": str(body.get("customer_text") or ""), "reply_text": payload["reply"],
|
|
- "purpose": str(body.get("purpose") or "chat"), "knowledge_retrieval": knowledge_result,
|
|
+ "desktop_account_id": int(account["id"]),
|
|
+ "tenant_id": str(account["tenant_id"]),
|
|
+ "device_id": x_device_id,
|
|
+ "task_id": usage_recorder.context["task_id"],
|
|
+ "roles_version": version,
|
|
+ "judge_mode": mode,
|
|
+ "chosen": payload["chosen"],
|
|
+ "judge": verdict,
|
|
+ "candidates": results,
|
|
+ "total_ms": payload["total_ms"],
|
|
+ "customer_text": str(body.get("customer_text") or ""),
|
|
+ "reply_text": payload["reply"],
|
|
+ "purpose": str(body.get("purpose") or "chat"),
|
|
+ "knowledge_retrieval": knowledge_result,
|
|
})
|
|
except asyncio.QueueFull:
|
|
- pass
|
|
+ pass # 观测数据丢一条,不能因此拖慢回复
|
|
return JSONResponse(payload)
|
|
-
|
|
- results = await asyncio.gather(
|
|
- *[
|
|
- call_outlet(
|
|
- request.app.state.client,
|
|
- outlet,
|
|
- messages,
|
|
- image_b64=image_b64,
|
|
- deadline=ANSWER_DEADLINE,
|
|
- )
|
|
- for outlet in answers
|
|
- ]
|
|
- )
|
|
- usable = [item for item in results if item.get("text")]
|
|
- if not usable:
|
|
- payload = {
|
|
- "reply": "", "chosen": "", "candidates": results,
|
|
- "judge": dict(_EMPTY_VERDICT), "judge_mode": mode,
|
|
- "roles_version": version,
|
|
- "total_ms": int((time.monotonic() - started) * 1000),
|
|
- }
|
|
- return JSONResponse(payload, status_code=502)
|
|
-
|
|
- verdict = await run_judge(
|
|
- request.app.state.client,
|
|
- judge,
|
|
- str(body.get("customer_text") or ""),
|
|
- usable[0]["text"],
|
|
- usable[1]["text"] if len(usable) > 1 else "",
|
|
- )
|
|
-
|
|
- chosen = usable[0]
|
|
- if (
|
|
- mode == "arbitrate"
|
|
- and verdict.get("participated")
|
|
- and verdict.get("winner") == "B"
|
|
- and len(usable) > 1
|
|
- ):
|
|
- chosen = usable[1]
|
|
-
|
|
- payload = {
|
|
- "reply": chosen["text"],
|
|
- "knowledge": knowledge_trace,
|
|
- "tool_calls": [],
|
|
- "chosen": chosen["provider"],
|
|
- "candidates": results,
|
|
- "judge": verdict,
|
|
- "judge_mode": mode,
|
|
- "roles_version": version,
|
|
- "total_ms": int((time.monotonic() - started) * 1000),
|
|
- }
|
|
- if idempotency_key:
|
|
- idempotency[idempotency_key] = (now, payload)
|
|
- try:
|
|
- log_queue.put_nowait({
|
|
- "desktop_account_id": int(account["id"]),
|
|
- "tenant_id": str(account["tenant_id"]),
|
|
- "device_id": x_device_id,
|
|
- "task_id": str(body.get("task_id") or ""),
|
|
- "roles_version": version,
|
|
- "judge_mode": mode,
|
|
- "chosen": payload["chosen"],
|
|
- "judge": verdict,
|
|
- "candidates": results,
|
|
- "total_ms": payload["total_ms"],
|
|
- "customer_text": str(body.get("customer_text") or ""),
|
|
- "reply_text": payload["reply"],
|
|
- "purpose": str(body.get("purpose") or "chat"),
|
|
- "knowledge_retrieval": knowledge_result,
|
|
- })
|
|
- except asyncio.QueueFull:
|
|
- pass # 观测数据丢一条,不能因此拖慢回复
|
|
- return JSONResponse(payload)
|
|
|
|
return app
|