feat(ai_agent): 新增openclaw_hermes混合AI适配器

新增OpenClawHermesAdapter适配器,结合Hermes记忆功能和OpenClaw执行能力,
实现AI问答流程中的记忆召回、执行操作和记忆存储的完整闭环。
同时更新NoopAdapter提示信息,添加新的模型提供商选项。

feat(business): 新增考勤、工作报告和风险事件业务模型

新增AttendanceRecord、WorkReport、RiskEvent和LegacySyncRun四个业务模型,
扩展业务领域注册表,支持考勤管理、工作报告生成和风险事件跟踪等核心业务功能。

feat(reports): 实现考勤汇总和工作日报周报生成功能

新增attendance_summary方法用于统计每日考勤情况,
新增generate_work_report方法用于生成日/周经营报告,
包含任务完成情况、待处理事项和风险指标等综合信息。

feat(risk): 扩展风险管理API端点和供应商风险检测

新增供应商风险查询端点和风险事件管理端点,
提供风险事件列表查询和自动生成功能,
增强供应商风险评估能力。

feat(feishu): 添加考勤查询命令和风险摘要增强

集成考勤汇总查询功能到飞书命令系统,
在风险摘要中添加供应商风险和开放风险事件统计,
丰富日常经营管理信息展示。

refactor(service): 优化业务服务数据验证和类型转换

重构_model_payload函数实现数据验证和类型转换,
添加列值类型强制转换逻辑,提高API数据处理的准确性和安全性。

build(deps): 添加postgresql数据库驱动依赖

在Dockerfile中添加psycopg[binary]==3.2.3依赖包,
支持PostgreSQL数据库连接和操作。

chore(config): 更新.gitignore文件排除备份和迁移目录

在.gitignore中添加AGENTS.md.bak-*和migration/目录排除规则,
避免备份文件和本地迁移工作区被提交到版本控制系统。
```
This commit is contained in:
2026-06-24 10:30:59 +08:00
parent 09933b15ad
commit d82116d637
22 changed files with 953 additions and 81 deletions

4
.gitignore vendored
View File

@@ -9,3 +9,7 @@ __pycache__/
/read.md
/README.md
/AGENTS.md
/AGENTS.md.bak-*
# Local migration workspace
/migration/

View File

@@ -11,6 +11,7 @@ RUN pip install --no-cache-dir \
"uvicorn[standard]==0.34.0" \
sqlalchemy==2.0.36 \
pymysql==1.1.1 \
"psycopg[binary]==3.2.3" \
pydantic-settings==2.7.1 \
python-dotenv==1.0.1 \
httpx==0.28.1 \

View File

@@ -26,7 +26,8 @@ class NoopAdapter(AIAdapter):
return {
"answer": (
"AI provider is not configured yet. This is a deterministic placeholder. "
"Set MODEL_PROVIDER to openclaw, hermes, or direct_llm after credentials are ready."
"Set MODEL_PROVIDER to openclaw_hermes, openclaw, hermes, or direct_llm "
"after credentials are ready."
),
"raw": {"prompt": prompt, "context": context or {}},
}
@@ -76,6 +77,78 @@ class HermesAdapter(AIAdapter):
return {"answer": data.get("answer") or data.get("content") or str(data), "raw": data}
class OpenClawHermesAdapter(AIAdapter):
"""Compose Hermes memory with OpenClaw execution."""
provider_name = "openclaw_hermes"
def __init__(self, settings: Settings):
self.openclaw = OpenClawAdapter(settings)
self.hermes = HermesAdapter(settings)
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 = {
**base_context,
"agent_pipeline": self.provider_name,
"hermes_memory": recall["answer"],
}
openclaw_result = self.openclaw.ask(prompt, openclaw_context)
remember = self._remember_interaction(prompt, base_context, openclaw_result["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,
},
}
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."
)
try:
result = self.hermes.ask(
recall_prompt,
{
"mode": "memory_recall",
"user_prompt": prompt,
"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", {})}
def _remember_interaction(
self,
prompt: str,
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."
)
try:
result = self.hermes.ask(
remember_prompt,
{
"mode": "memory_write",
"user_prompt": prompt,
"request_context": context,
"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"]}
class DirectLLMAdapter(AIAdapter):
"""Adapter for OpenAI-compatible chat completions APIs."""
@@ -118,6 +191,14 @@ def get_adapter() -> AIAdapter:
return OpenClawAdapter(settings)
if provider == "hermes":
return HermesAdapter(settings)
if provider in {"openclaw_hermes", "openclaw-hermes", "hybrid"}:
return OpenClawHermesAdapter(settings)
if provider == "direct_llm":
return DirectLLMAdapter(settings)
return NoopAdapter()
def _error_detail(exc: Exception) -> Any:
if isinstance(exc, HTTPException):
return exc.detail
return {"type": type(exc).__name__, "message": str(exc)}

View File

@@ -1,7 +1,7 @@
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, Integer, Numeric, String, Text
from sqlalchemy import JSON, Date, DateTime, Integer, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
@@ -157,3 +157,78 @@ class Supplier(Base, TimestampMixin):
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)
class AttendanceRecord(Base, TimestampMixin):
__tablename__ = "attendance_records"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
employee_name: Mapped[str] = mapped_column(String(128), index=True)
employee_id: Mapped[str | None] = mapped_column(String(128), nullable=True, 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)
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)
location: Mapped[str | None] = mapped_column(String(255), nullable=True)
source_system: Mapped[str] = mapped_column(String(64), default="internal")
external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
class WorkReport(Base, TimestampMixin):
__tablename__ = "work_reports"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
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)
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)
period_end: Mapped[date] = mapped_column(Date, index=True)
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")
class RiskEvent(Base, TimestampMixin):
__tablename__ = "risk_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
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)
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)
owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
detected_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
mitigation: Mapped[str | None] = mapped_column(Text, nullable=True)
evidence: Mapped[dict | None] = mapped_column(JSON, nullable=True)
class LegacySyncRun(Base, TimestampMixin):
__tablename__ = "legacy_sync_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
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)
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)
updated_count: Mapped[int] = mapped_column(Integer, default=0)
skipped_count: Mapped[int] = mapped_column(Integer, default=0)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -13,9 +13,25 @@ DOMAIN_MODELS: dict[str, type[DeclarativeMeta]] = {
"standards": models.Standard,
"performance-metrics": models.PerformanceMetric,
"suppliers": models.Supplier,
"attendance-records": models.AttendanceRecord,
"work-reports": models.WorkReport,
"risk-events": models.RiskEvent,
"legacy-sync-runs": models.LegacySyncRun,
}
LOW_RISK_DOMAINS = {"projects", "tasks", "procurements", "expenses", "policies", "standards"}
LOW_RISK_DOMAINS = {
"projects",
"tasks",
"procurements",
"expenses",
"policies",
"standards",
"suppliers",
"attendance-records",
"work-reports",
"risk-events",
"legacy-sync-runs",
}
HIGH_RISK_DOMAINS = {"fund-accounts", "performance-metrics"}

View File

@@ -1,9 +1,13 @@
from datetime import date, datetime
from decimal import Decimal
from decimal import Decimal, InvalidOperation
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import Date as SQLDate
from sqlalchemy import DateTime as SQLDateTime
from sqlalchemy import Numeric as SQLNumeric
from sqlalchemy import Select, func, select
from sqlalchemy.sql.schema import Column
from sqlalchemy.orm import Session
from app.modules.audit.schemas import AuditLogCreate
@@ -27,6 +31,39 @@ def serialize_model(record: Any) -> dict[str, Any]:
return data
def _coerce_column_value(column: Column, value: Any) -> Any:
"""Coerce API JSON values into the Python type expected by a SQLAlchemy column."""
if value is None:
return None
if isinstance(column.type, SQLDateTime) and isinstance(value, str):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
if isinstance(column.type, SQLDate) and isinstance(value, str):
return date.fromisoformat(value)
if isinstance(column.type, SQLNumeric) and not isinstance(value, Decimal):
return Decimal(str(value))
return value
def _model_payload(model: Any, data: dict[str, Any]) -> dict[str, Any]:
"""Filter unknown keys and coerce values according to model column types."""
columns = {column.name: column for column in model.__table__.columns if column.name != "id"}
payload: dict[str, Any] = {}
for key, value in data.items():
column = columns.get(key)
if column is None:
continue
try:
payload[key] = _coerce_column_value(column, value)
except (ValueError, TypeError, InvalidOperation) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid value for field '{key}'",
) from exc
return payload
class BusinessService:
"""Manage generic CRUD operations across registered business domains."""
@@ -65,8 +102,7 @@ class BusinessService:
actor: str = "api",
) -> dict[str, Any]:
model = get_domain_model(domain)
allowed = {column.name for column in model.__table__.columns if column.name != "id"}
payload = {key: value for key, value in data.items() if key in allowed}
payload = _model_payload(model, data)
record = model(**payload)
self.db.add(record)
self.db.commit()
@@ -116,10 +152,8 @@ class BusinessService:
status_code=status.HTTP_404_NOT_FOUND,
detail="Record not found",
)
allowed = {column.name for column in model.__table__.columns if column.name != "id"}
for key, value in data.items():
if key in allowed:
setattr(record, key, value)
for key, value in _model_payload(model, data).items():
setattr(record, key, value)
self.db.commit()
self.db.refresh(record)
result = serialize_model(record)

View File

@@ -108,6 +108,25 @@ class FeishuCommandService:
result["provider_response"] = provider_response
return result
if any(keyword in command_text for keyword in ["打卡", "考勤", "attendance"]):
report = ReportService(self.db).attendance_summary()
result = {
"command": "attendance_summary",
"reply_type": "card",
"title": report["title"],
"content": report["content"],
"lines": report["lines"],
}
if auto_reply:
provider_response = self._send_card_if_configured(
chat_id,
report["title"],
report["lines"],
actor,
)
result["provider_response"] = provider_response
return result
if any(keyword in command_text for keyword in ["风险", "预警", "risk"]):
summary = RiskService(self.db).summary()
lines = [
@@ -117,6 +136,8 @@ class FeishuCommandService:
f"- 延期项目:{len(summary['delayed_projects'])}",
f"- 超预算项目:{len(summary['over_budget_projects'])}",
f"- 资金风险账户:{len(summary['fund_risks'])}",
f"- 供应商风险:{len(summary['supplier_risks'])}",
f"- 打开风险事件:{len(summary['open_events'])}",
]
result = {
"command": "risk_summary",

View File

@@ -51,4 +51,5 @@ class LegacyProjectSyncResult(BaseModel):
created: int
updated: int
skipped: int
sync_run_code: str | None = None
items: list[dict[str, Any]]

View File

@@ -13,7 +13,7 @@ from app.core.config import get_settings
from app.core.database import legacy_engine
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.business.models import Project
from app.modules.business.models import LegacySyncRun, Project
from app.modules.business.service import serialize_model
FORBIDDEN_SQL_TOKENS = {
@@ -256,6 +256,22 @@ class LegacyMySQLService:
"skipped": skipped,
"items": items,
}
sync_run = LegacySyncRun(
code=f"SYNC-PROJECTS-{datetime.utcnow():%Y%m%d%H%M%S%f}",
domain="projects",
source_table="LEGACY_PROJECT_QUERY",
status="dry_run" if dry_run else "success",
finished_at=datetime.utcnow(),
created_count=created,
updated_count=updated,
skipped_count=skipped,
note="Project sync from readonly legacy MySQL",
)
self.db.add(sync_run)
self.db.commit()
self.db.refresh(sync_run)
result["sync_run_code"] = sync_run.code
AuditService(self.db).log(
AuditLogCreate(
actor=actor,

View File

@@ -1,9 +1,15 @@
from datetime import date
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_api_key
from app.modules.reports.schemas import PushReportRequest, ReportResponse
from app.modules.reports.schemas import (
PushReportRequest,
ReportResponse,
WorkReportGenerateRequest,
)
from app.modules.reports.service import ReportService
router = APIRouter(dependencies=[Depends(require_api_key)])
@@ -19,6 +25,31 @@ def project_weekly(db: Session = Depends(get_db)) -> dict:
return ReportService(db).project_weekly()
@router.get("/attendance-summary")
def attendance_summary(
work_date: date | None = None,
db: Session = Depends(get_db),
) -> dict:
return ReportService(db).attendance_summary(work_date)
@router.post("/work-reports/generate")
def generate_work_report(
payload: WorkReportGenerateRequest,
db: Session = Depends(get_db),
) -> dict:
return ReportService(db).generate_work_report(
report_type=payload.report_type,
reporter=payload.reporter,
department=payload.department,
project_code=payload.project_code,
period_start=payload.period_start,
period_end=payload.period_end,
persist=payload.persist,
actor=payload.actor,
)
@router.post("/daily-brief/push")
def push_daily_brief(payload: PushReportRequest, db: Session = Depends(get_db)) -> dict:
report = ReportService(db).daily_brief()

View File

@@ -1,3 +1,6 @@
from datetime import date
from typing import Literal
from pydantic import BaseModel
@@ -11,3 +14,14 @@ class PushReportRequest(BaseModel):
receive_id: str | None = None
receive_id_type: str = "chat_id"
actor: str = "system"
class WorkReportGenerateRequest(BaseModel):
report_type: Literal["daily", "weekly"] = "daily"
reporter: str = "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"

View File

@@ -1,12 +1,30 @@
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.modules.business.models import Expense, FundAccount, Procurement, Project, WorkTask
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.business.models import (
AttendanceRecord,
Expense,
FundAccount,
Procurement,
Project,
RiskEvent,
WorkReport,
WorkTask,
)
from app.modules.business.service import serialize_model
from app.modules.feishu.service import FeishuService
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."""
@@ -15,6 +33,26 @@ def _money(value: Decimal | int | float | None) -> str:
return f"{amount:,.2f}"
def _json_safe(value: Any) -> Any:
"""Convert nested report payloads into JSON-storable values."""
if isinstance(value, Decimal):
return float(value)
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, dict):
return {key: _json_safe(item) for key, item in value.items()}
return value
def _next_code(prefix: str) -> str:
"""Build a compact unique code for generated report records."""
return f"{prefix}-{datetime.utcnow():%Y%m%d%H%M%S%f}"
class ReportService:
"""Build operational reports and push them through Feishu."""
@@ -22,61 +60,59 @@ class ReportService:
self.db = db
self.risks = RiskService(db)
def _count(self, model: type, *conditions: Any) -> int:
stmt = select(func.count()).select_from(model)
for condition in conditions:
stmt = stmt.where(condition)
return int(self.db.execute(stmt).scalar() or 0)
def daily_brief(self) -> dict:
project_count = int(
self.db.execute(select(func.count()).select_from(Project)).scalar() or 0
project_count = self._count(Project)
task_count = self._count(WorkTask)
procurement_pending = self._count(
Procurement,
Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES),
)
task_count = int(self.db.execute(select(func.count()).select_from(WorkTask)).scalar() or 0)
procurement_pending = int(
self.db.execute(
select(func.count())
.select_from(Procurement)
.where(Procurement.approval_status.in_(["草稿", "审批中", "待审批"]))
).scalar()
or 0
)
expense_pending = int(
self.db.execute(
select(func.count())
.select_from(Expense)
.where(Expense.approval_status.in_(["草稿", "审批中", "待审批"]))
).scalar()
or 0
expense_pending = self._count(
Expense,
Expense.approval_status.in_(PENDING_APPROVAL_STATUSES),
)
fund_total = (
self.db.execute(select(func.sum(FundAccount.current_balance))).scalar()
or Decimal("0")
)
risk_summary = self.risks.summary()
attendance = self.attendance_summary()
lines = [
f"- 项目总数:{project_count}",
f"- 任务总数:{task_count}",
f"- 待处理采购:{procurement_pending}",
f"- 待处理费用:{expense_pending}",
f"- 当前账户总余额:{_money(fund_total)}",
f"- 今日打卡记录:{attendance['total']},异常:{attendance['abnormal_total']}",
f"- 逾期任务:{len(risk_summary['overdue_tasks'])}",
f"- 延期项目:{len(risk_summary['delayed_projects'])}",
f"- 超预算项目:{len(risk_summary['over_budget_projects'])}",
f"- 资金风险账户:{len(risk_summary['fund_risks'])}",
f"- 供应商风险:{len(risk_summary['supplier_risks'])}",
f"- 打开风险事件:{len(risk_summary['open_events'])}",
f"- 综合风险等级:{risk_summary['risk_level']}",
]
return {"title": "每日经营晨报", "lines": lines, "content": "\n".join(lines)}
def project_weekly(self) -> dict:
active = int(
self.db.execute(
select(func.count())
.select_from(Project)
.where(Project.status.notin_(["验收", "已完成", "复盘", "归档", "关闭"]))
).scalar()
or 0
active = self._count(
Project,
Project.status.notin_(PROJECT_CLOSED_STATUSES),
)
delayed = self.risks.delayed_projects()
over_budget = self.risks.over_budget_projects()
open_risks = self.risks.list_events(status_filter="open")
lines = [
f"- 活跃项目:{active}",
f"- 延期项目:{len(delayed)}",
f"- 超预算项目:{len(over_budget)}",
f"- 打开风险事件:{len(open_risks)}",
"- 需要管理层关注:",
]
for item in delayed[:10]:
@@ -85,6 +121,184 @@ class ReportService:
lines.append(f" - 超预算:{item.get('code')} {item.get('name')}")
return {"title": "项目周报", "lines": lines, "content": "\n".join(lines)}
def attendance_summary(self, work_date: date | None = None) -> dict[str, Any]:
"""Summarize attendance records for one business day."""
target_date = work_date or date.today()
rows = self.db.execute(
select(AttendanceRecord.status, func.count())
.where(AttendanceRecord.work_date == target_date)
.group_by(AttendanceRecord.status)
).all()
status_counts = {str(status): int(count) for status, count in rows}
abnormal_total = sum(
count
for status, count in status_counts.items()
if status in {"迟到", "早退", "缺卡", "旷工", "异常"}
)
total = sum(status_counts.values())
lines = [
f"- 日期:{target_date.isoformat()}",
f"- 打卡记录:{total}",
f"- 异常记录:{abnormal_total}",
]
for status, count in sorted(status_counts.items()):
lines.append(f"- {status}{count}")
return {
"title": "打卡汇总",
"work_date": target_date.isoformat(),
"total": total,
"abnormal_total": abnormal_total,
"status_counts": status_counts,
"lines": lines,
"content": "\n".join(lines),
}
def generate_work_report(
self,
report_type: str = "daily",
reporter: str = "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",
) -> 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 "经营周报"
lines = self._work_report_lines(title, start, end, metrics, risk_summary)
report = {
"title": title,
"report_type": report_type,
"period_start": start.isoformat(),
"period_end": end.isoformat(),
"lines": lines,
"content": "\n".join(lines),
"metrics": metrics,
"risk_summary": risk_summary,
}
record_data = None
if persist:
record = WorkReport(
code=_next_code(f"REPORT-{report_type.upper()}"),
report_type=report_type,
title=title,
reporter=reporter,
department=department,
project_code=project_code,
period_start=start,
period_end=end,
content=report["content"],
metrics=metrics,
risk_summary=risk_summary,
)
self.db.add(record)
self.db.commit()
self.db.refresh(record)
record_data = serialize_model(record)
AuditService(self.db).log(
AuditLogCreate(
actor=actor,
source="reports",
action=f"generate_{report_type}_report",
target_type="work-reports",
target_id=str(record.id),
response_payload=record_data,
)
)
return {"report": report, "data": record_data}
def _resolve_period(
self,
report_type: str,
period_start: date | None,
period_end: date | None,
) -> tuple[date, date]:
today = date.today()
if report_type == "daily":
start = period_start or period_end or today
return start, period_end or start
end = period_end or today
start = period_start or end - timedelta(days=6)
return start, end
def _report_metrics(
self,
start: date,
end: date,
project_code: str | None,
department: str | None,
) -> dict[str, Any]:
task_filters = [
WorkTask.due_date.is_not(None),
WorkTask.due_date >= start,
WorkTask.due_date <= end,
]
procurement_filters = [Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES)]
expense_filters = [Expense.approval_status.in_(PENDING_APPROVAL_STATUSES)]
attendance_filters = [
AttendanceRecord.work_date >= start,
AttendanceRecord.work_date <= end,
]
if project_code:
task_filters.append(WorkTask.project_code == project_code)
procurement_filters.append(Procurement.project_code == project_code)
expense_filters.append(Expense.project_code == project_code)
attendance_filters.append(AttendanceRecord.project_code == project_code)
if department:
expense_filters.append(Expense.department == department)
attendance_filters.append(AttendanceRecord.department == department)
completed_tasks = self._count(WorkTask, WorkTask.status.in_(DONE_STATUSES), *task_filters)
overdue_tasks = self._count(
WorkTask,
WorkTask.due_date < date.today(),
WorkTask.status.notin_(DONE_STATUSES),
*task_filters,
)
return {
"projects_total": self._count(Project),
"active_projects": self._count(
Project,
Project.status.notin_(PROJECT_CLOSED_STATUSES),
),
"tasks_total": self._count(WorkTask, *task_filters),
"tasks_completed": completed_tasks,
"tasks_overdue": overdue_tasks,
"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"),
}
def _work_report_lines(
self,
title: str,
start: date,
end: date,
metrics: dict[str, Any],
risk_summary: dict[str, Any],
) -> list[str]:
return [
f"- 报告:{title}",
f"- 周期:{start.isoformat()}{end.isoformat()}",
f"- 项目:总数 {metrics['projects_total']},活跃 {metrics['active_projects']}",
f"- 任务:总数 {metrics['tasks_total']},完成 {metrics['tasks_completed']}",
f"- 逾期任务:{metrics['tasks_overdue']}",
f"- 待处理采购:{metrics['procurements_pending']}",
f"- 待处理费用:{metrics['expenses_pending']}",
f"- 打卡记录:{metrics['attendance_total']}",
f"- 打开风险事件:{metrics['open_risk_events']}",
f"- 综合风险等级:{risk_summary['risk_level']}",
]
def push_report(
self,
report: dict,

View File

@@ -31,3 +31,22 @@ def over_budget_projects(db: Session = Depends(get_db)) -> dict:
@router.get("/funds")
def fund_risks(db: Session = Depends(get_db)) -> dict:
return {"items": RiskService(db).fund_risks()}
@router.get("/suppliers")
def supplier_risks(db: Session = Depends(get_db)) -> dict:
return {"items": RiskService(db).supplier_risks()}
@router.get("/events")
def risk_events(
limit: int = 100,
status: str | None = None,
db: Session = Depends(get_db),
) -> dict:
return {"items": RiskService(db).list_events(limit=limit, status_filter=status)}
@router.post("/events/generate")
def generate_risk_events(actor: str = "api", db: Session = Depends(get_db)) -> dict:
return RiskService(db).generate_events(actor=actor)

View File

@@ -1,13 +1,18 @@
from datetime import date
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.business.models import FundAccount, Project, WorkTask
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
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."""
@@ -19,7 +24,7 @@ class RiskService:
stmt = select(WorkTask).where(
WorkTask.due_date.is_not(None),
WorkTask.due_date < date.today(),
WorkTask.status.notin_(["完成", "已完成", "关闭"]),
WorkTask.status.notin_(DONE_STATUSES),
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
@@ -27,7 +32,7 @@ class RiskService:
stmt = select(Project).where(
Project.due_date.is_not(None),
Project.due_date < date.today(),
Project.status.notin_(["验收", "已完成", "复盘", "归档", "关闭"]),
Project.status.notin_(["验收", "已完成", "复盘", "归档", "关闭", "closed"]),
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
@@ -42,16 +47,42 @@ class RiskService:
stmt = select(FundAccount).where(FundAccount.current_balance < FundAccount.safety_line)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def supplier_risks(self) -> list[dict[str, Any]]:
stmt = select(Supplier).where(
(Supplier.blacklist_status != "normal")
| Supplier.risk_level.in_(["medium", "high"])
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def list_events(
self,
limit: int = 100,
status_filter: str | None = None,
) -> list[dict[str, Any]]:
stmt = select(RiskEvent).order_by(RiskEvent.id.desc()).limit(min(limit, 500))
if status_filter:
stmt = (
select(RiskEvent)
.where(RiskEvent.status == status_filter)
.order_by(RiskEvent.id.desc())
.limit(min(limit, 500))
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def summary(self) -> dict[str, Any]:
overdue_tasks = self.overdue_tasks()
delayed_projects = self.delayed_projects()
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")
risk_score = (
len(overdue_tasks) * 1
+ len(delayed_projects) * 3
+ len(over_budget_projects) * 4
+ len(fund_risks) * 5
+ len(supplier_risks) * 3
+ len(open_events) * 2
)
if risk_score >= 15:
level = "high"
@@ -66,4 +97,195 @@ class RiskService:
"delayed_projects": delayed_projects,
"over_budget_projects": over_budget_projects,
"fund_risks": fund_risks,
"supplier_risks": supplier_risks,
"open_events": open_events,
}
def generate_events(self, actor: str = "api") -> dict[str, Any]:
"""Generate or refresh risk-event ledger entries from current signals."""
payloads = self._build_event_payloads()
created = 0
updated = 0
skipped = 0
items: list[dict[str, Any]] = []
for payload in payloads:
record = self.db.execute(
select(RiskEvent).where(RiskEvent.code == payload["code"])
).scalar_one_or_none()
if record is None:
record = RiskEvent(**payload)
self.db.add(record)
self.db.flush()
created += 1
action = "created"
elif record.status in CLOSED_RISK_STATUSES:
skipped += 1
items.append({"action": "skipped", "risk_event": serialize_model(record)})
continue
else:
for key, value in payload.items():
if key != "code":
setattr(record, key, value)
updated += 1
action = "updated"
items.append({"action": action, "risk_event": serialize_model(record)})
self.db.commit()
AuditService(self.db).log(
AuditLogCreate(
actor=actor,
source="risk",
action="generate_events",
target_type="risk-events",
risk_level="medium",
response_payload={
"created": created,
"updated": updated,
"skipped": skipped,
},
)
)
return {"created": created, "updated": updated, "skipped": skipped, "items": items}
def _build_event_payloads(self) -> list[dict[str, Any]]:
payloads: list[dict[str, Any]] = []
payloads.extend(self._overdue_task_payloads())
payloads.extend(self._delayed_project_payloads())
payloads.extend(self._over_budget_project_payloads())
payloads.extend(self._fund_risk_payloads())
payloads.extend(self._supplier_risk_payloads())
return payloads
def _overdue_task_payloads(self) -> list[dict[str, Any]]:
stmt = select(WorkTask).where(
WorkTask.due_date.is_not(None),
WorkTask.due_date < date.today(),
WorkTask.status.notin_(DONE_STATUSES),
)
payloads = []
for task in self.db.execute(stmt).scalars():
payloads.append(
{
"code": f"RISK-TASK-OVERDUE-{task.id}",
"title": f"任务逾期:{task.title}",
"risk_type": "overdue_task",
"risk_level": "medium",
"status": "open",
"source_domain": "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": "请负责人更新进度、明确阻塞项并给出新的完成时间。",
"evidence": serialize_model(task),
}
)
return payloads
def _delayed_project_payloads(self) -> list[dict[str, Any]]:
stmt = select(Project).where(
Project.due_date.is_not(None),
Project.due_date < date.today(),
Project.status.notin_(["验收", "已完成", "复盘", "归档", "关闭", "closed"]),
)
payloads = []
for project in self.db.execute(stmt).scalars():
level = "high" if project.progress_percent < 80 else "medium"
payloads.append(
{
"code": f"RISK-PROJECT-DELAY-{project.id}",
"title": f"项目延期:{project.name}",
"risk_type": "delayed_project",
"risk_level": level,
"status": "open",
"source_domain": "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": "请项目负责人提交延期原因、资源需求和纠偏计划。",
"evidence": serialize_model(project),
}
)
return payloads
def _over_budget_project_payloads(self) -> list[dict[str, Any]]:
stmt = select(Project).where(
Project.budget_amount > 0,
Project.actual_amount > Project.budget_amount,
)
payloads = []
for project in self.db.execute(stmt).scalars():
payloads.append(
{
"code": f"RISK-PROJECT-BUDGET-{project.id}",
"title": f"项目超预算:{project.name}",
"risk_type": "over_budget_project",
"risk_level": "high",
"status": "open",
"source_domain": "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": "请复核预算科目、冻结非必要采购并补充审批依据。",
"evidence": serialize_model(project),
}
)
return payloads
def _fund_risk_payloads(self) -> list[dict[str, Any]]:
stmt = select(FundAccount).where(FundAccount.current_balance < FundAccount.safety_line)
payloads = []
for account in self.db.execute(stmt).scalars():
payloads.append(
{
"code": f"RISK-FUND-{account.id}",
"title": f"资金低于安全线:{account.name}",
"risk_type": "fund_safety_line",
"risk_level": "high",
"status": "open",
"source_domain": "fund-accounts",
"source_record_id": str(account.id),
"owner": None,
"detected_at": datetime.utcnow(),
"description": "账户当前余额低于设置的安全线。",
"mitigation": "请财务确认收付款计划,并优先处理关键项目资金安排。",
"evidence": serialize_model(account),
}
)
return payloads
def _supplier_risk_payloads(self) -> list[dict[str, Any]]:
stmt = select(Supplier).where(
(Supplier.blacklist_status != "normal")
| Supplier.risk_level.in_(["medium", "high"])
)
payloads = []
for supplier in self.db.execute(stmt).scalars():
level = "high" if supplier.blacklist_status != "normal" else supplier.risk_level
payloads.append(
{
"code": f"RISK-SUPPLIER-{supplier.id}",
"title": f"供应商风险:{supplier.name}",
"risk_type": "supplier_risk",
"risk_level": level,
"status": "open",
"source_domain": "suppliers",
"source_record_id": str(supplier.id),
"owner": supplier.contact,
"detected_at": datetime.utcnow(),
"description": "供应商风险等级或黑名单状态需要关注。",
"mitigation": "请采购负责人复核供应商准入、履约和替代方案。",
"evidence": serialize_model(supplier),
}
)
return payloads

View File

@@ -2,14 +2,18 @@ from app.core.database import Base, engine
from app.modules.approvals.models import ApprovalRequest
from app.modules.audit.models import AuditLog
from app.modules.business.models import (
AttendanceRecord,
Expense,
FundAccount,
LegacySyncRun,
PerformanceMetric,
Policy,
Procurement,
Project,
RiskEvent,
Standard,
Supplier,
WorkReport,
WorkTask,
)
@@ -25,6 +29,10 @@ _MODELS = [
Standard,
PerformanceMetric,
Supplier,
AttendanceRecord,
WorkReport,
RiskEvent,
LegacySyncRun,
]

Binary file not shown.

View File

@@ -4,37 +4,13 @@ services:
env_file:
- .env
environment:
DATABASE_URL: mysql+pymysql://company_ai:company_ai_password@mysql:3306/company_ai?charset=utf8mb4
REDIS_URL: redis://redis:6379/0
ports:
- "8010:8010"
depends_on:
- mysql
- redis
mysql:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: company_ai
MYSQL_USER: company_ai
MYSQL_PASSWORD: company_ai_password
ports:
- "3307:3306"
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:7-alpine
ports:
- "6379:6379"
adminer:
image: adminer:4
ports:
- "8088:8080"
depends_on:
- mysql
volumes:
mysql_data:

View File

@@ -9,6 +9,7 @@ dependencies:
- uvicorn[standard]==0.34.0
- sqlalchemy==2.0.36
- pymysql==1.1.1
- psycopg[binary]==3.2.3
- pydantic-settings==2.7.1
- python-dotenv==1.0.1
- httpx==0.28.1

View File

@@ -1,14 +1,14 @@
INFO:__main__:Starting Feishu long connection client
INFO:httpx:HTTP Request: POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal "HTTP/1.1 200 OK"
INFO:httpx:HTTP Request: POST https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id "HTTP/1.1 200 OK"
INFO:__main__:Handled Feishu long connection event: {'ok': True, 'handled': True, 'result': {'command': 'risk_summary', 'reply_type': 'card', 'title': '<27><><EFBFBD><EFBFBD>Ԥ<EFBFBD><D4A4>', 'content': '- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low\n- <20><><EFBFBD>շ֣<D5B7>0\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\n- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\n- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0', 'lines': ['- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low', '- <20><><EFBFBD>շ֣<D5B7>0', '- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0', '- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0', '- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0', '- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0'], 'provider_response': {'code': 0, 'data': {'body': {'content': '{"title":"<22><><EFBFBD><EFBFBD>Ԥ<EFBFBD><D4A4>","elements":[[{"tag":"text","text":"- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low\\n- <20><><EFBFBD>շ֣<D5B7>0\\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0\\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\\n- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\\n- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0"}]]}'}, 'chat_id': 'oc_4ea0ed9fbfe4da2aca67cca9ee22cb36', 'create_time': '1782103535265', 'deleted': False, 'message_id': 'om_x100b6cbf902318acb15878ba4b86f90', 'msg_type': 'interactive', 'sender': {'id': 'cli_aab22b1674799bef', 'id_type': 'app_id', 'sender_type': 'app', 'tenant_key': '1b3485e1c8ea174f'}, 'update_time': '1782103535265', 'updated': False}, 'msg': 'success'}}}
INFO:httpx:HTTP Request: POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal "HTTP/1.1 200 OK"
INFO:httpx:HTTP Request: POST https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id "HTTP/1.1 200 OK"
INFO:__main__:Handled Feishu long connection event: {'ok': True, 'handled': True, 'result': {'command': 'risk_summary', 'reply_type': 'card', 'title': '<27><><EFBFBD><EFBFBD>Ԥ<EFBFBD><D4A4>', 'content': '- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low\n- <20><><EFBFBD>շ֣<D5B7>0\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\n- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\n- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0', 'lines': ['- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low', '- <20><><EFBFBD>շ֣<D5B7>0', '- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0', '- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0', '- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0', '- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0'], 'provider_response': {'code': 0, 'data': {'body': {'content': '{"title":"<22><><EFBFBD><EFBFBD>Ԥ<EFBFBD><D4A4>","elements":[[{"tag":"text","text":"- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low\\n- <20><><EFBFBD>շ֣<D5B7>0\\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0\\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\\n- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\\n- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0"}]]}'}, 'chat_id': 'oc_4ea0ed9fbfe4da2aca67cca9ee22cb36', 'create_time': '1782103549102', 'deleted': False, 'message_id': 'om_x100b6cbf91060cacb1fa837ac0932ee', 'msg_type': 'interactive', 'sender': {'id': 'cli_aab22b1674799bef', 'id_type': 'app_id', 'sender_type': 'app', 'tenant_key': '1b3485e1c8ea174f'}, 'update_time': '1782103549102', 'updated': False}, 'msg': 'success'}}}
INFO:httpx:HTTP Request: POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal "HTTP/1.1 200 OK"
INFO:httpx:HTTP Request: POST https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id "HTTP/1.1 200 OK"
INFO:__main__:Handled Feishu long connection event: {'ok': True, 'handled': True, 'result': {'command': 'fallback_ai', 'reply_type': 'text', 'title': 'AI <20>ظ<EFBFBD>', 'content': 'AI provider is not configured yet. This is a deterministic placeholder. Set MODEL_PROVIDER to openclaw, hermes, or direct_llm after credentials are ready.', 'provider_response': {'code': 0, 'data': {'body': {'content': '{"text":"AI provider is not configured yet. This is a deterministic placeholder. Set MODEL_PROVIDER to openclaw, hermes, or direct_llm after credentials are ready."}'}, 'chat_id': 'oc_4ea0ed9fbfe4da2aca67cca9ee22cb36', 'create_time': '1782103556285', 'deleted': False, 'message_id': 'om_x100b6cbfae8fc8acb03feca923be229', 'msg_type': 'text', 'sender': {'id': 'cli_aab22b1674799bef', 'id_type': 'app_id', 'sender_type': 'app', 'tenant_key': '1b3485e1c8ea174f'}, 'update_time': '1782103556285', 'updated': False}, 'msg': 'success'}}}
INFO:httpx:HTTP Request: POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal "HTTP/1.1 200 OK"
INFO:httpx:HTTP Request: POST https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id "HTTP/1.1 200 OK"
INFO:__main__:Handled Feishu long connection event: {'ok': True, 'handled': True, 'result': {'command': 'risk_summary', 'reply_type': 'card', 'title': '<27><><EFBFBD><EFBFBD>Ԥ<EFBFBD><D4A4>', 'content': '- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low\n- <20><><EFBFBD>շ֣<D5B7>0\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\n- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\n- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0', 'lines': ['- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low', '- <20><><EFBFBD>շ֣<D5B7>0', '- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0', '- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0', '- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0', '- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0'], 'provider_response': {'code': 0, 'data': {'body': {'content': '{"title":"<22><><EFBFBD><EFBFBD>Ԥ<EFBFBD><D4A4>","elements":[[{"tag":"text","text":"- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low\\n- <20><><EFBFBD>շ֣<D5B7>0\\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0\\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\\n- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\\n- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0"}]]}'}, 'chat_id': 'oc_4ea0ed9fbfe4da2aca67cca9ee22cb36', 'create_time': '1782105193432', 'deleted': False, 'message_id': 'om_x100b6cb8085c94a4b12d99465abb1ca', 'msg_type': 'interactive', 'sender': {'id': 'cli_aab22b1674799bef', 'id_type': 'app_id', 'sender_type': 'app', 'tenant_key': '1b3485e1c8ea174f'}, 'update_time': '1782105193432', 'updated': False}, 'msg': 'success'}}}
ERROR:Lark:receive message loop exit, err: no close frame received or sent [conn_id=7654075862284225524]
INFO:__main__:Starting Feishu long connection client
INFO:httpx:HTTP Request: POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal "HTTP/1.1 200 OK"
INFO:httpx:HTTP Request: POST https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id "HTTP/1.1 200 OK"
INFO:__main__:Handled Feishu long connection event: {'ok': True, 'handled': True, 'result': {'command': 'risk_summary', 'reply_type': 'card', 'title': '<27><><EFBFBD><EFBFBD>Ԥ<EFBFBD><D4A4>', 'content': '- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low\n- <20><><EFBFBD>շ֣<D5B7>0\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\n- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\n- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0', 'lines': ['- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low', '- <20><><EFBFBD>շ֣<D5B7>0', '- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0', '- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0', '- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0', '- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0'], 'provider_response': {'code': 0, 'data': {'body': {'content': '{"title":"<22><><EFBFBD><EFBFBD>Ԥ<EFBFBD><D4A4>","elements":[[{"tag":"text","text":"- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low\\n- <20><><EFBFBD>շ֣<D5B7>0\\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0\\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\\n- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\\n- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0"}]]}'}, 'chat_id': 'oc_4ea0ed9fbfe4da2aca67cca9ee22cb36', 'create_time': '1782103535265', 'deleted': False, 'message_id': 'om_x100b6cbf902318acb15878ba4b86f90', 'msg_type': 'interactive', 'sender': {'id': 'cli_aab22b1674799bef', 'id_type': 'app_id', 'sender_type': 'app', 'tenant_key': '1b3485e1c8ea174f'}, 'update_time': '1782103535265', 'updated': False}, 'msg': 'success'}}}
INFO:httpx:HTTP Request: POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal "HTTP/1.1 200 OK"
INFO:httpx:HTTP Request: POST https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id "HTTP/1.1 200 OK"
INFO:__main__:Handled Feishu long connection event: {'ok': True, 'handled': True, 'result': {'command': 'risk_summary', 'reply_type': 'card', 'title': '<27><><EFBFBD><EFBFBD>Ԥ<EFBFBD><D4A4>', 'content': '- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low\n- <20><><EFBFBD>շ֣<D5B7>0\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\n- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\n- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0', 'lines': ['- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low', '- <20><><EFBFBD>շ֣<D5B7>0', '- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0', '- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0', '- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0', '- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0'], 'provider_response': {'code': 0, 'data': {'body': {'content': '{"title":"<22><><EFBFBD><EFBFBD>Ԥ<EFBFBD><D4A4>","elements":[[{"tag":"text","text":"- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low\\n- <20><><EFBFBD>շ֣<D5B7>0\\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0\\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\\n- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\\n- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0"}]]}'}, 'chat_id': 'oc_4ea0ed9fbfe4da2aca67cca9ee22cb36', 'create_time': '1782103549102', 'deleted': False, 'message_id': 'om_x100b6cbf91060cacb1fa837ac0932ee', 'msg_type': 'interactive', 'sender': {'id': 'cli_aab22b1674799bef', 'id_type': 'app_id', 'sender_type': 'app', 'tenant_key': '1b3485e1c8ea174f'}, 'update_time': '1782103549102', 'updated': False}, 'msg': 'success'}}}
INFO:httpx:HTTP Request: POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal "HTTP/1.1 200 OK"
INFO:httpx:HTTP Request: POST https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id "HTTP/1.1 200 OK"
INFO:__main__:Handled Feishu long connection event: {'ok': True, 'handled': True, 'result': {'command': 'fallback_ai', 'reply_type': 'text', 'title': 'AI <20>ظ<EFBFBD>', 'content': 'AI provider is not configured yet. This is a deterministic placeholder. Set MODEL_PROVIDER to openclaw, hermes, or direct_llm after credentials are ready.', 'provider_response': {'code': 0, 'data': {'body': {'content': '{"text":"AI provider is not configured yet. This is a deterministic placeholder. Set MODEL_PROVIDER to openclaw, hermes, or direct_llm after credentials are ready."}'}, 'chat_id': 'oc_4ea0ed9fbfe4da2aca67cca9ee22cb36', 'create_time': '1782103556285', 'deleted': False, 'message_id': 'om_x100b6cbfae8fc8acb03feca923be229', 'msg_type': 'text', 'sender': {'id': 'cli_aab22b1674799bef', 'id_type': 'app_id', 'sender_type': 'app', 'tenant_key': '1b3485e1c8ea174f'}, 'update_time': '1782103556285', 'updated': False}, 'msg': 'success'}}}
INFO:httpx:HTTP Request: POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal "HTTP/1.1 200 OK"
INFO:httpx:HTTP Request: POST https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id "HTTP/1.1 200 OK"
INFO:__main__:Handled Feishu long connection event: {'ok': True, 'handled': True, 'result': {'command': 'risk_summary', 'reply_type': 'card', 'title': '<27><><EFBFBD><EFBFBD>Ԥ<EFBFBD><D4A4>', 'content': '- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low\n- <20><><EFBFBD>շ֣<D5B7>0\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\n- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\n- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0', 'lines': ['- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low', '- <20><><EFBFBD>շ֣<D5B7>0', '- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0', '- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0', '- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0', '- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0'], 'provider_response': {'code': 0, 'data': {'body': {'content': '{"title":"<22><><EFBFBD><EFBFBD>Ԥ<EFBFBD><D4A4>","elements":[[{"tag":"text","text":"- <20>ۺϷ<DBBA><CFB7>յȼ<D5B5><C8BC><EFBFBD>low\\n- <20><><EFBFBD>շ֣<D5B7>0\\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>0\\n- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\\n- <20><>Ԥ<EFBFBD><D4A4><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF>0\\n- <20>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˻<EFBFBD><CBBB><EFBFBD>0"}]]}'}, 'chat_id': 'oc_4ea0ed9fbfe4da2aca67cca9ee22cb36', 'create_time': '1782105193432', 'deleted': False, 'message_id': 'om_x100b6cb8085c94a4b12d99465abb1ca', 'msg_type': 'interactive', 'sender': {'id': 'cli_aab22b1674799bef', 'id_type': 'app_id', 'sender_type': 'app', 'tenant_key': '1b3485e1c8ea174f'}, 'update_time': '1782105193432', 'updated': False}, 'msg': 'success'}}}
ERROR:Lark:receive message loop exit, err: no close frame received or sent [conn_id=7654075862284225524]

View File

@@ -1 +1 @@
[Lark] [2026-06-22 13:36:22,942] [ERROR] receive message loop exit, err: no close frame received or sent [conn_id=7654075862284225524]
[Lark] [2026-06-22 13:36:22,942] [ERROR] receive message loop exit, err: no close frame received or sent [conn_id=7654075862284225524]

72
tests/test_ai_adapters.py Normal file
View File

@@ -0,0 +1,72 @@
from typing import Any
from app.core.config import Settings
from app.modules.ai_agent import adapters
class DummyResponse:
def __init__(self, data: dict[str, Any], status_code: int = 200):
self._data = data
self.status_code = status_code
self.text = str(data)
def json(self) -> dict[str, Any]:
return self._data
class DummyClient:
calls: list[dict[str, Any]] = []
def __init__(self, timeout: int):
self.timeout = timeout
def __enter__(self) -> "DummyClient":
return self
def __exit__(self, exc_type, exc, traceback) -> None:
return None
def post(
self,
url: str,
json: dict[str, Any],
headers: dict[str, str],
) -> DummyResponse:
self.calls.append({"url": url, "json": json, "headers": headers})
if url.endswith("/api/v1/agent/ask"):
assert json["context"]["hermes_memory"] == "remembered project preference"
return DummyResponse({"answer": "openclaw final answer", "tool_calls": 1})
mode = json["context"]["mode"]
if mode == "memory_recall":
return DummyResponse({"answer": "remembered project preference"})
if mode == "memory_write":
return DummyResponse({"answer": "stored"})
raise AssertionError(f"unexpected Hermes mode: {mode}")
def test_openclaw_hermes_adapter_runs_recall_answer_and_remember(monkeypatch) -> None:
DummyClient.calls = []
monkeypatch.setattr(adapters.httpx, "Client", DummyClient)
settings = Settings(
model_provider="openclaw_hermes",
openclaw_base_url="http://openclaw.local",
openclaw_api_key="openclaw-key",
hermes_base_url="http://hermes.local",
hermes_api_key="hermes-key",
)
result = adapters.OpenClawHermesAdapter(settings).ask(
"summarize project risk",
{"project_code": "P-001"},
)
assert result["answer"] == "openclaw final answer"
assert result["raw"]["pipeline"] == "hermes_recall -> openclaw_answer -> hermes_remember"
assert [call["url"] for call in DummyClient.calls] == [
"http://hermes.local/api/v1/ask",
"http://openclaw.local/api/v1/agent/ask",
"http://hermes.local/api/v1/ask",
]
assert DummyClient.calls[0]["headers"]["Authorization"] == "Bearer hermes-key"
assert DummyClient.calls[1]["headers"]["Authorization"] == "Bearer openclaw-key"
assert DummyClient.calls[1]["json"]["context"]["agent_pipeline"] == "openclaw_hermes"

View File

@@ -1,6 +1,7 @@
import json
import os
import tempfile
from datetime import date, timedelta
from pathlib import Path
_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
@@ -154,6 +155,71 @@ def test_approval_gate_for_high_risk_update() -> None:
assert update_response.json()["data"]["current_balance"] == 100.0
def test_new_ledgers_reports_and_risk_events() -> None:
domains_response = client.get("/api/v1/business/domains", headers=headers)
assert domains_response.status_code == 200
domains = domains_response.json()["domains"]
assert "attendance-records" in domains
assert "work-reports" in domains
assert "risk-events" in domains
today = date.today()
attendance_response = client.post(
"/api/v1/business/attendance-records",
headers=headers,
json={
"actor": "pytest",
"data": {
"code": "ATT-SMOKE-001",
"employee_name": "Tester",
"department": "QA",
"work_date": today.isoformat(),
"status": "正常",
},
},
)
assert attendance_response.status_code == 200
task_response = client.post(
"/api/v1/business/tasks",
headers=headers,
json={
"actor": "pytest",
"data": {
"code": "TASK-RISK-001",
"title": "Overdue smoke task",
"owner": "tester",
"status": "待办",
"due_date": (today - timedelta(days=1)).isoformat(),
},
},
)
assert task_response.status_code == 200
attendance_summary = client.get("/api/v1/reports/attendance-summary", headers=headers)
assert attendance_summary.status_code == 200
assert attendance_summary.json()["total"] >= 1
report_response = client.post(
"/api/v1/reports/work-reports/generate",
headers=headers,
json={"report_type": "daily", "reporter": "pytest", "actor": "pytest"},
)
assert report_response.status_code == 200
assert report_response.json()["data"]["report_type"] == "daily"
risk_response = client.post(
"/api/v1/risks/events/generate?actor=pytest",
headers=headers,
)
assert risk_response.status_code == 200
assert risk_response.json()["created"] >= 1
events_response = client.get("/api/v1/risks/events?status=open", headers=headers)
assert events_response.status_code == 200
assert any(item["risk_type"] == "overdue_task" for item in events_response.json()["items"])
def test_ai_noop_provider() -> None:
response = client.post(
"/api/v1/ai/ask",