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:
2026-07-06 00:02:03 +08:00
parent d82116d637
commit aa81fc5321
36 changed files with 2328 additions and 337 deletions

View File

@@ -3,6 +3,8 @@ from functools import lru_cache
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
DEFAULT_MODEL_PROVIDER = "noop"
class Settings(BaseSettings):
"""Runtime settings loaded from environment variables and `.env`."""
@@ -29,11 +31,16 @@ class Settings(BaseSettings):
feishu_encrypt_key: str | None = None
feishu_default_chat_id: str | None = None
model_provider: str = "noop"
openclaw_base_url: str = "http://127.0.0.1:18789"
model_provider: str = DEFAULT_MODEL_PROVIDER
openclaw_base_url: str = "http://127.0.0.1:2070"
openclaw_http_url: str | None = None
openclaw_ws_url: str | None = None
openclaw_api_key: str | None = None
hermes_base_url: str = "http://127.0.0.1:8080"
openclaw_gateway_token: str | None = None
hermes_base_url: str = "http://127.0.0.1:2073/v1"
hermes_api_key: str | None = None
hermes_model: str = "hermes-agent"
hermes_session_id: str | None = None
direct_llm_base_url: str = "https://api.openai.com/v1"
direct_llm_api_key: str | None = None
direct_llm_model: str = "gpt-4.1-mini"

15
app/core/constants.py Normal file
View File

@@ -0,0 +1,15 @@
from enum import StrEnum
class ActorValue(StrEnum):
API = "api"
SYSTEM = "system"
SCHEDULER = "scheduler"
FEISHU = "feishu"
class HttpHeader(StrEnum):
AUTHORIZATION = "Authorization"
BEARER_TOKEN_TEMPLATE = "Bearer {token}"

View File

@@ -1,6 +1,8 @@
from fastapi import FastAPI
from app.core.constants import ActorValue
from app.core.config import get_settings
from app.modules.feishu.constants import FeishuReceiveIdType
def attach_scheduler(app: FastAPI) -> None:
@@ -30,8 +32,8 @@ def attach_scheduler(app: FastAPI) -> None:
ReportService(db).push_report(
report,
receive_id=settings.feishu_default_chat_id,
receive_id_type="chat_id",
actor="scheduler",
receive_id_type=FeishuReceiveIdType.CHAT_ID,
actor=ActorValue.SCHEDULER,
)
finally:
db.close()
@@ -49,8 +51,8 @@ def attach_scheduler(app: FastAPI) -> None:
ReportService(db).push_report(
report,
receive_id=settings.feishu_default_chat_id,
receive_id_type="chat_id",
actor="scheduler",
receive_id_type=FeishuReceiveIdType.CHAT_ID,
actor=ActorValue.SCHEDULER,
)
finally:
db.close()

View File

@@ -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("/")

View 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"

View File

@@ -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(

View File

@@ -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

View File

@@ -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")

View 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]

View File

@@ -0,0 +1,12 @@
from enum import StrEnum
class ApprovalStatus(StrEnum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class ApprovalActionValue(StrEnum):
UPDATE = "update"
WILDCARD = "*"

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,
}

View 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"

View File

@@ -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)

View File

@@ -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):

View 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})

View File

@@ -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)

View File

@@ -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.",

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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",

View 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

View File

@@ -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}

View File

@@ -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

View File

@@ -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 "暂无数据",
},
}
],
}

View File

@@ -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):

View File

@@ -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,

View 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 = (
"当前生命周期指标稳定,建议继续保持周度复盘和风险事件归档。"
)

View File

@@ -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,

View File

@@ -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

View File

@@ -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(

View File

@@ -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)

View File

@@ -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),
}
)