57c8a26147
- AI Service: 多阶段对话模型(理解→澄清→生成→校验→确认),支持对话历史和当前Schema上下文 - Backend: 适配多阶段AI模型,Gateway/Service/Controller传递history和currentSchema - Frontend: AiDesignerChat组件重写,支持阶段徽章、快速回复、Schema校验展示 - Frontend: 表单/流程设计器布局优化,AI助手和Schema预览作为独立列 - Frontend: 流程设计器元数据和模式选择器合并为一行 - Keycloak: 自定义登录主题(中文化) - 修复CSS媒体查询括号平衡问题
204 lines
9.5 KiB
Python
204 lines
9.5 KiB
Python
import json
|
||
from datetime import datetime, timezone
|
||
from typing import Protocol
|
||
|
||
import httpx
|
||
|
||
from app.config import Settings
|
||
from app.models import (
|
||
LeaveDraftSuggestion,
|
||
LeaveDraftSuggestionRequest,
|
||
LeaveProgressAnswerRequest,
|
||
DesignerSuggestionRequest,
|
||
DesignerSuggestion,
|
||
ChatTurn,
|
||
)
|
||
|
||
|
||
class SuggestionGateway(Protocol):
|
||
async def suggest(self, request: LeaveDraftSuggestionRequest) -> LeaveDraftSuggestion: ...
|
||
|
||
|
||
class ProgressGateway(Protocol):
|
||
async def answer_progress(self, request: LeaveProgressAnswerRequest) -> str: ...
|
||
|
||
|
||
class DesignerGateway(Protocol):
|
||
async def suggest_design(self, request: DesignerSuggestionRequest) -> DesignerSuggestion: ...
|
||
|
||
|
||
class QwenSuggestionGateway:
|
||
def __init__(self, settings: Settings) -> None:
|
||
self.settings = settings
|
||
|
||
async def suggest(self, request: LeaveDraftSuggestionRequest) -> LeaveDraftSuggestion:
|
||
if not self.settings.qwen_api_key:
|
||
raise QwenConfigurationError("QWEN_API_KEY is not configured")
|
||
reference_time = request.now or datetime.now(timezone.utc)
|
||
payload = {
|
||
"model": self.settings.qwen_model,
|
||
"temperature": 0.1,
|
||
"response_format": {"type": "json_object"},
|
||
"messages": [
|
||
{"role": "system", "content": SYSTEM_PROMPT},
|
||
{
|
||
"role": "user",
|
||
"content": json.dumps(
|
||
{
|
||
"text": request.text,
|
||
"timezone": request.timezone,
|
||
"referenceTime": reference_time.isoformat(),
|
||
},
|
||
ensure_ascii=False,
|
||
),
|
||
},
|
||
],
|
||
}
|
||
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
|
||
response = await client.post(
|
||
f"{self.settings.qwen_base_url}/chat/completions",
|
||
headers={"Authorization": f"Bearer {self.settings.qwen_api_key}"},
|
||
json=payload,
|
||
)
|
||
if response.status_code >= 400:
|
||
raise QwenUpstreamError(f"Qwen returned HTTP {response.status_code}")
|
||
try:
|
||
content = response.json()["choices"][0]["message"]["content"]
|
||
return LeaveDraftSuggestion.model_validate_json(content)
|
||
except (KeyError, IndexError, TypeError, ValueError) as exc:
|
||
raise QwenUpstreamError("Qwen returned an invalid structured response") from exc
|
||
|
||
async def answer_progress(self, request: LeaveProgressAnswerRequest) -> str:
|
||
if not self.settings.qwen_api_key:
|
||
raise QwenConfigurationError("QWEN_API_KEY is not configured")
|
||
payload = {
|
||
"model": self.settings.qwen_model,
|
||
"temperature": 0.1,
|
||
"response_format": {"type": "json_object"},
|
||
"messages": [
|
||
{"role": "system", "content": PROGRESS_SYSTEM_PROMPT},
|
||
{"role": "user", "content": request.model_dump_json()},
|
||
],
|
||
}
|
||
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
|
||
response = await client.post(f"{self.settings.qwen_base_url}/chat/completions", headers={"Authorization": f"Bearer {self.settings.qwen_api_key}"}, json=payload)
|
||
if response.status_code >= 400:
|
||
raise QwenUpstreamError(f"Qwen returned HTTP {response.status_code}")
|
||
try:
|
||
content = response.json()["choices"][0]["message"]["content"]
|
||
answer = json.loads(content)["answer"]
|
||
if not isinstance(answer, str) or not answer.strip() or len(answer) > 2000:
|
||
raise ValueError("invalid answer")
|
||
return answer.strip()
|
||
except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||
raise QwenUpstreamError("Qwen returned an invalid progress answer") from exc
|
||
|
||
async def suggest_design(self, request: DesignerSuggestionRequest) -> DesignerSuggestion:
|
||
if not self.settings.qwen_api_key:
|
||
raise QwenConfigurationError("QWEN_API_KEY is not configured")
|
||
user_content = json.dumps(
|
||
{
|
||
"message": request.message,
|
||
"history": [t.model_dump() for t in request.history],
|
||
"currentSchema": request.currentSchema,
|
||
"timezone": request.timezone,
|
||
},
|
||
ensure_ascii=False,
|
||
)
|
||
messages: list[dict[str, str]] = [{"role": "system", "content": DESIGNER_SYSTEM_PROMPT}]
|
||
for turn in request.history:
|
||
messages.append({"role": turn.role, "content": turn.content})
|
||
messages.append({"role": "user", "content": user_content})
|
||
payload = {
|
||
"model": self.settings.qwen_model,
|
||
"temperature": 0.2,
|
||
"response_format": {"type": "json_object"},
|
||
"messages": messages,
|
||
}
|
||
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
|
||
response = await client.post(
|
||
f"{self.settings.qwen_base_url}/chat/completions",
|
||
headers={"Authorization": f"Bearer {self.settings.qwen_api_key}"},
|
||
json=payload,
|
||
)
|
||
if response.status_code >= 400:
|
||
raise QwenUpstreamError(f"Qwen returned HTTP {response.status_code}")
|
||
try:
|
||
content = response.json()["choices"][0]["message"]["content"]
|
||
return DesignerSuggestion.model_validate_json(content)
|
||
except (KeyError, IndexError, TypeError, ValueError) as exc:
|
||
raise QwenUpstreamError("Qwen returned an invalid designer response") from exc
|
||
|
||
|
||
SYSTEM_PROMPT = """
|
||
你是企业 OA 请假表单解析器。只把用户自然语言转换为 JSON 建议值,不执行任何业务动作。
|
||
输出必须是一个 JSON 对象,只允许字段:type、startsAt、endsAt、reason、assumptions、needsClarification。
|
||
type 只能是 PERSONAL、SICK、ANNUAL 或 null。时间必须是带时区偏移的 ISO-8601。
|
||
不能确定的值输出 null,并把需要用户补充的问题写入 needsClarification。
|
||
不得输出申请人、审批人、租户、权限、流程或隐藏字段。不得使用 Markdown。
|
||
""".strip()
|
||
|
||
PROGRESS_SYSTEM_PROMPT = """
|
||
你是企业 OA 只读流程进度助手。数据库记录已经由业务后端鉴权并选定,你只能依据输入 context 回答 question。
|
||
输出必须是 JSON 对象且只包含 answer 字段。回答必须明确当前 status;有 activeTaskNames 时说明当前节点;流程结束时说明已结束。
|
||
不得猜测审批人、原因、流程变量或预计完成时间,不得给出批准、驳回、撤回、提交等写操作指令,不得使用 Markdown。
|
||
""".strip()
|
||
|
||
DESIGNER_SYSTEM_PROMPT = """
|
||
你是企业 OA 表单与流程设计助手,通过多阶段对话帮助用户生成正确的表单和审批流程 Schema。
|
||
|
||
## 工作流程(5 个阶段)
|
||
|
||
1. UNDERSTANDING(理解):分析用户需求,复述你的理解,判断信息是否充分。
|
||
- 如果信息不足需要澄清 → 进入 CLARIFYING
|
||
- 如果信息充分 → 直接进入 GENERATING
|
||
|
||
2. CLARIFYING(澄清):向用户提出具体问题,每次最多 3 个问题。
|
||
- 用户回答后重新评估,信息充分则进入 GENERATING
|
||
|
||
3. GENERATING(生成):根据理解生成完整的表单字段和审批流程 Schema。
|
||
- 生成后自动进入 VALIDATING
|
||
|
||
4. VALIDATING(校验):自检生成的 Schema 是否正确完整。
|
||
- 检查项:字段 key 唯一、必填字段合理、控件类型匹配、审批步骤 ≥ 2、assigneeVariable 不重复(PARALLEL/CONDITIONAL)、select 有 options
|
||
- 有问题则修复后重新校验,无问题则进入 CONFIRMING
|
||
|
||
5. CONFIRMING(确认):展示最终 Schema 摘要,请用户确认或调整。
|
||
- 用户确认 → schemaReady = true
|
||
- 用户要求调整 → 回到 GENERATING
|
||
|
||
## 输出格式
|
||
|
||
输出必须是 JSON 对象,包含以下字段:
|
||
- stage:当前阶段(UNDERSTANDING/CLARIFYING/GENERATING/VALIDATING/CONFIRMING)
|
||
- understanding:你对用户需求的理解复述(中文)
|
||
- formTitle:表单中文标题
|
||
- formKey:英文 kebab-case 标识符
|
||
- fields:数组,每项含 key(英文 camelCase)、label(中文)、control(text/textArea/select/dateTime/number)、required、placeholder、options(仅 select)、helperText
|
||
- process:对象,含 mode(SERIAL/PARALLEL/CONDITIONAL)、steps(数组,每项含 name 和 assigneeVariable)、conditionThresholdDays(仅 CONDITIONAL)
|
||
- summary:一句话总结
|
||
- assumptions:你做出的假设
|
||
- needsClarification:需要用户回答的问题(CLARIFYING 阶段使用)
|
||
- validationIssues:校验发现的问题数组,每项含 field、issue、severity(ERROR/WARNING)
|
||
- schemaReady:Schema 是否已确认可用(仅 CONFIRMING 阶段用户确认后为 true)
|
||
|
||
## 约束
|
||
|
||
- assigneeVariable 只能是:approverId(部门主管)、oaAdministratorId(OA 管理员)、hrReviewerId(HR 复核人)
|
||
- 字段 key 用英文 camelCase 且唯一,label 用中文
|
||
- 审批流程至少 2 个步骤
|
||
- PARALLEL 和 CONDITIONAL 模式下 assigneeVariable 不可重复
|
||
- CONDITIONAL 模式需设置 conditionThresholdDays
|
||
- 不得输出权限、租户、隐藏字段等敏感信息
|
||
- 不得使用 Markdown
|
||
- 如果用户提供了 currentSchema,说明用户已在图形界面修改过,你需要基于当前 Schema 进行调整而非重新生成
|
||
""".strip()
|
||
|
||
|
||
class QwenConfigurationError(RuntimeError):
|
||
pass
|
||
|
||
|
||
class QwenUpstreamError(RuntimeError):
|
||
pass
|