feat: 添加生命周期报告和AI规则管理功能

- 在Dockerfile中添加pillow依赖包用于图像处理
- 实现生命周期报告调度任务,支持日报和周报两种类型
- 新增TASK_RUN_LIFECYCLE任务常量和相关配置选项
- 扩展AI Agent服务以支持用户规则,并在分析时应用规则
- 添加AI用户规则创建、更新和查询接口
- 增加项目生命周期和财务需求分析技能
- 扩展现有模型以支持更完整的业务数据字段
- 实现飞书图片上传功能用于报告展示
```
This commit is contained in:
2026-07-12 17:44:49 +08:00
parent bf309ecdf7
commit 9cf7c44393
45 changed files with 5036 additions and 49 deletions

View File

@@ -1,5 +1,7 @@
from typing import Any
from uuid import uuid4
from fastapi import HTTPException, status
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
@@ -17,6 +19,8 @@ from app.modules.ai_memory.constants import (
AIMemorySource,
AIMemoryStatus,
AIMemoryText,
AI_USER_RULE_MAX_PRIORITY,
AI_USER_RULE_MIN_PRIORITY,
)
from app.modules.ai_memory.models import AIMemoryEntry
from app.modules.audit.constants import (
@@ -164,6 +168,144 @@ class AIMemoryService:
)
return record
def list_rules(
self,
scope: str | None = None,
subject: str | None = None,
status_filter: str | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
stmt = (
select(AIMemoryEntry)
.where(AIMemoryEntry.source == AIMemorySource.USER_RULE)
.order_by(AIMemoryEntry.importance.desc(), AIMemoryEntry.id.asc())
.limit(bounded_limit(limit))
)
if scope:
stmt = stmt.where(AIMemoryEntry.scope == scope)
if subject:
stmt = stmt.where(AIMemoryEntry.subject == subject)
if status_filter:
stmt = stmt.where(AIMemoryEntry.status == status_filter)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def active_rules(
self,
scope: str = AIMemoryScope.GLOBAL,
subject: str | None = None,
limit: int = 50,
) -> list[dict[str, Any]]:
stmt = (
select(AIMemoryEntry)
.where(
AIMemoryEntry.source == AIMemorySource.USER_RULE,
AIMemoryEntry.status == AIMemoryStatus.ACTIVE,
AIMemoryEntry.scope.in_({AIMemoryScope.GLOBAL, scope}),
)
.order_by(AIMemoryEntry.importance.desc(), AIMemoryEntry.id.asc())
.limit(min(bounded_limit(limit), 50))
)
if subject:
stmt = stmt.where(
or_(
AIMemoryEntry.scope == AIMemoryScope.GLOBAL,
AIMemoryEntry.subject == subject,
)
)
return [
{
"code": item.code,
"scope": item.scope,
"subject": item.subject,
"rule": item.content,
"priority": item.importance,
}
for item in self.db.execute(stmt).scalars()
]
def create_rule(
self,
content: str,
scope: str,
subject: str,
priority: int,
tags: list[str] | None,
actor: str,
) -> dict[str, Any]:
self._validate_rule(content, priority)
record = self._create_entry(
scope=scope,
subject=subject,
content=_truncate(content.strip(), AI_MEMORY_MAX_CONTENT_LENGTH),
summary=_truncate(content.strip(), AI_MEMORY_MAX_SUMMARY_LENGTH),
tags=["user-rule", *(tags or [])],
source=AIMemorySource.USER_RULE,
importance=priority,
status_value=AIMemoryStatus.ACTIVE,
actor=actor,
audit_action=AuditAction.AI_RULE_CREATE,
)
return serialize_model(record)
def update_rule(
self,
code: str,
content: str | None,
priority: int | None,
tags: list[str] | None,
enabled: bool | None,
actor: str,
) -> dict[str, Any]:
record = self.db.execute(
select(AIMemoryEntry).where(
AIMemoryEntry.code == code,
AIMemoryEntry.source == AIMemorySource.USER_RULE,
)
).scalar_one_or_none()
if record is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AI rule not found")
if content is not None:
self._validate_rule(content, priority or record.importance)
record.content = _truncate(content.strip(), AI_MEMORY_MAX_CONTENT_LENGTH)
record.summary = _truncate(content.strip(), AI_MEMORY_MAX_SUMMARY_LENGTH)
if priority is not None:
self._validate_rule(record.content, priority)
record.importance = priority
if tags is not None:
record.tags = ["user-rule", *tags]
if enabled is not None:
record.status = AIMemoryStatus.ACTIVE if enabled else AIMemoryStatus.ARCHIVED
self.db.commit()
self.db.refresh(record)
self.audit.log(
AuditLogCreate(
actor=actor,
source=AuditSource.AI_MEMORY,
action=AuditAction.AI_RULE_UPDATE,
target_type=AuditTargetType.AI_MEMORY,
target_id=record.code,
risk_level=AuditRiskLevel.MEDIUM,
response_payload={
AIMemoryPayloadKey.CODE: record.code,
AIMemoryPayloadKey.STATUS: record.status,
AIMemoryPayloadKey.IMPORTANCE: record.importance,
},
)
)
return serialize_model(record)
def _validate_rule(self, content: str, priority: int) -> None:
if not AI_USER_RULE_MIN_PRIORITY <= priority <= AI_USER_RULE_MAX_PRIORITY:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="AI rule priority is out of range",
)
if _contains_forbidden_value(content, get_settings().ai_memory_forbidden_keys):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="AI rule contains secret-like content",
)
def count_by_status(self) -> dict[str, int]:
rows = self.db.execute(
select(AIMemoryEntry.status, func.count()).group_by(AIMemoryEntry.status)
@@ -181,9 +323,13 @@ class AIMemoryService:
importance: int,
status_value: str,
actor: str,
audit_action: str = AuditAction.AI_MEMORY_WRITE,
) -> AIMemoryEntry:
record = AIMemoryEntry(
code=f"{AI_MEMORY_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}",
code=(
f"{AI_MEMORY_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}-"
f"{uuid4().hex[:8]}"
),
scope=scope,
subject=subject,
content=content,
@@ -201,7 +347,7 @@ class AIMemoryService:
AuditLogCreate(
actor=actor,
source=AuditSource.AI_MEMORY,
action=AuditAction.AI_MEMORY_WRITE,
action=audit_action,
target_type=AuditTargetType.AI_MEMORY,
target_id=record.code,
risk_level=AuditRiskLevel.LOW,