agent-Specialization/modules/user_question_manager.py
JOJO a2cf547400 feat(i18n): 后端用户可见消息国际化(zh/en 双语 + ui_locale 偏好持久化)
- 新增 modules/i18n.py:tr() + 进程级语言缓存 + modules/i18n_messages/ 域文案包自动聚合
- 新增 29 个域文案包,共 1153 条双语 key;90+ 源文件 1146 处用户可见消息 tr 化
- ui_locale 存入 personalization.json(用户级共享),前后端双向同步
- 前端匹配点双语兼容(history/shared/ChatArea/taskPolling/upload 等正则)
- 修复语言判等陷阱:审批等待加稳定 code 字段;conversation.py 不存在判等改双语 helper
- 边界:日志/prompt 注入/子智能体工具回填/容器内嵌脚本不迁移
2026-08-29 07:58:29 +08:00

177 lines
6.5 KiB
Python
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.

from __future__ import annotations
import threading
import time
import uuid
from typing import Any, Dict, List, Optional
from modules.i18n import tr
class UserQuestionManager:
"""In-memory manager for blocking model-to-user questions."""
def __init__(self):
self._items: Dict[str, Dict[str, Any]] = {}
self._lock = threading.Lock()
@staticmethod
def _normalize_options(options: Any) -> List[Dict[str, str]]:
if not isinstance(options, list):
return []
normalized: List[Dict[str, str]] = []
seen = set()
for idx, raw in enumerate(options[:8], start=1):
if not isinstance(raw, dict):
continue
label = str(raw.get("label") or "").strip()
if not label:
continue
option_id = str(raw.get("id") or "").strip() or f"option_{idx}"
base = option_id
suffix = 2
while option_id in seen:
option_id = f"{base}_{suffix}"
suffix += 1
seen.add(option_id)
item = {
"id": option_id,
"label": label[:120],
}
desc = str(raw.get("description") or "").strip()
if desc:
item["description"] = desc[:500]
normalized.append(item)
return normalized
def create_question(
self,
*,
username: str,
conversation_id: Optional[str],
task_id: Optional[str],
tool_call_id: Optional[str],
question: str,
context: Optional[str] = None,
options: Any = None,
batch_id: Optional[str] = None,
batch_index: int = 0,
batch_total: int = 1,
) -> Dict[str, Any]:
question_id = f"question_{uuid.uuid4().hex}"
text = str(question or "").strip()
item = {
"question_id": question_id,
"batch_id": batch_id,
"batch_index": int(batch_index),
"batch_total": int(batch_total),
"username": username,
"conversation_id": conversation_id,
"task_id": task_id,
"tool_call_id": tool_call_id,
"question": text,
"context": str(context or "").strip(),
"options": self._normalize_options(options),
"status": "pending",
"created_at": time.time(),
"answered_at": None,
"answer": None,
}
with self._lock:
self._items[question_id] = item
return dict(item)
def get(self, question_id: str) -> Optional[Dict[str, Any]]:
with self._lock:
item = self._items.get(question_id)
return dict(item) if item else None
def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]:
with self._lock:
rows = []
for item in self._items.values():
if item.get("username") != username:
continue
if item.get("status") != "pending":
continue
if conversation_id and item.get("conversation_id") != conversation_id:
continue
rows.append(dict(item))
rows.sort(key=lambda x: (x.get("created_at", 0.0), x.get("batch_index", 0)))
return rows
def answer(
self,
*,
question_id: str,
username: str,
selected_option_id: Optional[str] = None,
text: Optional[str] = None,
dismissed: bool = False,
) -> Dict[str, Any]:
clean_option_id = str(selected_option_id or "").strip()
clean_text = str(text or "").strip()
if not dismissed and not clean_option_id and not clean_text:
raise ValueError(tr("user_question.answer_empty"))
with self._lock:
item = self._items.get(question_id)
if not item:
raise KeyError(tr("user_question.question_not_found"))
if item.get("username") != username:
raise PermissionError(tr("user_question.no_permission"))
if item.get("status") != "pending":
return dict(item)
# 用户主动选择不回答:标记为 dismissed工具结果会提示模型改为在对话中提问
if dismissed:
item["status"] = "answered"
item["answered_at"] = time.time()
item["answer"] = {"type": "dismissed", "text": ""}
return dict(item)
selected_option = None
if clean_option_id:
for opt in item.get("options") or []:
if str(opt.get("id") or "") == clean_option_id:
selected_option = dict(opt)
break
if selected_option is None:
raise ValueError(tr("user_question.option_not_found"))
answer_type = "option_with_text" if selected_option and clean_text else "option" if selected_option else "free_text"
answer = {
"type": answer_type,
"text": clean_text,
}
if selected_option:
answer["selected_option_id"] = selected_option.get("id")
answer["selected_option_label"] = selected_option.get("label")
if selected_option.get("description"):
answer["selected_option_description"] = selected_option.get("description")
if not clean_text:
answer["text"] = selected_option.get("label") or ""
item["status"] = "answered"
item["answered_at"] = time.time()
item["answer"] = answer
return dict(item)
def format_user_question_answer(item: Dict[str, Any]) -> str:
"""Return the compact, model-facing result text for an answered question."""
answer = item.get("answer") if isinstance(item, dict) else None
if not isinstance(answer, dict):
return "用户未回答。"
if answer.get("type") == "dismissed":
return "用户没有回答或不想回答这个问题,请直接输出内容向用户提问"
lines: List[str] = []
label = str(answer.get("selected_option_label") or "").strip()
text = str(answer.get("text") or "").strip()
if label:
lines.append(f"用户选择:{label}")
if text and text != label:
lines.append(f"用户补充:{text}")
elif text:
lines.append(f"用户回答:{text}")
return "\n".join(lines).strip() or "用户未回答。"