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

@@ -18,6 +18,7 @@ class AIMemorySource(StrEnum):
AUTO = "auto"
HERMES = "hermes"
API = "api"
USER_RULE = "user_rule"
class AIMemoryResponseKey(StrEnum):
@@ -53,3 +54,5 @@ AI_MEMORY_CODE_PREFIX = "MEM"
AI_MEMORY_MAX_CONTENT_LENGTH = 2000
AI_MEMORY_MAX_SUMMARY_LENGTH = 500
AI_MEMORY_MIN_AUTO_WRITE_LENGTH = 12
AI_USER_RULE_MAX_PRIORITY = 100
AI_USER_RULE_MIN_PRIORITY = 1

View File

@@ -2,9 +2,13 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import ApiPrincipal, require_api_key
from app.core.security import ApiPrincipal, require_api_key, require_operations_enabled
from app.modules.ai_memory.constants import AIMemoryResponseKey, AIMemoryStatus
from app.modules.ai_memory.schemas import AIMemoryRecallRequest
from app.modules.ai_memory.schemas import (
AIMemoryRecallRequest,
AIUserRuleCreate,
AIUserRuleUpdate,
)
from app.modules.ai_memory.service import AIMemoryService
router = APIRouter(dependencies=[Depends(require_api_key)])
@@ -42,3 +46,60 @@ def recall_memory(
actor=principal.actor,
)
return {AIMemoryResponseKey.ITEMS: items}
@router.get("/rules")
def list_rules(
scope: str | None = None,
subject: str | None = None,
status: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> dict:
return {
AIMemoryResponseKey.ITEMS: AIMemoryService(db).list_rules(
scope=scope,
subject=subject,
status_filter=status,
limit=limit,
)
}
@router.post("/rules")
def create_rule(
payload: AIUserRuleCreate,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
require_operations_enabled()
return {
AIMemoryResponseKey.DATA: AIMemoryService(db).create_rule(
content=payload.content,
scope=payload.scope,
subject=payload.subject,
priority=payload.priority,
tags=payload.tags,
actor=principal.actor,
)
}
@router.patch("/rules/{code}")
def update_rule(
code: str,
payload: AIUserRuleUpdate,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
require_operations_enabled()
return {
AIMemoryResponseKey.DATA: AIMemoryService(db).update_rule(
code=code,
content=payload.content,
priority=payload.priority,
tags=payload.tags,
enabled=payload.enabled,
actor=principal.actor,
)
}

View File

@@ -27,3 +27,18 @@ class AIMemoryRead(BaseModel):
expires_at: str | None
created_at: str
updated_at: str
class AIUserRuleCreate(BaseModel):
content: str = Field(..., min_length=1, max_length=2000)
scope: str = AIMemoryScope.GLOBAL
subject: str = Field(default="company", min_length=1, max_length=128)
priority: int = Field(default=50, ge=1, le=100)
tags: list[str] = Field(default_factory=list, max_length=20)
class AIUserRuleUpdate(BaseModel):
content: str | None = Field(default=None, min_length=1, max_length=2000)
priority: int | None = Field(default=None, ge=1, le=100)
tags: list[str] | None = Field(default=None, max_length=20)
enabled: bool | None = None

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,