Files
AIOA/ai-service/app/qwen.py
T
selfrelease 57c8a26147 feat: LLM多阶段交互式Schema生成 + UI布局优化
- AI Service: 多阶段对话模型(理解→澄清→生成→校验→确认),支持对话历史和当前Schema上下文
- Backend: 适配多阶段AI模型,Gateway/Service/Controller传递history和currentSchema
- Frontend: AiDesignerChat组件重写,支持阶段徽章、快速回复、Schema校验展示
- Frontend: 表单/流程设计器布局优化,AI助手和Schema预览作为独立列
- Frontend: 流程设计器元数据和模式选择器合并为一行
- Keycloak: 自定义登录主题(中文化)
- 修复CSS媒体查询括号平衡问题
2026-07-19 09:36:42 +08:00

204 lines
9.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.
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(中文)、controltext/textArea/select/dateTime/number)、required、placeholder、options(仅 select)、helperText
- process:对象,含 modeSERIAL/PARALLEL/CONDITIONAL)、steps(数组,每项含 name 和 assigneeVariable)、conditionThresholdDays(仅 CONDITIONAL
- summary:一句话总结
- assumptions:你做出的假设
- needsClarification:需要用户回答的问题(CLARIFYING 阶段使用)
- validationIssues:校验发现的问题数组,每项含 field、issue、severityERROR/WARNING
- schemaReadySchema 是否已确认可用(仅 CONFIRMING 阶段用户确认后为 true
## 约束
- assigneeVariable 只能是:approverId(部门主管)、oaAdministratorIdOA 管理员)、hrReviewerIdHR 复核人)
- 字段 key 用英文 camelCase 且唯一,label 用中文
- 审批流程至少 2 个步骤
- PARALLEL 和 CONDITIONAL 模式下 assigneeVariable 不可重复
- CONDITIONAL 模式需设置 conditionThresholdDays
- 不得输出权限、租户、隐藏字段等敏感信息
- 不得使用 Markdown
- 如果用户提供了 currentSchema,说明用户已在图形界面修改过,你需要基于当前 Schema 进行调整而非重新生成
""".strip()
class QwenConfigurationError(RuntimeError):
pass
class QwenUpstreamError(RuntimeError):
pass