feat(knowledge): 企业记忆分区——知识三表entity_id多租户隔离 (entity-memory-partition P1)

- knowledge_events/knowledge_summaries: entity_id NOT NULL DEFAULT 1 + 索引(企业记忆抽屉)
- knowledge_articles: entity_id 可空——NULL=全局共享(CMA教学知识), 有值=企业专属
- knowledge_service 全链路 entity_id: extract_events(KPIAlert直过滤+OperationLog经user_entities归属)/_kpi_snapshot(修跨企业KPI泄漏)/generate_summary/get_last_summary/run_*/sync
- api/knowledge.py 8端点 Depends(get_entity_id) 强制过滤;详情越权404
- api/knowledge_articles.py 全局∪本企业查询;企业专属越权404
- 生产DB三表ALTER+索引已完成; 存量38篇教学文章保持NULL全局共享
- 测试 test_security_knowledge_partition.py 8条(双实体互查越权/生成自动带entity/全局文章双可见)
- 方案文档 docs/knowledge-entity-partition-plan-20260903.md(附评审决策点)
This commit is contained in:
Hermes CI Fix
2026-09-03 23:00:15 +08:00
parent a3111a25f6
commit e22e1702f8
7 changed files with 461 additions and 60 deletions
+68 -38
View File
@@ -19,7 +19,7 @@ from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy import func, and_
from app.models import KnowledgeEvent, KnowledgeSummary, OperationLog, KPIAlert, KPIDefinition, KPIValue
from app.models import KnowledgeEvent, KnowledgeSummary, OperationLog, KPIAlert, KPIDefinition, KPIValue, UserEntity
logger = logging.getLogger("cma.knowledge")
@@ -66,16 +66,31 @@ async def _call_deepseek(prompt: str, timeout: int = 30) -> str:
# ── 事件抽取 ──
def extract_events(db: Session, since: datetime, until: Optional[datetime] = None) -> list[dict]:
"""从 OperationLog + KPIAlert 中抽取关键事件
def extract_events(db: Session, since: datetime, until: Optional[datetime] = None, entity_id: int = 1) -> list[dict]:
"""从 OperationLog + KPIAlert 中抽取关键事件(按企业抽屉隔离)
返回 dict 列表,用于喂给摘要 prompt
隔离说明(2026-09-03 多租户分区):
- KPIAlert 带 entity_id 列 → 直接按 entity_id 过滤
- OperationLog 无 entity_id 列(数据债,P2 follow-up)→ 经 user_id→user_entities
归属当前企业;无授权归属的日志(user_id NULL/未授权)不进入本企业记忆
"""
now = until or datetime.utcnow()
# 1. 操作日志 → 事件
# 0. 当前企业授权用户集合(OperationLog 归属过滤用)
auth_user_ids = [
uid for (uid,) in db.query(UserEntity.user_id)
.filter(UserEntity.entity_id == entity_id).all()
]
# 1. 操作日志 → 事件(本企业授权用户的操作)
logs = (
db.query(OperationLog)
.filter(OperationLog.created_at >= since, OperationLog.created_at <= now)
.filter(
OperationLog.created_at >= since,
OperationLog.created_at <= now,
OperationLog.user_id.in_(auth_user_ids), # 空集合 → IN (NULL) 不匹配任何行
)
.order_by(OperationLog.created_at.asc())
.all()
)
@@ -96,11 +111,15 @@ def extract_events(db: Session, since: datetime, until: Optional[datetime] = Non
"detail": detail_str,
})
# 2. 预警记录 → 事件
# 2. 预警记录 → 事件(KPIAlert 直接按企业过滤)
alerts = (
db.query(KPIAlert, KPIDefinition.kpi_name, KPIDefinition.kpi_code)
.join(KPIDefinition, KPIAlert.kpi_id == KPIDefinition.id)
.filter(KPIAlert.created_at >= since, KPIAlert.created_at <= now)
.filter(
KPIAlert.created_at >= since,
KPIAlert.created_at <= now,
KPIAlert.entity_id == entity_id,
)
.order_by(KPIAlert.created_at.asc())
.all()
)
@@ -136,9 +155,12 @@ def _build_summary_prompt(events: list[dict], level: str, period_key: str) -> st
return "\n".join(lines)
async def _kpi_snapshot(db: Session) -> list[dict]:
"""当前KPI快照 — 每个活跃KPI的最新实际值"""
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
async def _kpi_snapshot(db: Session, entity_id: int = 1) -> list[dict]:
"""当前KPI快照 — 本企业每个活跃KPI的最新实际值(按账套隔离)"""
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).all()
snapshot = []
for k in kpis:
latest = (
@@ -165,6 +187,7 @@ async def generate_summary(
since: datetime,
until: Optional[datetime] = None,
prev_summary: Optional[KnowledgeSummary] = None,
entity_id: int = 1,
) -> KnowledgeSummary:
"""生成一层摘要(daily/weekly/monthly/cumulative)
@@ -174,11 +197,12 @@ async def generate_summary(
since: 事件开始时间
until: 事件结束时间
prev_summary: 前一层摘要(用于累积摘要继承)
entity_id: 企业账套(企业记忆分区 2026-09-03)
"""
now = until or datetime.utcnow()
# 1. 抽取事件
events = extract_events(db, since, now)
# 1. 抽取事件(本企业)
events = extract_events(db, since, now, entity_id=entity_id)
# 2. 构建 prompt
prompt = _build_summary_prompt(events, level, period_key)
@@ -194,11 +218,12 @@ async def generate_summary(
if not result_text or "无重要变化" in result_text:
result_text = f"{level}汇总: 窗口 {period_key} 内共 {len(events)} 条事件记录,无重大变化需记录。"
# 6. 提取 KPI 快照
kpi_snapshot = await _kpi_snapshot(db)
# 6. 提取 KPI 快照(本企业)
kpi_snapshot = await _kpi_snapshot(db, entity_id=entity_id)
# 7. 存入数据库
summary = KnowledgeSummary(
entity_id=entity_id,
level=level,
period_key=period_key,
title=f"{level.upper()}摘要 - {period_key}",
@@ -223,32 +248,36 @@ async def generate_summary(
# ── 分层调度 ──
def get_last_summary(db: Session, level: str) -> Optional[KnowledgeSummary]:
"""获取该层最新(最新创建)的摘要"""
def get_last_summary(db: Session, level: str, entity_id: int = 1) -> Optional[KnowledgeSummary]:
"""获取该层最新(最新创建)的摘要(按企业隔离)"""
return (
db.query(KnowledgeSummary)
.filter(KnowledgeSummary.level == level, KnowledgeSummary.is_stale == 0)
.filter(
KnowledgeSummary.level == level,
KnowledgeSummary.is_stale == 0,
KnowledgeSummary.entity_id == entity_id,
)
.order_by(KnowledgeSummary.id.desc())
.first()
)
async def run_daily_summary(db: Session) -> KnowledgeSummary:
"""运行每日摘要"""
async def run_daily_summary(db: Session, entity_id: int = 1) -> KnowledgeSummary:
"""运行每日摘要(企业记忆分区)"""
today = date.today()
period_key = today.isoformat()
since = datetime(today.year, today.month, today.day)
# 获取前一天摘要作为 prev
yesterday = today - timedelta(days=1)
prev = get_last_summary(db, "daily")
prev = get_last_summary(db, "daily", entity_id=entity_id)
return await generate_summary(
db, "daily", period_key, since, prev_summary=prev,
db, "daily", period_key, since, prev_summary=prev, entity_id=entity_id,
)
async def run_weekly_summary(db: Session) -> KnowledgeSummary:
async def run_weekly_summary(db: Session, entity_id: int = 1) -> KnowledgeSummary:
"""运行每周摘要(周日执行)"""
today = date.today()
# ISO 周算法: 本周一到今天
@@ -257,13 +286,13 @@ async def run_weekly_summary(db: Session) -> KnowledgeSummary:
since = today - timedelta(days=today.weekday()) # 本周一
since_dt = datetime(since.year, since.month, since.day)
prev = get_last_summary(db, "weekly")
prev = get_last_summary(db, "weekly", entity_id=entity_id)
return await generate_summary(
db, "weekly", period_key, since_dt, prev_summary=prev,
db, "weekly", period_key, since_dt, prev_summary=prev, entity_id=entity_id,
)
async def run_monthly_summary(db: Session) -> KnowledgeSummary:
async def run_monthly_summary(db: Session, entity_id: int = 1) -> KnowledgeSummary:
"""运行月度摘要"""
today = date.today()
period_key = today.strftime("%Y-%m")
@@ -271,10 +300,10 @@ async def run_monthly_summary(db: Session) -> KnowledgeSummary:
# 附属前一个月的 daily 和 weekly 摘要
prev_month = today.replace(day=1) - timedelta(days=1)
prev = get_last_summary(db, "monthly")
prev = get_last_summary(db, "monthly", entity_id=entity_id)
return await generate_summary(
db, "monthly", period_key, since, prev_summary=prev,
db, "monthly", period_key, since, prev_summary=prev, entity_id=entity_id,
)
@@ -289,40 +318,41 @@ def generate_summary_sync(
period_key: str,
since: datetime,
until: Optional[datetime] = None,
entity_id: int = 1,
) -> dict:
"""同步包装,用于 API 手动触发"""
loop = asyncio.new_event_loop()
try:
summary = loop.run_until_complete(
generate_summary(db, level, period_key, since, until)
generate_summary(db, level, period_key, since, until, entity_id=entity_id)
)
return {"id": summary.id, "level": summary.level, "period_key": summary.period_key}
return {"id": summary.id, "entity_id": summary.entity_id, "level": summary.level, "period_key": summary.period_key}
finally:
loop.close()
def generate_daily_sync(db: Session) -> dict:
def generate_daily_sync(db: Session, entity_id: int = 1) -> dict:
loop = asyncio.new_event_loop()
try:
summary = loop.run_until_complete(run_daily_summary(db))
return {"id": summary.id, "level": summary.level, "period_key": summary.period_key}
summary = loop.run_until_complete(run_daily_summary(db, entity_id))
return {"id": summary.id, "entity_id": summary.entity_id, "level": summary.level, "period_key": summary.period_key}
finally:
loop.close()
def generate_weekly_sync(db: Session) -> dict:
def generate_weekly_sync(db: Session, entity_id: int = 1) -> dict:
loop = asyncio.new_event_loop()
try:
summary = loop.run_until_complete(run_weekly_summary(db))
return {"id": summary.id, "level": summary.level, "period_key": summary.period_key}
summary = loop.run_until_complete(run_weekly_summary(db, entity_id))
return {"id": summary.id, "entity_id": summary.entity_id, "level": summary.level, "period_key": summary.period_key}
finally:
loop.close()
def generate_monthly_sync(db: Session) -> dict:
def generate_monthly_sync(db: Session, entity_id: int = 1) -> dict:
loop = asyncio.new_event_loop()
try:
summary = loop.run_until_complete(run_monthly_summary(db))
return {"id": summary.id, "level": summary.level, "period_key": summary.period_key}
summary = loop.run_until_complete(run_monthly_summary(db, entity_id))
return {"id": summary.id, "entity_id": summary.entity_id, "level": summary.level, "period_key": summary.period_key}
finally:
loop.close()