diff --git a/app/core/config.py b/app/core/config.py index be795cd..beba442 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -3,6 +3,8 @@ from functools import lru_cache from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +DEFAULT_MODEL_PROVIDER = "noop" + class Settings(BaseSettings): """Runtime settings loaded from environment variables and `.env`.""" @@ -29,11 +31,16 @@ class Settings(BaseSettings): feishu_encrypt_key: str | None = None feishu_default_chat_id: str | None = None - model_provider: str = "noop" - openclaw_base_url: str = "http://127.0.0.1:18789" + model_provider: str = DEFAULT_MODEL_PROVIDER + openclaw_base_url: str = "http://127.0.0.1:2070" + openclaw_http_url: str | None = None + openclaw_ws_url: str | None = None openclaw_api_key: str | None = None - hermes_base_url: str = "http://127.0.0.1:8080" + openclaw_gateway_token: str | None = None + hermes_base_url: str = "http://127.0.0.1:2073/v1" hermes_api_key: str | None = None + hermes_model: str = "hermes-agent" + hermes_session_id: str | None = None direct_llm_base_url: str = "https://api.openai.com/v1" direct_llm_api_key: str | None = None direct_llm_model: str = "gpt-4.1-mini" diff --git a/app/core/constants.py b/app/core/constants.py new file mode 100644 index 0000000..4396b32 --- /dev/null +++ b/app/core/constants.py @@ -0,0 +1,15 @@ +from enum import StrEnum + + +class ActorValue(StrEnum): + API = "api" + SYSTEM = "system" + SCHEDULER = "scheduler" + FEISHU = "feishu" + + +class HttpHeader(StrEnum): + AUTHORIZATION = "Authorization" + + +BEARER_TOKEN_TEMPLATE = "Bearer {token}" diff --git a/app/core/scheduler.py b/app/core/scheduler.py index 1232711..4fb2ec0 100644 --- a/app/core/scheduler.py +++ b/app/core/scheduler.py @@ -1,6 +1,8 @@ from fastapi import FastAPI +from app.core.constants import ActorValue from app.core.config import get_settings +from app.modules.feishu.constants import FeishuReceiveIdType def attach_scheduler(app: FastAPI) -> None: @@ -30,8 +32,8 @@ def attach_scheduler(app: FastAPI) -> None: ReportService(db).push_report( report, receive_id=settings.feishu_default_chat_id, - receive_id_type="chat_id", - actor="scheduler", + receive_id_type=FeishuReceiveIdType.CHAT_ID, + actor=ActorValue.SCHEDULER, ) finally: db.close() @@ -49,8 +51,8 @@ def attach_scheduler(app: FastAPI) -> None: ReportService(db).push_report( report, receive_id=settings.feishu_default_chat_id, - receive_id_type="chat_id", - actor="scheduler", + receive_id_type=FeishuReceiveIdType.CHAT_ID, + actor=ActorValue.SCHEDULER, ) finally: db.close() diff --git a/app/modules/ai_agent/adapters.py b/app/modules/ai_agent/adapters.py index a969e29..445211c 100644 --- a/app/modules/ai_agent/adapters.py +++ b/app/modules/ai_agent/adapters.py @@ -5,6 +5,28 @@ import httpx from fastapi import HTTPException from app.core.config import Settings, get_settings +from app.modules.ai_agent.constants import ( + AUTHORIZATION_BEARER_TEMPLATE, + CHAT_USER_CONTENT_TEMPLATE, + COMPANY_MANAGEMENT_SYSTEM_INSTRUCTIONS, + DIRECT_LLM_API_KEY_MISSING, + NOOP_PROVIDER_ANSWER, + OPENCLAW_HERMES_PIPELINE, + OPENCLAW_TOOL_COMPLETED_ANSWER, + UNEXPECTED_HERMES_RESPONSE, + AIDefault, + AIChatRole, + AIContextKey, + AIErrorKey, + AIHttpHeader, + AIHttpPath, + AIHttpPayloadKey, + AIMemoryMode, + AIProviderName, + AIRequestKey, + AIResponseKey, +) +from app.modules.ai_agent.skills import AISkillId, get_ai_skill class AIAdapter(ABC): @@ -20,67 +42,163 @@ class AIAdapter(ABC): class NoopAdapter(AIAdapter): """Deterministic adapter used when no model provider is configured.""" - provider_name = "noop" + provider_name = AIProviderName.NOOP def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: return { - "answer": ( - "AI provider is not configured yet. This is a deterministic placeholder. " - "Set MODEL_PROVIDER to openclaw_hermes, openclaw, hermes, or direct_llm " - "after credentials are ready." - ), - "raw": {"prompt": prompt, "context": context or {}}, + AIResponseKey.ANSWER: NOOP_PROVIDER_ANSWER, + AIResponseKey.RAW: { + AIRequestKey.PROMPT: prompt, + AIRequestKey.CONTEXT: context or {}, + }, } class OpenClawAdapter(AIAdapter): - """Adapter for an OpenClaw-compatible agent endpoint.""" + """Adapter for the OpenClaw Gateway control-plane API.""" - provider_name = "openclaw" + provider_name = AIProviderName.OPENCLAW def __init__(self, settings: Settings): self.settings = settings def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: - url = f"{self.settings.openclaw_base_url.rstrip('/')}/api/v1/agent/ask" - headers = {} - if self.settings.openclaw_api_key: - headers["Authorization"] = f"Bearer {self.settings.openclaw_api_key}" - payload = {"prompt": prompt, "context": context or {}} - with httpx.Client(timeout=60) as client: - response = client.post(url, json=payload, headers=headers) + context = context or {} + tool = context.get(AIContextKey.OPENCLAW_TOOL) + if not tool: + raise HTTPException( + status_code=400, + detail=( + "OpenClaw Gateway is not configured as a chat provider. " + "Provide context.openclaw_tool for /tools/invoke, or use " + "MODEL_PROVIDER=hermes/openclaw_hermes for AI answers." + ), + ) + result = self.invoke_tool( + tool=str(tool), + action=str(context.get(AIContextKey.OPENCLAW_ACTION) or AIDefault.ACTION_JSON), + args=context.get(AIContextKey.OPENCLAW_ARGS) or {}, + session_key=str( + context.get(AIContextKey.OPENCLAW_SESSION_KEY) or AIDefault.SESSION_KEY_MAIN + ), + ) + return { + AIResponseKey.ANSWER: OPENCLAW_TOOL_COMPLETED_ANSWER, + AIResponseKey.RAW: result, + } + + def health(self) -> dict[str, Any]: + """Check OpenClaw Gateway health endpoints.""" + + headers = self._headers() + base_url = self._base_url() + with httpx.Client(timeout=5, trust_env=False) as client: + healthz = client.get(f"{base_url}{AIHttpPath.HEALTHZ}", headers=headers) + readyz = client.get(f"{base_url}{AIHttpPath.READYZ}", headers=headers) + return { + AIResponseKey.OK: healthz.status_code < 400 and readyz.status_code < 400, + AIResponseKey.BASE_URL: base_url, + AIResponseKey.HEALTHZ: _response_payload(healthz), + AIResponseKey.READYZ: _response_payload(readyz), + } + + def invoke_tool( + self, + tool: str, + action: str = AIDefault.ACTION_JSON, + args: dict[str, Any] | None = None, + session_key: str = AIDefault.SESSION_KEY_MAIN, + ) -> dict[str, Any]: + """Invoke one OpenClaw Gateway tool through `/tools/invoke`.""" + + payload = { + AIHttpPayloadKey.TOOL: tool, + AIHttpPayloadKey.ACTION: action, + AIHttpPayloadKey.ARGS: args or {}, + AIHttpPayloadKey.SESSION_KEY: session_key, + } + url = f"{self._base_url()}{AIHttpPath.TOOLS_INVOKE}" + with httpx.Client(timeout=120, trust_env=False) as client: + response = client.post(url, json=payload, headers=self._headers()) if response.status_code >= 400: - raise HTTPException(status_code=502, detail={"openclaw_error": response.text}) - data = response.json() - return {"answer": data.get("answer") or data.get("content") or str(data), "raw": data} + raise HTTPException(status_code=502, detail={AIErrorKey.OPENCLAW: response.text}) + return _response_payload(response) + + def _base_url(self) -> str: + return (self.settings.openclaw_http_url or self.settings.openclaw_base_url).rstrip("/") + + def _headers(self) -> dict[str, str]: + token = self.settings.openclaw_gateway_token or self.settings.openclaw_api_key + if not token: + return {} + return { + AIHttpHeader.AUTHORIZATION: AUTHORIZATION_BEARER_TEMPLATE.format(token=token) + } class HermesAdapter(AIAdapter): - """Adapter for a Hermes-compatible memory or agent endpoint.""" + """Adapter for the Hermes OpenAI-compatible agent endpoint.""" - provider_name = "hermes" + provider_name = AIProviderName.HERMES def __init__(self, settings: Settings): self.settings = settings def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: - url = f"{self.settings.hermes_base_url.rstrip('/')}/api/v1/ask" + url = f"{self.settings.hermes_base_url.rstrip('/')}{AIHttpPath.CHAT_COMPLETIONS}" headers = {} if self.settings.hermes_api_key: - headers["Authorization"] = f"Bearer {self.settings.hermes_api_key}" - payload = {"prompt": prompt, "context": context or {}} - with httpx.Client(timeout=60) as client: + headers[AIHttpHeader.AUTHORIZATION] = AUTHORIZATION_BEARER_TEMPLATE.format( + token=self.settings.hermes_api_key + ) + if self.settings.hermes_session_id: + headers[AIHttpHeader.HERMES_SESSION_ID] = self.settings.hermes_session_id + payload = { + AIHttpPayloadKey.MODEL: self.settings.hermes_model, + AIHttpPayloadKey.MESSAGES: _chat_messages(prompt, context), + AIHttpPayloadKey.STREAM: False, + } + with httpx.Client(timeout=300, trust_env=False) as client: response = client.post(url, json=payload, headers=headers) if response.status_code >= 400: - raise HTTPException(status_code=502, detail={"hermes_error": response.text}) + raise HTTPException(status_code=502, detail={AIErrorKey.HERMES: response.text}) data = response.json() - return {"answer": data.get("answer") or data.get("content") or str(data), "raw": data} + try: + answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][ + AIHttpPayloadKey.CONTENT + ] + except (KeyError, IndexError, TypeError) as exc: + raise HTTPException( + status_code=502, + detail={ + AIErrorKey.HERMES: UNEXPECTED_HERMES_RESPONSE, + AIResponseKey.RAW: data, + }, + ) from exc + return {AIResponseKey.ANSWER: answer, AIResponseKey.RAW: data} + + def health(self) -> dict[str, Any]: + """Check Hermes Agent health without triggering a chat completion.""" + + url = f"{_service_root(self.settings.hermes_base_url, AIHttpPath.V1)}{AIHttpPath.HEALTH}" + headers = {} + if self.settings.hermes_api_key: + headers[AIHttpHeader.AUTHORIZATION] = AUTHORIZATION_BEARER_TEMPLATE.format( + token=self.settings.hermes_api_key + ) + with httpx.Client(timeout=5, trust_env=False) as client: + response = client.get(url, headers=headers) + return { + AIResponseKey.OK: response.status_code < 400, + AIResponseKey.BASE_URL: self.settings.hermes_base_url.rstrip("/"), + AIResponseKey.HEALTH: _response_payload(response), + } class OpenClawHermesAdapter(AIAdapter): - """Compose Hermes memory with OpenClaw execution.""" + """Compose OpenClaw Gateway context with Hermes Agent answers.""" - provider_name = "openclaw_hermes" + provider_name = AIProviderName.OPENCLAW_HERMES def __init__(self, settings: Settings): self.openclaw = OpenClawAdapter(settings) @@ -89,40 +207,80 @@ class OpenClawHermesAdapter(AIAdapter): def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: base_context = context or {} recall = self._recall_memory(prompt, base_context) - openclaw_context = { + openclaw = self._openclaw_context(base_context) + hermes_context = { **base_context, - "agent_pipeline": self.provider_name, - "hermes_memory": recall["answer"], + AIContextKey.AGENT_PIPELINE: self.provider_name, + AIContextKey.HERMES_MEMORY: recall[AIResponseKey.ANSWER], + AIContextKey.OPENCLAW: openclaw, } - openclaw_result = self.openclaw.ask(prompt, openclaw_context) - remember = self._remember_interaction(prompt, base_context, openclaw_result["answer"]) + hermes_result = self.hermes.ask(prompt, hermes_context) + remember = self._remember_interaction( + prompt, + base_context, + hermes_result[AIResponseKey.ANSWER], + ) return { - "answer": openclaw_result["answer"], - "raw": { - "pipeline": "hermes_recall -> openclaw_answer -> hermes_remember", - "hermes_recall": recall, - "openclaw": openclaw_result.get("raw", {}), - "hermes_remember": remember, + AIResponseKey.ANSWER: hermes_result[AIResponseKey.ANSWER], + AIResponseKey.RAW: { + AIResponseKey.PIPELINE: OPENCLAW_HERMES_PIPELINE, + AIResponseKey.HERMES_RECALL: recall, + AIResponseKey.OPENCLAW: openclaw, + AIResponseKey.HERMES_ANSWER: hermes_result.get(AIResponseKey.RAW, {}), + AIResponseKey.HERMES_REMEMBER: remember, }, } + def _openclaw_context(self, context: dict[str, Any]) -> dict[str, Any]: + result: dict[str, Any] = {AIResponseKey.TOOL_INVOKED: False} + try: + result[AIResponseKey.HEALTH] = self.openclaw.health() + except Exception as exc: + result[AIResponseKey.HEALTH] = { + AIResponseKey.OK: False, + AIResponseKey.ERROR: _error_detail(exc), + } + + tool = context.get(AIContextKey.OPENCLAW_TOOL) + if not tool: + return result + + try: + result[AIResponseKey.TOOL_INVOKED] = True + result[AIResponseKey.TOOL] = self.openclaw.invoke_tool( + tool=str(tool), + action=str(context.get(AIContextKey.OPENCLAW_ACTION) or AIDefault.ACTION_JSON), + args=context.get(AIContextKey.OPENCLAW_ARGS) or {}, + session_key=str( + context.get(AIContextKey.OPENCLAW_SESSION_KEY) + or AIDefault.SESSION_KEY_MAIN + ), + ) + except Exception as exc: + result[AIResponseKey.TOOL_ERROR] = _error_detail(exc) + return result + def _recall_memory(self, prompt: str, context: dict[str, Any]) -> dict[str, Any]: - recall_prompt = ( - "Retrieve concise long-term memory, preferences, prior decisions, and relevant " - "business context for this request. Return only information useful to answer it." - ) + recall_skill = get_ai_skill(AISkillId.HERMES_MEMORY_RECALL) try: result = self.hermes.ask( - recall_prompt, + recall_skill.render(), { - "mode": "memory_recall", - "user_prompt": prompt, - "request_context": context, + AIContextKey.MODE: AIMemoryMode.RECALL, + AIContextKey.USER_PROMPT: prompt, + AIContextKey.REQUEST_CONTEXT: context, }, ) except Exception as exc: # Hermes memory should not block OpenClaw execution. - return {"answer": "", "raw": {}, "error": _error_detail(exc)} - return {"answer": result["answer"], "raw": result.get("raw", {})} + return { + AIResponseKey.ANSWER: "", + AIResponseKey.RAW: {}, + AIResponseKey.ERROR: _error_detail(exc), + } + return { + AIResponseKey.ANSWER: result[AIResponseKey.ANSWER], + AIResponseKey.RAW: result.get(AIResponseKey.RAW, {}), + } def _remember_interaction( self, @@ -130,56 +288,60 @@ class OpenClawHermesAdapter(AIAdapter): context: dict[str, Any], answer: str, ) -> dict[str, Any]: - remember_prompt = ( - "Store durable lessons from this interaction for future company management " - "assistance. Ignore transient details and do not store secrets." - ) + remember_skill = get_ai_skill(AISkillId.HERMES_MEMORY_WRITE) try: result = self.hermes.ask( - remember_prompt, + remember_skill.render(), { - "mode": "memory_write", - "user_prompt": prompt, - "request_context": context, - "assistant_answer": answer, + AIContextKey.MODE: AIMemoryMode.WRITE, + AIContextKey.USER_PROMPT: prompt, + AIContextKey.REQUEST_CONTEXT: context, + AIContextKey.ASSISTANT_ANSWER: answer, }, ) except Exception as exc: - return {"ok": False, "raw": {}, "error": _error_detail(exc)} - return {"ok": True, "raw": result.get("raw", {}), "answer": result["answer"]} + return { + AIResponseKey.OK: False, + AIResponseKey.RAW: {}, + AIResponseKey.ERROR: _error_detail(exc), + } + return { + AIResponseKey.OK: True, + AIResponseKey.RAW: result.get(AIResponseKey.RAW, {}), + AIResponseKey.ANSWER: result[AIResponseKey.ANSWER], + } class DirectLLMAdapter(AIAdapter): """Adapter for OpenAI-compatible chat completions APIs.""" - provider_name = "direct_llm" + provider_name = AIProviderName.DIRECT_LLM def __init__(self, settings: Settings): self.settings = settings def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: if not self.settings.direct_llm_api_key: - raise HTTPException(status_code=503, detail="DIRECT_LLM_API_KEY is not configured") - url = f"{self.settings.direct_llm_base_url.rstrip('/')}/chat/completions" - headers = {"Authorization": f"Bearer {self.settings.direct_llm_api_key}"} - messages = [ - { - "role": "system", - "content": ( - "You are a company management AI. Be concise, cite data from context, " - "and never approve payments, performance changes, or trades automatically." - ), - }, - {"role": "user", "content": f"Context:\n{context or {}}\n\nTask:\n{prompt}"}, - ] - payload = {"model": self.settings.direct_llm_model, "messages": messages} - with httpx.Client(timeout=60) as client: + raise HTTPException(status_code=503, detail=DIRECT_LLM_API_KEY_MISSING) + url = f"{self.settings.direct_llm_base_url.rstrip('/')}{AIHttpPath.CHAT_COMPLETIONS}" + headers = { + AIHttpHeader.AUTHORIZATION: AUTHORIZATION_BEARER_TEMPLATE.format( + token=self.settings.direct_llm_api_key + ) + } + payload = { + AIHttpPayloadKey.MODEL: self.settings.direct_llm_model, + AIHttpPayloadKey.MESSAGES: _chat_messages(prompt, context), + } + with httpx.Client(timeout=60, trust_env=False) as client: response = client.post(url, json=payload, headers=headers) if response.status_code >= 400: - raise HTTPException(status_code=502, detail={"llm_error": response.text}) + raise HTTPException(status_code=502, detail={AIErrorKey.DIRECT_LLM: response.text}) data = response.json() - answer = data["choices"][0]["message"]["content"] - return {"answer": answer, "raw": data} + answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][ + AIHttpPayloadKey.CONTENT + ] + return {AIResponseKey.ANSWER: answer, AIResponseKey.RAW: data} def get_adapter() -> AIAdapter: @@ -187,13 +349,17 @@ def get_adapter() -> AIAdapter: settings = get_settings() provider = settings.model_provider.lower() - if provider == "openclaw": + if provider == AIProviderName.OPENCLAW: return OpenClawAdapter(settings) - if provider == "hermes": + if provider == AIProviderName.HERMES: return HermesAdapter(settings) - if provider in {"openclaw_hermes", "openclaw-hermes", "hybrid"}: + if provider in { + AIProviderName.OPENCLAW_HERMES, + AIProviderName.OPENCLAW_HERMES_DASH, + AIProviderName.HYBRID, + }: return OpenClawHermesAdapter(settings) - if provider == "direct_llm": + if provider == AIProviderName.DIRECT_LLM: return DirectLLMAdapter(settings) return NoopAdapter() @@ -201,4 +367,36 @@ def get_adapter() -> AIAdapter: def _error_detail(exc: Exception) -> Any: if isinstance(exc, HTTPException): return exc.detail - return {"type": type(exc).__name__, "message": str(exc)} + return {AIResponseKey.TYPE: type(exc).__name__, AIResponseKey.MESSAGE: str(exc)} + + +def _response_payload(response: httpx.Response) -> dict[str, Any]: + try: + data = response.json() + except ValueError: + data = {AIResponseKey.TEXT: response.text} + return {AIResponseKey.STATUS_CODE: response.status_code, AIResponseKey.DATA: data} + + +def _chat_messages(prompt: str, context: dict[str, Any] | None = None) -> list[dict[str, str]]: + return [ + { + AIHttpPayloadKey.ROLE: AIChatRole.SYSTEM, + AIHttpPayloadKey.CONTENT: COMPANY_MANAGEMENT_SYSTEM_INSTRUCTIONS, + }, + { + AIHttpPayloadKey.ROLE: AIChatRole.USER, + AIHttpPayloadKey.CONTENT: CHAT_USER_CONTENT_TEMPLATE.format( + context=context or {}, + task=prompt, + ), + }, + ] + + +def _service_root(base_url: str, suffix: str) -> str: + root = base_url.rstrip("/") + normalized_suffix = suffix.rstrip("/") + if root.endswith(normalized_suffix): + root = root[: -len(normalized_suffix)] + return root.rstrip("/") diff --git a/app/modules/ai_agent/constants.py b/app/modules/ai_agent/constants.py new file mode 100644 index 0000000..7c2c1e8 --- /dev/null +++ b/app/modules/ai_agent/constants.py @@ -0,0 +1,128 @@ +from enum import StrEnum + + +class AIProviderName(StrEnum): + NOOP = "noop" + OPENCLAW = "openclaw" + HERMES = "hermes" + OPENCLAW_HERMES = "openclaw_hermes" + OPENCLAW_HERMES_DASH = "openclaw-hermes" + HYBRID = "hybrid" + DIRECT_LLM = "direct_llm" + + +class AIResponseKey(StrEnum): + PROVIDER = "provider" + ANSWER = "answer" + RAW = "raw" + RESULT = "result" + OK = "ok" + ERROR = "error" + MESSAGE = "message" + TYPE = "type" + BASE_URL = "base_url" + HEALTH = "health" + HEALTHZ = "healthz" + READYZ = "readyz" + DATA = "data" + STATUS_CODE = "status_code" + TEXT = "text" + PIPELINE = "pipeline" + HERMES_RECALL = "hermes_recall" + HERMES_ANSWER = "hermes_answer" + HERMES_REMEMBER = "hermes_remember" + OPENCLAW = "openclaw" + TOOL = "tool" + TOOL_INVOKED = "tool_invoked" + TOOL_ERROR = "tool_error" + + +class AIRequestKey(StrEnum): + PROMPT = "prompt" + CONTEXT = "context" + + +class AIContextKey(StrEnum): + OPENCLAW_TOOL = "openclaw_tool" + OPENCLAW_ACTION = "openclaw_action" + OPENCLAW_ARGS = "openclaw_args" + OPENCLAW_SESSION_KEY = "openclaw_session_key" + AGENT_PIPELINE = "agent_pipeline" + HERMES_MEMORY = "hermes_memory" + OPENCLAW = "openclaw" + MODE = "mode" + USER_PROMPT = "user_prompt" + REQUEST_CONTEXT = "request_context" + ASSISTANT_ANSWER = "assistant_answer" + + +class AIMemoryMode(StrEnum): + RECALL = "memory_recall" + WRITE = "memory_write" + + +class AIHttpPath(StrEnum): + CHAT_COMPLETIONS = "/chat/completions" + HEALTH = "/health" + HEALTHZ = "/healthz" + READYZ = "/readyz" + TOOLS_INVOKE = "/tools/invoke" + V1 = "/v1" + + +class AIHttpHeader(StrEnum): + AUTHORIZATION = "Authorization" + HERMES_SESSION_ID = "X-Hermes-Session-Id" + + +class AIHttpPayloadKey(StrEnum): + MODEL = "model" + MESSAGES = "messages" + ROLE = "role" + CONTENT = "content" + STREAM = "stream" + TOOL = "tool" + ACTION = "action" + ARGS = "args" + SESSION_KEY = "sessionKey" + CHOICES = "choices" + MESSAGE = "message" + + +class AIChatRole(StrEnum): + SYSTEM = "system" + USER = "user" + + +class AIDefault(StrEnum): + ACTION_JSON = "json" + SESSION_KEY_MAIN = "main" + + +class AIRiskPreference(StrEnum): + BALANCED = "balanced" + + +class AIErrorKey(StrEnum): + OPENCLAW = "openclaw_error" + HERMES = "hermes_error" + DIRECT_LLM = "llm_error" + + +OPENCLAW_HERMES_PIPELINE = ( + "hermes_recall -> openclaw_gateway -> hermes_answer -> hermes_remember" +) +COMPANY_MANAGEMENT_SYSTEM_INSTRUCTIONS = ( + "You are a company management AI. Be concise, cite data from context, " + "and never approve payments, performance changes, or trades automatically." +) +CHAT_USER_CONTENT_TEMPLATE = "Context:\n{context}\n\nTask:\n{task}" +AUTHORIZATION_BEARER_TEMPLATE = "Bearer {token}" +NOOP_PROVIDER_ANSWER = ( + "AI provider is not configured yet. This is a deterministic placeholder. " + "Set MODEL_PROVIDER to openclaw_hermes, openclaw, hermes, or direct_llm " + "after credentials are ready." +) +OPENCLAW_TOOL_COMPLETED_ANSWER = "OpenClaw tool invocation completed." +DIRECT_LLM_API_KEY_MISSING = "DIRECT_LLM_API_KEY is not configured" +UNEXPECTED_HERMES_RESPONSE = "Unexpected chat completion response" diff --git a/app/modules/ai_agent/routes.py b/app/modules/ai_agent/routes.py index 809b527..3ef82e1 100644 --- a/app/modules/ai_agent/routes.py +++ b/app/modules/ai_agent/routes.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session +from app.core.constants import ActorValue from app.core.database import get_db from app.core.security import require_api_key from app.modules.ai_agent.schemas import ( @@ -8,6 +9,7 @@ from app.modules.ai_agent.schemas import ( AIAskResponse, DraftPolicyRequest, InvestmentResearchRequest, + OpenClawToolInvokeRequest, ) from app.modules.ai_agent.service import AIService @@ -19,6 +21,25 @@ def ask(payload: AIAskRequest, db: Session = Depends(get_db)) -> dict: return AIService(db).ask(payload.prompt, payload.context, payload.actor, payload.source) +@router.get("/provider-health") +def provider_health(actor: str = ActorValue.API, db: Session = Depends(get_db)) -> dict: + return AIService(db).provider_health(actor=actor) + + +@router.post("/openclaw/tools/invoke") +def invoke_openclaw_tool( + payload: OpenClawToolInvokeRequest, + db: Session = Depends(get_db), +) -> dict: + return AIService(db).invoke_openclaw_tool( + tool=payload.tool, + action=payload.action, + args=payload.args, + session_key=payload.session_key, + actor=payload.actor, + ) + + @router.post("/draft-policy", response_model=AIAskResponse) def draft_policy(payload: DraftPolicyRequest, db: Session = Depends(get_db)) -> dict: return AIService(db).draft_policy( diff --git a/app/modules/ai_agent/schemas.py b/app/modules/ai_agent/schemas.py index 130909b..f57e03e 100644 --- a/app/modules/ai_agent/schemas.py +++ b/app/modules/ai_agent/schemas.py @@ -2,12 +2,16 @@ from typing import Any from pydantic import BaseModel, Field +from app.core.constants import ActorValue +from app.modules.ai_agent.constants import AIDefault, AIRiskPreference +from app.modules.audit.constants import AuditSource + class AIAskRequest(BaseModel): prompt: str context: dict[str, Any] = Field(default_factory=dict) - actor: str = "api" - source: str = "api" + actor: str = ActorValue.API + source: str = AuditSource.API class AIAskResponse(BaseModel): @@ -16,14 +20,22 @@ class AIAskResponse(BaseModel): raw: dict[str, Any] = Field(default_factory=dict) +class OpenClawToolInvokeRequest(BaseModel): + tool: str + action: str = AIDefault.ACTION_JSON + args: dict[str, Any] = Field(default_factory=dict) + session_key: str = AIDefault.SESSION_KEY_MAIN + actor: str = ActorValue.API + + class DraftPolicyRequest(BaseModel): title: str policy_type: str requirements: list[str] - actor: str = "api" + actor: str = ActorValue.API class InvestmentResearchRequest(BaseModel): symbol_or_topic: str - risk_preference: str = "balanced" - actor: str = "api" + risk_preference: str = AIRiskPreference.BALANCED + actor: str = ActorValue.API diff --git a/app/modules/ai_agent/service.py b/app/modules/ai_agent/service.py index 6760e82..60e9860 100644 --- a/app/modules/ai_agent/service.py +++ b/app/modules/ai_agent/service.py @@ -2,7 +2,22 @@ from typing import Any from sqlalchemy.orm import Session -from app.modules.ai_agent.adapters import get_adapter +from app.core.constants import ActorValue +from app.core.config import get_settings +from app.modules.ai_agent.adapters import HermesAdapter, OpenClawAdapter, get_adapter +from app.modules.ai_agent.constants import ( + AIDefault, + AIProviderName, + AIRequestKey, + AIResponseKey, +) +from app.modules.ai_agent.skills import AISkillId, get_ai_skill +from app.modules.audit.constants import ( + AuditAction, + AuditRiskLevel, + AuditSource, + AuditTargetType, +) from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService @@ -18,29 +33,109 @@ class AIService: self, prompt: str, context: dict[str, Any] | None = None, - actor: str = "api", - source: str = "api", + actor: str = ActorValue.API, + source: str = AuditSource.API, ) -> dict[str, Any]: adapter = get_adapter() result = adapter.ask(prompt, context or {}) response = { - "provider": adapter.provider_name, - "answer": result["answer"], - "raw": result.get("raw", {}), + AIResponseKey.PROVIDER: adapter.provider_name, + AIResponseKey.ANSWER: result[AIResponseKey.ANSWER], + AIResponseKey.RAW: result.get(AIResponseKey.RAW, {}), } self.audit.log( AuditLogCreate( actor=actor, source=source, - action="ai.ask", - target_type="ai", - risk_level="medium", - request_payload={"prompt": prompt, "context": context or {}}, + action=AuditAction.AI_ASK, + target_type=AuditTargetType.AI, + risk_level=AuditRiskLevel.MEDIUM, + request_payload={ + AIRequestKey.PROMPT: prompt, + AIRequestKey.CONTEXT: context or {}, + }, response_payload=response, ) ) return response + def run_skill( + self, + skill_id: AISkillId | str, + context: dict[str, Any] | None = None, + variables: dict[str, Any] | None = None, + actor: str = ActorValue.API, + ) -> dict[str, Any]: + skill = get_ai_skill(skill_id) + return self.ask( + skill.render(variables), + context=context or {}, + actor=actor, + source=skill.source, + ) + + def provider_health(self, actor: str = ActorValue.API) -> dict[str, Any]: + settings = get_settings() + openclaw = self._health_result(OpenClawAdapter(settings).health) + hermes = self._health_result(HermesAdapter(settings).health) + response = { + "model_provider": settings.model_provider, + AIProviderName.OPENCLAW: openclaw, + AIProviderName.HERMES: hermes, + } + self.audit.log( + AuditLogCreate( + actor=actor, + source=AuditSource.API, + action=AuditAction.AI_PROVIDER_HEALTH, + target_type=AuditTargetType.AI, + risk_level=AuditRiskLevel.LOW, + response_payload=response, + ) + ) + return response + + def invoke_openclaw_tool( + self, + tool: str, + action: str = AIDefault.ACTION_JSON, + args: dict[str, Any] | None = None, + session_key: str = AIDefault.SESSION_KEY_MAIN, + actor: str = ActorValue.API, + ) -> dict[str, Any]: + result = OpenClawAdapter(get_settings()).invoke_tool(tool, action, args or {}, session_key) + response = {AIResponseKey.PROVIDER: AIProviderName.OPENCLAW, AIResponseKey.RESULT: result} + self.audit.log( + AuditLogCreate( + actor=actor, + source=AuditSource.OPENCLAW, + action=AuditAction.OPENCLAW_TOOLS_INVOKE, + target_type=AuditTargetType.OPENCLAW_TOOL, + target_id=tool, + risk_level=AuditRiskLevel.HIGH, + request_payload={ + "tool": tool, + "action": action, + "args": args or {}, + "session_key": session_key, + }, + response_payload=result, + ) + ) + return response + + @staticmethod + def _health_result(check: Any) -> dict[str, Any]: + try: + return check() + except Exception as exc: + # Health checks should report failures, not mask the other provider. + return { + AIResponseKey.OK: False, + AIResponseKey.ERROR: str(exc), + AIResponseKey.TYPE: type(exc).__name__, + } + def draft_policy( self, title: str, @@ -48,12 +143,15 @@ class AIService: requirements: list[str], actor: str, ) -> dict[str, Any]: - prompt = ( - f"Draft a company policy in Chinese. Title: {title}. Type: {policy_type}. " - "Include purpose, scope, roles, process, approval rules, audit rules, and KPI linkage. " - f"Requirements: {requirements}" + return self.run_skill( + AISkillId.DRAFT_POLICY, + variables={ + "title": title, + "policy_type": policy_type, + "requirements": requirements, + }, + actor=actor, ) - return self.ask(prompt, actor=actor, source="policy") def draft_investment_research( self, @@ -61,10 +159,11 @@ class AIService: risk_preference: str, actor: str, ) -> dict[str, Any]: - prompt = ( - "Create an investment research memo in Chinese. Do not give direct trading " - "instructions. Include thesis, risks, data needed, position sizing constraints, " - "and human approval checklist. " - f"Topic: {symbol_or_topic}. Risk preference: {risk_preference}." + return self.run_skill( + AISkillId.INVESTMENT_RESEARCH, + variables={ + "symbol_or_topic": symbol_or_topic, + "risk_preference": risk_preference, + }, + actor=actor, ) - return self.ask(prompt, actor=actor, source="investment") diff --git a/app/modules/ai_agent/skills.py b/app/modules/ai_agent/skills.py new file mode 100644 index 0000000..1833cb5 --- /dev/null +++ b/app/modules/ai_agent/skills.py @@ -0,0 +1,101 @@ +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from fastapi import HTTPException, status + + +class AISkillId(StrEnum): + """Stable identifiers for AI capabilities exposed to business modules.""" + + DRAFT_POLICY = "draft_policy" + INVESTMENT_RESEARCH = "investment_research" + PROJECT_LIFECYCLE_ANALYSIS = "project_lifecycle_analysis" + HERMES_MEMORY_RECALL = "hermes_memory_recall" + HERMES_MEMORY_WRITE = "hermes_memory_write" + + +class AISkillSource(StrEnum): + """Audit source names for AI skill invocations.""" + + POLICY = "policy" + INVESTMENT = "investment" + REPORTS_LIFECYCLE = "reports.lifecycle" + AI_MEMORY = "ai.memory" + + +@dataclass(frozen=True) +class AISkill: + """Business-facing AI skill definition.""" + + skill_id: AISkillId + source: AISkillSource + instruction_template: str + + def render(self, variables: dict[str, Any] | None = None) -> str: + return self.instruction_template.format(**(variables or {})) + + +POLICY_DRAFT_INSTRUCTIONS = ( + "Draft a company policy in Chinese. Title: {title}. Type: {policy_type}. " + "Include purpose, scope, roles, process, approval rules, audit rules, and KPI linkage. " + "Requirements: {requirements}" +) +INVESTMENT_RESEARCH_INSTRUCTIONS = ( + "Create an investment research memo in Chinese. Do not give direct trading " + "instructions. Include thesis, risks, data needed, position sizing constraints, " + "and human approval checklist. Topic: {symbol_or_topic}. " + "Risk preference: {risk_preference}." +) +PROJECT_LIFECYCLE_ANALYSIS_INSTRUCTIONS = ( + "请基于项目全生命周期统计,输出中文管理层分析。" + "包括总体判断、前三个风险、接下来一周优先动作。" + "不要审批付款、不要最终定绩效、不要下投资交易指令。" +) +HERMES_MEMORY_RECALL_INSTRUCTIONS = ( + "Retrieve concise long-term memory, preferences, prior decisions, and relevant " + "business context for this request. Return only information useful to answer it." +) +HERMES_MEMORY_WRITE_INSTRUCTIONS = ( + "Store durable lessons from this interaction for future company management " + "assistance. Ignore transient details and do not store secrets." +) + +AI_SKILLS: dict[AISkillId, AISkill] = { + AISkillId.DRAFT_POLICY: AISkill( + skill_id=AISkillId.DRAFT_POLICY, + source=AISkillSource.POLICY, + instruction_template=POLICY_DRAFT_INSTRUCTIONS, + ), + AISkillId.INVESTMENT_RESEARCH: AISkill( + skill_id=AISkillId.INVESTMENT_RESEARCH, + source=AISkillSource.INVESTMENT, + instruction_template=INVESTMENT_RESEARCH_INSTRUCTIONS, + ), + AISkillId.PROJECT_LIFECYCLE_ANALYSIS: AISkill( + skill_id=AISkillId.PROJECT_LIFECYCLE_ANALYSIS, + source=AISkillSource.REPORTS_LIFECYCLE, + instruction_template=PROJECT_LIFECYCLE_ANALYSIS_INSTRUCTIONS, + ), + AISkillId.HERMES_MEMORY_RECALL: AISkill( + skill_id=AISkillId.HERMES_MEMORY_RECALL, + source=AISkillSource.AI_MEMORY, + instruction_template=HERMES_MEMORY_RECALL_INSTRUCTIONS, + ), + AISkillId.HERMES_MEMORY_WRITE: AISkill( + skill_id=AISkillId.HERMES_MEMORY_WRITE, + source=AISkillSource.AI_MEMORY, + instruction_template=HERMES_MEMORY_WRITE_INSTRUCTIONS, + ), +} + + +def get_ai_skill(skill_id: AISkillId | str) -> AISkill: + try: + normalized_id = AISkillId(skill_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Unsupported AI skill: {skill_id}", + ) from exc + return AI_SKILLS[normalized_id] diff --git a/app/modules/approvals/constants.py b/app/modules/approvals/constants.py new file mode 100644 index 0000000..21e1f8f --- /dev/null +++ b/app/modules/approvals/constants.py @@ -0,0 +1,12 @@ +from enum import StrEnum + + +class ApprovalStatus(StrEnum): + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" + + +class ApprovalActionValue(StrEnum): + UPDATE = "update" + WILDCARD = "*" diff --git a/app/modules/approvals/models.py b/app/modules/approvals/models.py index 740cb39..a4403c2 100644 --- a/app/modules/approvals/models.py +++ b/app/modules/approvals/models.py @@ -3,7 +3,9 @@ from datetime import datetime from sqlalchemy import DateTime, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column +from app.core.constants import ActorValue from app.core.database import Base +from app.modules.approvals.constants import ApprovalStatus class ApprovalRequest(Base): @@ -14,9 +16,9 @@ class ApprovalRequest(Base): domain: Mapped[str] = mapped_column(String(128), index=True) record_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) action: Mapped[str] = mapped_column(String(128), index=True) - applicant: Mapped[str] = mapped_column(String(128), default="api", index=True) + applicant: Mapped[str] = mapped_column(String(128), default=ActorValue.API, index=True) approver: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - status: Mapped[str] = mapped_column(String(32), default="pending", index=True) + status: Mapped[str] = mapped_column(String(32), default=ApprovalStatus.PENDING, index=True) reason: Mapped[str | None] = mapped_column(Text, nullable=True) payload: Mapped[str | None] = mapped_column(Text, nullable=True) decision_comment: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/app/modules/approvals/schemas.py b/app/modules/approvals/schemas.py index a65c8b9..1e6a028 100644 --- a/app/modules/approvals/schemas.py +++ b/app/modules/approvals/schemas.py @@ -3,12 +3,14 @@ from typing import Any from pydantic import BaseModel, ConfigDict, Field +from app.core.constants import ActorValue + class ApprovalCreate(BaseModel): domain: str record_id: str | None = None action: str - applicant: str = "api" + applicant: str = ActorValue.API reason: str | None = None payload: dict[str, Any] = Field(default_factory=dict) diff --git a/app/modules/approvals/service.py b/app/modules/approvals/service.py index 0c2b4da..833aefe 100644 --- a/app/modules/approvals/service.py +++ b/app/modules/approvals/service.py @@ -5,8 +5,10 @@ from fastapi import HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session +from app.modules.approvals.constants import ApprovalActionValue, ApprovalStatus from app.modules.approvals.models import ApprovalRequest from app.modules.approvals.schemas import ApprovalCreate +from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService @@ -34,11 +36,11 @@ class ApprovalService: self.audit.log( AuditLogCreate( actor=payload.applicant, - source="approval", - action="approval.create", + source=AuditSource.APPROVAL, + action=AuditAction.APPROVAL_CREATE, target_type=payload.domain, target_id=payload.record_id, - risk_level="medium", + risk_level=AuditRiskLevel.MEDIUM, request_payload=payload.model_dump(), response_payload={"ticket_id": ticket.ticket_id, "status": ticket.status}, ) @@ -70,9 +72,9 @@ class ApprovalService: comment: str | None, ) -> ApprovalRequest: ticket = self.get_by_ticket(ticket_id) - if ticket.status != "pending": + if ticket.status != ApprovalStatus.PENDING: raise HTTPException(status_code=409, detail="Approval ticket already decided") - ticket.status = "approved" if approved else "rejected" + ticket.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED ticket.approver = approver ticket.decision_comment = comment from datetime import datetime @@ -83,11 +85,11 @@ class ApprovalService: self.audit.log( AuditLogCreate( actor=approver, - source="approval", - action="approval.approve" if approved else "approval.reject", + source=AuditSource.APPROVAL, + action=AuditAction.APPROVAL_APPROVE if approved else AuditAction.APPROVAL_REJECT, target_type=ticket.domain, target_id=ticket.record_id, - risk_level="high", + risk_level=AuditRiskLevel.HIGH, request_payload={"ticket_id": ticket_id, "comment": comment}, response_payload={"status": ticket.status}, ) @@ -102,10 +104,15 @@ class ApprovalService: action: str, ) -> bool: ticket = self.get_by_ticket(ticket_id) - if ticket.status != "approved": + if ticket.status != ApprovalStatus.APPROVED: return False if ticket.domain != domain: return False if ticket.record_id and record_id is not None and str(ticket.record_id) != str(record_id): return False - return ticket.action in {action, "update", f"update:{domain}", "*"} + return ticket.action in { + action, + ApprovalActionValue.UPDATE, + f"{ApprovalActionValue.UPDATE}:{domain}", + ApprovalActionValue.WILDCARD, + } diff --git a/app/modules/audit/constants.py b/app/modules/audit/constants.py new file mode 100644 index 0000000..4a1799a --- /dev/null +++ b/app/modules/audit/constants.py @@ -0,0 +1,39 @@ +from enum import StrEnum + + +class AuditAction(StrEnum): + AI_ASK = "ai.ask" + AI_PROVIDER_HEALTH = "ai.provider_health" + OPENCLAW_TOOLS_INVOKE = "openclaw.tools.invoke" + GENERATE_EVENTS = "generate_events" + FEISHU_SEND_TEXT = "send_text" + FEISHU_SEND_CARD = "send_card" + APPROVAL_CREATE = "approval.create" + APPROVAL_APPROVE = "approval.approve" + APPROVAL_REJECT = "approval.reject" + LEGACY_SYNC_PROJECTS = "sync_projects" + + +class AuditRiskLevel(StrEnum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class AuditSource(StrEnum): + API = "api" + OPENCLAW = "openclaw" + RISK = "risk" + FEISHU = "feishu" + APPROVAL = "approval" + LEGACY_MYSQL = "legacy_mysql" + + +class AuditTargetType(StrEnum): + AI = "ai" + OPENCLAW_TOOL = "openclaw_tool" + RISK_EVENTS = "risk-events" + + +class AuditStatus(StrEnum): + SUCCESS = "success" diff --git a/app/modules/audit/models.py b/app/modules/audit/models.py index 4ac08a2..15151ef 100644 --- a/app/modules/audit/models.py +++ b/app/modules/audit/models.py @@ -3,20 +3,22 @@ from datetime import datetime from sqlalchemy import DateTime, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column +from app.core.constants import ActorValue from app.core.database import Base +from app.modules.audit.constants import AuditRiskLevel, AuditSource, AuditStatus class AuditLog(Base): __tablename__ = "audit_logs" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - actor: Mapped[str] = mapped_column(String(128), default="system", index=True) - source: Mapped[str] = mapped_column(String(64), default="api", index=True) + actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True) + source: Mapped[str] = mapped_column(String(64), default=AuditSource.API, index=True) action: Mapped[str] = mapped_column(String(128), index=True) target_type: Mapped[str | None] = mapped_column(String(128), nullable=True) target_id: Mapped[str | None] = mapped_column(String(128), nullable=True) - risk_level: Mapped[str] = mapped_column(String(32), default="low", index=True) + risk_level: Mapped[str] = mapped_column(String(32), default=AuditRiskLevel.LOW, index=True) request_payload: Mapped[str | None] = mapped_column(Text, nullable=True) response_payload: Mapped[str | None] = mapped_column(Text, nullable=True) - status: Mapped[str] = mapped_column(String(32), default="success", index=True) + status: Mapped[str] = mapped_column(String(32), default=AuditStatus.SUCCESS, index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) diff --git a/app/modules/audit/schemas.py b/app/modules/audit/schemas.py index dcbe697..b27ffd1 100644 --- a/app/modules/audit/schemas.py +++ b/app/modules/audit/schemas.py @@ -3,17 +3,20 @@ from typing import Any from pydantic import BaseModel, ConfigDict +from app.core.constants import ActorValue +from app.modules.audit.constants import AuditRiskLevel, AuditSource, AuditStatus + class AuditLogCreate(BaseModel): - actor: str = "system" - source: str = "api" + actor: str = ActorValue.SYSTEM + source: str = AuditSource.API action: str target_type: str | None = None target_id: str | None = None - risk_level: str = "low" + risk_level: str = AuditRiskLevel.LOW request_payload: Any | None = None response_payload: Any | None = None - status: str = "success" + status: str = AuditStatus.SUCCESS class AuditLogRead(BaseModel): diff --git a/app/modules/business/constants.py b/app/modules/business/constants.py new file mode 100644 index 0000000..78b10ca --- /dev/null +++ b/app/modules/business/constants.py @@ -0,0 +1,117 @@ +from enum import StrEnum + + +class StatusValue(StrEnum): + INITIATED = "立项" + TODO = "待办" + DRAFT = "草稿" + POLICY_DRAFT = "草案" + VALID = "有效" + APPROVING = "审批中" + PENDING_APPROVAL = "待审批" + PENDING = "pending" + ACCEPTED = "验收" + COMPLETED_CN = "已完成" + DONE_CN = "完成" + REVIEWED = "复盘" + ARCHIVED = "归档" + CLOSED_CN = "关闭" + CLOSED = "closed" + RESOLVED = "resolved" + DONE = "done" + COMPLETED = "completed" + GENERATED = "已生成" + UNKNOWN = "未知" + PAID = "已付款" + UNPAID = "未付款" + UNDELIVERED = "未到货" + INVOICE_NOT_RECEIVED = "未收票" + NORMAL = "normal" + NORMAL_CN = "正常" + LATE = "迟到" + LEAVE_EARLY = "早退" + MISSING_PUNCH = "缺卡" + ABSENT = "旷工" + ABNORMAL = "异常" + OPEN = "open" + RUNNING = "running" + DRY_RUN = "dry_run" + + +class RiskLevel(StrEnum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class PriorityValue(StrEnum): + P2 = "P2" + + +class SourceSystem(StrEnum): + INTERNAL = "internal" + LEGACY_MYSQL = "legacy_mysql" + + +class AccountType(StrEnum): + BANK = "bank" + + +class VersionValue(StrEnum): + V1_0 = "v1.0" + + +class BusinessDomain(StrEnum): + TASKS = "tasks" + PROJECTS = "projects" + FUND_ACCOUNTS = "fund-accounts" + SUPPLIERS = "suppliers" + + +class RiskEventType(StrEnum): + OVERDUE_TASK = "overdue_task" + DELAYED_PROJECT = "delayed_project" + OVER_BUDGET_PROJECT = "over_budget_project" + FUND_SAFETY_LINE = "fund_safety_line" + SUPPLIER_RISK = "supplier_risk" + + +DONE_STATUSES = frozenset( + { + StatusValue.DONE_CN, + StatusValue.COMPLETED_CN, + StatusValue.CLOSED_CN, + StatusValue.DONE, + StatusValue.COMPLETED, + StatusValue.CLOSED, + } +) +PENDING_APPROVAL_STATUSES = frozenset( + { + StatusValue.DRAFT, + StatusValue.APPROVING, + StatusValue.PENDING_APPROVAL, + StatusValue.PENDING, + } +) +PROJECT_CLOSED_STATUSES = frozenset( + { + StatusValue.ACCEPTED, + StatusValue.COMPLETED_CN, + StatusValue.REVIEWED, + StatusValue.ARCHIVED, + StatusValue.CLOSED_CN, + StatusValue.CLOSED, + } +) +ATTENDANCE_ABNORMAL_STATUSES = frozenset( + { + StatusValue.LATE, + StatusValue.LEAVE_EARLY, + StatusValue.MISSING_PUNCH, + StatusValue.ABSENT, + StatusValue.ABNORMAL, + } +) +SUPPLIER_RISK_LEVELS = frozenset({RiskLevel.MEDIUM, RiskLevel.HIGH}) +CLOSED_RISK_STATUSES = frozenset({StatusValue.CLOSED, StatusValue.RESOLVED}) diff --git a/app/modules/business/models.py b/app/modules/business/models.py index 83db0b7..a2aefd7 100644 --- a/app/modules/business/models.py +++ b/app/modules/business/models.py @@ -4,7 +4,16 @@ from decimal import Decimal from sqlalchemy import JSON, Date, DateTime, Integer, Numeric, String, Text from sqlalchemy.orm import Mapped, mapped_column +from app.core.constants import ActorValue from app.core.database import Base +from app.modules.business.constants import ( + AccountType, + PriorityValue, + RiskLevel, + SourceSystem, + StatusValue, + VersionValue, +) class TimestampMixin: @@ -21,16 +30,16 @@ class Project(Base, TimestampMixin): code: Mapped[str] = mapped_column(String(64), unique=True, index=True) name: Mapped[str] = mapped_column(String(255), index=True) owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - status: Mapped[str] = mapped_column(String(64), default="立项", index=True) - priority: Mapped[str] = mapped_column(String(32), default="P2") + status: Mapped[str] = mapped_column(String(64), default=StatusValue.INITIATED, index=True) + priority: Mapped[str] = mapped_column(String(32), default=PriorityValue.P2) progress_percent: Mapped[int] = mapped_column(Integer, default=0) - risk_level: Mapped[str] = mapped_column(String(32), default="low", index=True) + risk_level: Mapped[str] = mapped_column(String(32), default=RiskLevel.LOW, index=True) budget_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0) actual_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0) start_date: Mapped[date | None] = mapped_column(Date, nullable=True) due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) description: Mapped[str | None] = mapped_column(Text, nullable=True) - source_system: Mapped[str] = mapped_column(String(64), default="internal") + source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL) external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) @@ -42,8 +51,8 @@ class WorkTask(Base, TimestampMixin): title: Mapped[str] = mapped_column(String(255), index=True) project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - status: Mapped[str] = mapped_column(String(64), default="待办", index=True) - priority: Mapped[str] = mapped_column(String(32), default="P2") + status: Mapped[str] = mapped_column(String(64), default=StatusValue.TODO, index=True) + priority: Mapped[str] = mapped_column(String(32), default=PriorityValue.P2) due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) blocker: Mapped[str | None] = mapped_column(Text, nullable=True) @@ -62,9 +71,13 @@ class Procurement(Base, TimestampMixin): budget_subject: Mapped[str | None] = mapped_column(String(128), nullable=True) expected_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0) actual_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0) - approval_status: Mapped[str] = mapped_column(String(64), default="草稿", index=True) - delivery_status: Mapped[str] = mapped_column(String(64), default="未到货", index=True) - payment_status: Mapped[str] = mapped_column(String(64), default="未付款", index=True) + approval_status: Mapped[str] = mapped_column(String(64), default=StatusValue.DRAFT, index=True) + delivery_status: Mapped[str] = mapped_column( + String(64), + default=StatusValue.UNDELIVERED, + index=True, + ) + payment_status: Mapped[str] = mapped_column(String(64), default=StatusValue.UNPAID, index=True) comparison_summary: Mapped[str | None] = mapped_column(Text, nullable=True) @@ -80,9 +93,12 @@ class Expense(Base, TimestampMixin): project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) budget_subject: Mapped[str | None] = mapped_column(String(128), nullable=True) payment_account: Mapped[str | None] = mapped_column(String(128), nullable=True) - invoice_status: Mapped[str] = mapped_column(String(64), default="未收票") - approval_status: Mapped[str] = mapped_column(String(64), default="草稿", index=True) - payment_status: Mapped[str] = mapped_column(String(64), default="未付款", index=True) + invoice_status: Mapped[str] = mapped_column( + String(64), + default=StatusValue.INVOICE_NOT_RECEIVED, + ) + approval_status: Mapped[str] = mapped_column(String(64), default=StatusValue.DRAFT, index=True) + payment_status: Mapped[str] = mapped_column(String(64), default=StatusValue.UNPAID, index=True) class FundAccount(Base, TimestampMixin): @@ -91,12 +107,12 @@ class FundAccount(Base, TimestampMixin): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) code: Mapped[str] = mapped_column(String(64), unique=True, index=True) name: Mapped[str] = mapped_column(String(255), index=True) - account_type: Mapped[str] = mapped_column(String(64), default="bank") + account_type: Mapped[str] = mapped_column(String(64), default=AccountType.BANK) current_balance: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0) expected_receivable: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0) expected_payable: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0) safety_line: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0) - risk_level: Mapped[str] = mapped_column(String(32), default="low", index=True) + risk_level: Mapped[str] = mapped_column(String(32), default=RiskLevel.LOW, index=True) note: Mapped[str | None] = mapped_column(Text, nullable=True) @@ -108,8 +124,8 @@ class Policy(Base, TimestampMixin): title: Mapped[str] = mapped_column(String(255), index=True) policy_type: Mapped[str | None] = mapped_column(String(128), nullable=True) owner_department: Mapped[str | None] = mapped_column(String(128), nullable=True) - version: Mapped[str] = mapped_column(String(32), default="v1.0") - status: Mapped[str] = mapped_column(String(64), default="草案", index=True) + version: Mapped[str] = mapped_column(String(32), default=VersionValue.V1_0) + status: Mapped[str] = mapped_column(String(64), default=StatusValue.POLICY_DRAFT, index=True) effective_date: Mapped[date | None] = mapped_column(Date, nullable=True) feishu_doc_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) summary: Mapped[str | None] = mapped_column(Text, nullable=True) @@ -123,7 +139,7 @@ class Standard(Base, TimestampMixin): title: Mapped[str] = mapped_column(String(255), index=True) standard_type: Mapped[str | None] = mapped_column(String(128), nullable=True) applies_to: Mapped[str | None] = mapped_column(String(255), nullable=True) - status: Mapped[str] = mapped_column(String(64), default="有效", index=True) + status: Mapped[str] = mapped_column(String(64), default=StatusValue.VALID, index=True) check_items: Mapped[str | None] = mapped_column(Text, nullable=True) remediation: Mapped[str | None] = mapped_column(Text, nullable=True) policy_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) @@ -141,7 +157,7 @@ class PerformanceMetric(Base, TimestampMixin): data_source: Mapped[str | None] = mapped_column(String(255), nullable=True) auto_score: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=0) confirmed_score: Mapped[Decimal | None] = mapped_column(Numeric(8, 2), nullable=True) - status: Mapped[str] = mapped_column(String(64), default="草稿", index=True) + status: Mapped[str] = mapped_column(String(64), default=StatusValue.DRAFT, index=True) class Supplier(Base, TimestampMixin): @@ -155,8 +171,12 @@ class Supplier(Base, TimestampMixin): quality_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0) delivery_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0) price_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0) - risk_level: Mapped[str] = mapped_column(String(32), default="low", index=True) - blacklist_status: Mapped[str] = mapped_column(String(32), default="normal", index=True) + risk_level: Mapped[str] = mapped_column(String(32), default=RiskLevel.LOW, index=True) + blacklist_status: Mapped[str] = mapped_column( + String(32), + default=StatusValue.NORMAL, + index=True, + ) class AttendanceRecord(Base, TimestampMixin): @@ -171,9 +191,9 @@ class AttendanceRecord(Base, TimestampMixin): work_date: Mapped[date] = mapped_column(Date, index=True) check_in_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) check_out_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - status: Mapped[str] = mapped_column(String(64), default="正常", index=True) + status: Mapped[str] = mapped_column(String(64), default=StatusValue.NORMAL_CN, index=True) location: Mapped[str | None] = mapped_column(String(255), nullable=True) - source_system: Mapped[str] = mapped_column(String(64), default="internal") + source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL) external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) note: Mapped[str | None] = mapped_column(Text, nullable=True) @@ -185,7 +205,7 @@ class WorkReport(Base, TimestampMixin): code: Mapped[str] = mapped_column(String(64), unique=True, index=True) report_type: Mapped[str] = mapped_column(String(32), index=True) title: Mapped[str] = mapped_column(String(255), index=True) - reporter: Mapped[str] = mapped_column(String(128), default="system", index=True) + reporter: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True) department: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) period_start: Mapped[date] = mapped_column(Date, index=True) @@ -193,8 +213,8 @@ class WorkReport(Base, TimestampMixin): content: Mapped[str] = mapped_column(Text) metrics: Mapped[dict | None] = mapped_column(JSON, nullable=True) risk_summary: Mapped[dict | None] = mapped_column(JSON, nullable=True) - status: Mapped[str] = mapped_column(String(64), default="已生成", index=True) - source_system: Mapped[str] = mapped_column(String(64), default="internal") + status: Mapped[str] = mapped_column(String(64), default=StatusValue.GENERATED, index=True) + source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL) class RiskEvent(Base, TimestampMixin): @@ -204,8 +224,8 @@ class RiskEvent(Base, TimestampMixin): code: Mapped[str] = mapped_column(String(64), unique=True, index=True) title: Mapped[str] = mapped_column(String(255), index=True) risk_type: Mapped[str] = mapped_column(String(64), index=True) - risk_level: Mapped[str] = mapped_column(String(32), default="medium", index=True) - status: Mapped[str] = mapped_column(String(32), default="open", index=True) + risk_level: Mapped[str] = mapped_column(String(32), default=RiskLevel.MEDIUM, index=True) + status: Mapped[str] = mapped_column(String(32), default=StatusValue.OPEN, index=True) source_domain: Mapped[str] = mapped_column(String(128), index=True) source_record_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) @@ -224,7 +244,7 @@ class LegacySyncRun(Base, TimestampMixin): code: Mapped[str] = mapped_column(String(64), unique=True, index=True) domain: Mapped[str] = mapped_column(String(128), index=True) source_table: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - status: Mapped[str] = mapped_column(String(32), default="running", index=True) + status: Mapped[str] = mapped_column(String(32), default=StatusValue.RUNNING, index=True) started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) created_count: Mapped[int] = mapped_column(Integer, default=0) diff --git a/app/modules/business/schemas.py b/app/modules/business/schemas.py index ced9cdb..ae64161 100644 --- a/app/modules/business/schemas.py +++ b/app/modules/business/schemas.py @@ -2,15 +2,17 @@ from typing import Any from pydantic import BaseModel, Field +from app.core.constants import ActorValue + class DomainRecordCreate(BaseModel): data: dict[str, Any] = Field(..., description="Domain fields to create.") - actor: str = "api" + actor: str = ActorValue.API class DomainRecordUpdate(BaseModel): data: dict[str, Any] = Field(..., description="Domain fields to update.") - actor: str = "api" + actor: str = ActorValue.API approval_ticket_id: str | None = Field( default=None, description="Required by policy for high-risk updates such as funds or performance.", diff --git a/app/modules/business/service.py b/app/modules/business/service.py index 3fcb840..d9de071 100644 --- a/app/modules/business/service.py +++ b/app/modules/business/service.py @@ -10,6 +10,8 @@ from sqlalchemy import Select, func, select from sqlalchemy.sql.schema import Column from sqlalchemy.orm import Session +from app.core.constants import ActorValue +from app.modules.audit.constants import AuditRiskLevel, AuditSource from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService from app.modules.approvals.service import ApprovalService @@ -99,7 +101,7 @@ class BusinessService: self, domain: str, data: dict[str, Any], - actor: str = "api", + actor: str = ActorValue.API, ) -> dict[str, Any]: model = get_domain_model(domain) payload = _model_payload(model, data) @@ -111,7 +113,7 @@ class BusinessService: self.audit.log( AuditLogCreate( actor=actor, - source="api", + source=AuditSource.API, action=f"create:{domain}", target_type=domain, target_id=str(record.id), @@ -126,7 +128,7 @@ class BusinessService: domain: str, record_id: int, data: dict[str, Any], - actor: str = "api", + actor: str = ActorValue.API, approval_ticket_id: str | None = None, ) -> dict[str, Any]: if domain in HIGH_RISK_DOMAINS: @@ -160,11 +162,13 @@ class BusinessService: self.audit.log( AuditLogCreate( actor=actor, - source="api", + source=AuditSource.API, action=f"update:{domain}", target_type=domain, target_id=str(record.id), - risk_level="high" if domain in HIGH_RISK_DOMAINS else "low", + risk_level=( + AuditRiskLevel.HIGH if domain in HIGH_RISK_DOMAINS else AuditRiskLevel.LOW + ), request_payload={"data": data, "approval_ticket_id": approval_ticket_id}, response_payload=result, ) diff --git a/app/modules/feishu/client.py b/app/modules/feishu/client.py index 6a5fa09..3c3984f 100644 --- a/app/modules/feishu/client.py +++ b/app/modules/feishu/client.py @@ -5,7 +5,20 @@ from typing import Any import httpx from fastapi import HTTPException +from app.core.constants import BEARER_TOKEN_TEMPLATE, HttpHeader from app.core.config import get_settings +from app.modules.feishu.constants import ( + FEISHU_AUTH_MISSING, + FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS, + FEISHU_MESSAGE_PATH, + FEISHU_RECEIVE_ID_MISSING, + FEISHU_SUCCESS_CODE, + FEISHU_TENANT_TOKEN_PATH, + FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS, + FeishuMessageType, + FeishuPayloadKey, + FeishuReceiveIdType, +) class FeishuClient: @@ -21,23 +34,26 @@ class FeishuClient: def _get_tenant_access_token(self) -> str: if not self._is_configured(): - raise HTTPException(status_code=503, detail="Feishu app credentials are not configured") + raise HTTPException(status_code=503, detail=FEISHU_AUTH_MISSING) if self._tenant_access_token and time.time() < self._token_expires_at: return self._tenant_access_token - url = f"{self.settings.feishu_base_url}/auth/v3/tenant_access_token/internal" + url = f"{self.settings.feishu_base_url}{FEISHU_TENANT_TOKEN_PATH}" payload = { - "app_id": self.settings.feishu_app_id, - "app_secret": self.settings.feishu_app_secret, + FeishuPayloadKey.APP_ID: self.settings.feishu_app_id, + FeishuPayloadKey.APP_SECRET: self.settings.feishu_app_secret, } with httpx.Client(timeout=20) as client: response = client.post(url, json=payload) response.raise_for_status() data = response.json() - if data.get("code") != 0: - raise HTTPException(status_code=502, detail={"feishu_error": data}) - self._tenant_access_token = data["tenant_access_token"] - self._token_expires_at = time.time() + int(data.get("expire", 7200)) - 300 + if data.get(FeishuPayloadKey.CODE) != FEISHU_SUCCESS_CODE: + raise HTTPException(status_code=502, detail={FeishuPayloadKey.FEISHU_ERROR: data}) + self._tenant_access_token = data[FeishuPayloadKey.TENANT_ACCESS_TOKEN] + expire_seconds = int( + data.get(FeishuPayloadKey.EXPIRE, FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS) + ) + self._token_expires_at = time.time() + expire_seconds - FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS return self._tenant_access_token def send_message( @@ -48,13 +64,13 @@ class FeishuClient: content: dict[str, Any], ) -> dict[str, Any]: token = self._get_tenant_access_token() - url = f"{self.settings.feishu_base_url}/im/v1/messages" - headers = {"Authorization": f"Bearer {token}"} - params = {"receive_id_type": receive_id_type} + url = f"{self.settings.feishu_base_url}{FEISHU_MESSAGE_PATH}" + headers = {HttpHeader.AUTHORIZATION: BEARER_TOKEN_TEMPLATE.format(token=token)} + params = {FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type} payload = { - "receive_id": receive_id, - "msg_type": msg_type, - "content": json.dumps(content, ensure_ascii=False), + FeishuPayloadKey.RECEIVE_ID: receive_id, + FeishuPayloadKey.MESSAGE_TYPE: msg_type, + FeishuPayloadKey.CONTENT: json.dumps(content, ensure_ascii=False), } with httpx.Client(timeout=20) as client: response = client.post(url, headers=headers, params=params, json=payload) @@ -66,26 +82,31 @@ class FeishuClient: self, text: str, receive_id: str | None = None, - receive_id_type: str = "chat_id", + receive_id_type: str = FeishuReceiveIdType.CHAT_ID, ) -> dict: chat_id = receive_id or self.settings.feishu_default_chat_id if not chat_id: raise HTTPException( status_code=400, - detail="receive_id or FEISHU_DEFAULT_CHAT_ID is required", + detail=FEISHU_RECEIVE_ID_MISSING, ) - return self.send_message(chat_id, receive_id_type, "text", {"text": text}) + return self.send_message( + chat_id, + receive_id_type, + FeishuMessageType.TEXT, + {FeishuPayloadKey.TEXT: text}, + ) def send_card( self, card: dict[str, Any], receive_id: str | None = None, - receive_id_type: str = "chat_id", + receive_id_type: str = FeishuReceiveIdType.CHAT_ID, ) -> dict: chat_id = receive_id or self.settings.feishu_default_chat_id if not chat_id: raise HTTPException( status_code=400, - detail="receive_id or FEISHU_DEFAULT_CHAT_ID is required", + detail=FEISHU_RECEIVE_ID_MISSING, ) - return self.send_message(chat_id, receive_id_type, "interactive", card) + return self.send_message(chat_id, receive_id_type, FeishuMessageType.INTERACTIVE, card) diff --git a/app/modules/feishu/commands.py b/app/modules/feishu/commands.py index c1bc9e9..7acb0a6 100644 --- a/app/modules/feishu/commands.py +++ b/app/modules/feishu/commands.py @@ -4,12 +4,24 @@ from typing import Any from sqlalchemy.orm import Session +from app.core.constants import ActorValue from app.core.config import get_settings from app.modules.ai_agent.service import AIService +from app.modules.ai_agent.constants import AIResponseKey +from app.modules.audit.constants import AuditSource +from app.modules.feishu.constants import FeishuCommandKey from app.modules.feishu.service import FeishuService from app.modules.reports.service import ReportService from app.modules.risk.service import RiskService +DAILY_REPORT_KEYWORDS = ("日报", "晨报", "经营日报", "经营晨报") +PROJECT_WEEKLY_KEYWORDS = ("周报", "项目周报") +ATTENDANCE_KEYWORDS = ("打卡", "考勤", "attendance") +RISK_KEYWORDS = ("风险", "预警", "risk") +AI_COMMAND_PREFIXES = ("问 ", "ai ", "AI ", "/ask ") +RISK_TITLE = "风险预警" +DEFAULT_AI_PROMPT = "请说明你能做什么。" + def _parse_content_text(content: Any) -> str: """Extract plain command text from a Feishu message content payload.""" @@ -52,25 +64,25 @@ class FeishuCommandService: return None sender = event.get("sender") or {} sender_id = sender.get("sender_id") or {} - actor = sender_id.get("open_id") or sender_id.get("user_id") or "feishu" + actor = sender_id.get("open_id") or sender_id.get("user_id") or ActorValue.FEISHU return { - "text": text, - "chat_id": message.get("chat_id"), - "actor": actor, + FeishuCommandKey.TEXT: text, + FeishuCommandKey.CHAT_ID: message.get(FeishuCommandKey.CHAT_ID), + FeishuCommandKey.ACTOR: actor, } def handle_text( self, text: str, chat_id: str | None = None, - actor: str = "feishu", + actor: str = ActorValue.FEISHU, auto_reply: bool = True, ) -> dict[str, Any]: command_text = _clean_command_text(text) lowered = command_text.lower() provider_response: dict[str, Any] | None = None - if any(keyword in command_text for keyword in ["日报", "晨报", "经营日报", "经营晨报"]): + if any(keyword in command_text for keyword in DAILY_REPORT_KEYWORDS): report = ReportService(self.db).daily_brief() result = { "command": "daily_brief", @@ -89,7 +101,7 @@ class FeishuCommandService: result["provider_response"] = provider_response return result - if any(keyword in command_text for keyword in ["周报", "项目周报"]): + if any(keyword in command_text for keyword in PROJECT_WEEKLY_KEYWORDS): report = ReportService(self.db).project_weekly() result = { "command": "project_weekly", @@ -108,7 +120,7 @@ class FeishuCommandService: result["provider_response"] = provider_response return result - if any(keyword in command_text for keyword in ["打卡", "考勤", "attendance"]): + if any(keyword in command_text for keyword in ATTENDANCE_KEYWORDS): report = ReportService(self.db).attendance_summary() result = { "command": "attendance_summary", @@ -127,7 +139,7 @@ class FeishuCommandService: result["provider_response"] = provider_response return result - if any(keyword in command_text for keyword in ["风险", "预警", "risk"]): + if any(keyword in command_text for keyword in RISK_KEYWORDS): summary = RiskService(self.db).summary() lines = [ f"- 综合风险等级:{summary['risk_level']}", @@ -142,25 +154,38 @@ class FeishuCommandService: result = { "command": "risk_summary", "reply_type": "card", - "title": "风险预警", + "title": RISK_TITLE, "content": "\n".join(lines), "lines": lines, } if auto_reply: - provider_response = self._send_card_if_configured(chat_id, "风险预警", lines, actor) + provider_response = self._send_card_if_configured( + chat_id, + RISK_TITLE, + lines, + actor, + ) result["provider_response"] = provider_response return result prompt = command_text - for prefix in ["问 ", "ai ", "AI ", "/ask "]: + for prefix in AI_COMMAND_PREFIXES: if command_text.startswith(prefix): prompt = command_text[len(prefix) :].strip() break if not prompt: - prompt = "请说明你能做什么。" - ai_result = AIService(self.db).ask(prompt, context={}, actor=actor, source="feishu") - content = ai_result["answer"] - is_explicit_ai = lowered.startswith(("ai ", "/ask")) or command_text.startswith("问 ") + prompt = DEFAULT_AI_PROMPT + ai_result = AIService(self.db).ask( + prompt, + context={}, + actor=actor, + source=AuditSource.FEISHU, + ) + content = ai_result[AIResponseKey.ANSWER] + is_explicit_ai = any( + command_text.startswith(prefix) or lowered.startswith(prefix) + for prefix in AI_COMMAND_PREFIXES + ) result = { "command": "ai_ask" if is_explicit_ai else "fallback_ai", "reply_type": "text", diff --git a/app/modules/feishu/constants.py b/app/modules/feishu/constants.py new file mode 100644 index 0000000..75b1561 --- /dev/null +++ b/app/modules/feishu/constants.py @@ -0,0 +1,40 @@ +from enum import StrEnum + + +class FeishuReceiveIdType(StrEnum): + CHAT_ID = "chat_id" + + +class FeishuMessageType(StrEnum): + TEXT = "text" + INTERACTIVE = "interactive" + + +class FeishuPayloadKey(StrEnum): + RECEIVE_ID = "receive_id" + RECEIVE_ID_TYPE = "receive_id_type" + MESSAGE_TYPE = "msg_type" + CONTENT = "content" + TEXT = "text" + CODE = "code" + TENANT_ACCESS_TOKEN = "tenant_access_token" + EXPIRE = "expire" + APP_ID = "app_id" + APP_SECRET = "app_secret" + FEISHU_ERROR = "feishu_error" + CARD = "card" + + +class FeishuCommandKey(StrEnum): + TEXT = "text" + CHAT_ID = "chat_id" + ACTOR = "actor" + + +FEISHU_TENANT_TOKEN_PATH = "/auth/v3/tenant_access_token/internal" +FEISHU_MESSAGE_PATH = "/im/v1/messages" +FEISHU_AUTH_MISSING = "Feishu app credentials are not configured" +FEISHU_RECEIVE_ID_MISSING = "receive_id or FEISHU_DEFAULT_CHAT_ID is required" +FEISHU_SUCCESS_CODE = 0 +FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS = 7200 +FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS = 300 diff --git a/app/modules/feishu/events.py b/app/modules/feishu/events.py index 73f70fd..f3e9125 100644 --- a/app/modules/feishu/events.py +++ b/app/modules/feishu/events.py @@ -2,8 +2,11 @@ from typing import Any from sqlalchemy.orm import Session +from app.core.constants import ActorValue +from app.modules.audit.constants import AuditSource from app.modules.audit.schemas import AuditLogCreate from app.modules.feishu.commands import FeishuCommandService +from app.modules.feishu.constants import FeishuCommandKey from app.modules.feishu.service import FeishuService @@ -24,8 +27,8 @@ class FeishuEventService: self.feishu.verify_event(payload) self.feishu.audit.log( AuditLogCreate( - actor="feishu", - source="feishu", + actor=ActorValue.FEISHU, + source=AuditSource.FEISHU, action=f"{source}_event", request_payload=payload, response_payload={"accepted": True}, @@ -35,9 +38,9 @@ class FeishuEventService: if not command: return {"ok": True, "handled": False} result = self.commands.handle_text( - command["text"], - chat_id=command["chat_id"], - actor=command["actor"], + command[FeishuCommandKey.TEXT], + chat_id=command[FeishuCommandKey.CHAT_ID], + actor=command[FeishuCommandKey.ACTOR], auto_reply=auto_reply, ) return {"ok": True, "handled": True, "result": result} diff --git a/app/modules/feishu/schemas.py b/app/modules/feishu/schemas.py index 5690def..96377f3 100644 --- a/app/modules/feishu/schemas.py +++ b/app/modules/feishu/schemas.py @@ -2,19 +2,22 @@ from typing import Any from pydantic import BaseModel, Field +from app.core.constants import ActorValue +from app.modules.feishu.constants import FeishuReceiveIdType + class FeishuTextMessage(BaseModel): receive_id: str | None = Field( default=None, description="chat_id or open_id depending on type.", ) - receive_id_type: str = "chat_id" + receive_id_type: str = FeishuReceiveIdType.CHAT_ID text: str class FeishuCardMessage(BaseModel): receive_id: str | None = None - receive_id_type: str = "chat_id" + receive_id_type: str = FeishuReceiveIdType.CHAT_ID card: dict[str, Any] @@ -35,7 +38,7 @@ class FeishuSendResult(BaseModel): class FeishuCommandRequest(BaseModel): text: str chat_id: str | None = None - actor: str = "api" + actor: str = ActorValue.API auto_reply: bool = False diff --git a/app/modules/feishu/service.py b/app/modules/feishu/service.py index 27078e6..76630e2 100644 --- a/app/modules/feishu/service.py +++ b/app/modules/feishu/service.py @@ -3,10 +3,13 @@ from typing import Any from fastapi import HTTPException, status from sqlalchemy.orm import Session +from app.core.constants import ActorValue from app.core.config import get_settings +from app.modules.audit.constants import AuditAction, AuditSource from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService from app.modules.feishu.client import FeishuClient +from app.modules.feishu.constants import FeishuPayloadKey, FeishuReceiveIdType class FeishuService: @@ -32,19 +35,19 @@ class FeishuService: self, text: str, receive_id: str | None = None, - receive_id_type: str = "chat_id", - actor: str = "system", + receive_id_type: str = FeishuReceiveIdType.CHAT_ID, + actor: str = ActorValue.SYSTEM, ) -> dict[str, Any]: result = self.client.send_text(text, receive_id, receive_id_type) self.audit.log( AuditLogCreate( actor=actor, - source="feishu", - action="send_text", + source=AuditSource.FEISHU, + action=AuditAction.FEISHU_SEND_TEXT, request_payload={ - "receive_id": receive_id, - "receive_id_type": receive_id_type, - "text": text, + FeishuPayloadKey.RECEIVE_ID: receive_id, + FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type, + FeishuPayloadKey.TEXT: text, }, response_payload=result, ) @@ -55,19 +58,19 @@ class FeishuService: self, card: dict[str, Any], receive_id: str | None = None, - receive_id_type: str = "chat_id", - actor: str = "system", + receive_id_type: str = FeishuReceiveIdType.CHAT_ID, + actor: str = ActorValue.SYSTEM, ) -> dict[str, Any]: result = self.client.send_card(card, receive_id, receive_id_type) self.audit.log( AuditLogCreate( actor=actor, - source="feishu", - action="send_card", + source=AuditSource.FEISHU, + action=AuditAction.FEISHU_SEND_CARD, request_payload={ - "receive_id": receive_id, - "receive_id_type": receive_id_type, - "card": card, + FeishuPayloadKey.RECEIVE_ID: receive_id, + FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type, + FeishuPayloadKey.CARD: card, }, response_payload=result, ) @@ -80,6 +83,12 @@ class FeishuService: "config": {"wide_screen_mode": True}, "header": {"title": {"tag": "plain_text", "content": title}}, "elements": [ - {"tag": "div", "text": {"tag": "lark_md", "content": "\n".join(lines) or "暂无数据"}} + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": "\n".join(lines) or "暂无数据", + }, + } ], } diff --git a/app/modules/legacy_mysql/schemas.py b/app/modules/legacy_mysql/schemas.py index 8008571..67b58cb 100644 --- a/app/modules/legacy_mysql/schemas.py +++ b/app/modules/legacy_mysql/schemas.py @@ -2,6 +2,8 @@ from typing import Any from pydantic import BaseModel, Field +from app.core.constants import ActorValue + class ReadonlyQueryRequest(BaseModel): """Readonly SQL query request for the legacy MySQL connection.""" @@ -41,7 +43,7 @@ class LegacyProjectSyncRequest(BaseModel): ) limit: int = Field(default=100, ge=1, le=500) dry_run: bool = True - actor: str = "api" + actor: str = ActorValue.API class LegacyProjectSyncResult(BaseModel): diff --git a/app/modules/legacy_mysql/service.py b/app/modules/legacy_mysql/service.py index dce8d57..000ef65 100644 --- a/app/modules/legacy_mysql/service.py +++ b/app/modules/legacy_mysql/service.py @@ -9,10 +9,13 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy import select from sqlalchemy.orm import Session +from app.core.constants import ActorValue from app.core.config import get_settings from app.core.database import legacy_engine +from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService +from app.modules.business.constants import BusinessDomain, SourceSystem, StatusValue from app.modules.business.models import LegacySyncRun, Project from app.modules.business.service import serialize_model @@ -152,10 +155,10 @@ class LegacyMySQLService: return { "code": code, "external_id": str(external_id) if external_id is not None else code, - "source_system": "legacy_mysql", + "source_system": SourceSystem.LEGACY_MYSQL, "name": self._value(row, field_map, "name", "未命名项目"), "owner": self._value(row, field_map, "owner", None), - "status": self._value(row, field_map, "status", "未知"), + "status": self._value(row, field_map, "status", StatusValue.UNKNOWN), "progress_percent": int( self._value( row, @@ -182,7 +185,7 @@ class LegacyMySQLService: field_map: dict[str, str] | None = None, limit: int = 100, dry_run: bool = True, - actor: str = "api", + actor: str = ActorValue.API, ) -> dict[str, Any]: if self.db is None: raise HTTPException( @@ -216,7 +219,7 @@ class LegacyMySQLService: continue stmt = select(Project).where( - Project.source_system == "legacy_mysql", + Project.source_system == SourceSystem.LEGACY_MYSQL, Project.external_id == payload["external_id"], ) record = self.db.execute(stmt).scalar_one_or_none() @@ -258,9 +261,9 @@ class LegacyMySQLService: } sync_run = LegacySyncRun( code=f"SYNC-PROJECTS-{datetime.utcnow():%Y%m%d%H%M%S%f}", - domain="projects", + domain=BusinessDomain.PROJECTS, source_table="LEGACY_PROJECT_QUERY", - status="dry_run" if dry_run else "success", + status=StatusValue.DRY_RUN if dry_run else AuditStatus.SUCCESS, finished_at=datetime.utcnow(), created_count=created, updated_count=updated, @@ -275,10 +278,10 @@ class LegacyMySQLService: AuditService(self.db).log( AuditLogCreate( actor=actor, - source="legacy_mysql", - action="sync_projects", - target_type="projects", - risk_level="medium", + source=AuditSource.LEGACY_MYSQL, + action=AuditAction.LEGACY_SYNC_PROJECTS, + target_type=BusinessDomain.PROJECTS, + risk_level=AuditRiskLevel.MEDIUM, request_payload={ "source_query": source_query or "LEGACY_PROJECT_QUERY", "field_map": field_map, diff --git a/app/modules/reports/constants.py b/app/modules/reports/constants.py new file mode 100644 index 0000000..ee0a1ad --- /dev/null +++ b/app/modules/reports/constants.py @@ -0,0 +1,133 @@ +from enum import StrEnum + + +class ReportType(StrEnum): + DAILY = "daily" + WEEKLY = "weekly" + + +class ReportTitle(StrEnum): + DAILY_BRIEF = "每日经营晨报" + PROJECT_WEEKLY = "项目周报" + ATTENDANCE_SUMMARY = "打卡汇总" + WORK_DAILY = "经营日报" + WORK_WEEKLY = "经营周报" + PROJECT_LIFECYCLE = "项目全生命周期报告" + + +class ReportStatus(StrEnum): + UNKNOWN = "未设置" + GENERATED = "已生成" + + +class LifecycleSection(StrEnum): + HEALTH = "health" + PROJECTS = "projects" + TASKS = "tasks" + PROCUREMENTS = "procurements" + EXPENSES = "expenses" + FUNDS = "funds" + SUPPLIERS = "suppliers" + ATTENDANCE = "attendance" + RISKS = "risks" + + +class LifecycleFilterKey(StrEnum): + PROJECT_CODE = "project_code" + OWNER = "owner" + PERIOD_START = "period_start" + PERIOD_END = "period_end" + LABELS = "labels" + + +class LifecycleResponseKey(StrEnum): + TITLE = "title" + FILTERS = "filters" + METRICS = "metrics" + ATTENTION = "attention" + RECOMMENDATIONS = "recommendations" + LINES = "lines" + CONTENT = "content" + AI_ANALYSIS = "ai_analysis" + PROJECT_LIFECYCLE_REPORT = "project_lifecycle_report" + + +class LifecycleAttentionKey(StrEnum): + DELAYED_PROJECTS = "delayed_projects" + OVER_BUDGET_PROJECTS = "over_budget_projects" + OVERDUE_TASKS = "overdue_tasks" + + +class MetricKey(StrEnum): + TOTAL = "total" + ACTIVE = "active" + CLOSED = "closed" + AVERAGE_PROGRESS_PERCENT = "average_progress_percent" + BY_STATUS = "by_status" + BY_RISK_LEVEL = "by_risk_level" + BUDGET_TOTAL = "budget_total" + ACTUAL_TOTAL = "actual_total" + BUDGET_USAGE_RATE = "budget_usage_rate" + DELAYED = "delayed" + OVER_BUDGET = "over_budget" + COMPLETED = "completed" + OPEN = "open" + OVERDUE = "overdue" + BLOCKED = "blocked" + COMPLETION_RATE = "completion_rate" + BY_PRIORITY = "by_priority" + PENDING_APPROVAL = "pending_approval" + PENDING_DELIVERY = "pending_delivery" + UNPAID = "unpaid" + EXPECTED_TOTAL = "expected_total" + ACTUAL_VS_EXPECTED_RATE = "actual_vs_expected_rate" + AMOUNT_TOTAL = "amount_total" + BY_TYPE = "by_type" + ACCOUNTS_TOTAL = "accounts_total" + CURRENT_BALANCE_TOTAL = "current_balance_total" + EXPECTED_RECEIVABLE_TOTAL = "expected_receivable_total" + EXPECTED_PAYABLE_TOTAL = "expected_payable_total" + SAFETY_LINE_TOTAL = "safety_line_total" + NET_POSITION = "net_position" + RISK_ACCOUNTS = "risk_accounts" + RISKY = "risky" + BLACKLISTED = "blacklisted" + ABNORMAL = "abnormal" + ABNORMAL_RATE = "abnormal_rate" + RISK_LEVEL = "risk_level" + RISK_SCORE = "risk_score" + OVERDUE_TASKS = "overdue_tasks" + DELAYED_PROJECTS = "delayed_projects" + OVER_BUDGET_PROJECTS = "over_budget_projects" + OPEN_EVENTS = "open_events" + HIGH_EVENTS = "high_events" + EVENTS_BY_TYPE = "events_by_type" + EVENTS_BY_LEVEL = "events_by_level" + SCORE = "score" + LEVEL = "level" + + +class HealthLevel(StrEnum): + HEALTHY = "healthy" + ATTENTION = "attention" + CRITICAL = "critical" + + +class ReportText(StrEnum): + DEFAULT_SCOPE = "全部项目" + ACTION_HEADER = "- 建议动作:" + RECOMMEND_DELAYED = ( + "优先组织延期项目复盘,明确新截止时间、负责人和资源缺口。" + ) + RECOMMEND_OVER_BUDGET = ( + "对超预算项目冻结非必要采购和费用,补齐预算调整审批依据。" + ) + RECOMMEND_OVERDUE_TASKS = ( + "清理逾期任务,要求负责人更新阻塞项和恢复计划。" + ) + RECOMMEND_APPROVALS = "集中处理采购和费用审批,减少交付与付款等待。" + RECOMMEND_FUNDS = "复核资金安全线账户,滚动更新收付款预测。" + RECOMMEND_SUPPLIERS = "复核中高风险供应商,准备替代供应和履约跟踪。" + RECOMMEND_STABLE = ( + "当前生命周期指标稳定,建议继续保持周度复盘和风险事件归档。" + ) diff --git a/app/modules/reports/routes.py b/app/modules/reports/routes.py index 7b48cda..cf25e44 100644 --- a/app/modules/reports/routes.py +++ b/app/modules/reports/routes.py @@ -3,6 +3,7 @@ from datetime import date from fastapi import APIRouter, Depends from sqlalchemy.orm import Session +from app.core.constants import ActorValue from app.core.database import get_db from app.core.security import require_api_key from app.modules.reports.schemas import ( @@ -25,6 +26,26 @@ def project_weekly(db: Session = Depends(get_db)) -> dict: return ReportService(db).project_weekly() +@router.get("/project-lifecycle") +def project_lifecycle_report( + project_code: str | None = None, + owner: str | None = None, + period_start: date | None = None, + period_end: date | None = None, + include_ai: bool = False, + actor: str = ActorValue.API, + db: Session = Depends(get_db), +) -> dict: + return ReportService(db).project_lifecycle_report( + project_code=project_code, + owner=owner, + period_start=period_start, + period_end=period_end, + include_ai=include_ai, + actor=actor, + ) + + @router.get("/attendance-summary") def attendance_summary( work_date: date | None = None, diff --git a/app/modules/reports/schemas.py b/app/modules/reports/schemas.py index a1a1832..1b8d6d8 100644 --- a/app/modules/reports/schemas.py +++ b/app/modules/reports/schemas.py @@ -1,8 +1,11 @@ from datetime import date -from typing import Literal from pydantic import BaseModel +from app.core.constants import ActorValue +from app.modules.feishu.constants import FeishuReceiveIdType +from app.modules.reports.constants import ReportType + class ReportResponse(BaseModel): title: str @@ -12,16 +15,16 @@ class ReportResponse(BaseModel): class PushReportRequest(BaseModel): receive_id: str | None = None - receive_id_type: str = "chat_id" - actor: str = "system" + receive_id_type: str = FeishuReceiveIdType.CHAT_ID + actor: str = ActorValue.SYSTEM class WorkReportGenerateRequest(BaseModel): - report_type: Literal["daily", "weekly"] = "daily" - reporter: str = "system" + report_type: ReportType = ReportType.DAILY + reporter: str = ActorValue.SYSTEM department: str | None = None project_code: str | None = None period_start: date | None = None period_end: date | None = None persist: bool = True - actor: str = "api" + actor: str = ActorValue.API diff --git a/app/modules/reports/service.py b/app/modules/reports/service.py index 8041e77..cbf3144 100644 --- a/app/modules/reports/service.py +++ b/app/modules/reports/service.py @@ -2,11 +2,21 @@ from datetime import date, datetime, timedelta from decimal import Decimal from typing import Any -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.orm import Session +from app.core.constants import ActorValue from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService +from app.modules.business.constants import ( + ATTENDANCE_ABNORMAL_STATUSES, + DONE_STATUSES, + PROJECT_CLOSED_STATUSES, + PENDING_APPROVAL_STATUSES, + SUPPLIER_RISK_LEVELS, + RiskLevel, + StatusValue, +) from app.modules.business.models import ( AttendanceRecord, Expense, @@ -14,17 +24,26 @@ from app.modules.business.models import ( Procurement, Project, RiskEvent, + Supplier, WorkReport, WorkTask, ) from app.modules.business.service import serialize_model from app.modules.feishu.service import FeishuService +from app.modules.reports.constants import ( + HealthLevel, + LifecycleAttentionKey, + LifecycleFilterKey, + LifecycleResponseKey, + LifecycleSection, + MetricKey, + ReportStatus, + ReportText, + ReportTitle, + ReportType, +) from app.modules.risk.service import RiskService -DONE_STATUSES = {"完成", "已完成", "关闭", "done", "completed", "closed"} -PENDING_APPROVAL_STATUSES = {"草稿", "审批中", "待审批", "pending"} -PROJECT_CLOSED_STATUSES = {"验收", "已完成", "复盘", "归档", "关闭", "closed"} - def _money(value: Decimal | int | float | None) -> str: """Format a numeric value as a two-decimal money string.""" @@ -53,6 +72,18 @@ def _next_code(prefix: str) -> str: return f"{prefix}-{datetime.utcnow():%Y%m%d%H%M%S%f}" +def _rate(numerator: int | Decimal, denominator: int | Decimal) -> float: + """Return a rounded percentage rate, using zero when the denominator is empty.""" + + if not denominator: + return 0.0 + return round(float(numerator) / float(denominator) * 100, 2) + + +def _as_decimal(value: Decimal | int | float | None) -> Decimal: + return Decimal(str(value or 0)) + + class ReportService: """Build operational reports and push them through Feishu.""" @@ -66,6 +97,45 @@ class ReportService: stmt = stmt.where(condition) return int(self.db.execute(stmt).scalar() or 0) + def _sum(self, column: Any, *conditions: Any) -> Decimal: + stmt = select(func.sum(column)) + for condition in conditions: + stmt = stmt.where(condition) + return _as_decimal(self.db.execute(stmt).scalar()) + + def _avg(self, column: Any, *conditions: Any) -> float: + stmt = select(func.avg(column)) + for condition in conditions: + stmt = stmt.where(condition) + value = self.db.execute(stmt).scalar() + return round(float(value or 0), 2) + + def _group_counts(self, model: type, column: Any, *conditions: Any) -> dict[str, int]: + stmt = select(column, func.count()).select_from(model) + for condition in conditions: + stmt = stmt.where(condition) + stmt = stmt.group_by(column) + return { + str(key or ReportStatus.UNKNOWN): int(count) + for key, count in self.db.execute(stmt).all() + } + + def _records( + self, + model: type, + *conditions: Any, + limit: int = 10, + order_by: Any | None = None, + ) -> list[Any]: + stmt = select(model) + for condition in conditions: + stmt = stmt.where(condition) + if order_by is not None: + stmt = stmt.order_by(order_by) + else: + stmt = stmt.order_by(model.id.desc()) + return list(self.db.execute(stmt.limit(limit)).scalars()) + def daily_brief(self) -> dict: project_count = self._count(Project) task_count = self._count(WorkTask) @@ -89,7 +159,10 @@ class ReportService: f"- 待处理采购:{procurement_pending}", f"- 待处理费用:{expense_pending}", f"- 当前账户总余额:{_money(fund_total)}", - f"- 今日打卡记录:{attendance['total']},异常:{attendance['abnormal_total']}", + ( + f"- 今日打卡记录:{attendance['total']}," + f"异常:{attendance['abnormal_total']}" + ), f"- 逾期任务:{len(risk_summary['overdue_tasks'])}", f"- 延期项目:{len(risk_summary['delayed_projects'])}", f"- 超预算项目:{len(risk_summary['over_budget_projects'])}", @@ -107,7 +180,7 @@ class ReportService: ) delayed = self.risks.delayed_projects() over_budget = self.risks.over_budget_projects() - open_risks = self.risks.list_events(status_filter="open") + open_risks = self.risks.list_events(status_filter=StatusValue.OPEN) lines = [ f"- 活跃项目:{active}", f"- 延期项目:{len(delayed)}", @@ -116,11 +189,564 @@ class ReportService: "- 需要管理层关注:", ] for item in delayed[:10]: - lines.append(f" - 延期:{item.get('code')} {item.get('name')},负责人 {item.get('owner')}") + lines.append( + f" - 延期:{item.get('code')} {item.get('name')}," + f"负责人 {item.get('owner')}" + ) for item in over_budget[:10]: lines.append(f" - 超预算:{item.get('code')} {item.get('name')}") return {"title": "项目周报", "lines": lines, "content": "\n".join(lines)} + def project_lifecycle_report( + self, + project_code: str | None = None, + owner: str | None = None, + period_start: date | None = None, + period_end: date | None = None, + include_ai: bool = False, + actor: str = ActorValue.API, + ) -> dict[str, Any]: + """Build a full lifecycle report for project progress, delivery, cost, and risk.""" + + filters = self._lifecycle_filters(project_code, owner, period_start, period_end) + project_conditions = filters[LifecycleSection.PROJECTS] + task_conditions = filters[LifecycleSection.TASKS] + procurement_conditions = filters[LifecycleSection.PROCUREMENTS] + expense_conditions = filters[LifecycleSection.EXPENSES] + attendance_conditions = filters[LifecycleSection.ATTENDANCE] + risk_conditions = filters[LifecycleSection.RISKS] + + project_stats = self._lifecycle_project_stats(project_conditions) + task_stats = self._lifecycle_task_stats(task_conditions) + procurement_stats = self._lifecycle_procurement_stats(procurement_conditions) + expense_stats = self._lifecycle_expense_stats(expense_conditions) + fund_stats = self._lifecycle_fund_stats() + supplier_stats = self._lifecycle_supplier_stats() + attendance_stats = self._lifecycle_attendance_stats(attendance_conditions) + risk_stats = self._lifecycle_risk_stats( + project_conditions, + task_conditions, + risk_conditions, + ) + health = self._lifecycle_health(project_stats, task_stats, risk_stats, supplier_stats) + attention = self._lifecycle_attention(project_conditions, task_conditions) + recommendations = self._lifecycle_recommendations( + project_stats, + task_stats, + procurement_stats, + expense_stats, + fund_stats, + supplier_stats, + risk_stats, + ) + + metrics = { + LifecycleSection.HEALTH: health, + LifecycleSection.PROJECTS: project_stats, + LifecycleSection.TASKS: task_stats, + LifecycleSection.PROCUREMENTS: procurement_stats, + LifecycleSection.EXPENSES: expense_stats, + LifecycleSection.FUNDS: fund_stats, + LifecycleSection.SUPPLIERS: supplier_stats, + LifecycleSection.ATTENDANCE: attendance_stats, + LifecycleSection.RISKS: risk_stats, + } + lines = self._lifecycle_lines( + filters[LifecycleFilterKey.LABELS], + metrics, + recommendations, + ) + report = { + LifecycleResponseKey.TITLE: ReportTitle.PROJECT_LIFECYCLE, + LifecycleResponseKey.FILTERS: filters[LifecycleFilterKey.LABELS], + LifecycleResponseKey.METRICS: _json_safe(metrics), + LifecycleResponseKey.ATTENTION: _json_safe(attention), + LifecycleResponseKey.RECOMMENDATIONS: recommendations, + LifecycleResponseKey.LINES: lines, + LifecycleResponseKey.CONTENT: "\n".join(lines), + } + if include_ai: + report[LifecycleResponseKey.AI_ANALYSIS] = self._lifecycle_ai_analysis(report, actor) + return report + + def _lifecycle_filters( + self, + project_code: str | None, + owner: str | None, + period_start: date | None, + period_end: date | None, + ) -> dict[str, Any]: + project_conditions: list[Any] = [] + task_conditions: list[Any] = [] + procurement_conditions: list[Any] = [] + expense_conditions: list[Any] = [] + attendance_conditions: list[Any] = [] + risk_conditions: list[Any] = [] + labels = { + LifecycleFilterKey.PROJECT_CODE: project_code, + LifecycleFilterKey.OWNER: owner, + LifecycleFilterKey.PERIOD_START: period_start.isoformat() if period_start else None, + LifecycleFilterKey.PERIOD_END: period_end.isoformat() if period_end else None, + } + + if project_code: + project_conditions.append(Project.code == project_code) + task_conditions.append(WorkTask.project_code == project_code) + procurement_conditions.append(Procurement.project_code == project_code) + expense_conditions.append(Expense.project_code == project_code) + attendance_conditions.append(AttendanceRecord.project_code == project_code) + risk_conditions.append(RiskEvent.project_code == project_code) + if owner: + project_conditions.append(Project.owner == owner) + task_conditions.append(WorkTask.owner == owner) + risk_conditions.append(RiskEvent.owner == owner) + if period_start: + project_conditions.append( + or_(Project.due_date.is_(None), Project.due_date >= period_start) + ) + task_conditions.append(WorkTask.due_date >= period_start) + attendance_conditions.append(AttendanceRecord.work_date >= period_start) + risk_conditions.append( + RiskEvent.detected_at >= datetime.combine(period_start, datetime.min.time()) + ) + if period_end: + project_conditions.append( + or_(Project.start_date.is_(None), Project.start_date <= period_end) + ) + task_conditions.append(WorkTask.due_date <= period_end) + attendance_conditions.append(AttendanceRecord.work_date <= period_end) + risk_conditions.append( + RiskEvent.detected_at <= datetime.combine(period_end, datetime.max.time()) + ) + if period_start: + start_at = datetime.combine(period_start, datetime.min.time()) + procurement_conditions.append(Procurement.created_at >= start_at) + expense_conditions.append(Expense.created_at >= start_at) + if period_end: + end_at = datetime.combine(period_end, datetime.max.time()) + procurement_conditions.append(Procurement.created_at <= end_at) + expense_conditions.append(Expense.created_at <= end_at) + + return { + LifecycleFilterKey.LABELS: labels, + LifecycleSection.PROJECTS: project_conditions, + LifecycleSection.TASKS: task_conditions, + LifecycleSection.PROCUREMENTS: procurement_conditions, + LifecycleSection.EXPENSES: expense_conditions, + LifecycleSection.ATTENDANCE: attendance_conditions, + LifecycleSection.RISKS: risk_conditions, + } + + def _lifecycle_project_stats(self, conditions: list[Any]) -> dict[str, Any]: + total = self._count(Project, *conditions) + active = self._count(Project, Project.status.notin_(PROJECT_CLOSED_STATUSES), *conditions) + closed = total - active + budget_total = self._sum(Project.budget_amount, *conditions) + actual_total = self._sum(Project.actual_amount, *conditions) + delayed = self._count( + Project, + Project.due_date.is_not(None), + Project.due_date < date.today(), + Project.status.notin_(PROJECT_CLOSED_STATUSES), + *conditions, + ) + over_budget = self._count( + Project, + Project.budget_amount > 0, + Project.actual_amount > Project.budget_amount, + *conditions, + ) + return { + MetricKey.TOTAL: total, + MetricKey.ACTIVE: active, + MetricKey.CLOSED: closed, + MetricKey.AVERAGE_PROGRESS_PERCENT: self._avg( + Project.progress_percent, + *conditions, + ), + MetricKey.BY_STATUS: self._group_counts(Project, Project.status, *conditions), + MetricKey.BY_RISK_LEVEL: self._group_counts(Project, Project.risk_level, *conditions), + MetricKey.BUDGET_TOTAL: budget_total, + MetricKey.ACTUAL_TOTAL: actual_total, + MetricKey.BUDGET_USAGE_RATE: _rate(actual_total, budget_total), + MetricKey.DELAYED: delayed, + MetricKey.OVER_BUDGET: over_budget, + } + + def _lifecycle_task_stats(self, conditions: list[Any]) -> dict[str, Any]: + total = self._count(WorkTask, *conditions) + completed = self._count(WorkTask, WorkTask.status.in_(DONE_STATUSES), *conditions) + overdue = self._count( + WorkTask, + WorkTask.due_date.is_not(None), + WorkTask.due_date < date.today(), + WorkTask.status.notin_(DONE_STATUSES), + *conditions, + ) + blocked = self._count( + WorkTask, + WorkTask.blocker.is_not(None), + WorkTask.status.notin_(DONE_STATUSES), + *conditions, + ) + return { + MetricKey.TOTAL: total, + MetricKey.COMPLETED: completed, + MetricKey.OPEN: total - completed, + MetricKey.OVERDUE: overdue, + MetricKey.BLOCKED: blocked, + MetricKey.COMPLETION_RATE: _rate(completed, total), + MetricKey.BY_STATUS: self._group_counts(WorkTask, WorkTask.status, *conditions), + MetricKey.BY_PRIORITY: self._group_counts(WorkTask, WorkTask.priority, *conditions), + } + + def _lifecycle_procurement_stats(self, conditions: list[Any]) -> dict[str, Any]: + total = self._count(Procurement, *conditions) + pending_approval = self._count( + Procurement, + Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES), + *conditions, + ) + pending_delivery = self._count( + Procurement, + Procurement.delivery_status.notin_(DONE_STATUSES), + *conditions, + ) + unpaid = self._count( + Procurement, + Procurement.payment_status != StatusValue.PAID, + *conditions, + ) + expected_total = self._sum(Procurement.expected_amount, *conditions) + actual_total = self._sum(Procurement.actual_amount, *conditions) + return { + MetricKey.TOTAL: total, + MetricKey.PENDING_APPROVAL: pending_approval, + MetricKey.PENDING_DELIVERY: pending_delivery, + MetricKey.UNPAID: unpaid, + MetricKey.EXPECTED_TOTAL: expected_total, + MetricKey.ACTUAL_TOTAL: actual_total, + MetricKey.ACTUAL_VS_EXPECTED_RATE: _rate(actual_total, expected_total), + } + + def _lifecycle_expense_stats(self, conditions: list[Any]) -> dict[str, Any]: + total = self._count(Expense, *conditions) + pending_approval = self._count( + Expense, + Expense.approval_status.in_(PENDING_APPROVAL_STATUSES), + *conditions, + ) + unpaid = self._count(Expense, Expense.payment_status != StatusValue.PAID, *conditions) + return { + MetricKey.TOTAL: total, + MetricKey.PENDING_APPROVAL: pending_approval, + MetricKey.UNPAID: unpaid, + MetricKey.AMOUNT_TOTAL: self._sum(Expense.amount, *conditions), + MetricKey.BY_TYPE: self._group_counts(Expense, Expense.expense_type, *conditions), + } + + def _lifecycle_fund_stats(self) -> dict[str, Any]: + balance = self._sum(FundAccount.current_balance) + receivable = self._sum(FundAccount.expected_receivable) + payable = self._sum(FundAccount.expected_payable) + safety_line = self._sum(FundAccount.safety_line) + risk_accounts = self._count( + FundAccount, + FundAccount.current_balance < FundAccount.safety_line, + ) + return { + MetricKey.ACCOUNTS_TOTAL: self._count(FundAccount), + MetricKey.CURRENT_BALANCE_TOTAL: balance, + MetricKey.EXPECTED_RECEIVABLE_TOTAL: receivable, + MetricKey.EXPECTED_PAYABLE_TOTAL: payable, + MetricKey.SAFETY_LINE_TOTAL: safety_line, + MetricKey.NET_POSITION: balance + receivable - payable, + MetricKey.RISK_ACCOUNTS: risk_accounts, + } + + def _lifecycle_supplier_stats(self) -> dict[str, Any]: + risky = self._count( + Supplier, + (Supplier.blacklist_status != StatusValue.NORMAL) + | Supplier.risk_level.in_(SUPPLIER_RISK_LEVELS), + ) + blacklisted = self._count(Supplier, Supplier.blacklist_status != StatusValue.NORMAL) + return { + MetricKey.TOTAL: self._count(Supplier), + MetricKey.RISKY: risky, + MetricKey.BLACKLISTED: blacklisted, + MetricKey.BY_RISK_LEVEL: self._group_counts(Supplier, Supplier.risk_level), + } + + def _lifecycle_attendance_stats(self, conditions: list[Any]) -> dict[str, Any]: + total = self._count(AttendanceRecord, *conditions) + abnormal = self._count( + AttendanceRecord, + AttendanceRecord.status.in_(ATTENDANCE_ABNORMAL_STATUSES), + *conditions, + ) + return { + MetricKey.TOTAL: total, + MetricKey.ABNORMAL: abnormal, + MetricKey.ABNORMAL_RATE: _rate(abnormal, total), + MetricKey.BY_STATUS: self._group_counts( + AttendanceRecord, + AttendanceRecord.status, + *conditions, + ), + } + + def _lifecycle_risk_stats( + self, + project_conditions: list[Any], + task_conditions: list[Any], + risk_conditions: list[Any], + ) -> dict[str, Any]: + overdue_tasks = self._count( + WorkTask, + WorkTask.due_date.is_not(None), + WorkTask.due_date < date.today(), + WorkTask.status.notin_(DONE_STATUSES), + *task_conditions, + ) + delayed_projects = self._count( + Project, + Project.due_date.is_not(None), + Project.due_date < date.today(), + Project.status.notin_(PROJECT_CLOSED_STATUSES), + *project_conditions, + ) + over_budget_projects = self._count( + Project, + Project.budget_amount > 0, + Project.actual_amount > Project.budget_amount, + *project_conditions, + ) + open_events = self._count( + RiskEvent, + RiskEvent.status == StatusValue.OPEN, + *risk_conditions, + ) + high_events = self._count( + RiskEvent, + RiskEvent.status == StatusValue.OPEN, + RiskEvent.risk_level == RiskLevel.HIGH, + *risk_conditions, + ) + risk_score = ( + overdue_tasks * 1 + + delayed_projects * 3 + + over_budget_projects * 4 + + open_events * 2 + + high_events * 3 + ) + if risk_score >= 15: + level = RiskLevel.HIGH + elif risk_score >= 5: + level = RiskLevel.MEDIUM + else: + level = RiskLevel.LOW + return { + MetricKey.RISK_LEVEL: level, + MetricKey.RISK_SCORE: risk_score, + MetricKey.OVERDUE_TASKS: overdue_tasks, + MetricKey.DELAYED_PROJECTS: delayed_projects, + MetricKey.OVER_BUDGET_PROJECTS: over_budget_projects, + MetricKey.OPEN_EVENTS: open_events, + MetricKey.HIGH_EVENTS: high_events, + MetricKey.EVENTS_BY_TYPE: self._group_counts( + RiskEvent, + RiskEvent.risk_type, + *risk_conditions, + ), + MetricKey.EVENTS_BY_LEVEL: self._group_counts( + RiskEvent, + RiskEvent.risk_level, + *risk_conditions, + ), + } + + def _lifecycle_health( + self, + projects: dict[str, Any], + tasks: dict[str, Any], + risks: dict[str, Any], + suppliers: dict[str, Any], + ) -> dict[str, Any]: + penalty = ( + risks[MetricKey.OVERDUE_TASKS] * 3 + + risks[MetricKey.DELAYED_PROJECTS] * 8 + + risks[MetricKey.OVER_BUDGET_PROJECTS] * 10 + + risks[MetricKey.HIGH_EVENTS] * 8 + + suppliers[MetricKey.BLACKLISTED] * 10 + + max(0, projects[MetricKey.BUDGET_USAGE_RATE] - 100) * 0.4 + + (100 - tasks[MetricKey.COMPLETION_RATE]) * 0.1 + ) + score = max(0, min(100, round(100 - penalty, 2))) + if score >= 80: + level = HealthLevel.HEALTHY + elif score >= 60: + level = HealthLevel.ATTENTION + else: + level = HealthLevel.CRITICAL + return {MetricKey.SCORE: score, MetricKey.LEVEL: level} + + def _lifecycle_attention( + self, + project_conditions: list[Any], + task_conditions: list[Any], + ) -> dict[str, Any]: + delayed = self._records( + Project, + Project.due_date.is_not(None), + Project.due_date < date.today(), + Project.status.notin_(PROJECT_CLOSED_STATUSES), + *project_conditions, + limit=10, + order_by=Project.due_date.asc(), + ) + over_budget = self._records( + Project, + Project.budget_amount > 0, + Project.actual_amount > Project.budget_amount, + *project_conditions, + limit=10, + order_by=Project.id.desc(), + ) + overdue_tasks = self._records( + WorkTask, + WorkTask.due_date.is_not(None), + WorkTask.due_date < date.today(), + WorkTask.status.notin_(DONE_STATUSES), + *task_conditions, + limit=10, + order_by=WorkTask.due_date.asc(), + ) + return { + LifecycleAttentionKey.DELAYED_PROJECTS: [serialize_model(item) for item in delayed], + LifecycleAttentionKey.OVER_BUDGET_PROJECTS: [ + serialize_model(item) for item in over_budget + ], + LifecycleAttentionKey.OVERDUE_TASKS: [ + serialize_model(item) for item in overdue_tasks + ], + } + + def _lifecycle_recommendations( + self, + projects: dict[str, Any], + tasks: dict[str, Any], + procurements: dict[str, Any], + expenses: dict[str, Any], + funds: dict[str, Any], + suppliers: dict[str, Any], + risks: dict[str, Any], + ) -> list[str]: + recommendations: list[str] = [] + if risks[MetricKey.DELAYED_PROJECTS]: + recommendations.append(ReportText.RECOMMEND_DELAYED) + if risks[MetricKey.OVER_BUDGET_PROJECTS]: + recommendations.append(ReportText.RECOMMEND_OVER_BUDGET) + if tasks[MetricKey.OVERDUE]: + recommendations.append(ReportText.RECOMMEND_OVERDUE_TASKS) + if ( + procurements[MetricKey.PENDING_APPROVAL] + or expenses[MetricKey.PENDING_APPROVAL] + ): + recommendations.append(ReportText.RECOMMEND_APPROVALS) + if funds[MetricKey.RISK_ACCOUNTS]: + recommendations.append(ReportText.RECOMMEND_FUNDS) + if suppliers[MetricKey.RISKY]: + recommendations.append(ReportText.RECOMMEND_SUPPLIERS) + if not recommendations: + recommendations.append(ReportText.RECOMMEND_STABLE) + return [str(item) for item in recommendations] + + def _lifecycle_lines( + self, + filters: dict[str, Any], + metrics: dict[str, Any], + recommendations: list[str], + ) -> list[str]: + scope = ( + "、".join(f"{key}={value}" for key, value in filters.items() if value) + or ReportText.DEFAULT_SCOPE + ) + projects = metrics[LifecycleSection.PROJECTS] + tasks = metrics[LifecycleSection.TASKS] + procurements = metrics[LifecycleSection.PROCUREMENTS] + expenses = metrics[LifecycleSection.EXPENSES] + funds = metrics[LifecycleSection.FUNDS] + suppliers = metrics[LifecycleSection.SUPPLIERS] + attendance = metrics[LifecycleSection.ATTENDANCE] + risks = metrics[LifecycleSection.RISKS] + health = metrics[LifecycleSection.HEALTH] + lines = [ + f"- 范围:{scope}", + f"- 生命周期健康分:{health[MetricKey.SCORE]}({health[MetricKey.LEVEL]})", + ( + f"- 项目:总数 {projects[MetricKey.TOTAL]}," + f"活跃 {projects[MetricKey.ACTIVE]}," + f"平均进度 {projects[MetricKey.AVERAGE_PROGRESS_PERCENT]}%" + ), + ( + f"- 成本:预算 {_money(projects[MetricKey.BUDGET_TOTAL])}," + f"实际 {_money(projects[MetricKey.ACTUAL_TOTAL])}," + f"预算使用率 {projects[MetricKey.BUDGET_USAGE_RATE]}%" + ), + ( + f"- 任务:总数 {tasks[MetricKey.TOTAL]}," + f"完成 {tasks[MetricKey.COMPLETED]}," + f"完成率 {tasks[MetricKey.COMPLETION_RATE]}%," + f"逾期 {tasks[MetricKey.OVERDUE]}" + ), + ( + f"- 采购/费用:待批采购 {procurements[MetricKey.PENDING_APPROVAL]}," + f"待批费用 {expenses[MetricKey.PENDING_APPROVAL]}," + f"未付款采购 {procurements[MetricKey.UNPAID]}" + ), + ( + f"- 资金:余额 {_money(funds[MetricKey.CURRENT_BALANCE_TOTAL])}," + f"净头寸 {_money(funds[MetricKey.NET_POSITION])}," + f"风险账户 {funds[MetricKey.RISK_ACCOUNTS]}" + ), + ( + f"- 风险:等级 {risks[MetricKey.RISK_LEVEL]}," + f"风险分 {risks[MetricKey.RISK_SCORE]}," + f"延期项目 {risks[MetricKey.DELAYED_PROJECTS]}," + f"超预算项目 {risks[MetricKey.OVER_BUDGET_PROJECTS]}," + f"打开事件 {risks[MetricKey.OPEN_EVENTS]}" + ), + ( + f"- 供应商/考勤:风险供应商 {suppliers[MetricKey.RISKY]}," + f"异常打卡 {attendance[MetricKey.ABNORMAL]}," + f"异常率 {attendance[MetricKey.ABNORMAL_RATE]}%" + ), + ReportText.ACTION_HEADER, + ] + lines.extend(f" - {item}" for item in recommendations) + return lines + + def _lifecycle_ai_analysis(self, report: dict[str, Any], actor: str) -> dict[str, Any]: + try: + from app.modules.ai_agent.constants import AIResponseKey + from app.modules.ai_agent.skills import AISkillId + from app.modules.ai_agent.service import AIService + + report_snapshot = _json_safe(report) + result = AIService(self.db).run_skill( + AISkillId.PROJECT_LIFECYCLE_ANALYSIS, + context={LifecycleResponseKey.PROJECT_LIFECYCLE_REPORT: report_snapshot}, + actor=actor, + ) + except Exception as exc: + return { + AIResponseKey.OK: False, + AIResponseKey.ERROR: str(exc), + AIResponseKey.TYPE: type(exc).__name__, + } + return _json_safe({AIResponseKey.OK: True, **result}) + def attendance_summary(self, work_date: date | None = None) -> dict[str, Any]: """Summarize attendance records for one business day.""" @@ -134,7 +760,7 @@ class ReportService: abnormal_total = sum( count for status, count in status_counts.items() - if status in {"迟到", "早退", "缺卡", "旷工", "异常"} + if status in ATTENDANCE_ABNORMAL_STATUSES ) total = sum(status_counts.values()) lines = [ @@ -156,21 +782,25 @@ class ReportService: def generate_work_report( self, - report_type: str = "daily", - reporter: str = "system", + report_type: str = ReportType.DAILY, + reporter: str = ActorValue.SYSTEM, department: str | None = None, project_code: str | None = None, period_start: date | None = None, period_end: date | None = None, persist: bool = True, - actor: str = "api", + actor: str = ActorValue.API, ) -> dict[str, Any]: """Generate a daily or weekly operating report, optionally persisting it.""" start, end = self._resolve_period(report_type, period_start, period_end) metrics = self._report_metrics(start, end, project_code, department) risk_summary = _json_safe(self.risks.summary()) - title = "经营日报" if report_type == "daily" else "经营周报" + title = ( + ReportTitle.WORK_DAILY + if report_type == ReportType.DAILY + else ReportTitle.WORK_WEEKLY + ) lines = self._work_report_lines(title, start, end, metrics, risk_summary) report = { "title": title, @@ -222,7 +852,7 @@ class ReportService: period_end: date | None, ) -> tuple[date, date]: today = date.today() - if report_type == "daily": + if report_type == ReportType.DAILY: start = period_start or period_end or today return start, period_end or start end = period_end or today @@ -275,7 +905,10 @@ class ReportService: "procurements_pending": self._count(Procurement, *procurement_filters), "expenses_pending": self._count(Expense, *expense_filters), "attendance_total": self._count(AttendanceRecord, *attendance_filters), - "open_risk_events": self._count(RiskEvent, RiskEvent.status == "open"), + "open_risk_events": self._count( + RiskEvent, + RiskEvent.status == StatusValue.OPEN, + ), } def _work_report_lines( diff --git a/app/modules/risk/routes.py b/app/modules/risk/routes.py index 02ef471..803e587 100644 --- a/app/modules/risk/routes.py +++ b/app/modules/risk/routes.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session +from app.core.constants import ActorValue from app.core.database import get_db from app.core.security import require_api_key from app.modules.risk.service import RiskService @@ -48,5 +49,5 @@ def risk_events( @router.post("/events/generate") -def generate_risk_events(actor: str = "api", db: Session = Depends(get_db)) -> dict: +def generate_risk_events(actor: str = ActorValue.API, db: Session = Depends(get_db)) -> dict: return RiskService(db).generate_events(actor=actor) diff --git a/app/modules/risk/service.py b/app/modules/risk/service.py index 44f1708..7944a11 100644 --- a/app/modules/risk/service.py +++ b/app/modules/risk/service.py @@ -5,14 +5,28 @@ from typing import Any from sqlalchemy import select from sqlalchemy.orm import Session +from app.core.constants import ActorValue +from app.modules.audit.constants import ( + AuditAction, + AuditRiskLevel, + AuditSource, + AuditTargetType, +) from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService +from app.modules.business.constants import ( + CLOSED_RISK_STATUSES, + DONE_STATUSES, + PROJECT_CLOSED_STATUSES, + SUPPLIER_RISK_LEVELS, + BusinessDomain, + RiskEventType, + RiskLevel, + StatusValue, +) from app.modules.business.models import FundAccount, Project, RiskEvent, Supplier, WorkTask from app.modules.business.service import serialize_model -DONE_STATUSES = {"完成", "已完成", "关闭", "done", "completed", "closed"} -CLOSED_RISK_STATUSES = {"closed", "resolved"} - class RiskService: """Evaluate rule-based business risk signals from internal ledgers.""" @@ -32,7 +46,7 @@ class RiskService: stmt = select(Project).where( Project.due_date.is_not(None), Project.due_date < date.today(), - Project.status.notin_(["验收", "已完成", "复盘", "归档", "关闭", "closed"]), + Project.status.notin_(PROJECT_CLOSED_STATUSES), ) return [serialize_model(item) for item in self.db.execute(stmt).scalars()] @@ -49,8 +63,8 @@ class RiskService: def supplier_risks(self) -> list[dict[str, Any]]: stmt = select(Supplier).where( - (Supplier.blacklist_status != "normal") - | Supplier.risk_level.in_(["medium", "high"]) + (Supplier.blacklist_status != StatusValue.NORMAL) + | Supplier.risk_level.in_(SUPPLIER_RISK_LEVELS) ) return [serialize_model(item) for item in self.db.execute(stmt).scalars()] @@ -75,7 +89,7 @@ class RiskService: over_budget_projects = self.over_budget_projects() fund_risks = self.fund_risks() supplier_risks = self.supplier_risks() - open_events = self.list_events(status_filter="open") + open_events = self.list_events(status_filter=StatusValue.OPEN) risk_score = ( len(overdue_tasks) * 1 + len(delayed_projects) * 3 @@ -85,11 +99,11 @@ class RiskService: + len(open_events) * 2 ) if risk_score >= 15: - level = "high" + level = RiskLevel.HIGH elif risk_score >= 5: - level = "medium" + level = RiskLevel.MEDIUM else: - level = "low" + level = RiskLevel.LOW return { "risk_level": level, "risk_score": Decimal(risk_score), @@ -101,7 +115,7 @@ class RiskService: "open_events": open_events, } - def generate_events(self, actor: str = "api") -> dict[str, Any]: + def generate_events(self, actor: str = ActorValue.API) -> dict[str, Any]: """Generate or refresh risk-event ledger entries from current signals.""" payloads = self._build_event_payloads() @@ -136,10 +150,10 @@ class RiskService: AuditService(self.db).log( AuditLogCreate( actor=actor, - source="risk", - action="generate_events", - target_type="risk-events", - risk_level="medium", + source=AuditSource.RISK, + action=AuditAction.GENERATE_EVENTS, + target_type=AuditTargetType.RISK_EVENTS, + risk_level=AuditRiskLevel.MEDIUM, response_payload={ "created": created, "updated": updated, @@ -170,17 +184,19 @@ class RiskService: { "code": f"RISK-TASK-OVERDUE-{task.id}", "title": f"任务逾期:{task.title}", - "risk_type": "overdue_task", - "risk_level": "medium", - "status": "open", - "source_domain": "tasks", + "risk_type": RiskEventType.OVERDUE_TASK, + "risk_level": RiskLevel.MEDIUM, + "status": StatusValue.OPEN, + "source_domain": BusinessDomain.TASKS, "source_record_id": str(task.id), "project_code": task.project_code, "owner": task.owner, "due_date": task.due_date, "detected_at": datetime.utcnow(), "description": "任务已超过截止日期且未完成。", - "mitigation": "请负责人更新进度、明确阻塞项并给出新的完成时间。", + "mitigation": ( + "请负责人更新进度、明确阻塞项并给出新的完成时间。" + ), "evidence": serialize_model(task), } ) @@ -190,26 +206,28 @@ class RiskService: stmt = select(Project).where( Project.due_date.is_not(None), Project.due_date < date.today(), - Project.status.notin_(["验收", "已完成", "复盘", "归档", "关闭", "closed"]), + Project.status.notin_(PROJECT_CLOSED_STATUSES), ) payloads = [] for project in self.db.execute(stmt).scalars(): - level = "high" if project.progress_percent < 80 else "medium" + level = RiskLevel.HIGH if project.progress_percent < 80 else RiskLevel.MEDIUM payloads.append( { "code": f"RISK-PROJECT-DELAY-{project.id}", "title": f"项目延期:{project.name}", - "risk_type": "delayed_project", + "risk_type": RiskEventType.DELAYED_PROJECT, "risk_level": level, - "status": "open", - "source_domain": "projects", + "status": StatusValue.OPEN, + "source_domain": BusinessDomain.PROJECTS, "source_record_id": str(project.id), "project_code": project.code, "owner": project.owner, "due_date": project.due_date, "detected_at": datetime.utcnow(), "description": "项目已超过计划截止日期且未进入完成状态。", - "mitigation": "请项目负责人提交延期原因、资源需求和纠偏计划。", + "mitigation": ( + "请项目负责人提交延期原因、资源需求和纠偏计划。" + ), "evidence": serialize_model(project), } ) @@ -226,17 +244,19 @@ class RiskService: { "code": f"RISK-PROJECT-BUDGET-{project.id}", "title": f"项目超预算:{project.name}", - "risk_type": "over_budget_project", - "risk_level": "high", - "status": "open", - "source_domain": "projects", + "risk_type": RiskEventType.OVER_BUDGET_PROJECT, + "risk_level": RiskLevel.HIGH, + "status": StatusValue.OPEN, + "source_domain": BusinessDomain.PROJECTS, "source_record_id": str(project.id), "project_code": project.code, "owner": project.owner, "due_date": project.due_date, "detected_at": datetime.utcnow(), "description": "项目实际成本已超过预算。", - "mitigation": "请复核预算科目、冻结非必要采购并补充审批依据。", + "mitigation": ( + "请复核预算科目、冻结非必要采购并补充审批依据。" + ), "evidence": serialize_model(project), } ) @@ -250,15 +270,18 @@ class RiskService: { "code": f"RISK-FUND-{account.id}", "title": f"资金低于安全线:{account.name}", - "risk_type": "fund_safety_line", - "risk_level": "high", - "status": "open", - "source_domain": "fund-accounts", + "risk_type": RiskEventType.FUND_SAFETY_LINE, + "risk_level": RiskLevel.HIGH, + "status": StatusValue.OPEN, + "source_domain": BusinessDomain.FUND_ACCOUNTS, "source_record_id": str(account.id), "owner": None, "detected_at": datetime.utcnow(), "description": "账户当前余额低于设置的安全线。", - "mitigation": "请财务确认收付款计划,并优先处理关键项目资金安排。", + "mitigation": ( + "请财务确认收付款计划," + "并优先处理关键项目资金安排。" + ), "evidence": serialize_model(account), } ) @@ -266,25 +289,31 @@ class RiskService: def _supplier_risk_payloads(self) -> list[dict[str, Any]]: stmt = select(Supplier).where( - (Supplier.blacklist_status != "normal") - | Supplier.risk_level.in_(["medium", "high"]) + (Supplier.blacklist_status != StatusValue.NORMAL) + | Supplier.risk_level.in_(SUPPLIER_RISK_LEVELS) ) payloads = [] for supplier in self.db.execute(stmt).scalars(): - level = "high" if supplier.blacklist_status != "normal" else supplier.risk_level + level = ( + RiskLevel.HIGH + if supplier.blacklist_status != StatusValue.NORMAL + else supplier.risk_level + ) payloads.append( { "code": f"RISK-SUPPLIER-{supplier.id}", "title": f"供应商风险:{supplier.name}", - "risk_type": "supplier_risk", + "risk_type": RiskEventType.SUPPLIER_RISK, "risk_level": level, - "status": "open", - "source_domain": "suppliers", + "status": StatusValue.OPEN, + "source_domain": BusinessDomain.SUPPLIERS, "source_record_id": str(supplier.id), "owner": supplier.contact, "detected_at": datetime.utcnow(), "description": "供应商风险等级或黑名单状态需要关注。", - "mitigation": "请采购负责人复核供应商准入、履约和替代方案。", + "mitigation": ( + "请采购负责人复核供应商准入、履约和替代方案。" + ), "evidence": serialize_model(supplier), } ) diff --git a/tests/test_ai_adapters.py b/tests/test_ai_adapters.py index 28b80c5..cee345c 100644 --- a/tests/test_ai_adapters.py +++ b/tests/test_ai_adapters.py @@ -2,6 +2,16 @@ from typing import Any from app.core.config import Settings from app.modules.ai_agent import adapters +from app.modules.ai_agent.constants import ( + OPENCLAW_HERMES_PIPELINE, + AIChatRole, + AIContextKey, + AIHttpHeader, + AIHttpPath, + AIHttpPayloadKey, + AIMemoryMode, + AIResponseKey, +) class DummyResponse: @@ -14,11 +24,26 @@ class DummyResponse: return self._data +def chat_response(content: str) -> DummyResponse: + return DummyResponse( + { + AIHttpPayloadKey.CHOICES: [ + { + AIHttpPayloadKey.MESSAGE: { + AIHttpPayloadKey.CONTENT: content, + } + } + ] + } + ) + + class DummyClient: calls: list[dict[str, Any]] = [] - def __init__(self, timeout: int): + def __init__(self, timeout: int, trust_env: bool = True): self.timeout = timeout + self.trust_env = trust_env def __enter__(self) -> "DummyClient": return self @@ -32,16 +57,98 @@ class DummyClient: json: dict[str, Any], headers: dict[str, str], ) -> DummyResponse: - self.calls.append({"url": url, "json": json, "headers": headers}) - if url.endswith("/api/v1/agent/ask"): - assert json["context"]["hermes_memory"] == "remembered project preference" - return DummyResponse({"answer": "openclaw final answer", "tool_calls": 1}) - mode = json["context"]["mode"] - if mode == "memory_recall": - return DummyResponse({"answer": "remembered project preference"}) - if mode == "memory_write": - return DummyResponse({"answer": "stored"}) - raise AssertionError(f"unexpected Hermes mode: {mode}") + self.calls.append({"method": "POST", "url": url, "json": json, "headers": headers}) + if url.endswith(AIHttpPath.CHAT_COMPLETIONS): + user_content = json[AIHttpPayloadKey.MESSAGES][-1][AIHttpPayloadKey.CONTENT] + if AIMemoryMode.RECALL in user_content: + return chat_response("remembered project preference") + if AIMemoryMode.WRITE in user_content: + return chat_response("stored") + if AIContextKey.AGENT_PIPELINE in user_content: + return chat_response("hermes final answer") + return chat_response("hermes direct answer") + if url.endswith(AIHttpPath.TOOLS_INVOKE): + return DummyResponse( + { + AIResponseKey.OK: True, + AIHttpPayloadKey.TOOL: json[AIHttpPayloadKey.TOOL], + AIHttpPayloadKey.ACTION: json[AIHttpPayloadKey.ACTION], + AIHttpPayloadKey.ARGS: json[AIHttpPayloadKey.ARGS], + AIHttpPayloadKey.SESSION_KEY: json[AIHttpPayloadKey.SESSION_KEY], + } + ) + raise AssertionError(f"unexpected URL: {url}") + + def get(self, url: str, headers: dict[str, str]) -> DummyResponse: + self.calls.append({"method": "GET", "url": url, "headers": headers}) + if url.endswith(AIHttpPath.HEALTHZ): + return DummyResponse({"status": "ok"}) + if url.endswith(AIHttpPath.READYZ): + return DummyResponse({"status": "ready"}) + if url.endswith(AIHttpPath.HEALTH): + return DummyResponse({"status": "ok", "platform": "hermes-agent"}) + raise AssertionError(f"unexpected URL: {url}") + + +def test_hermes_adapter_uses_openai_chat_completions(monkeypatch) -> None: + DummyClient.calls = [] + monkeypatch.setattr(adapters.httpx, "Client", DummyClient) + settings = Settings( + hermes_base_url="http://hermes.local/v1", + hermes_api_key="hermes-key", + hermes_model="hermes-agent", + hermes_session_id="company-ai-platform-test", + ) + + result = adapters.HermesAdapter(settings).ask( + "summarize project risk", + {"project_code": "P-001"}, + ) + + assert result[AIResponseKey.ANSWER] == "hermes direct answer" + assert DummyClient.calls[0]["url"] == "http://hermes.local/v1/chat/completions" + assert DummyClient.calls[0]["headers"][AIHttpHeader.AUTHORIZATION] == "Bearer hermes-key" + assert ( + DummyClient.calls[0]["headers"][AIHttpHeader.HERMES_SESSION_ID] + == "company-ai-platform-test" + ) + assert DummyClient.calls[0]["json"][AIHttpPayloadKey.MODEL] == "hermes-agent" + assert ( + DummyClient.calls[0]["json"][AIHttpPayloadKey.MESSAGES][0][AIHttpPayloadKey.ROLE] + == AIChatRole.SYSTEM + ) + assert "P-001" in DummyClient.calls[0]["json"][AIHttpPayloadKey.MESSAGES][1][ + AIHttpPayloadKey.CONTENT + ] + + +def test_openclaw_adapter_uses_gateway_health_and_tool_invoke(monkeypatch) -> None: + DummyClient.calls = [] + monkeypatch.setattr(adapters.httpx, "Client", DummyClient) + settings = Settings( + openclaw_http_url="http://openclaw.local", + openclaw_gateway_token="gateway-token", + ) + + health = adapters.OpenClawAdapter(settings).health() + result = adapters.OpenClawAdapter(settings).ask( + "list sessions", + { + AIContextKey.OPENCLAW_TOOL: "sessions_list", + AIContextKey.OPENCLAW_ARGS: {}, + AIContextKey.OPENCLAW_SESSION_KEY: "main", + }, + ) + + assert health[AIResponseKey.OK] is True + assert result[AIResponseKey.RAW][AIResponseKey.DATA][AIHttpPayloadKey.TOOL] == "sessions_list" + assert [call["url"] for call in DummyClient.calls] == [ + "http://openclaw.local/healthz", + "http://openclaw.local/readyz", + "http://openclaw.local/tools/invoke", + ] + assert DummyClient.calls[0]["headers"][AIHttpHeader.AUTHORIZATION] == "Bearer gateway-token" + assert DummyClient.calls[2]["json"][AIHttpPayloadKey.SESSION_KEY] == "main" def test_openclaw_hermes_adapter_runs_recall_answer_and_remember(monkeypatch) -> None: @@ -49,24 +156,34 @@ def test_openclaw_hermes_adapter_runs_recall_answer_and_remember(monkeypatch) -> monkeypatch.setattr(adapters.httpx, "Client", DummyClient) settings = Settings( model_provider="openclaw_hermes", - openclaw_base_url="http://openclaw.local", - openclaw_api_key="openclaw-key", - hermes_base_url="http://hermes.local", + openclaw_http_url="http://openclaw.local", + openclaw_gateway_token="openclaw-key", + hermes_base_url="http://hermes.local/v1", hermes_api_key="hermes-key", ) result = adapters.OpenClawHermesAdapter(settings).ask( "summarize project risk", - {"project_code": "P-001"}, + { + "project_code": "P-001", + AIContextKey.OPENCLAW_TOOL: "sessions_list", + AIContextKey.OPENCLAW_ARGS: {}, + }, ) - assert result["answer"] == "openclaw final answer" - assert result["raw"]["pipeline"] == "hermes_recall -> openclaw_answer -> hermes_remember" + assert result[AIResponseKey.ANSWER] == "hermes final answer" + assert result[AIResponseKey.RAW][AIResponseKey.PIPELINE] == OPENCLAW_HERMES_PIPELINE assert [call["url"] for call in DummyClient.calls] == [ - "http://hermes.local/api/v1/ask", - "http://openclaw.local/api/v1/agent/ask", - "http://hermes.local/api/v1/ask", + "http://hermes.local/v1/chat/completions", + "http://openclaw.local/healthz", + "http://openclaw.local/readyz", + "http://openclaw.local/tools/invoke", + "http://hermes.local/v1/chat/completions", + "http://hermes.local/v1/chat/completions", + ] + assert DummyClient.calls[0]["headers"][AIHttpHeader.AUTHORIZATION] == "Bearer hermes-key" + assert DummyClient.calls[1]["headers"][AIHttpHeader.AUTHORIZATION] == "Bearer openclaw-key" + assert DummyClient.calls[3]["json"][AIHttpPayloadKey.TOOL] == "sessions_list" + assert AIContextKey.OPENCLAW in DummyClient.calls[4]["json"][AIHttpPayloadKey.MESSAGES][1][ + AIHttpPayloadKey.CONTENT ] - assert DummyClient.calls[0]["headers"]["Authorization"] == "Bearer hermes-key" - assert DummyClient.calls[1]["headers"]["Authorization"] == "Bearer openclaw-key" - assert DummyClient.calls[1]["json"]["context"]["agent_pipeline"] == "openclaw_hermes" diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 221c15a..1d3f9af 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -4,6 +4,9 @@ import tempfile from datetime import date, timedelta from pathlib import Path +from app.modules.ai_agent.constants import AIProviderName, AIResponseKey +from app.modules.business.constants import StatusValue + _db = tempfile.NamedTemporaryFile(delete=False, suffix=".db") _db.close() @@ -11,13 +14,21 @@ os.environ["DATABASE_URL"] = "sqlite:///" + _db.name.replace("\\", "/") os.environ["API_KEY"] = "test-key" os.environ["FEISHU_APP_ID"] = "" os.environ["FEISHU_APP_SECRET"] = "" -os.environ["MODEL_PROVIDER"] = "noop" +os.environ["MODEL_PROVIDER"] = AIProviderName.NOOP os.environ["SCHEDULER_ENABLED"] = "false" from fastapi.testclient import TestClient from app.core.database import Base, engine from app.main import app +from app.modules.reports.constants import ( + LifecycleAttentionKey, + LifecycleResponseKey, + LifecycleSection, + MetricKey, + ReportTitle, + ReportType, +) Base.metadata.create_all(bind=engine) @@ -203,10 +214,10 @@ def test_new_ledgers_reports_and_risk_events() -> None: report_response = client.post( "/api/v1/reports/work-reports/generate", headers=headers, - json={"report_type": "daily", "reporter": "pytest", "actor": "pytest"}, + json={"report_type": ReportType.DAILY, "reporter": "pytest", "actor": "pytest"}, ) assert report_response.status_code == 200 - assert report_response.json()["data"]["report_type"] == "daily" + assert report_response.json()["data"]["report_type"] == ReportType.DAILY risk_response = client.post( "/api/v1/risks/events/generate?actor=pytest", @@ -220,11 +231,155 @@ def test_new_ledgers_reports_and_risk_events() -> None: assert any(item["risk_type"] == "overdue_task" for item in events_response.json()["items"]) +def test_project_lifecycle_report_summarizes_progress_cost_and_risk() -> None: + today = date.today() + project_code = "P-LIFECYCLE-001" + + project_response = client.post( + "/api/v1/business/projects", + headers=headers, + json={ + "actor": "pytest", + "data": { + "code": project_code, + "name": "Lifecycle Project", + "owner": "lifecycle-owner", + "status": "执行中", + "progress_percent": 40, + "budget_amount": 1000, + "actual_amount": 1500, + "due_date": (today - timedelta(days=1)).isoformat(), + }, + }, + ) + assert project_response.status_code == 200 + + task_response = client.post( + "/api/v1/business/tasks", + headers=headers, + json={ + "actor": "pytest", + "data": { + "code": "TASK-LIFECYCLE-001", + "title": "Lifecycle overdue task", + "project_code": project_code, + "owner": "lifecycle-owner", + "status": "待办", + "due_date": (today - timedelta(days=1)).isoformat(), + "blocker": "waiting for decision", + }, + }, + ) + assert task_response.status_code == 200 + + procurement_response = client.post( + "/api/v1/business/procurements", + headers=headers, + json={ + "actor": "pytest", + "data": { + "code": "PROC-LIFECYCLE-001", + "name": "Lifecycle procurement", + "project_code": project_code, + "expected_amount": 300, + "actual_amount": 100, + "approval_status": StatusValue.PENDING_APPROVAL, + "delivery_status": StatusValue.UNDELIVERED, + "payment_status": StatusValue.UNPAID, + }, + }, + ) + assert procurement_response.status_code == 200 + + expense_response = client.post( + "/api/v1/business/expenses", + headers=headers, + json={ + "actor": "pytest", + "data": { + "code": "EXP-LIFECYCLE-001", + "expense_type": "差旅", + "amount": 80, + "project_code": project_code, + "approval_status": StatusValue.PENDING_APPROVAL, + "payment_status": StatusValue.UNPAID, + }, + }, + ) + assert expense_response.status_code == 200 + + attendance_response = client.post( + "/api/v1/business/attendance-records", + headers=headers, + json={ + "actor": "pytest", + "data": { + "code": "ATT-LIFECYCLE-001", + "employee_name": "Lifecycle Tester", + "project_code": project_code, + "work_date": today.isoformat(), + "status": StatusValue.MISSING_PUNCH, + }, + }, + ) + assert attendance_response.status_code == 200 + + response = client.get( + f"/api/v1/reports/project-lifecycle?project_code={project_code}", + headers=headers, + ) + assert response.status_code == 200 + data = response.json() + assert data[LifecycleResponseKey.TITLE] == ReportTitle.PROJECT_LIFECYCLE + assert data[LifecycleResponseKey.METRICS][LifecycleSection.PROJECTS][MetricKey.TOTAL] == 1 + assert data[LifecycleResponseKey.METRICS][LifecycleSection.PROJECTS][MetricKey.DELAYED] == 1 + assert ( + data[LifecycleResponseKey.METRICS][LifecycleSection.PROJECTS][MetricKey.OVER_BUDGET] + == 1 + ) + assert data[LifecycleResponseKey.METRICS][LifecycleSection.TASKS][MetricKey.OVERDUE] == 1 + assert ( + data[LifecycleResponseKey.METRICS][LifecycleSection.PROCUREMENTS][ + MetricKey.PENDING_APPROVAL + ] + == 1 + ) + assert ( + data[LifecycleResponseKey.METRICS][LifecycleSection.EXPENSES][ + MetricKey.PENDING_APPROVAL + ] + == 1 + ) + assert data[LifecycleResponseKey.METRICS][LifecycleSection.ATTENDANCE][MetricKey.ABNORMAL] == 1 + assert ( + data[LifecycleResponseKey.ATTENTION][LifecycleAttentionKey.DELAYED_PROJECTS][0]["code"] + == project_code + ) + assert "生命周期健康分" in data[LifecycleResponseKey.CONTENT] + assert data[LifecycleResponseKey.RECOMMENDATIONS] + + ai_response = client.get( + f"/api/v1/reports/project-lifecycle?project_code={project_code}&include_ai=true", + headers=headers, + ) + assert ai_response.status_code == 200 + ai_data = ai_response.json() + assert ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.OK] is True + assert ( + ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.PROVIDER] + == AIProviderName.NOOP + ) + + def test_ai_noop_provider() -> None: response = client.post( "/api/v1/ai/ask", headers=headers, - json={"prompt": "生成项目摘要", "actor": "pytest", "context": {"project": "P-SMOKE-001"}}, + json={ + "prompt": "生成项目摘要", + "actor": "pytest", + "context": {"project": "P-SMOKE-001"}, + }, ) assert response.status_code == 200 - assert response.json()["provider"] == "noop" + assert response.json()[AIResponseKey.PROVIDER] == AIProviderName.NOOP