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