```
feat(ai_agent): 完善AI适配器和服务功能 - 添加OpenClaw和Hermes健康检查接口 - 实现OpenClaw工具调用功能 - 重构AI适配器使用常量定义 - 增加AI技能系统支持 - 更新配置文件中的默认模型提供者设置 refactor(scheduler): 使用常量替换硬编码值 - 将硬编码的actor值替换为ActorValue常量 - 将receive_id_type替换为FeishuReceiveIdType枚举 refactor(audit): 统一审计日志常量使用 - 将硬编码的actor、source、risk_level等值替换为对应常量 - 更新审核服务中的状态和操作常量引用 refactor(approvals): 标准化审批模块常量使用 - 将applicant默认值替换为ActorValue.API常量 - 使用ApprovalStatus常量替代硬编码状态值 - 更新审核操作常量引用 ```
This commit is contained in:
@@ -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("/")
|
||||
|
||||
128
app/modules/ai_agent/constants.py
Normal file
128
app/modules/ai_agent/constants.py
Normal file
@@ -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"
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
101
app/modules/ai_agent/skills.py
Normal file
101
app/modules/ai_agent/skills.py
Normal file
@@ -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]
|
||||
12
app/modules/approvals/constants.py
Normal file
12
app/modules/approvals/constants.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class ApprovalStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class ApprovalActionValue(StrEnum):
|
||||
UPDATE = "update"
|
||||
WILDCARD = "*"
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
39
app/modules/audit/constants.py
Normal file
39
app/modules/audit/constants.py
Normal file
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
117
app/modules/business/constants.py
Normal file
117
app/modules/business/constants.py
Normal file
@@ -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})
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
40
app/modules/feishu/constants.py
Normal file
40
app/modules/feishu/constants.py
Normal file
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 "暂无数据",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
133
app/modules/reports/constants.py
Normal file
133
app/modules/reports/constants.py
Normal file
@@ -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 = (
|
||||
"当前生命周期指标稳定,建议继续保持周度复盘和风险事件归档。"
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user