Summarize backend API updates

This commit is contained in:
Yuuuu 2026-03-15 15:00:55 +08:00
parent b7fc357027
commit 739e6947ea
583 changed files with 62168 additions and 57202 deletions

View File

@ -120,6 +120,42 @@ backend/
- 新增:`backend/app/services/ai_pmo_kernel_mixins_closure_data.py`(Kernel 闭环历史上下文回放) - 新增:`backend/app/services/ai_pmo_kernel_mixins_closure_data.py`(Kernel 闭环历史上下文回放)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_closure_payload.py`(Kernel 闭环语义与来源上下文组装) - 新增:`backend/app/services/ai_pmo_kernel_mixins_closure_payload.py`(Kernel 闭环语义与来源上下文组装)
- 更新:`backend/app/services/ai_pmo_kernel_service.py`(兼容入口层,仅导出 `AIPMOKernelService`) - 更新:`backend/app/services/ai_pmo_kernel_service.py`(兼容入口层,仅导出 `AIPMOKernelService`)
- 新增:`backend/app/services/feishu_signal_pipeline/`(飞书信号管道按职责拆分的服务包,所有子文件 <= 300 行)
- 新增:`backend/app/services/feishu_signal_pipeline/service.py`(组合入口,按 mixin 聚合能力)
- 新增:`backend/app/services/feishu_signal_pipeline/shared_core.py`、`shared_schema_utils.py`(共享常量/正则/Schema/工具函数拆分)
- 新增:`backend/app/services/feishu_signal_pipeline/mixin_*.py`(按职责拆分的处理流、gate、followup、queue、序列化、提取分类等实现)
- 更新:`backend/app/services/feishu_signal_pipeline.py`(降级为兼容入口,仅导出包内 `FeishuSignalPipelineService`)
- 新增:`backend/app/api/feishu_api/`(飞书接口按配置/长连接/消息发送/连通性测试拆分的子模块目录)
- 新增:`backend/app/api/feishu_api/router.py`(飞书 API 聚合路由)
- 新增:`backend/app/api/feishu_api/config_routes.py`(飞书配置相关接口)
- 新增:`backend/app/api/feishu_api/long_connection_routes.py`(飞书长连接状态与重启接口)
- 新增:`backend/app/api/feishu_api/message_routes.py`(飞书消息与卡片发送接口)
- 新增:`backend/app/api/feishu_api/test_routes.py`(飞书连通性测试接口)
- 新增:`backend/app/api/feishu_api/schemas.py`(飞书 API 请求模型)
- 新增:`backend/app/api/feishu_api/helpers.py`(飞书 API 公共辅助函数)
- 更新:`backend/app/api/feishu.py`(降级为兼容入口,仅导出聚合 `router`)
- 新增:`backend/app/api/sstc_api/`(SSTC 路由按领域拆分:signals/charters/tasks/releases/reviews/overview/ai-routing)
- 新增:`backend/app/api/sstc_api/shared.py`(SSTC 路由层公共依赖导入)
- 更新:`backend/app/api/sstc.py`(降级为兼容入口,仅导出聚合 `router`)
- 新增:`backend/app/services/sstc/`(SSTC 业务辅助能力拆分:signal/charter/governance/entity_lookup/review)
- 新增:`backend/app/services/llm_service/`(LLM 服务按职责拆分的 mixin 包,所有子文件 <= 300 行)
- 更新:`backend/app/services/llm.py`(降级为兼容入口,导出 `LLMService` 与 `llm_service`)
- 新增:`backend/app/services/signal_capture_service/`(主动信号捕获服务按入口/编排/分析/新项目拆分)
- 更新:`backend/app/services/signal_capture.py`(降级为兼容入口)
- 新增:`backend/app/services/project_knowledge_graph_service/`(知识图谱服务按 CRUD/增量同步/守卫/快照拆分)
- 更新:`backend/app/services/project_knowledge_graph.py`(降级为兼容入口)
- 新增:`backend/app/services/context_cache_service/`(上下文缓存服务按缓存核心/画像/快照/失效钩子拆分)
- 更新:`backend/app/services/context_cache.py`(降级为兼容入口)
- 新增:`backend/app/config/ai_prompts_catalog/`(Prompt 常量分片目录,按 part_01..part_04 管理)
- 更新:`backend/app/config/ai_prompts.py`(兼容聚合入口,保留 `build_default_role_prompt_bundle`)
- 新增:`backend/app/api/signals_api/`(signals 路由拆分为 list/create、ingest/fetch、feedback 子模块)
- 新增:`backend/app/api/wecom_api/`(wecom 路由拆分为 config/webhook/messaging 子模块)
- 新增:`backend/app/api/endpoints/skill_api/`(人员能力图谱路由拆分为 tags/bindings/workload/recommend 子模块)
- 新增:`backend/app/api/ai_logs_utils.py`、`backend/app/api/signal_sources_models.py`、`backend/app/api/signal_sources_utils.py`(API 共享序列化/Schema/项目解析工具)
- 新增:`backend/app/config/operation_catalog_core/`(operation catalog 按模型/构造器/领域条目拆分)
- 新增:`backend/app/dispatcher_rules.py`、`backend/app/events_models.py`、`backend/app/seed_projects_helpers.py`(规则/事件模型/seed helper 拆分)
- 新增:`backend/app/main_db_guard.py`、`backend/app/main_feishu_outbox.py`、`backend/app/main_warmup.py`、`backend/app/main_model_probe.py`(main 启动职责拆分)
- 新增:`backend/app/services/ai_pmo_kernel_world_model_aggregates/`、`backend/app/services/ai_pmo_kernel_world_model_core/`、`backend/app/services/ai_pmo_kernel_common_core/`(kernel world-model/common 拆分)
## 5. 前端结构(`frontend/`) ## 5. 前端结构(`frontend/`)
```text ```text

View File

@ -2,158 +2,22 @@
from __future__ import annotations from __future__ import annotations
import json
from datetime import date, datetime from datetime import date, datetime
from typing import Any, Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, case, func, or_, select, union_all from sqlalchemy import and_, case, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..core.response import success_response from ..core.response import success_response
from ..database import get_db from ..database import get_db
from ..dependencies.auth import get_current_user from ..dependencies.auth import get_current_user
from ..models import ( from ..models import AIProcessingLog, User
AIProcessingLog, from .ai_logs_utils import apply_project_filter, serialize_log_detail, serialize_log_row
FeishuItem,
ProjectSignalLink,
ProjectSuggestion,
Signal,
User,
WorkItem,
)
router = APIRouter(prefix="/ai-logs", tags=["AI Logs"]) router = APIRouter(prefix="/ai-logs", tags=["AI Logs"])
def _normalize_bullets(value: Any) -> list[str]:
if isinstance(value, list):
return [str(x).strip() for x in value if str(x).strip()][:3]
if isinstance(value, str):
raw = value.strip()
if not raw:
return []
try:
parsed = json.loads(raw)
if isinstance(parsed, list):
return [str(x).strip() for x in parsed if str(x).strip()][:3]
except Exception:
pass
return [line.strip(" -•\t") for line in raw.splitlines() if line.strip()][:3]
return []
def _safe_json(value: Optional[str]) -> Any:
text = (value or "").strip()
if not text:
return None
try:
return json.loads(text)
except Exception:
return None
def _serialize_log_row(row: AIProcessingLog) -> dict[str, Any]:
return {
"id": str(row.id),
"feishu_message_id": row.feishu_message_id,
"feishu_item_id": str(row.feishu_item_id) if row.feishu_item_id is not None else None,
"raw_message": row.raw_message,
"project_score": row.project_score,
"signal_confidence": row.signal_confidence,
"level": row.level,
"routing_decision": row.routing_decision,
"routing_reason": row.routing_reason,
"reasoning_bullets": _normalize_bullets(row.reasoning_bullets),
"processing_status": row.processing_status,
"error_message": row.error_message,
"duration_ms": row.duration_ms,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
def _serialize_log_detail(row: AIProcessingLog) -> dict[str, Any]:
payload = _serialize_log_row(row)
payload["ai_raw_output"] = row.ai_raw_output
payload["ai_raw_output_json"] = _safe_json(row.ai_raw_output)
return payload
def _apply_project_filter(stmt, *, tenant_id: int, project_id: Optional[int]):
if project_id is None:
return stmt
work_item_message_ids = select(func.distinct(WorkItem.source_message_id)).where(
and_(
WorkItem.tenant_id == tenant_id,
WorkItem.project_id == int(project_id),
WorkItem.source_system == "feishu",
WorkItem.source_message_id.is_not(None),
)
)
direct_items = select(FeishuItem.id.label("item_id")).where(
and_(
FeishuItem.tenant_id == tenant_id,
FeishuItem.matched_project_id == int(project_id),
)
)
signal_matched_items = select(Signal.feishu_item_id.label("item_id")).where(
and_(
Signal.tenant_id == tenant_id,
Signal.matched_project_id == int(project_id),
Signal.feishu_item_id.is_not(None),
)
)
linked_items = (
select(Signal.feishu_item_id.label("item_id"))
.select_from(Signal)
.join(
ProjectSignalLink,
and_(
ProjectSignalLink.tenant_id == tenant_id,
ProjectSignalLink.project_id == int(project_id),
ProjectSignalLink.signal_id == Signal.id,
),
)
.where(
and_(
Signal.tenant_id == tenant_id,
Signal.feishu_item_id.is_not(None),
)
)
)
auto_created_items = select(ProjectSuggestion.feishu_item_id.label("item_id")).where(
and_(
ProjectSuggestion.tenant_id == tenant_id,
ProjectSuggestion.created_project_id == int(project_id),
ProjectSuggestion.feishu_item_id.is_not(None),
)
)
all_item_ids = union_all(
direct_items,
signal_matched_items,
linked_items,
auto_created_items,
).subquery("project_item_ids")
item_ids_stmt = select(func.distinct(all_item_ids.c.item_id)).where(all_item_ids.c.item_id.is_not(None))
message_ids_stmt = select(func.distinct(FeishuItem.feishu_message_id)).where(
and_(
FeishuItem.tenant_id == tenant_id,
FeishuItem.id.in_(item_ids_stmt),
FeishuItem.feishu_message_id.is_not(None),
)
)
return stmt.where(
or_(
AIProcessingLog.feishu_item_id.in_(item_ids_stmt),
AIProcessingLog.feishu_message_id.in_(message_ids_stmt),
AIProcessingLog.feishu_message_id.in_(work_item_message_ids),
)
)
@router.get("") @router.get("")
async def list_ai_logs( async def list_ai_logs(
project_id: Optional[int] = Query(default=None, ge=1), project_id: Optional[int] = Query(default=None, ge=1),
@ -171,12 +35,12 @@ async def list_ai_logs(
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
) )
total_stmt = _apply_project_filter(total_stmt, tenant_id=tenant_id, project_id=project_id) total_stmt = apply_project_filter(total_stmt, tenant_id=tenant_id, project_id=project_id)
data_stmt = _apply_project_filter(data_stmt, tenant_id=tenant_id, project_id=project_id) data_stmt = apply_project_filter(data_stmt, tenant_id=tenant_id, project_id=project_id)
total = int((await db.execute(total_stmt)).scalar() or 0) total = int((await db.execute(total_stmt)).scalar() or 0)
rows = (await db.execute(data_stmt)).scalars().all() rows = (await db.execute(data_stmt)).scalars().all()
items = [_serialize_log_row(row) for row in rows] items = [serialize_log_row(row) for row in rows]
return success_response( return success_response(
{ {
"items": items, "items": items,
@ -218,9 +82,7 @@ async def get_ai_log_stats(
func.sum( func.sum(
case( case(
( (
AIProcessingLog.routing_decision.in_( AIProcessingLog.routing_decision.in_(["auto_create_project", "auto_enter_signal"]),
["auto_create_project", "auto_enter_signal"]
),
1, 1,
), ),
else_=0, else_=0,
@ -254,7 +116,7 @@ async def get_ai_log_stats(
) )
) )
) )
stats_stmt = _apply_project_filter(stats_stmt, tenant_id=tenant_id, project_id=project_id) stats_stmt = apply_project_filter(stats_stmt, tenant_id=tenant_id, project_id=project_id)
row = (await db.execute(stats_stmt)).one() row = (await db.execute(stats_stmt)).one()
return success_response( return success_response(
{ {
@ -296,7 +158,7 @@ async def list_execute_reply_guard_alerts(
return success_response( return success_response(
{ {
"count": len(rows), "count": len(rows),
"items": [_serialize_log_row(row) for row in rows], "items": [serialize_log_row(row) for row in rows],
} }
) )
@ -314,9 +176,9 @@ async def get_latest_ai_log(
.order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc()) .order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc())
.limit(1) .limit(1)
) )
stmt = _apply_project_filter(stmt, tenant_id=tenant_id, project_id=project_id) stmt = apply_project_filter(stmt, tenant_id=tenant_id, project_id=project_id)
row = (await db.execute(stmt)).scalar_one_or_none() row = (await db.execute(stmt)).scalar_one_or_none()
return success_response(_serialize_log_detail(row) if row else None) return success_response(serialize_log_detail(row) if row else None)
@router.get("/{log_id}") @router.get("/{log_id}")
@ -338,4 +200,4 @@ async def get_ai_log_detail(
).scalar_one_or_none() ).scalar_one_or_none()
if row is None: if row is None:
raise HTTPException(status_code=404, detail="ai_log_not_found") raise HTTPException(status_code=404, detail="ai_log_not_found")
return success_response(_serialize_log_detail(row)) return success_response(serialize_log_detail(row))

View File

@ -0,0 +1,137 @@
"""Shared serializers and query helpers for AI logs APIs."""
from __future__ import annotations
import json
from typing import Any, Optional
from sqlalchemy import and_, func, or_, select, union_all
from ..models import AIProcessingLog, FeishuItem, ProjectSignalLink, ProjectSuggestion, Signal, WorkItem
def normalize_bullets(value: Any) -> list[str]:
if isinstance(value, list):
return [str(x).strip() for x in value if str(x).strip()][:3]
if isinstance(value, str):
raw = value.strip()
if not raw:
return []
try:
parsed = json.loads(raw)
if isinstance(parsed, list):
return [str(x).strip() for x in parsed if str(x).strip()][:3]
except Exception:
pass
return [line.strip(" -•\t") for line in raw.splitlines() if line.strip()][:3]
return []
def safe_json(value: Optional[str]) -> Any:
text = (value or "").strip()
if not text:
return None
try:
return json.loads(text)
except Exception:
return None
def serialize_log_row(row: AIProcessingLog) -> dict[str, Any]:
return {
"id": str(row.id),
"feishu_message_id": row.feishu_message_id,
"feishu_item_id": str(row.feishu_item_id) if row.feishu_item_id is not None else None,
"raw_message": row.raw_message,
"project_score": row.project_score,
"signal_confidence": row.signal_confidence,
"level": row.level,
"routing_decision": row.routing_decision,
"routing_reason": row.routing_reason,
"reasoning_bullets": normalize_bullets(row.reasoning_bullets),
"processing_status": row.processing_status,
"error_message": row.error_message,
"duration_ms": row.duration_ms,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
def serialize_log_detail(row: AIProcessingLog) -> dict[str, Any]:
payload = serialize_log_row(row)
payload["ai_raw_output"] = row.ai_raw_output
payload["ai_raw_output_json"] = safe_json(row.ai_raw_output)
return payload
def apply_project_filter(stmt, *, tenant_id: int, project_id: Optional[int]):
if project_id is None:
return stmt
work_item_message_ids = select(func.distinct(WorkItem.source_message_id)).where(
and_(
WorkItem.tenant_id == tenant_id,
WorkItem.project_id == int(project_id),
WorkItem.source_system == "feishu",
WorkItem.source_message_id.is_not(None),
)
)
direct_items = select(FeishuItem.id.label("item_id")).where(
and_(
FeishuItem.tenant_id == tenant_id,
FeishuItem.matched_project_id == int(project_id),
)
)
signal_matched_items = select(Signal.feishu_item_id.label("item_id")).where(
and_(
Signal.tenant_id == tenant_id,
Signal.matched_project_id == int(project_id),
Signal.feishu_item_id.is_not(None),
)
)
linked_items = (
select(Signal.feishu_item_id.label("item_id"))
.select_from(Signal)
.join(
ProjectSignalLink,
and_(
ProjectSignalLink.tenant_id == tenant_id,
ProjectSignalLink.project_id == int(project_id),
ProjectSignalLink.signal_id == Signal.id,
),
)
.where(
and_(
Signal.tenant_id == tenant_id,
Signal.feishu_item_id.is_not(None),
)
)
)
auto_created_items = select(ProjectSuggestion.feishu_item_id.label("item_id")).where(
and_(
ProjectSuggestion.tenant_id == tenant_id,
ProjectSuggestion.created_project_id == int(project_id),
ProjectSuggestion.feishu_item_id.is_not(None),
)
)
all_item_ids = union_all(
direct_items,
signal_matched_items,
linked_items,
auto_created_items,
).subquery("project_item_ids")
item_ids_stmt = select(func.distinct(all_item_ids.c.item_id)).where(all_item_ids.c.item_id.is_not(None))
message_ids_stmt = select(func.distinct(FeishuItem.feishu_message_id)).where(
and_(
FeishuItem.tenant_id == tenant_id,
FeishuItem.id.in_(item_ids_stmt),
FeishuItem.feishu_message_id.is_not(None),
)
)
return stmt.where(
or_(
AIProcessingLog.feishu_item_id.in_(item_ids_stmt),
AIProcessingLog.feishu_message_id.in_(message_ids_stmt),
AIProcessingLog.feishu_message_id.in_(work_item_message_ids),
)
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
from __future__ import annotations
from fastapi import APIRouter
from . import routes_cadence, routes_core, routes_resume
router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"])
for _module in [routes_core, routes_resume, routes_cadence]:
router.include_router(_module.router)
__all__ = ["router"]

View File

@ -0,0 +1,183 @@
from __future__ import annotations
from .shared import *
def _normalize_noise_text(text: str) -> str:
return re.sub(r"[\\s\\??!!,,。~~、]+", "", str(text or "")).strip().lower()
def _should_hide_work_item_from_inbox(*, row: WorkItem, ingest: IngestEvent) -> bool:
intent_name = str(row.intent or "").strip().lower()
if intent_name not in FRONT_NOISE_INTENTS:
return False
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
message_text = str(payload.get("message_text") or "").strip()
normalized = _normalize_noise_text(message_text)
if not normalized:
return True
if any(keyword in normalized for keyword in FRONT_NOISE_STRUCTURED_HINTS):
return False
if normalized in FRONT_NOISE_EXACT:
return True
if len(normalized) <= 8 and any(keyword in normalized for keyword in FRONT_NOISE_HINTS):
return True
return False
def _safe_int(value: Any) -> Optional[int]:
try:
if value is None or value == "":
return None
return int(value)
except (TypeError, ValueError):
return None
def _compact_text(value: Any, limit: int) -> str:
text = " ".join(str(value or "").split()).strip()
if len(text) <= limit:
return text
return f"{text[: max(limit - 1, 1)].rstrip()}…"
def _normalize_module_list(value: Any) -> list[str]:
if not isinstance(value, list):
return []
modules: list[str] = []
for row in value:
item = str(row or "").strip()
if not item or item in modules:
continue
modules.append(item[:50])
return modules[:8]
def _is_low_value_closure_payload(payload: dict[str, Any]) -> bool:
summary = str(payload.get("closure_summary") or "").strip()
dialogue = str(payload.get("closure_dialogue") or "").strip()
modules = _normalize_module_list(payload.get("closure_modules"))
turns = payload.get("closure_turns") if isinstance(payload.get("closure_turns"), list) else []
has_closure = bool(summary or dialogue or modules)
if not has_closure:
return True
compact_summary = re.sub(r"\s+", "", summary).lower() if summary else ""
summary_is_generic = bool(compact_summary and GENERIC_SOURCE_TEXT_RE.match(compact_summary))
weak_summary = bool(summary and any(token in summary for token in LOW_VALUE_CLOSURE_HINTS))
has_assistant_turn = any(
str(item.get("role") or "").strip().lower() == "assistant" and str(item.get("text") or "").strip()
for item in turns
if isinstance(item, dict)
)
if weak_summary and not has_assistant_turn:
return True
if summary_is_generic and not modules and not has_assistant_turn:
return True
if dialogue and "机器人:" not in dialogue and len(turns) <= 1 and weak_summary:
return True
return False
def _priority_from_review(review_summary: Any) -> tuple[str, int]:
payload = review_summary if isinstance(review_summary, dict) else {}
risk_score = float(payload.get("risk_score_after") or 0.0)
if risk_score >= 0.75:
return "high", 3
if risk_score >= 0.45:
return "medium", 2
return "low", 1
def _blocking_reason(row: WorkItem) -> Optional[str]:
execution_result = row.execution_result if isinstance(row.execution_result, dict) else {}
review_summary = row.review_summary if isinstance(row.review_summary, dict) else {}
reason = str(execution_result.get("reason") or execution_result.get("error_message") or "").strip()
if reason:
return reason
divergences = review_summary.get("divergences") if isinstance(review_summary.get("divergences"), list) else []
if divergences and isinstance(divergences[0], dict):
return str(divergences[0].get("detail") or "").strip() or None
return None
def _next_step_text(row: WorkItem) -> Optional[str]:
review_summary = row.review_summary if isinstance(row.review_summary, dict) else {}
next_actions = review_summary.get("next_actions") if isinstance(review_summary.get("next_actions"), list) else []
if next_actions:
return str(next_actions[0]).strip()
action_bundle = row.action_bundle if isinstance(row.action_bundle, dict) else {}
summary = str(action_bundle.get("summary") or "").strip()
return summary or None
def _stage_text(state: str) -> str:
mapping = {
"received": "已接收",
"modeled": "已建模",
"decided": "已决策",
"planned": "已规划",
"executing": "执行中",
"waiting_manual": "待人工确认",
"succeeded": "执行完成",
"failed": "执行失败",
"ignored": "已忽略",
}
return mapping.get(str(state or "").strip(), str(state or "").strip() or "-")
def _normalize_approval_requirement(value: Any) -> Optional[dict[str, Any]]:
if not isinstance(value, dict):
return None
reason = str(value.get("reason") or value.get("policy_gate_code") or "").strip() or None
message = str(value.get("message") or value.get("hint") or value.get("detail") or "").strip() or None
return {
**value,
"required": bool(value.get("required")),
"reason": reason,
"message": message,
}
def _extract_gate_projection_from_work_item(row: WorkItem) -> dict[str, Any]:
execution_result = row.execution_result if isinstance(row.execution_result, dict) else {}
review_summary = row.review_summary if isinstance(row.review_summary, dict) else {}
policy_gate_code = (
str(
execution_result.get("policy_gate_code")
or execution_result.get("gate_code")
or review_summary.get("policy_gate_code")
or review_summary.get("gate_code")
or ""
).strip()
or None
)
approval_requirement = _normalize_approval_requirement(
execution_result.get("approval_requirement")
if isinstance(execution_result.get("approval_requirement"), dict)
else review_summary.get("approval_requirement")
)
return {
"latest_policy_gate_code": policy_gate_code,
"approval_requirement": approval_requirement,
}
async def _build_latest_decision_projection(
*,
db: AsyncSession,
tenant_id: int,
work_item_ids: list[int],
) -> dict[int, dict[str, Any]]:
if not work_item_ids:
return {}
rows = (
await db.execute(
select(DecisionRecord)
.where(
and_(
DecisionRecord.tenant_id == tenant_id,
DecisionRecord.work_item_id.in_(work_item_ids),
)
)
.order_by(DecisionRecord.work_item_id.asc(), DecisionRecord.id.desc())
)
).scalars().all()
projection: dict[int, dict[str, Any]] = {}
for row in rows:
work_item_id = _safe_int(row.work_item_id)
if work_item_id is None or work_item_id in projection:
continue
projection[work_item_id] = {
"latest_policy_gate_code": str(row.policy_gate_code or "").strip() or None,
"approval_requirement": _normalize_approval_requirement(row.approval_requirement),
}
return projection

View File

@ -0,0 +1,140 @@
from __future__ import annotations
from .shared import *
from .helpers_inbox_filters import _compact_text, _normalize_module_list, _safe_int
async def _build_closure_payload_overrides(
*,
db: AsyncSession,
tenant_id: int,
rows: list[tuple[WorkItem, IngestEvent, Optional[Project]]],
) -> dict[int, dict[str, Any]]:
if not rows:
return {}
service = AIPMOKernelService(db, tenant_id=tenant_id)
overrides: dict[int, dict[str, Any]] = {}
for row, ingest, _project in rows:
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
if not isinstance(payload, dict):
continue
has_closure = bool(
str(payload.get("closure_summary") or "").strip()
or str(payload.get("closure_dialogue") or "").strip()
or (isinstance(payload.get("closure_modules"), list) and payload.get("closure_modules"))
)
if has_closure and not _is_low_value_closure_payload(payload):
continue
message_text = str(payload.get("message_text") or "").strip()
if not message_text:
continue
source_system = str(row.source_system or "").strip() or "pmflow_manual"
captured_at_seed: Optional[str] = str(payload.get("captured_at") or "").strip() or None
if not captured_at_seed and isinstance(row.created_at, datetime):
captured_at_seed = row.created_at.isoformat()
closure_payload = await service._build_message_closure_payload(
source_system=source_system,
channel_ref=str(payload.get("channel_ref") or "").strip() or None,
sender=str(payload.get("sender") or "").strip(),
source_sender_open_id=str(payload.get("source_sender_open_id") or "").strip() or None,
message_text=message_text,
source_message_id=str(row.source_message_id or "").strip() or None,
owner_user_id=_safe_int(row.owner_user_id),
project_id=_safe_int(row.project_id),
captured_at=captured_at_seed,
)
if not any(
(
str(closure_payload.get("closure_summary") or "").strip(),
str(closure_payload.get("closure_dialogue") or "").strip(),
isinstance(closure_payload.get("closure_modules"), list) and closure_payload.get("closure_modules"),
)
):
continue
overrides[int(row.id)] = {
**payload,
"closure_summary": closure_payload.get("closure_summary"),
"closure_dialogue": closure_payload.get("closure_dialogue"),
"closure_modules": closure_payload.get("closure_modules"),
"closure_turns": closure_payload.get("closure_turns"),
}
return overrides
def _serialize_work_item_summary(
*,
row: WorkItem,
ingest: IngestEvent,
project: Optional[Project],
latest_decision_projection: Optional[dict[str, Any]] = None,
payload_override: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
payload = payload_override if isinstance(payload_override, dict) else (
ingest.payload if isinstance(ingest.payload, dict) else {}
)
message_text = str(payload.get("message_text") or "").strip()
closure_dialogue = str(payload.get("closure_dialogue") or "").strip() or None
closure_summary = str(payload.get("closure_summary") or "").strip() or None
closure_modules = _normalize_module_list(payload.get("closure_modules"))
closure_turns = payload.get("closure_turns") if isinstance(payload.get("closure_turns"), list) else []
source_captured_at = str(payload.get("captured_at") or "").strip() or None
source_sender = str(payload.get("sender") or "").strip() or None
source_channel_ref = str(payload.get("channel_ref") or "").strip() or None
summary_seed = closure_summary or message_text or _next_step_text(row) or row.intent or "AI PMO 工作项"
summary = _compact_text(summary_seed, 180)
compact_message = re.sub(r"\s+", "", message_text).lower() if message_text else ""
prefer_closure_title = bool(compact_message and GENERIC_SOURCE_TEXT_RE.match(compact_message))
title_seed = (
(closure_summary or message_text)
if prefer_closure_title
else (message_text or closure_summary or f"AI PMO · {row.intent or 'work_item'}")
)
title = _compact_text(title_seed, 64)
priority, priority_rank = _priority_from_review(row.review_summary)
project_name = None
if project is not None:
project_name = str(project.title or project.name or "").strip() or None
project_id = _safe_int(row.project_id)
decision_projection = latest_decision_projection if isinstance(latest_decision_projection, dict) else {}
latest_policy_gate_code = str(decision_projection.get("latest_policy_gate_code") or "").strip() or None
approval_requirement = _normalize_approval_requirement(decision_projection.get("approval_requirement"))
if latest_policy_gate_code is None or approval_requirement is None:
fallback_projection = _extract_gate_projection_from_work_item(row)
if latest_policy_gate_code is None:
latest_policy_gate_code = str(fallback_projection.get("latest_policy_gate_code") or "").strip() or None
if approval_requirement is None:
approval_requirement = _normalize_approval_requirement(fallback_projection.get("approval_requirement"))
return {
"id": int(row.id),
"source_system": row.source_system,
"source_message_id": row.source_message_id,
"source_message_text": _compact_text(message_text, 600) or None,
"source_conversation_closure": _compact_text(closure_dialogue, 2000) or None,
"source_captured_at": source_captured_at,
"source_sender": source_sender,
"source_channel_ref": source_channel_ref,
"closure_summary": closure_summary,
"implementation_modules": closure_modules,
"source_closure_turns": closure_turns[:4],
"state": row.state,
"intent": row.intent,
"decision_level": row.decision_level,
"project_id": project_id,
"project_name": project_name,
"signal_id": _safe_int(row.signal_id),
"reply_template_key": row.reply_template_key,
"title": title or f"工作项#{int(row.id)}",
"summary": summary,
"ai_plan": _compact_text(((row.action_bundle or {}) if isinstance(row.action_bundle, dict) else {}).get("summary") or "", 180) or None,
"blocking_reason": _blocking_reason(row),
"next_step_text": _next_step_text(row),
"stage_text": _stage_text(str(row.state or "")),
"priority": priority,
"priority_rank": priority_rank,
"latest_policy_gate_code": latest_policy_gate_code,
"approval_requirement": approval_requirement,
"target_url": f"/projects/{project_id}/board" if project_id is not None else None,
"review_summary": row.review_summary,
"execution_result": row.execution_result,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}

View File

@ -0,0 +1,191 @@
from __future__ import annotations
from .shared import *
def _queue_resume_work_item(
*,
tenant_id: int,
work_item_id: int,
source_message_id: Optional[str],
sender: str,
project_id: int,
) -> None:
message_id = (
str(source_message_id or "").strip()
or f"decision-approve-bg-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}"
)[:128]
task = asyncio.create_task(
_run_resume_work_item_in_background(
tenant_id=int(tenant_id),
work_item_id=int(work_item_id),
source_message_id=message_id,
sender=str(sender or "PMFlow用户"),
project_id=int(project_id),
),
name=f"ai-pmo-resume-{int(work_item_id)}",
)
def _on_done(done_task: asyncio.Task[None]) -> None:
if done_task.cancelled():
return
exc = done_task.exception()
if exc is not None:
logger.warning(
"ai_pmo_background_resume_failed",
tenant_id=int(tenant_id),
work_item_id=int(work_item_id),
error=str(exc)[:400],
)
task.add_done_callback(_on_done)
async def _run_resume_work_item_in_background(
*,
tenant_id: int,
work_item_id: int,
source_message_id: str,
sender: str,
project_id: int,
) -> None:
async with AsyncSessionLocal() as bg_db:
service = AIPMOKernelService(bg_db, tenant_id=int(tenant_id))
await service.process_ingress(
source_system="ai_pmo_ui",
source_message_id=source_message_id,
message_text=f"继续处理 work_item #{int(work_item_id)}",
sender=str(sender or "PMFlow用户"),
channel_ref="ai_pmo_ui",
source_sender_open_id=None,
captured_at=None,
chat_type="manual",
project_hint_id=int(project_id),
)
def _normalize_resume_result(*, raw: dict[str, Any], target_work_item: WorkItem) -> dict[str, Any]:
if not bool(raw.get("handled")):
reason = str(raw.get("error") or raw.get("reason") or "继续执行失败").strip() or "继续执行失败"
return {
"handled": False,
"status": "failed_safe",
"followup_reply": reason,
"target_work_item_id": int(target_work_item.id),
"target_state": str(target_work_item.state or "").strip() or None,
"target_intent": str(target_work_item.intent or "").strip() or None,
"project_id": _safe_int(target_work_item.project_id),
"signal_id": _safe_int(target_work_item.signal_id),
"auto_tasks": [],
}
state = None
rows = raw.get("results") if isinstance(raw.get("results"), list) else []
if rows and isinstance(rows[0], dict):
state = str(rows[0].get("state") or "").strip() or None
if not state:
state = str(target_work_item.state or "").strip() or None
if state == "succeeded":
status = "succeeded"
elif state == "waiting_manual":
status = "pending_manual"
elif state == "ignored":
status = "invalidated"
else:
status = "failed_safe"
followup_reply = str(raw.get("direct_reply_text") or "").strip()
if not followup_reply:
if status == "succeeded":
followup_reply = "已继续执行并完成。"
elif status == "pending_manual":
followup_reply = "继续执行后仍需补充信息或人工确认。"
elif status == "invalidated":
followup_reply = "该工作项已失效,不再继续执行。"
else:
followup_reply = "继续执行失败,请查看详情。"
linked_project_ids = raw.get("linked_project_ids") if isinstance(raw.get("linked_project_ids"), list) else []
project_id = _safe_int(linked_project_ids[0]) if linked_project_ids else _safe_int(target_work_item.project_id)
return {
"handled": True,
"status": status,
"followup_reply": followup_reply,
"target_work_item_id": int(target_work_item.id),
"target_state": state,
"target_intent": str(target_work_item.intent or "").strip() or None,
"project_id": project_id,
"signal_id": _safe_int(raw.get("signal_id")) or _safe_int(target_work_item.signal_id),
"auto_tasks": raw.get("auto_tasks") if isinstance(raw.get("auto_tasks"), list) else [],
}
async def _reject_work_item(
*,
db: AsyncSession,
tenant_id: int,
target: WorkItem,
current_user: User,
reason: Optional[str],
) -> dict[str, Any]:
current_state = str(target.state or "").strip()
if current_state == "ignored":
return {
"handled": True,
"status": "invalidated",
"followup_reply": "该工作项已处于失效状态。",
"target_work_item_id": int(target.id),
"target_state": "ignored",
"target_intent": str(target.intent or "").strip() or None,
"project_id": _safe_int(target.project_id),
"signal_id": _safe_int(target.signal_id),
"auto_tasks": [],
}
reject_reason = str(reason or "").strip() or "人工审批拒绝,工作项已失效。"
existing_execution = target.execution_result if isinstance(target.execution_result, dict) else {}
target.state = "ignored"
target.execution_result = {
**existing_execution,
"status": "invalidated",
"manual_decision": "reject",
"manual_reason": reject_reason,
"operator_user_id": _safe_int(current_user.id),
"processed_at": datetime.now().isoformat(),
}
target.review_summary = {
"verified": False,
"issues": ["rejected_by_user"],
"next_step": "该工作项已进入失效状态",
"operator": str(current_user.name or current_user.username or "").strip() or None,
"reason": reject_reason,
}
target.reply_template_key = "action_rejected"
decision_row = (
await db.execute(
select(DecisionRecord)
.where(
and_(
DecisionRecord.tenant_id == tenant_id,
DecisionRecord.work_item_id == int(target.id),
)
)
.order_by(DecisionRecord.id.desc())
.limit(1)
)
).scalar_one_or_none()
if decision_row is not None:
decision_row.execution_result = target.execution_result
decision_row.final_outcome = "ignored"
await db.commit()
return {
"handled": True,
"status": "invalidated",
"followup_reply": reject_reason,
"target_work_item_id": int(target.id),
"target_state": "ignored",
"target_intent": str(target.intent or "").strip() or None,
"project_id": _safe_int(target.project_id),
"signal_id": _safe_int(target.signal_id),
"auto_tasks": [],
}

View File

@ -0,0 +1,84 @@
from __future__ import annotations
from .shared import *
from .schemas import PMOCadenceRunRequest
@router.post("/cadence/run")
async def run_cadence(
request: PMOCadenceRunRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
service = AIPMOCadenceService(db, tenant_id=tenant_id)
try:
snapshot = await service.generate_snapshot(
cadence_type=request.cadence_type,
project_id=request.project_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
await db.commit()
return success_response(snapshot)
@router.get("/cadence/latest")
async def get_latest_cadence_snapshot(
cadence_type: str,
project_id: Optional[int] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
service = AIPMOCadenceService(db, tenant_id=tenant_id)
snapshot = await service.get_latest_snapshot(
cadence_type=cadence_type,
project_id=project_id,
)
return success_response(snapshot)
@router.get("/portfolio/program-view")
async def get_program_view(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
service = AIPMOCadenceService(db, tenant_id=tenant_id)
return success_response(await service.build_program_view())
@router.get("/portfolio/latest")
async def get_latest_portfolio_snapshot(
scope_type: str = "tenant",
project_id: Optional[int] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{tenant_id}"
row = (
await db.execute(
select(PortfolioSnapshot).where(
and_(
PortfolioSnapshot.tenant_id == tenant_id,
PortfolioSnapshot.scope_type == scope_type,
PortfolioSnapshot.scope_ref == scope_ref,
)
).order_by(PortfolioSnapshot.id.desc()).limit(1)
)
).scalar_one_or_none()
if row is None:
return success_response(None)
return success_response(
{
"id": int(row.id),
"scope_type": row.scope_type,
"scope_ref": row.scope_ref,
"summary": row.summary,
"payload": row.payload,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
)

View File

@ -0,0 +1,181 @@
from __future__ import annotations
from .shared import *
from .helpers_inbox_filters import _should_hide_work_item_from_inbox, _safe_int
from .helpers_inbox_payload import _build_closure_payload_overrides, _serialize_work_item_summary
from .schemas import PMOProcessTextRequest
@router.post("/process-text")
async def process_text(
request: PMOProcessTextRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AIPMOKernelService(db, tenant_id=tenant_id)
result = await service.process_ingress(
source_system="pmflow_manual",
source_message_id=request.source_message_id,
message_text=request.message_text,
sender=str(request.sender or current_user.name or current_user.username or "PMFlow用户"),
channel_ref="pmflow_manual",
source_sender_open_id=None,
captured_at=None,
chat_type="manual",
project_hint_id=request.project_id,
)
if not result.get("handled"):
raise HTTPException(status_code=400, detail=str(result.get("reason") or "AI PMO kernel 未处理该输入"))
return success_response(result)
@router.get("/work-items")
async def list_work_items(
limit: int = 20,
state: Optional[str] = None,
project_id: Optional[int] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
stmt = (
select(WorkItem, IngestEvent, Project)
.join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
.outerjoin(Project, and_(Project.tenant_id == tenant_id, Project.id == WorkItem.project_id))
.where(WorkItem.tenant_id == tenant_id)
.order_by(WorkItem.updated_at.desc(), WorkItem.id.desc())
)
if state:
stmt = stmt.where(WorkItem.state == state)
if project_id is not None:
stmt = stmt.where(WorkItem.project_id == int(project_id))
rows = (await db.execute(stmt.limit(max(1, min(limit, 100))))).all()
work_item_ids = [int(row.id) for row, _, _ in rows]
latest_decision_projection = await _build_latest_decision_projection(
db=db,
tenant_id=tenant_id,
work_item_ids=work_item_ids,
)
closure_payload_overrides = await _build_closure_payload_overrides(
db=db,
tenant_id=tenant_id,
rows=rows,
)
items = [
_serialize_work_item_summary(
row=row,
ingest=ingest,
project=project,
latest_decision_projection=latest_decision_projection.get(int(row.id)),
payload_override=closure_payload_overrides.get(int(row.id)),
)
for row, ingest, project in rows
if not _should_hide_work_item_from_inbox(row=row, ingest=ingest)
]
return success_response({"items": items})
@router.get("/work-items/{work_item_id}")
async def get_work_item(
work_item_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
row = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == tenant_id,
WorkItem.id == int(work_item_id),
)
)
)
).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="work_item_not_found")
decision_rows = (
await db.execute(
select(DecisionRecord).where(
and_(
DecisionRecord.tenant_id == tenant_id,
DecisionRecord.work_item_id == int(work_item_id),
)
).order_by(DecisionRecord.id.asc())
)
).scalars().all()
plan_rows = (
await db.execute(
select(PlanBundle).where(
and_(
PlanBundle.tenant_id == tenant_id,
PlanBundle.work_item_id == int(work_item_id),
)
).order_by(PlanBundle.id.asc())
)
).scalars().all()
reply_rows = (
await db.execute(
select(ReplyReceipt).where(
and_(
ReplyReceipt.tenant_id == tenant_id,
ReplyReceipt.work_item_id == int(work_item_id),
)
).order_by(ReplyReceipt.id.asc())
)
).scalars().all()
payload: dict[str, Any] = {
"id": int(row.id),
"source_system": row.source_system,
"source_message_id": row.source_message_id,
"state": row.state,
"intent": row.intent,
"decision_level": row.decision_level,
"project_id": _safe_int(row.project_id),
"signal_id": _safe_int(row.signal_id),
"context_snapshot": row.context_snapshot,
"action_bundle": row.action_bundle,
"execution_result": row.execution_result,
"review_summary": row.review_summary,
"reply_template_key": row.reply_template_key,
"decision_records": [
{
"id": int(item.id),
"decision_level": item.decision_level,
"decision_object_type": item.decision_object_type,
"decision_object_id": _safe_int(item.decision_object_id),
"rationale": item.rationale,
"risk_score": item.risk_score,
"budget_impact": item.budget_impact,
"resource_impact": item.resource_impact,
"simulation_result": item.simulation_result,
"policy_gate_hit": bool(item.policy_gate_hit),
"policy_gate_code": item.policy_gate_code,
"approval_requirement": item.approval_requirement,
"execution_result": item.execution_result,
"final_outcome": item.final_outcome,
}
for item in decision_rows
],
"plan_bundles": [
{
"id": int(item.id),
"plan_type": item.plan_type,
"summary": item.summary,
"payload": item.payload,
}
for item in plan_rows
],
"reply_receipts": [
{
"id": int(item.id),
"receipt_phase": item.receipt_phase,
"template_key": item.template_key,
"payload": item.payload,
"status": item.status,
}
for item in reply_rows
],
}
return success_response(payload)

View File

@ -0,0 +1,151 @@
from __future__ import annotations
from .shared import *
from .helpers_resume import _queue_resume_work_item, _reject_work_item
from .schemas import PMOResumeWorkItemRequest, PMOWorkItemDecisionRequest
@router.post("/work-items/{work_item_id}/resume")
async def resume_work_item(
work_item_id: int,
request: PMOResumeWorkItemRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
target = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == tenant_id,
WorkItem.id == int(work_item_id),
)
).limit(1)
)
).scalar_one_or_none()
if target is None:
raise HTTPException(status_code=404, detail="work_item_not_found")
action_text_map = {
"continue": "继续处理",
"commit": "确认并提交候选",
"refresh": "换一批候选",
"cancel": "取消本次候选",
}
action_message = action_text_map.get(str(request.action or "").strip().lower(), "继续处理")
service = AIPMOKernelService(db, tenant_id=tenant_id)
raw_result = await service.process_ingress(
source_system="ai_pmo_ui",
source_message_id=request.source_message_id or f"resume-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}",
message_text=f"{action_message} work_item #{int(work_item_id)}",
sender=str(current_user.name or current_user.username or "PMFlow用户"),
channel_ref="ai_pmo_ui",
source_sender_open_id=None,
captured_at=None,
chat_type="manual",
project_hint_id=request.project_id or _safe_int(target.project_id),
)
result = _normalize_resume_result(raw=raw_result, target_work_item=target)
return success_response(result)
@router.post("/work-items/{work_item_id}/decision")
async def decide_work_item(
work_item_id: int,
request: PMOWorkItemDecisionRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
target = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == tenant_id,
WorkItem.id == int(work_item_id),
)
).limit(1)
)
).scalar_one_or_none()
if target is None:
raise HTTPException(status_code=404, detail="work_item_not_found")
if request.action == "reject":
result = await _reject_work_item(
db=db,
tenant_id=tenant_id,
target=target,
current_user=current_user,
reason=request.reason,
)
return success_response(result)
if str(target.state or "").strip() == "waiting_manual" and request.project_id is not None:
target.project_id = int(request.project_id)
target.state = "executing"
target.reply_template_key = "action_processing"
current_execution = target.execution_result if isinstance(target.execution_result, dict) else {}
target.execution_result = {
**current_execution,
"status": "executing",
"manual_decision": "approve",
"resume_trigger": "queued_background",
"project_id": int(request.project_id),
"queued_at": datetime.now().isoformat(),
}
decision_row = (
await db.execute(
select(DecisionRecord)
.where(
and_(
DecisionRecord.tenant_id == tenant_id,
DecisionRecord.work_item_id == int(target.id),
)
)
.order_by(DecisionRecord.id.desc())
.limit(1)
)
).scalar_one_or_none()
if decision_row is not None and str(decision_row.policy_gate_code or "").strip() == "missing_project_scope":
decision_row.policy_gate_hit = False
decision_row.policy_gate_code = None
decision_row.approval_requirement = {
"required": False,
"reason": "project_scope_confirmed",
"message": "项目归属已确认,正在后台继续执行。",
}
decision_row.final_outcome = "executing"
await db.commit()
_queue_resume_work_item(
tenant_id=tenant_id,
work_item_id=int(work_item_id),
source_message_id=request.source_message_id,
sender=str(current_user.name or current_user.username or "PMFlow用户"),
project_id=int(request.project_id),
)
return success_response(
{
"handled": True,
"status": "pending_manual",
"followup_reply": "已确认关联项目,正在后台继续执行。",
"target_work_item_id": int(target.id),
"target_state": str(target.state or "").strip() or "executing",
"target_intent": str(target.intent or "").strip() or None,
"project_id": int(request.project_id),
"signal_id": _safe_int(target.signal_id),
"auto_tasks": [],
}
)
service = AIPMOKernelService(db, tenant_id=tenant_id)
raw_result = await service.process_ingress(
source_system="ai_pmo_ui",
source_message_id=request.source_message_id or f"decision-approve-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}",
message_text=f"继续处理 work_item #{int(work_item_id)}",
sender=str(current_user.name or current_user.username or "PMFlow用户"),
channel_ref="ai_pmo_ui",
source_sender_open_id=None,
captured_at=None,
chat_type="manual",
project_hint_id=request.project_id or _safe_int(target.project_id),
)
result = _normalize_resume_result(raw=raw_result, target_work_item=target)
return success_response(result)

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from .shared import BaseModel, Field, Literal, Optional
class PMOProcessTextRequest(BaseModel):
message_text: str = Field(..., min_length=1, max_length=4000)
sender: Optional[str] = Field(default=None, max_length=120)
source_message_id: Optional[str] = Field(default=None, max_length=128)
project_id: Optional[int] = Field(default=None)
class PMOCadenceRunRequest(BaseModel):
cadence_type: str = Field(..., min_length=1, max_length=32)
project_id: Optional[int] = Field(default=None)
class PMOResumeWorkItemRequest(BaseModel):
action: Literal["continue", "commit", "refresh", "cancel"] = Field(default="continue")
project_id: Optional[int] = Field(default=None)
source_message_id: Optional[str] = Field(default=None, max_length=128)
class PMOWorkItemDecisionRequest(BaseModel):
action: Literal["approve", "reject"] = Field(..., description="审批动作:approve=继续执行,reject=标记失效")
project_id: Optional[int] = Field(default=None)
source_message_id: Optional[str] = Field(default=None, max_length=128)
reason: Optional[str] = Field(default=None, max_length=500)

View File

@ -0,0 +1,48 @@
from __future__ import annotations
import asyncio
from datetime import datetime
import re
from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ...core.logging import get_logger
from ...core.response import success_response
from ...database import AsyncSessionLocal, get_db
from ...dependencies.auth import get_current_user
from ...dependencies.tenant import get_tenant_id
from ...models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem
from ...services.ai_pmo_cadence_service import AIPMOCadenceService
from ...services.ai_pmo_kernel_service import AIPMOKernelService
router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"])
logger = get_logger(__name__)
FRONT_NOISE_INTENTS = {"query.read_only", "query.answer"}
FRONT_NOISE_EXACT = {
"收到",
"收到啦",
"好的",
"ok",
"okk",
"yes",
"no",
"谢谢",
"thanks",
"thx",
"在吗",
"在不在",
"怎么样了",
"咋样了",
"辛苦了",
}
FRONT_NOISE_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "ok", "谢谢", "收到")
FRONT_NOISE_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#")
GENERIC_SOURCE_TEXT_RE = re.compile(
r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$",
re.IGNORECASE,
)
LOW_VALUE_CLOSURE_HINTS = ("缺少项目归属", "必须先确认项目", "无法创建", "不能创建")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from fastapi import APIRouter
from . import (
routes_brief_blockers,
routes_capture,
routes_conflicts,
routes_rag_sources,
routes_stats_config,
routes_workflow,
)
router = APIRouter(prefix="/ai-steward", tags=["AI Steward"])
for _module in [
routes_rag_sources,
routes_capture,
routes_conflicts,
routes_brief_blockers,
routes_stats_config,
routes_workflow,
]:
router.include_router(_module.router)
__all__ = ["router"]

View File

@ -0,0 +1,63 @@
from __future__ import annotations
from .shared import *
def _utc_now_naive() -> datetime:
return now_cn_naive()
async def _ensure_project_signal_link(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
signal_id: int,
link_type: str = "related",
) -> None:
existing = (
await db.execute(
select(ProjectSignalLink.id).where(
and_(
ProjectSignalLink.tenant_id == int(tenant_id),
ProjectSignalLink.project_id == int(project_id),
ProjectSignalLink.signal_id == int(signal_id),
)
).limit(1)
)
).scalar_one_or_none()
if existing is not None:
return
db.add(
ProjectSignalLink(
tenant_id=int(tenant_id),
project_id=int(project_id),
signal_id=int(signal_id),
link_type=link_type,
relevance_score=1.0,
)
)
def _parse_related_users(raw_value: Any) -> List[str]:
"""兼容 involved_user_ids 的 list / json-string / 单值字符串。"""
if raw_value is None:
return []
if isinstance(raw_value, list):
return [str(item) for item in raw_value if item is not None]
if isinstance(raw_value, (bytes, bytearray)):
raw_value = raw_value.decode("utf-8", errors="ignore")
if isinstance(raw_value, str):
text = raw_value.strip()
if not text:
return []
try:
parsed = json.loads(text)
except (TypeError, ValueError, json.JSONDecodeError):
if "," in text:
return [v.strip() for v in text.split(",") if v.strip()]
return [text]
if isinstance(parsed, list):
return [str(item) for item in parsed if item is not None]
if parsed is None:
return []
return [str(parsed)]
return [str(raw_value)]

View File

@ -0,0 +1,251 @@
from __future__ import annotations
from .shared import *
from .schemas import BlockerResolveRequest, CodeCommentScanRequest, DailyBriefingResponse
@router.get("/daily-briefing")
async def get_daily_briefing(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
获取个性化每日简报
AI 为每个角色生成定制化简报,只包含:
- 与你相关的任务变更
- 风险提醒
- 需要对齐的事项
- 高影响信号
- 团队更新
彻底过滤无关信息,替代传统日报/周报
"""
try:
service = get_daily_briefing_service(db)
briefing = await service.generate_personalized_briefing(
user_id=int(current_user.id),
tenant_id=tenant_id
)
return briefing
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"生成简报失败: {str(e)}"
)
@router.get("/daily-briefing/team/{project_id}")
async def get_team_briefing(
project_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
获取团队简报(给管理者)
包含:
- 整体进度
- 每个成员的工作量
- 风险和阻塞
- 需要决策的事项
"""
try:
service = get_daily_briefing_service(db)
briefing = await service.generate_team_briefing(
project_id=project_id,
tenant_id=tenant_id
)
return briefing
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"生成团队简报失败: {str(e)}"
)
@router.post("/analyze-blocker")
async def analyze_blocker(
code_context: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
分析代码阻塞
当开发在代码注释中提到「这个接口达不到效果」时,
AI 自动触发分析,推荐替代方案或生成代码片段
"""
from ..services.llm import LLMService
service = get_ai_steward_service(db)
prompt = f"""分析以下代码/注释中提到的问题,提供解决方案:
代码上下文:
{code_context}
请提供:
1. 问题分析
2. 推荐的替代方案
3. 示例代码(如适用)
以 JSON 格式返回。"""
try:
llm = LLMService()
response = await llm.generate(
prompt,
db=db,
tenant_id=tenant_id,
)
return {
'analysis': response,
'suggestions': [],
'auto_generated': True
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"分析失败: {str(e)}"
)
@router.post("/blockers/scan-comment")
async def scan_code_comment_for_blockers(
request: CodeCommentScanRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
扫描代码注释,检测阻塞
自动识别阻塞模式:
- "等待 X 接口完成"
- "这个 API 达不到效果"
- "由于 Y 的限制,暂时跳过"
如果检测到阻塞,AI 会自动:
1. 分析严重程度
2. 生成解决方案建议
3. 创建追踪任务(如需要)
"""
try:
service = get_blocker_detection_service(db)
result = await service.scan_code_comment(
code_content=request.code_content,
file_path=request.file_path,
line_number=request.line_number,
project_id=request.project_id,
user_id=int(current_user.id),
tenant_id=tenant_id
)
if result:
return {
'status': 'blocker_detected',
'blocker': result
}
return {
'status': 'no_blocker',
'message': '未检测到阻塞'
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"扫描失败: {str(e)}"
)
@router.post("/blockers/detect-tasks")
async def detect_task_blockers(
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
检测所有任务中的阻塞
扫描所有超过3天未更新的进行中任务,
使用 AI 判断是否存在阻塞
"""
try:
service = get_blocker_detection_service(db)
blockers = await service.detect_task_blockers(tenant_id=tenant_id)
return {
'total_detected': len(blockers),
'blockers': blockers
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"检测失败: {str(e)}"
)
@router.get("/blockers/active")
async def get_active_blockers(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
获取所有活跃的阻塞
返回所有未解决的阻塞,按严重程度排序
"""
try:
service = get_blocker_detection_service(db)
blockers = await service.get_active_blockers(tenant_id=tenant_id)
# 按严重程度排序
severity_order = {'high': 0, 'medium': 1, 'low': 2}
blockers.sort(key=lambda b: severity_order.get(b['severity'], 1))
return {
'total': len(blockers),
'blockers': blockers
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"获取失败: {str(e)}"
)
@router.post("/blockers/resolve")
async def resolve_blocker(
request: BlockerResolveRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
标记阻塞为已解决
记录解决方案并关闭阻塞记录
"""
try:
service = get_blocker_detection_service(db)
result = await service.resolve_blocker(
blocker_id=request.blocker_id,
resolution_note=request.resolution_note,
tenant_id=tenant_id
)
return result
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"解决失败: {str(e)}"
)

View File

@ -0,0 +1,234 @@
from __future__ import annotations
from .shared import *
from .schemas import BrowserCaptureRequest, FeishuCaptureRequest, GitHubCaptureRequest, ProjectFromTextRequest, ProjectFromTextResponse
@router.post("/project-from-text", response_model=ProjectFromTextResponse)
async def create_project_from_text(
request: ProjectFromTextRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
零配置项目创建 - 从一句话生成完整项目
示例输入:
```
我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线
```
AI 将自动生成:
- 完整的项目配置
- 竞品分析信号
- 项目宪章
- 任务链(含依赖关系)
- 风险预警规则
"""
try:
service = get_ai_steward_service(db)
result = await service.create_project_from_natural_language(
input_text=request.input_text,
creator_id=int(current_user.id),
tenant_id=tenant_id
)
return ProjectFromTextResponse(**result)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"项目创建失败: {str(e)}"
)
@router.post("/signal/capture-feishu")
@audited_write(caller="ai")
async def capture_feishu_signal(
request: FeishuCaptureRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
从飞书群聊捕获信号
AI 会自动判断消息是否包含有价值的项目或技术信息,
如果是,则创建信号并自动关联到相关项目
"""
try:
service = ProactiveSignalCaptureService(db)
result = await service.capture_from_feishu(
group_chat_id=request.group_chat_id,
message_content=request.message_content,
sender=request.sender,
tenant_id=tenant_id,
feishu_message_id=request.feishu_message_id,
)
response_payload = dict(result) if isinstance(result, dict) else {"result": result}
should_enqueue_reply = bool(response_payload.get("direct_reply_required"))
reply_text = str(response_payload.get("direct_reply_text") or "").strip() or "已收到,正在处理。"
source_message_id = (str(request.feishu_message_id or "").strip()[:128] or None)
normalized_chat_id = str(request.group_chat_id or "").strip()
if should_enqueue_reply and normalized_chat_id:
try:
outbox_id = await FeishuOutboxService.enqueue(
db,
tenant_id=int(tenant_id),
chat_id=normalized_chat_id,
text=reply_text,
source_message_id=source_message_id,
dedupe_seed=f"capture-feishu:{normalized_chat_id}:{str(request.sender or '').strip()[:64]}",
trace_id=str(response_payload.get("ai_trace_id") or "").strip()[:64] or None,
)
await db.commit()
response_payload["reply_outbox_enqueued"] = bool(outbox_id)
response_payload["reply_outbox_id"] = int(outbox_id) if outbox_id else None
except Exception as outbox_exc:
await db.rollback()
response_payload["reply_outbox_enqueued"] = False
response_payload["reply_outbox_error"] = str(outbox_exc)[:200]
return response_payload
except Exception as e:
raise HTTPException(status_code=500, detail=f"飞书信号捕获失败: {str(e)}")
@router.post("/signal/capture-browser")
async def capture_browser_signal(
request: BrowserCaptureRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
从浏览器标签页捕获信号
监控用户访问的技术文档、竞品页面、GitHub issues等
"""
try:
service = ProactiveSignalCaptureService(db)
result = await service.capture_from_browser(
user_id=request.user_id,
url=request.url,
title=request.title,
content_preview=request.content_preview,
tenant_id=tenant_id
)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"浏览器信号捕获失败: {str(e)}")
@router.post("/signal/capture-github")
async def capture_github_signal(
request: GitHubCaptureRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
从GitHub事件捕获信号
监控竞品仓库更新、上游依赖更新等
"""
try:
service = ProactiveSignalCaptureService(db)
result = await service.capture_from_github(
repo_url=request.repo_url,
event_type=request.event_type,
event_data=request.event_data,
tenant_id=tenant_id
)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"GitHub信号捕获失败: {str(e)}")
@router.post("/signal/start-hn-monitor")
async def start_hn_monitor(
background_tasks: BackgroundTasks,
interval_minutes: int = 30,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
启动 HackerNews 定时监控(后台任务)
定期抓取 HackerNews 首页,分析技术趋势
"""
async def run_monitor():
service = ProactiveSignalCaptureService(db)
await service.start_hackernews_monitor(interval_minutes=interval_minutes, tenant_id=tenant_id)
background_tasks.add_task(run_monitor)
return {
"status": "started",
"message": f"HackerNews 监控已启动,间隔: {interval_minutes} 分钟"
}
@router.post("/sync-signal")
async def sync_external_signal(
source: str,
content: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
手动同步外部信号
支持从以下来源同步:
- 飞书群聊
- GitHub commits/PRs
- 浏览器标签页
"""
from ..models import Signal
from ..services.ai_steward import get_ai_steward_service
service = get_ai_steward_service(db)
# AI 分析信号重要性
prompt = f"""分析以下信号的重要性:
来源:{source}
内容:{content}
请评估:
1. impact_score (0-1)
2. priority (low/medium/high/critical)
3. 是否需要立即行动
以 JSON 格式返回。"""
try:
from ..services.llm import LLMService
llm = LLMService()
response = await llm.generate(
prompt,
db=db,
tenant_id=tenant_id,
)
# 创建信号
signal = Signal(
source=source,
content=content,
impact_score=0.7,
priority='medium',
processed=False,
tenant_id=tenant_id
)
db.add(signal)
await db.commit()
return {
'signal_id': str(signal.id),
'analysis': response,
'status': 'captured'
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"信号同步失败: {str(e)}"
)

View File

@ -0,0 +1,253 @@
from __future__ import annotations
from .shared import *
from .helpers import _parse_related_users, _utc_now_naive
from .schemas import CognitiveConflictResponse, ConflictDetectRequest
@router.get("/cognitive-conflicts", response_model=List[CognitiveConflictResponse])
async def get_cognitive_conflicts(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
project_id: Optional[int] = None
):
"""
获取认知冲突列表
AI 会自动识别团队中的隐性认知差异,例如:
- 产品说"用户留存提升10%",开发实现的是"DAU提升10%"
- 双方没有争论,但目标不一致,AI 会主动识别并推送对齐提醒
"""
from ..models import CognitiveConflict, TruthNode
from sqlalchemy.future import select
query = select(CognitiveConflict).where(
CognitiveConflict.tenant_id == tenant_id,
CognitiveConflict.status == 'active'
)
if project_id:
# 通过 truth_node 关联到项目
query = query.join(
TruthNode, CognitiveConflict.truth_node_id == TruthNode.id
).where(
TruthNode.project_id == project_id
)
result = await db.execute(query)
conflicts = result.scalars().all()
return [
CognitiveConflictResponse(
id=str(c.id),
type=c.conflict_type,
description=c.description,
severity="high" if "critical" in (c.description or "").lower() else "medium",
related_users=_parse_related_users(c.involved_user_ids),
)
for c in conflicts
]
@router.post("/conflicts/detect")
async def detect_conflicts(
request: ConflictDetectRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
检测项目中的认知冲突
支持检测类型:
- all: 全部检测(宪章冲突 + 定义冲突)
- charter: 仅检测宪章中的潜在冲突
- definition: 仅检测用户定义冲突
"""
try:
service = get_cognitive_conflict_service(db)
detected_conflicts = []
if request.detect_type in ["all", "charter"]:
charter_conflicts = await service.detect_charter_conflicts(
project_id=request.project_id,
tenant_id=tenant_id
)
detected_conflicts.extend(charter_conflicts)
if request.detect_type in ["all", "definition"]:
definition_conflicts = await service.detect_user_definition_conflicts(
project_id=request.project_id,
tenant_id=tenant_id
)
detected_conflicts.extend(definition_conflicts)
return {
"project_id": request.project_id,
"detected_count": len(detected_conflicts),
"conflicts": detected_conflicts
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"冲突检测失败: {str(e)}")
@router.post("/conflicts/{conflict_id}/propose-resolution")
async def propose_conflict_resolution(
conflict_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
AI 生成认知冲突解决方案
为冲突双方生成对齐方案:
1. 分析冲突根因
2. 提出妥协方案
3. 生成可执行的对齐计划
"""
try:
service = get_cognitive_conflict_service(db)
resolution = await service.propose_alignment_resolution(
conflict_id=conflict_id,
tenant_id=tenant_id
)
return resolution
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"方案生成失败: {str(e)}")
@router.post("/conflicts/{conflict_id}/align")
async def trigger_async_alignment(
conflict_id: int,
user1_id: int,
user2_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
触发异步 1v1 认知对齐
为冲突双方创建独立的对齐任务:
1. 发送对齐邀请
2. 提供背景信息
3. 收集双方观点
4. AI 生成共识方案
"""
try:
service = get_cognitive_conflict_service(db)
alignment = await service.trigger_async_alignment(
conflict_id=conflict_id,
user1_id=user1_id,
user2_id=user2_id,
tenant_id=tenant_id
)
return alignment
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"对齐触发失败: {str(e)}")
@router.post("/cognitive-conflicts/{conflict_id}/resolve")
async def resolve_cognitive_conflict(
conflict_id: int,
resolution_note: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
解决认知冲突
AI 会生成解决方案建议,双方确认后标记为已解决
"""
from ..models import CognitiveConflict
from sqlalchemy.future import select
result = await db.execute(
select(CognitiveConflict).where(
CognitiveConflict.id == conflict_id,
CognitiveConflict.tenant_id == tenant_id
)
)
conflict = result.scalar_one_or_none()
if not conflict:
raise HTTPException(status_code=404, detail="冲突记录不存在")
conflict.status = 'resolved'
conflict.resolved_at = _utc_now_naive()
conflict.description += f"\n\n解决方案({current_user.username}): {resolution_note}"
await db.commit()
return {"message": "冲突已标记为已解决"}
@router.get("/conflicts/task-mismatch/{task_id}")
async def check_task_charter_mismatch(
task_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
检测任务与宪章的一致性
检查:
1. 任务描述是否与宪章目标一致
2. 技术选型是否符合约束
3. 是否偏离项目方向
"""
from ..models import Task, TruthNode
from sqlalchemy.future import select
try:
# 获取任务
result = await db.execute(
select(Task).where(
Task.id == task_id,
Task.tenant_id == tenant_id
)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
# 获取宪章
charter_result = await db.execute(
select(TruthNode).where(
TruthNode.id == task.truth_node_id,
TruthNode.tenant_id == tenant_id
)
)
charter = charter_result.scalar_one_or_none()
if not charter:
raise HTTPException(status_code=404, detail="宪章不存在")
# 检测一致性
service = get_cognitive_conflict_service(db)
mismatch = await service.detect_task_charter_mismatch(
task=task,
charter_content=charter.content,
tenant_id=tenant_id
)
if mismatch:
return {
"is_aligned": False,
"mismatch": mismatch
}
return {
"is_aligned": True,
"message": "任务与宪章一致"
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"一致性检测失败: {str(e)}")

View File

@ -0,0 +1,151 @@
from __future__ import annotations
from .shared import *
from .helpers import _ensure_project_signal_link
from .schemas import SignalSourceListResponse, SignalSourceOnboardRequest, SignalSourceRepairRequest
@router.post("/rag/ask")
async def ask_ai_steward_rag(
request: RAGAskRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
service = AdaptiveRAGService(db, tenant_id=effective_tenant_id)
result = await service.answer_question(
question=request.question,
asked_by=int(current_user.id),
asked_via="pmflow",
project_id=request.project_id,
source_chat_id=request.source_chat_id,
session_id=request.session_id,
force_new_session=bool(request.new_session),
thinking_mode=request.thinking_mode,
action_mode=request.action_mode,
sync_requirement=bool(request.sync_requirement),
)
return success_response(result)
@router.post("/signal-sources/onboard")
@audited_write(caller="user")
async def onboard_signal_source(
request: SignalSourceOnboardRequest,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
signal = Signal(
tenant_id=effective_tenant_id,
source="PMFlow",
content=f"[PMFlow用户#{int(current_user.id)}]: {request.instruction[:1800]}",
impact_score=0.7,
priority="medium",
processed=False,
strategic_analysis=json.dumps(
{
"source": "pmflow_manual_signal_source_onboard",
"instruction": request.instruction[:2000],
"created_at": now_cn_aware().isoformat(),
},
ensure_ascii=False,
),
tasks_generated=json.dumps([], ensure_ascii=False),
matched_project_id=int(request.project_id),
title=(request.instruction[:80] or "新增信号源"),
signal_type="新需求",
summary=request.instruction[:300],
reasoning=json.dumps(["来自 AI Steward 手动信号源接入请求。"], ensure_ascii=False),
confidence=0.82,
recommended_action="enter_only",
status="待处理",
)
if not request.dry_run:
db.add(signal)
await db.flush()
await _ensure_project_signal_link(
db,
tenant_id=effective_tenant_id,
project_id=int(request.project_id),
signal_id=int(signal.id),
)
await db.commit()
await db.refresh(signal)
signal_id_value = int(signal.id) if not request.dry_run and getattr(signal, "id", None) is not None else None
service = SignalSourceOrchestrationService(db)
result = await service.orchestrate_from_signal(
tenant_id=effective_tenant_id,
project_id=int(request.project_id),
signal_id=signal_id_value,
initiating_text=request.instruction,
requested_via="pmflow",
operator_user_id=int(current_user.id),
dry_run=bool(request.dry_run),
response_mode=request.response_mode,
)
return success_response(
{
"signal_id": signal_id_value,
**result,
}
)
@router.get("/orchestration-runs/{trace_id}")
async def get_orchestration_run(
trace_id: str,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
row = await AIOrchestrationRunService(db).get_run(
tenant_id=effective_tenant_id,
trace_id=str(trace_id or "").strip(),
)
if row is None:
raise HTTPException(status_code=404, detail="orchestration run not found")
return success_response(AIOrchestrationRunService.serialize_run(row))
@router.get("/projects/{project_id}/signal-sources")
async def list_project_signal_sources(
project_id: int,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
items = await SignalSourceOrchestrationService(db).list_project_sources(
tenant_id=effective_tenant_id,
project_id=int(project_id),
)
return success_response(SignalSourceListResponse(items=items).model_dump())
@router.post("/signal-sources/repair-dirty")
@audited_write(caller="system")
async def repair_dirty_signal_sources(
request: SignalSourceRepairRequest,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
try:
rows = await SignalSourceRepairService(db).repair_registries(
tenant_id=effective_tenant_id,
project_id=int(request.project_id),
registry_ids=[int(item) for item in request.registry_ids],
sender=str(request.sender or "PMFlow修复器").strip()[:64] or "PMFlow修复器",
)
except SignalSourceRepairTimeoutError as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except SignalSourceRepairMissingSignalError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return success_response(
{
"project_id": int(request.project_id),
"items": rows,
}
)

View File

@ -0,0 +1,197 @@
from __future__ import annotations
from .shared import *
from .helpers import _utc_now_naive
@router.get("/stats")
async def get_ai_steward_stats(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
获取 AI Steward 统计数据
展示 AI 为团队节省的工作量:
- 自动捕获的信号数
- 避免的认知冲突数
- 自动解决的阻塞数
- 节省的会议时间(小时)
"""
from ..models import Signal, CognitiveConflict
from sqlalchemy import func, text
# 统计信号数量
week_ago = _utc_now_naive() - timedelta(days=7)
month_ago = _utc_now_naive() - timedelta(days=30)
signal_result = await db.execute(
select(func.count(Signal.id)).where(
Signal.tenant_id == tenant_id,
Signal.created_at >= week_ago
)
)
signals_captured = signal_result.scalar() or 0
# 统计高影响信号
high_impact_result = await db.execute(
select(func.count(Signal.id)).where(
Signal.tenant_id == tenant_id,
Signal.impact_score >= 0.8,
Signal.created_at >= week_ago
)
)
high_impact_count = high_impact_result.scalar() or 0
# 统计认知冲突
conflict_result = await db.execute(
select(func.count(CognitiveConflict.id)).where(
CognitiveConflict.tenant_id == tenant_id,
CognitiveConflict.status.in_(['detected', 'active'])
)
)
active_conflicts = conflict_result.scalar() or 0
# 统计已解决的冲突
resolved_conflict_result = await db.execute(
select(func.count(CognitiveConflict.id)).where(
CognitiveConflict.tenant_id == tenant_id,
CognitiveConflict.status == 'resolved',
CognitiveConflict.resolved_at >= month_ago
)
)
conflicts_resolved = resolved_conflict_result.scalar() or 0
# 统计阻塞
blocker_result = await db.execute(
text("""
SELECT COUNT(*) FROM cognitive_conflicts
WHERE tenant_id = :tenant_id
AND conflict_type IN ('code_blocker', 'task_blocker')
AND status = 'detected'
"""),
{"tenant_id": tenant_id}
)
active_blockers = blocker_result.scalar() or 0
# 活跃项目数
from ..models import Project
project_result = await db.execute(
select(func.count(Project.id)).where(
Project.tenant_id == tenant_id,
Project.status == 'active'
)
)
active_projects = project_result.scalar() or 0
# 估算节省时间(每个冲突/阻塞避免约2小时会议)
hours_saved = conflicts_resolved * 2
stats = {
'signals_captured': signals_captured,
'high_impact_signals': high_impact_count,
'active_conflicts': active_conflicts,
'conflicts_resolved': conflicts_resolved,
'active_blockers': active_blockers,
'active_projects': active_projects,
'hours_saved': hours_saved,
'last_updated': now_cn_aware().isoformat()
}
return stats
@router.get("/config/suggest")
async def get_ai_config_suggestion(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
AI 建议配置方案
根据团队现状,智能推荐应该优先配置哪些集成源
"""
from ..models import Integration, Project, Signal
from sqlalchemy.future import select
from sqlalchemy import func
# 检查已配置的集成
configured_sources = []
# 检查飞书配置
feishu_result = await db.execute(
select(Integration).where(
Integration.tenant_id == tenant_id,
Integration.type == 'feishu',
Integration.is_active == True
)
)
if feishu_result.scalar_one_or_none():
configured_sources.append('feishu')
# 检查活跃项目数
project_result = await db.execute(
select(func.count(Project.id)).where(
Project.tenant_id == tenant_id,
Project.status == 'active'
)
)
active_projects = project_result.scalar() or 0
# 检查近期信号数量
signal_result = await db.execute(
select(func.count(Signal.id)).where(
Signal.tenant_id == tenant_id,
Signal.created_at >= (_utc_now_naive() - timedelta(days=7))
)
)
recent_signals = signal_result.scalar() or 0
# AI 分析并推荐配置
recommended = []
reasoning_parts = []
# 根据项目数量推荐
if active_projects > 0:
if 'feishu' not in configured_sources:
recommended.append('feishu')
reasoning_parts.append(f"您有 {active_projects} 个活跃项目,建议配置飞书群聊集成以自动捕获需求讨论")
# 根据信号量推荐
if recent_signals < 10:
if 'hn' not in [s.split('_')[0] for s in configured_sources]:
recommended.append('hn')
reasoning_parts.append("近期信号较少,建议启用 HackerNews 监控获取更多技术趋势")
# 默认推荐
if not recommended:
if 'feishu' not in configured_sources:
recommended.append('feishu')
if 'hn' not in [s.split('_')[0] for s in configured_sources]:
recommended.append('hn')
reasoning_parts.append("建议配置基础集成源以启动 AI 主动捕获能力")
reasoning = ";".join(reasoning_parts) if reasoning_parts else "所有基础集成已配置,AI 主动捕获能力已全面启用"
# 估算影响
impact_levels = {
0: "暂无明显影响",
1: "预计每周节省 2 小时手动录入时间",
2: "预计每周节省 4 小时手动录入时间,捕获 10+ 个需求信号",
3: "预计每周节省 6 小时会议时间,自动对齐 5+ 个认知冲突",
4: "预计每周节省 8 小时会议时间,主动捕获 20+ 个需求信号"
}
impact = impact_levels.get(len(recommended), impact_levels[4])
return {
'recommended_sources': recommended,
'reasoning': reasoning,
'estimated_impact': impact,
'current_config': {
'feishu_configured': 'feishu' in configured_sources,
'hn_configured': any('hn' in s for s in configured_sources),
'github_configured': any('github' in s for s in configured_sources),
'browser_installed': False # 需要前端检测
}
}

View File

@ -0,0 +1,149 @@
from __future__ import annotations
from .shared import *
from .schemas import WorkflowTriggerRequest
@router.post("/workflow/trigger")
async def trigger_workflow(
request: WorkflowTriggerRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
触发事件驱动工作流
支持的事件类型:
- high_impact_signal: 高影响信号到达
- blocker_detected: 阻塞检测
- cognitive_conflict: 认知冲突
- tech_stack_change: 技术栈变更
- competitor_update: 竞品动态
- task_completed: 任务完成
- task_blocked: 任务阻塞
- project_risk: 项目风险
工作流会自动:
1. 调整项目优先级
2. 创建响应任务
3. 通知相关角色
4. 更新依赖关系
"""
try:
engine = get_event_workflow_engine(db)
result = await engine.trigger_workflow(
event_type=request.event_type,
event_data=request.event_data,
tenant_id=tenant_id
)
return result
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"工作流触发失败: {str(e)}"
)
@router.post("/workflow/signal/{signal_id}")
async def trigger_signal_workflow(
signal_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
当接收到高影响信号时自动触发工作流
检查信号的 impact_score,如果超过阈值(0.85),
自动触发工作流来调整相关项目的优先级
"""
from ..models import Signal
from sqlalchemy.future import select
signal_result = await db.execute(
select(Signal).where(
Signal.id == signal_id,
Signal.tenant_id == tenant_id
)
)
signal = signal_result.scalar_one_or_none()
if not signal:
raise HTTPException(status_code=404, detail="信号不存在")
# 检查是否达到阈值
if signal.impact_score < 0.85:
return {
'status': 'ignored',
'reason': f'信号影响分数 {signal.impact_score} 低于阈值 0.85'
}
# 触发高影响信号工作流
engine = get_event_workflow_engine(db)
result = await engine.trigger_workflow(
event_type=WorkflowEventType.HIGH_IMPACT_SIGNAL,
event_data={'signal_id': signal_id},
tenant_id=tenant_id
)
return result
@router.get("/workflow/status")
async def get_workflow_status(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
获取工作流状态
显示最近触发的工作流及其结果
"""
from ..models import AICallLog
from sqlalchemy.future import select
# 获取最近的工作流记录
# 这里简化处理,实际应该有专门的工作流记录表
return {
'active_workflows': [],
'recent_triggers': [],
'message': '工作流状态追踪'
}
@router.post("/workflow/auto-enable")
async def enable_auto_workflow(
project_id: int,
enable: bool = True,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
启用/禁用项目的自动工作流
启用后,当以下情况发生时会自动触发工作流:
- 高影响信号到达(>0.85)
- 任务阻塞检测
- 认知冲突检测
"""
from ..models import Project
from sqlalchemy.future import select
project_result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.tenant_id == tenant_id
)
)
project = project_result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 这里应该设置项目的 auto_workflow 标志
# 由于 Project 模型可能没有这个字段,暂时返回成功
return {
'project_id': project_id,
'auto_workflow': enable,
'message': f'自动工作流已{"启用" if enable else "禁用"}'
}

View File

@ -0,0 +1,96 @@
from __future__ import annotations
from .shared import *
class ProjectFromTextRequest(BaseModel):
"""零配置项目创建请求"""
input_text: str = Field(
...,
description="自然语言项目描述,例如:我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线",
min_length=10,
max_length=500
)
class ProjectFromTextResponse(BaseModel):
"""零配置项目创建响应"""
project: Dict[str, Any]
signal: Dict[str, Any]
charter: Dict[str, Any]
tasks: List[Dict[str, Any]]
risks: List[Dict[str, Any]]
analysis: Dict[str, Any]
class CognitiveConflictResponse(BaseModel):
"""认知冲突响应"""
id: str
type: str
description: str
severity: str
resolution_suggestion: Optional[str] = None
related_users: List[str]
class DailyBriefingResponse(BaseModel):
"""每日简报响应"""
date: str
total_updates: int
your_updates: int
relevant_changes: List[Dict[str, Any]]
risk_alerts: List[Dict[str, Any]]
alignment_needed: List[Dict[str, Any]]
class FeishuCaptureRequest(BaseModel):
"""飞书信号捕获请求"""
group_chat_id: str
message_content: str
sender: str
feishu_message_id: Optional[str] = None
class BrowserCaptureRequest(BaseModel):
"""浏览器信号捕获请求"""
user_id: int
url: str
title: str
content_preview: str
class GitHubCaptureRequest(BaseModel):
"""GitHub信号捕获请求"""
repo_url: str
event_type: str
event_data: Dict[str, Any]
class ConflictDetectRequest(BaseModel):
"""认知冲突检测请求"""
project_id: int
detect_type: str = "all" # all, charter, definition
class SignalSourceOnboardRequest(BaseModel):
project_id: int = Field(..., ge=1)
instruction: str = Field(..., min_length=6, max_length=2000)
dry_run: bool = False
response_mode: Literal["fast", "complete"] = "fast"
class SignalSourceListResponse(BaseModel):
items: List[Dict[str, Any]]
class SignalSourceRepairRequest(BaseModel):
project_id: int = Field(..., ge=1)
registry_ids: List[int] = Field(default_factory=list, min_length=1, max_length=50)
sender: str = Field(default="PMFlow修复器", min_length=1, max_length=64)
class CodeCommentScanRequest(BaseModel):
"""代码注释扫描请求"""
code_content: str
file_path: str
line_number: int
project_id: int
class BlockerResolveRequest(BaseModel):
"""阻塞解决请求"""
blocker_id: int
resolution_note: str
class WorkflowTriggerRequest(BaseModel):
"""工作流触发请求"""
event_type: str
event_data: Dict[str, Any]

View File

@ -0,0 +1,37 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Literal, Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ...core.response import success_response
from ...database import get_db
from ...dependencies.auth import get_current_user
from ...dependencies.permissions import require_manager_user
from ...dependencies.tenant import get_tenant_id
from ...models import ProjectSignalLink, Signal, User
from ...schemas.rag import RAGAskRequest
from ...services.adaptive_rag_service import AdaptiveRAGService
from ...services.ai_orchestration_run_service import AIOrchestrationRunService
from ...services.ai_steward import AIStewardService, get_ai_steward_service
from ...services.blocker_detection import get_blocker_detection_service
from ...services.cognitive_conflict import get_cognitive_conflict_service
from ...services.daily_briefing import get_daily_briefing_service
from ...services.event_workflow import WorkflowEventType, get_event_workflow_engine
from ...services.feishu_outbox_service import FeishuOutboxService
from ...services.signal_capture import ProactiveSignalCaptureService
from ...services.signal_source_orchestration_service import SignalSourceOrchestrationService
from ...services.signal_source_repair_service import (
SignalSourceRepairMissingSignalError,
SignalSourceRepairService,
SignalSourceRepairTimeoutError,
)
from ...utils.audited_write import audited_write
from ...utils.timezone import now_cn_aware, now_cn_naive
router = APIRouter(prefix="/ai-steward", tags=["AI Steward"])

View File

@ -1,381 +1,7 @@
""" """PMFlow 人员能力图谱 API 端点。"""
PMFlow 人员能力图谱 API 端点
- 技能标签 CRUD from __future__ import annotations
- 用户技能绑定
- 团队负载看板
- 用户个人负载查询
- 人员推荐
"""
from typing import List, Optional from .skill_api import router
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Query __all__ = ["router"]
from sqlalchemy.ext.asyncio import AsyncSession
from ...database import get_db
from ...dependencies import get_current_user, require_roles, get_tenant_id
from ...models import User
from ...schemas.skill import (
SkillTagCreate,
SkillTagUpdate,
SkillTagResponse,
UserSkillBindingCreate,
UserSkillBindingUpdate,
UserSkillBindingBatchUpdate,
UserSkillBindingResponse,
UserWorkloadResponse,
PersonnelRecommendationResponse,
PersonnelRecommendationItem,
SkillTaskHistoryResponse,
SkillTaskHistoryItem,
)
from ...services import skill_service
from ...services import workload_service
from ...services.auth import has_any_role
router = APIRouter(prefix="/skills", tags=["人员能力图谱"])
def _skill_tag_to_response(tag) -> SkillTagResponse:
"""将 SkillTag ORM 转为响应模型"""
return SkillTagResponse(
id=str(tag.id),
tenant_id=tag.tenant_id,
name=tag.name,
slug=tag.slug,
category=tag.category,
description=tag.description,
is_system=tag.is_system,
sort_order=tag.sort_order,
created_at=tag.created_at.isoformat() if tag.created_at else None,
updated_at=tag.updated_at.isoformat() if tag.updated_at else None,
)
# ============================================================================
# 技能标签 CRUD
# ============================================================================
@router.get("/tags", response_model=List[SkillTagResponse])
async def list_skill_tags(
category: Optional[str] = Query(None, description="按分类过滤"),
include_system: bool = Query(True, description="是否包含系统标签"),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
列出技能标签
支持按分类过滤,系统标签可选包含。
"""
tags = await skill_service.list_skill_tags(
db, tenant_id=tenant_id, category=category, include_system=include_system
)
return [_skill_tag_to_response(t) for t in tags]
@router.post("/tags", response_model=SkillTagResponse)
async def create_skill_tag(
body: SkillTagCreate,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
"""
创建技能标签
仅 BOSS、ADMIN 可创建。
"""
try:
tag = await skill_service.create_skill_tag(
db,
tenant_id=tenant_id,
name=body.name,
slug=body.slug,
category=body.category,
description=body.description,
sort_order=body.sort_order,
)
return _skill_tag_to_response(tag)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/tags/{tag_id}", response_model=SkillTagResponse)
async def get_skill_tag(
tag_id: UUID,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取单个技能标签"""
tag = await skill_service.get_skill_tag(db, tag_id, tenant_id)
if not tag:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在")
return _skill_tag_to_response(tag)
@router.patch("/tags/{tag_id}", response_model=SkillTagResponse)
async def update_skill_tag(
tag_id: UUID,
body: SkillTagUpdate,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
"""
更新技能标签
系统预设标签不可修改。
"""
try:
tag = await skill_service.update_skill_tag(
db,
tag_id=tag_id,
tenant_id=tenant_id,
name=body.name,
category=body.category,
description=body.description,
sort_order=body.sort_order,
)
if not tag:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在")
return _skill_tag_to_response(tag)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.delete("/tags/{tag_id}")
async def delete_skill_tag(
tag_id: UUID,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
"""
删除技能标签
系统预设标签不可删除。
"""
try:
ok = await skill_service.delete_skill_tag(db, tag_id, tenant_id)
if not ok:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在")
return {"message": "已删除"}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ============================================================================
# 用户技能绑定
# ============================================================================
@router.get("/users/{user_id}/bindings", response_model=List[UserSkillBindingResponse])
async def get_user_skill_bindings(
user_id: int,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取用户的技能绑定列表"""
bindings = await skill_service.get_user_skill_bindings(db, tenant_id, user_id)
return [
UserSkillBindingResponse(
id=str(b.id),
user_id=str(b.user_id),
skill_tag_id=str(b.skill_tag_id),
skill_name=b.skill_tag.name if b.skill_tag else "",
proficiency=b.proficiency or 50,
level=b.level or b.proficiency_level or "intermediate",
years_experience=b.years_experience,
quality_score=getattr(b, "quality_score", None),
efficiency_score=getattr(b, "efficiency_score", None),
last_used_at=b.last_used_at.isoformat() if b.last_used_at else None,
created_at=b.created_at.isoformat() if b.created_at else None,
updated_at=b.updated_at.isoformat() if b.updated_at else None,
)
for b in bindings
]
@router.put("/users/{user_id}/bindings/batch")
async def batch_update_user_skill_bindings(
user_id: int,
body: UserSkillBindingBatchUpdate,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
批量更新用户技能绑定
请求体 bindings 每项格式: { "skill_tag_id": "uuid", "proficiency": 80, "level": "advanced", "years_experience": 3 }
使用路径中的 user_id,body.user_id 可省略。
"""
bindings = await skill_service.batch_update_user_skill_bindings(
db, tenant_id, user_id, body.bindings
)
return {"updated_count": len(bindings), "message": "已更新"}
@router.get("/tags/{tag_id}/users")
async def get_skill_tag_users(
tag_id: int,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""按技能标签查询拥有该技能的用户"""
bindings = await skill_service.get_skill_tag_user_bindings(db, tenant_id, tag_id)
return [
{
"user_id": str(b.user_id),
"user_name": b.user.name if b.user else "",
"proficiency": b.proficiency,
"level": b.level,
}
for b in bindings
]
@router.get("/users/{user_id}/tasks", response_model=SkillTaskHistoryResponse)
async def get_user_skill_task_history(
user_id: int,
skill_id: int = Query(..., description="技能标签 ID"),
limit: int = Query(50, ge=1, le=200, description="返回条数上限"),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
查询用户某技能下的历史任务。
权限:
- 用户可查询自己
- BOSS / ADMIN 可查询任意用户
"""
from enum import Enum
current_role = (
current_user.role.value
if isinstance(current_user.role, Enum)
else current_user.role
)
if current_user.id != user_id and not has_any_role(str(current_role), ["BOSS", "ADMIN"]):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="仅可查询自己的技能历史任务",
)
items = await skill_service.get_user_skill_task_history(
db=db,
tenant_id=tenant_id,
user_id=user_id,
skill_tag_id=skill_id,
limit=limit,
)
return SkillTaskHistoryResponse(
items=[SkillTaskHistoryItem(**item) for item in items],
total=len(items),
)
# ============================================================================
# 团队负载看板
# ============================================================================
@router.get("/workload/team")
async def get_team_workload_board(
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
获取团队负载看板数据
返回当前周期内所有用户的工作量汇总。
"""
data = await workload_service.get_team_workload_board(db, tenant_id)
return {"items": data, "total": len(data)}
# ============================================================================
# 用户个人负载
# ============================================================================
@router.get("/workload/users/{user_id}", response_model=dict)
async def get_user_personal_workload(
user_id: int,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
获取用户个人负载详情
用户只能查询自己的负载,BOSS/ADMIN 可查询任意用户。
"""
from enum import Enum
current_role = (
current_user.role.value
if isinstance(current_user.role, Enum)
else current_user.role
)
if current_user.id != user_id and not has_any_role(str(current_role), ["BOSS", "ADMIN"]):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="仅可查询自己的负载",
)
data = await workload_service.get_user_personal_workload(db, tenant_id, user_id)
if not data:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="无负载数据")
return data
# ============================================================================
# 人员推荐
# ============================================================================
@router.get("/recommend", response_model=PersonnelRecommendationResponse)
async def recommend_personnel(
skills: str = Query(..., description="所需技能,逗号分隔,如: python,react,fastapi"),
task_description: Optional[str] = Query(None),
task_id: Optional[str] = Query(None),
limit: int = Query(5, ge=1, le=20),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
根据所需技能推荐匹配人员
返回匹配度评分、推荐理由等。
"""
required_skills = [s.strip() for s in skills.split(",") if s.strip()]
result = await skill_service.recommend_personnel(
db,
tenant_id=tenant_id,
required_skills=required_skills,
task_description=task_description,
task_id=task_id,
limit=limit,
)
return PersonnelRecommendationResponse(
task_id=result.get("task_id"),
task_description=result.get("task_description"),
required_skills=result.get("required_skills", []),
recommendations=[
PersonnelRecommendationItem(**r) for r in result.get("recommendations", [])
],
total_count=result.get("total_count", 0),
)

View File

@ -0,0 +1,18 @@
"""Router assembly for skill endpoints."""
from __future__ import annotations
from fastapi import APIRouter
from .bindings import router as bindings_router
from .recommend import router as recommend_router
from .tags import router as tags_router
from .workload import router as workload_router
router = APIRouter(prefix="/skills", tags=["人员能力图谱"])
router.include_router(tags_router)
router.include_router(bindings_router)
router.include_router(workload_router)
router.include_router(recommend_router)
__all__ = ["router"]

View File

@ -0,0 +1,115 @@
"""User skill bindings and history endpoints."""
from __future__ import annotations
from typing import List
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from ....database import get_db
from ....dependencies import get_current_user, get_tenant_id
from ....models import User
from ....schemas.skill import (
SkillTaskHistoryItem,
SkillTaskHistoryResponse,
UserSkillBindingBatchUpdate,
UserSkillBindingResponse,
)
from ....services import skill_service
from .shared import ensure_self_or_admin
router = APIRouter()
@router.get("/users/{user_id}/bindings", response_model=List[UserSkillBindingResponse])
async def get_user_skill_bindings(
user_id: int,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
bindings = await skill_service.get_user_skill_bindings(db, tenant_id, user_id)
return [
UserSkillBindingResponse(
id=str(binding.id),
user_id=str(binding.user_id),
skill_tag_id=str(binding.skill_tag_id),
skill_name=binding.skill_tag.name if binding.skill_tag else "",
proficiency=binding.proficiency or 50,
level=binding.level or binding.proficiency_level or "intermediate",
years_experience=binding.years_experience,
quality_score=getattr(binding, "quality_score", None),
efficiency_score=getattr(binding, "efficiency_score", None),
last_used_at=binding.last_used_at.isoformat() if binding.last_used_at else None,
created_at=binding.created_at.isoformat() if binding.created_at else None,
updated_at=binding.updated_at.isoformat() if binding.updated_at else None,
)
for binding in bindings
]
@router.put("/users/{user_id}/bindings/batch")
async def batch_update_user_skill_bindings(
user_id: int,
body: UserSkillBindingBatchUpdate,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
bindings = await skill_service.batch_update_user_skill_bindings(
db,
tenant_id,
user_id,
body.bindings,
)
return {"updated_count": len(bindings), "message": "已更新"}
@router.get("/tags/{tag_id}/users")
async def get_skill_tag_users(
tag_id: int,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
bindings = await skill_service.get_skill_tag_user_bindings(db, tenant_id, tag_id)
return [
{
"user_id": str(binding.user_id),
"user_name": binding.user.name if binding.user else "",
"proficiency": binding.proficiency,
"level": binding.level,
}
for binding in bindings
]
@router.get("/users/{user_id}/tasks", response_model=SkillTaskHistoryResponse)
async def get_user_skill_task_history(
user_id: int,
skill_id: int = Query(..., description="技能标签 ID"),
limit: int = Query(50, ge=1, le=200, description="返回条数上限"),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
ensure_self_or_admin(
current_user=current_user,
target_user_id=user_id,
detail="仅可查询自己的技能历史任务",
)
items = await skill_service.get_user_skill_task_history(
db=db,
tenant_id=tenant_id,
user_id=user_id,
skill_tag_id=skill_id,
limit=limit,
)
return SkillTaskHistoryResponse(
items=[SkillTaskHistoryItem(**item) for item in items],
total=len(items),
)

View File

@ -0,0 +1,51 @@
"""Personnel recommendation endpoint."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from ....database import get_db
from ....dependencies import get_current_user, get_tenant_id
from ....models import User
from ....schemas.skill import (
PersonnelRecommendationItem,
PersonnelRecommendationResponse,
)
from ....services import skill_service
router = APIRouter()
@router.get("/recommend", response_model=PersonnelRecommendationResponse)
async def recommend_personnel(
skills: str = Query(..., description="所需技能,逗号分隔,如: python,react,fastapi"),
task_description: Optional[str] = Query(None),
task_id: Optional[str] = Query(None),
limit: int = Query(5, ge=1, le=20),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
required_skills = [skill.strip() for skill in skills.split(",") if skill.strip()]
result = await skill_service.recommend_personnel(
db,
tenant_id=tenant_id,
required_skills=required_skills,
task_description=task_description,
task_id=task_id,
limit=limit,
)
return PersonnelRecommendationResponse(
task_id=result.get("task_id"),
task_description=result.get("task_description"),
required_skills=result.get("required_skills", []),
recommendations=[
PersonnelRecommendationItem(**item)
for item in result.get("recommendations", [])
],
total_count=result.get("total_count", 0),
)

View File

@ -0,0 +1,31 @@
"""Shared helpers for skill endpoints."""
from __future__ import annotations
from enum import Enum
from fastapi import HTTPException, status
from ....schemas.skill import SkillTagResponse
from ....services.auth import has_any_role
def skill_tag_to_response(tag) -> SkillTagResponse:
return SkillTagResponse(
id=str(tag.id),
tenant_id=tag.tenant_id,
name=tag.name,
slug=tag.slug,
category=tag.category,
description=tag.description,
is_system=tag.is_system,
sort_order=tag.sort_order,
created_at=tag.created_at.isoformat() if tag.created_at else None,
updated_at=tag.updated_at.isoformat() if tag.updated_at else None,
)
def ensure_self_or_admin(*, current_user, target_user_id: int, detail: str) -> None:
current_role = current_user.role.value if isinstance(current_user.role, Enum) else current_user.role
if current_user.id != target_user_id and not has_any_role(str(current_role), ["BOSS", "ADMIN"]):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)

View File

@ -0,0 +1,116 @@
"""Skill tag CRUD endpoints."""
from __future__ import annotations
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from ....database import get_db
from ....dependencies import get_current_user, get_tenant_id, require_roles
from ....models import User
from ....schemas.skill import SkillTagCreate, SkillTagResponse, SkillTagUpdate
from ....services import skill_service
from .shared import skill_tag_to_response
router = APIRouter()
@router.get("/tags", response_model=List[SkillTagResponse])
async def list_skill_tags(
category: Optional[str] = Query(None, description="按分类过滤"),
include_system: bool = Query(True, description="是否包含系统标签"),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
tags = await skill_service.list_skill_tags(
db,
tenant_id=tenant_id,
category=category,
include_system=include_system,
)
return [skill_tag_to_response(tag) for tag in tags]
@router.post("/tags", response_model=SkillTagResponse)
async def create_skill_tag(
body: SkillTagCreate,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
_ = current_user
try:
tag = await skill_service.create_skill_tag(
db,
tenant_id=tenant_id,
name=body.name,
slug=body.slug,
category=body.category,
description=body.description,
sort_order=body.sort_order,
)
return skill_tag_to_response(tag)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
@router.get("/tags/{tag_id}", response_model=SkillTagResponse)
async def get_skill_tag(
tag_id: UUID,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
tag = await skill_service.get_skill_tag(db, tag_id, tenant_id)
if not tag:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在")
return skill_tag_to_response(tag)
@router.patch("/tags/{tag_id}", response_model=SkillTagResponse)
async def update_skill_tag(
tag_id: UUID,
body: SkillTagUpdate,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
_ = current_user
try:
tag = await skill_service.update_skill_tag(
db,
tag_id=tag_id,
tenant_id=tenant_id,
name=body.name,
category=body.category,
description=body.description,
sort_order=body.sort_order,
)
if not tag:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在")
return skill_tag_to_response(tag)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
@router.delete("/tags/{tag_id}")
async def delete_skill_tag(
tag_id: UUID,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
_ = current_user
try:
ok = await skill_service.delete_skill_tag(db, tag_id, tenant_id)
if not ok:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在")
return {"message": "已删除"}
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))

View File

@ -0,0 +1,44 @@
"""Workload endpoints for skill domain."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from ....database import get_db
from ....dependencies import get_current_user, get_tenant_id
from ....models import User
from ....services import workload_service
from .shared import ensure_self_or_admin
router = APIRouter()
@router.get("/workload/team")
async def get_team_workload_board(
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
data = await workload_service.get_team_workload_board(db, tenant_id)
return {"items": data, "total": len(data)}
@router.get("/workload/users/{user_id}", response_model=dict)
async def get_user_personal_workload(
user_id: int,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
ensure_self_or_admin(
current_user=current_user,
target_user_id=user_id,
detail="仅可查询自己的负载",
)
data = await workload_service.get_user_personal_workload(db, tenant_id, user_id)
if not data:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="无负载数据")
return data

View File

@ -1,376 +1,7 @@
"""Feishu Integration API - 飞书集成接口。""" """Feishu API router compatibility entrypoint."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from ..database import get_db
from ..dependencies.auth import get_current_user
from ..dependencies.tenant import get_tenant_id
from ..models import User
from ..services.auth import has_any_role
from ..services.feishu_integration import get_feishu_service
from ..services.feishu_long_connection import get_feishu_long_connection_manager
from __future__ import annotations
router = APIRouter(prefix="/feishu", tags=["Feishu Integration"]) from .feishu_api import router
__all__ = ["router"]
def _build_long_connection_failure(long_status: Dict[str, Any]) -> tuple[str, Optional[str]]:
code = str(long_status.get("last_error_code") or "").strip().lower()
last_error = str(long_status.get("last_error") or "").strip()
retryable = bool(long_status.get("last_error_retryable"))
if code in {"network_error", "thread_exited"} and not retryable:
return (
"长连接自动重连次数已耗尽,请检查网络/凭证后手动重启飞书长连接",
last_error or None,
)
if code == "auth_failed":
return (
"长连接鉴权失败,请检查 App ID / App Secret、应用凭证是否已生效,以及飞书应用权限是否完整",
last_error or None,
)
if code == "network_error":
return (
"长连接预检失败,后端到飞书开放平台的网络或 TLS 证书链异常",
last_error or None,
)
if code == "sdk_missing":
return ("长连接 SDK 未安装(缺少 lark-oapi)", None)
if code == "thread_exited":
return ("长连接已退出,系统正在等待下一次自动重连窗口", last_error or None)
if last_error:
return ("长连接未建立,请检查配置与后端日志", last_error)
return ("长连接未建立,请检查配置后重试", None)
# ==================== Request/Response Models ====================
class FeishuConfigRequest(BaseModel):
"""飞书配置请求"""
app_id: str = Field(..., description="飞书 App ID")
app_secret: str = Field(..., description="飞书 App Secret")
encrypt_key: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端")
verification_token: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端")
event_mode: str = Field("long_connection", description="事件接收模式,现仅支持 long_connection")
class FeishuSendMessageRequest(BaseModel):
"""发送消息请求"""
chat_id: str = Field(..., description="群聊 ID")
message: str = Field(..., description="消息内容")
class FeishuSendCardRequest(BaseModel):
"""发送卡片请求"""
chat_id: str = Field(..., description="群聊 ID")
title: str = Field(..., description="卡片标题")
content: List[Dict[str, Any]] = Field(..., description="卡片内容")
# ==================== Configuration Endpoints ====================
@router.post("/config")
async def save_feishu_config(
request: FeishuConfigRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
保存飞书配置
配置信息说明:
- app_id: 飞书应用的 App ID,从飞书开放平台获取
- app_secret: 飞书应用的 App Secret
获取步骤:
1. 访问 https://open.feishu.cn/
2. 创建企业自建应用
3. 在「凭证与基础信息」页面获取 App ID 和 App Secret
4. 添加权限:im:message, im:message:group_at_msg, im:chat
5. 订阅事件:接收消息 v1 (message.receive_v1)
"""
# 检查用户权限
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成")
try:
event_mode = "long_connection"
service = get_feishu_service(db)
await service.save_config(
app_id=request.app_id,
app_secret=request.app_secret,
encrypt_key="",
verification_token="",
event_mode=event_mode,
tenant_id=tenant_id
)
manager = get_feishu_long_connection_manager()
if manager:
await manager.restart()
next_steps = [
"1. 飞书应用事件订阅至少包含:接收消息 v1 (message.receive_v1)",
"2. 当前模式:long_connection",
"3. 后端已自动重启飞书长连接",
]
return {
'status': 'success',
'message': '飞书配置已保存,并已切换到长连接模式',
'next_steps': next_steps
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"保存配置失败: {str(e)}"
)
@router.get("/config")
async def get_feishu_config(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""获取飞书配置状态。管理员可获取完整配置用于回填修改表单。"""
try:
service = get_feishu_service(db)
await service.load_config(tenant_id)
manager = get_feishu_long_connection_manager()
can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"])
return {
'is_configured': service.is_configured(),
'app_id': service.app_id,
'app_secret': service.app_secret if can_view_secrets else None,
'event_mode': "long_connection",
'long_connection': manager.status() if manager else None,
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"获取配置失败: {str(e)}"
)
@router.delete("/config")
async def delete_feishu_config(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""删除飞书配置"""
from ..models import Integration
from sqlalchemy.future import select
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
raise HTTPException(status_code=403, detail="只有管理员可以删除配置")
try:
result = await db.execute(
select(Integration).where(
Integration.tenant_id == tenant_id,
Integration.type == 'feishu'
)
)
integration = result.scalar_one_or_none()
if integration:
integration.is_active = False
await db.commit()
manager = get_feishu_long_connection_manager()
if manager:
await manager.restart()
return {'status': 'success', 'message': '飞书配置已删除'}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"删除配置失败: {str(e)}"
)
@router.get("/long-connection/status")
async def feishu_long_connection_status(
current_user: User = Depends(get_current_user),
):
"""查询飞书长连接状态。"""
manager = get_feishu_long_connection_manager()
if not manager:
return {
"status": "error",
"message": "Feishu 长连接管理器未初始化",
"mode": "long_connection",
"data": None,
}
return {
"status": "success",
"mode": "long_connection",
"data": manager.status(),
}
@router.post("/long-connection/restart")
async def restart_feishu_long_connection(
current_user: User = Depends(get_current_user),
):
"""手动重启飞书长连接。"""
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接")
manager = get_feishu_long_connection_manager()
if not manager:
raise HTTPException(status_code=500, detail="Feishu 长连接管理器未初始化")
await manager.restart()
return {
"status": "success",
"message": "Feishu 长连接已重启",
"mode": "long_connection",
"data": manager.status(),
}
# ==================== Message Endpoints ====================
@router.post("/send-message")
async def send_feishu_message(
request: FeishuSendMessageRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
发送文本消息到飞书群
用于发送简报、通知等
"""
try:
service = get_feishu_service(db)
await service.load_config(tenant_id)
result = await service.send_message_to_group(
chat_id=request.chat_id,
message=request.message
)
return {
'status': 'success',
'result': result
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"发送消息失败: {str(e)}"
)
@router.post("/send-card")
async def send_feishu_card(
request: FeishuSendCardRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
发送富文本卡片到飞书群
用于发送格式化的每日简报、风险预警等
"""
try:
service = get_feishu_service(db)
await service.load_config(tenant_id)
result = await service.send_rich_card(
chat_id=request.chat_id,
title=request.title,
content=request.content
)
return {
'status': 'success',
'result': result
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"发送卡片失败: {str(e)}"
)
# ==================== Test Endpoints ====================
@router.post("/test-connection")
async def test_feishu_connection(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""测试飞书连接是否正常"""
try:
service = get_feishu_service(db)
await service.load_config(tenant_id)
manager = get_feishu_long_connection_manager()
if not service.is_configured():
return {
'status': 'error',
'message': '飞书未配置'
}
# 尝试获取 tenant_access_token 测试连接
try:
access_token = await service._get_tenant_access_token()
event_mode = "long_connection"
long_status = manager.status() if manager else None
if not long_status:
return {
'status': 'error',
'message': '长连接管理器未初始化',
'event_mode': event_mode,
}
if not long_status.get("sdk_available"):
return {
'status': 'error',
'message': '长连接 SDK 未安装(缺少 lark-oapi)',
'event_mode': event_mode,
'long_connection': long_status,
}
if not long_status.get("thread_alive"):
message, detail = _build_long_connection_failure(long_status)
return {
'status': 'error',
'message': message,
'event_mode': event_mode,
'long_connection': long_status,
'detail': detail,
}
return {
'status': 'success',
'message': '飞书连接正常',
'has_token': bool(access_token),
'mode': 'long_connection',
'event_mode': event_mode,
'long_connection': long_status,
}
except Exception as token_error:
return {
'status': 'error',
'message': f'获取 Token 失败: {str(token_error)}',
'hint': '请检查 App ID 和 App Secret 是否正确'
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"测试连接失败: {str(e)}"
)

View File

@ -0,0 +1,5 @@
"""Feishu API module package."""
from .router import router
__all__ = ["router"]

View File

@ -0,0 +1,129 @@
"""Feishu configuration routes."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from ...database import get_db
from ...dependencies.auth import get_current_user
from ...dependencies.tenant import get_tenant_id
from ...models import Integration, User
from ...services.auth import has_any_role
from ...services.feishu_integration import get_feishu_service
from ...services.feishu_long_connection import get_feishu_long_connection_manager
from .schemas import FeishuConfigRequest
router = APIRouter()
@router.post("/config")
async def save_feishu_config(
request: FeishuConfigRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""
保存飞书配置
配置信息说明:
- app_id: 飞书应用的 App ID,从飞书开放平台获取
- app_secret: 飞书应用的 App Secret
获取步骤:
1. 访问 https://open.feishu.cn/
2. 创建企业自建应用
3. 在「凭证与基础信息」页面获取 App ID 和 App Secret
4. 添加权限:im:message, im:message:group_at_msg, im:chat
5. 订阅事件:接收消息 v1 (message.receive_v1)
"""
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成")
try:
event_mode = "long_connection"
service = get_feishu_service(db)
await service.save_config(
app_id=request.app_id,
app_secret=request.app_secret,
encrypt_key="",
verification_token="",
event_mode=event_mode,
tenant_id=tenant_id,
)
manager = get_feishu_long_connection_manager()
if manager:
await manager.restart()
next_steps = [
"1. 飞书应用事件订阅至少包含:接收消息 v1 (message.receive_v1)",
"2. 当前模式:long_connection",
"3. 后端已自动重启飞书长连接",
]
return {
"status": "success",
"message": "飞书配置已保存,并已切换到长连接模式",
"next_steps": next_steps,
}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"保存配置失败: {str(exc)}")
@router.get("/config")
async def get_feishu_config(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""获取飞书配置状态。管理员可获取完整配置用于回填修改表单。"""
try:
service = get_feishu_service(db)
await service.load_config(tenant_id)
manager = get_feishu_long_connection_manager()
can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"])
return {
"is_configured": service.is_configured(),
"app_id": service.app_id,
"app_secret": service.app_secret if can_view_secrets else None,
"event_mode": "long_connection",
"long_connection": manager.status() if manager else None,
}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"获取配置失败: {str(exc)}")
@router.delete("/config")
async def delete_feishu_config(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""删除飞书配置。"""
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
raise HTTPException(status_code=403, detail="只有管理员可以删除配置")
try:
result = await db.execute(
select(Integration).where(
Integration.tenant_id == tenant_id,
Integration.type == "feishu",
)
)
integration = result.scalar_one_or_none()
if integration:
integration.is_active = False
await db.commit()
manager = get_feishu_long_connection_manager()
if manager:
await manager.restart()
return {"status": "success", "message": "飞书配置已删除"}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"删除配置失败: {str(exc)}")

View File

@ -0,0 +1,33 @@
"""Shared helpers for Feishu API routes."""
from __future__ import annotations
from typing import Any, Dict, Optional
def build_long_connection_failure(long_status: Dict[str, Any]) -> tuple[str, Optional[str]]:
code = str(long_status.get("last_error_code") or "").strip().lower()
last_error = str(long_status.get("last_error") or "").strip()
retryable = bool(long_status.get("last_error_retryable"))
if code in {"network_error", "thread_exited"} and not retryable:
return (
"长连接自动重连次数已耗尽,请检查网络/凭证后手动重启飞书长连接",
last_error or None,
)
if code == "auth_failed":
return (
"长连接鉴权失败,请检查 App ID / App Secret、应用凭证是否已生效,以及飞书应用权限是否完整",
last_error or None,
)
if code == "network_error":
return (
"长连接预检失败,后端到飞书开放平台的网络或 TLS 证书链异常",
last_error or None,
)
if code == "sdk_missing":
return ("长连接 SDK 未安装(缺少 lark-oapi)", None)
if code == "thread_exited":
return ("长连接已退出,系统正在等待下一次自动重连窗口", last_error or None)
if last_error:
return ("长连接未建立,请检查配置与后端日志", last_error)
return ("长连接未建立,请检查配置后重试", None)

View File

@ -0,0 +1,54 @@
"""Feishu long-connection routes."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from ...dependencies.auth import get_current_user
from ...models import User
from ...services.auth import has_any_role
from ...services.feishu_long_connection import get_feishu_long_connection_manager
router = APIRouter()
@router.get("/long-connection/status")
async def feishu_long_connection_status(
current_user: User = Depends(get_current_user),
):
"""查询飞书长连接状态。"""
_ = current_user
manager = get_feishu_long_connection_manager()
if not manager:
return {
"status": "error",
"message": "Feishu 长连接管理器未初始化",
"mode": "long_connection",
"data": None,
}
return {
"status": "success",
"mode": "long_connection",
"data": manager.status(),
}
@router.post("/long-connection/restart")
async def restart_feishu_long_connection(
current_user: User = Depends(get_current_user),
):
"""手动重启飞书长连接。"""
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接")
manager = get_feishu_long_connection_manager()
if not manager:
raise HTTPException(status_code=500, detail="Feishu 长连接管理器未初始化")
await manager.restart()
return {
"status": "success",
"message": "Feishu 长连接已重启",
"mode": "long_connection",
"data": manager.status(),
}

View File

@ -0,0 +1,76 @@
"""Feishu message sending routes."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from ...database import get_db
from ...dependencies.auth import get_current_user
from ...dependencies.tenant import get_tenant_id
from ...models import User
from ...services.feishu_integration import get_feishu_service
from .schemas import FeishuSendCardRequest, FeishuSendMessageRequest
router = APIRouter()
@router.post("/send-message")
async def send_feishu_message(
request: FeishuSendMessageRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""
发送文本消息到飞书群。
用于发送简报、通知等。
"""
_ = current_user
try:
service = get_feishu_service(db)
await service.load_config(tenant_id)
result = await service.send_message_to_group(
chat_id=request.chat_id,
message=request.message,
)
return {
"status": "success",
"result": result,
}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
raise HTTPException(status_code=500, detail=f"发送消息失败: {str(exc)}")
@router.post("/send-card")
async def send_feishu_card(
request: FeishuSendCardRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""
发送富文本卡片到飞书群。
用于发送格式化的每日简报、风险预警等。
"""
_ = current_user
try:
service = get_feishu_service(db)
await service.load_config(tenant_id)
result = await service.send_rich_card(
chat_id=request.chat_id,
title=request.title,
content=request.content,
)
return {
"status": "success",
"result": result,
}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
raise HTTPException(status_code=500, detail=f"发送卡片失败: {str(exc)}")

View File

@ -0,0 +1,16 @@
"""Aggregate Feishu API routes."""
from __future__ import annotations
from fastapi import APIRouter
from .config_routes import router as config_router
from .long_connection_routes import router as long_connection_router
from .message_routes import router as message_router
from .test_routes import router as test_router
router = APIRouter(prefix="/feishu", tags=["Feishu Integration"])
router.include_router(config_router)
router.include_router(long_connection_router)
router.include_router(message_router)
router.include_router(test_router)

View File

@ -0,0 +1,32 @@
"""Schemas for Feishu API endpoints."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class FeishuConfigRequest(BaseModel):
"""飞书配置请求。"""
app_id: str = Field(..., description="飞书 App ID")
app_secret: str = Field(..., description="飞书 App Secret")
encrypt_key: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端")
verification_token: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端")
event_mode: str = Field("long_connection", description="事件接收模式,现仅支持 long_connection")
class FeishuSendMessageRequest(BaseModel):
"""发送消息请求。"""
chat_id: str = Field(..., description="群聊 ID")
message: str = Field(..., description="消息内容")
class FeishuSendCardRequest(BaseModel):
"""发送卡片请求。"""
chat_id: str = Field(..., description="群聊 ID")
title: str = Field(..., description="卡片标题")
content: List[Dict[str, Any]] = Field(..., description="卡片内容")

View File

@ -0,0 +1,82 @@
"""Feishu connection test routes."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from ...database import get_db
from ...dependencies.auth import get_current_user
from ...dependencies.tenant import get_tenant_id
from ...models import User
from ...services.feishu_integration import get_feishu_service
from ...services.feishu_long_connection import get_feishu_long_connection_manager
from .helpers import build_long_connection_failure
router = APIRouter()
@router.post("/test-connection")
async def test_feishu_connection(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""测试飞书连接是否正常。"""
_ = current_user
try:
service = get_feishu_service(db)
await service.load_config(tenant_id)
manager = get_feishu_long_connection_manager()
if not service.is_configured():
return {
"status": "error",
"message": "飞书未配置",
}
try:
access_token = await service._get_tenant_access_token()
event_mode = "long_connection"
long_status = manager.status() if manager else None
if not long_status:
return {
"status": "error",
"message": "长连接管理器未初始化",
"event_mode": event_mode,
}
if not long_status.get("sdk_available"):
return {
"status": "error",
"message": "长连接 SDK 未安装(缺少 lark-oapi)",
"event_mode": event_mode,
"long_connection": long_status,
}
if not long_status.get("thread_alive"):
message, detail = build_long_connection_failure(long_status)
return {
"status": "error",
"message": message,
"event_mode": event_mode,
"long_connection": long_status,
"detail": detail,
}
return {
"status": "success",
"message": "飞书连接正常",
"has_token": bool(access_token),
"mode": "long_connection",
"event_mode": event_mode,
"long_connection": long_status,
}
except Exception as token_error:
return {
"status": "error",
"message": f"获取 Token 失败: {str(token_error)}",
"hint": "请检查 App ID 和 App Secret 是否正确",
}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"测试连接失败: {str(exc)}")

View File

@ -1,491 +1,5 @@
"""模型能力矩阵 API""" from __future__ import annotations
from typing import Optional from .model_capability_api import router
from fastapi import APIRouter, Depends, Query
from fastapi.responses import JSONResponse
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.response import ErrorCode, error_response, success_response
from ..database import get_db
from ..dependencies import get_current_user, get_tenant_id
from ..models import Experiment, TruthNode, User
from ..schemas.model_capability import (
CostOptimizationAnalyzeRequest,
ExperimentSyncRequest,
ExperimentSyncReviewRequest,
ExperimentSyncRollbackRequest,
GenerateMigrationTasksRequest,
LifecycleImpactRequest,
ValidateMatchRequest,
)
from ..services.ai_service import get_ai_service
from ..services.cost_optimization_service import CostOptimizationService
from ..services.experiment_sync_service import ExperimentSyncService
from ..services.model_capability_service import ModelCapabilityService
from ..services.model_lifecycle_service import ModelLifecycleService
router = APIRouter(prefix="/model-capability", tags=["model-capability"])
def _build_service(db: AsyncSession) -> ModelCapabilityService:
return ModelCapabilityService(db=db, ai_service=get_ai_service())
@router.post("/validate-match")
async def validate_model_requirement_match(
request: ValidateMatchRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""需求-模型匹配校验"""
_ = current_user
service = _build_service(db)
try:
data = await service.validate_model_requirement_match(
truth_node_id=request.truth_node_id,
model_version_id=request.model_version_id,
tenant_id=tenant_id,
project_id=request.project_id,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=404,
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"匹配校验失败: {exc}"),
)
@router.get("/capability-gaps")
async def list_capability_gaps(
status: Optional[str] = Query(default=None),
severity: Optional[str] = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""列出能力差距预警"""
_ = current_user
service = _build_service(db)
try:
data = await service.list_capability_gaps(
tenant_id=tenant_id,
status=status,
severity=severity,
limit=limit,
)
return success_response(data=data)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询能力差距失败: {exc}"),
)
@router.get("/cost-tracking/{truth_node_id}")
async def get_cost_tracking(
truth_node_id: int,
period: str = Query(default="weekly", pattern="^(daily|weekly|monthly|project)$"),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""查询成本跟踪与 ROI"""
_ = current_user
service = _build_service(db)
try:
data = await service.get_cost_tracking(
truth_node_id=truth_node_id,
period=period,
tenant_id=tenant_id,
)
return success_response(data=data)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询成本跟踪失败: {exc}"),
)
@router.get("/cost-optimization/{truth_node_id}")
async def get_cost_optimization(
truth_node_id: int,
period: str = Query(default="weekly", pattern="^(daily|weekly|monthly|project)$"),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""查询成本优化建议(必要时自动触发一次计算)"""
_ = current_user
service = _build_service(db)
try:
tracking = await service.get_cost_tracking(
truth_node_id=truth_node_id,
period=period,
tenant_id=tenant_id,
)
optimization = tracking.get("optimization_suggestions") or {}
if not optimization:
computed = await service.track_cost_and_roi(
truth_node_id=truth_node_id,
period=period,
tenant_id=tenant_id,
)
optimization = computed.get("optimization_suggestions", {})
tracking = await service.get_cost_tracking(
truth_node_id=truth_node_id,
period=period,
tenant_id=tenant_id,
)
return success_response(
data={
"truth_node_id": truth_node_id,
"period": period,
"optimization_suggestions": optimization,
"roi_score": tracking.get("roi_score"),
"actual_cost_usd": tracking.get("actual_cost_usd", 0.0),
}
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询成本优化失败: {exc}"),
)
@router.post("/lifecycle/assess-impact")
async def assess_model_update_impact(
request: LifecycleImpactRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""评估模型升级对现有需求集合的影响。"""
_ = current_user
service = ModelLifecycleService(db)
try:
data = await service.assess_model_update_impact(
old_model_version_id=request.old_model_version_id,
new_model_version_id=request.new_model_version_id,
tenant_id=tenant_id,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=404,
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"影响评估失败: {exc}"),
)
@router.get("/lifecycle/impacts")
async def list_lifecycle_impacts(
limit: int = Query(default=50, ge=1, le=200),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""列出模型生命周期影响评估记录。"""
_ = current_user
service = ModelLifecycleService(db)
try:
data = await service.list_impacts(tenant_id=tenant_id, limit=limit)
return success_response(data=data)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询影响评估失败: {exc}"),
)
@router.post("/lifecycle/{impact_id}/migration-tasks")
async def create_migration_tasks(
impact_id: int,
request: GenerateMigrationTasksRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""基于影响评估自动生成迁移任务建议。"""
_ = current_user
service = ModelLifecycleService(db)
try:
data = await service.generate_migration_tasks(
impact_id=impact_id,
tenant_id=tenant_id,
max_tasks=request.max_tasks,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=404,
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"生成迁移任务失败: {exc}"),
)
@router.post("/experiment-sync")
async def create_experiment_sync(
request: ExperimentSyncRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""创建实验结果同步记录,满足阈值时自动同步。"""
_ = current_user
service = ExperimentSyncService(db)
try:
data = await service.auto_sync_experiment_result(
experiment_id=request.experiment_id,
truth_node_id=request.truth_node_id,
tenant_id=tenant_id,
project_id=request.project_id,
sync_type=request.sync_type,
auto_apply_threshold=request.auto_apply_threshold,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=404,
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"创建同步失败: {exc}"),
)
@router.get("/experiment-sync")
async def list_experiment_sync_records(
truth_node_id: Optional[int] = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""查询实验同步记录。"""
_ = current_user
service = ExperimentSyncService(db)
try:
data = await service.list_sync_records(
tenant_id=tenant_id,
truth_node_id=truth_node_id,
limit=limit,
)
return success_response(data=data)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询同步记录失败: {exc}"),
)
@router.post("/experiment-sync/{sync_id}/review")
async def review_experiment_sync(
sync_id: int,
request: ExperimentSyncReviewRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""人工审核实验同步记录。"""
service = ExperimentSyncService(db)
try:
data = await service.review_sync(
sync_id=sync_id,
tenant_id=tenant_id,
reviewer_id=current_user.id,
action=request.action,
review_notes=request.review_notes,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=400,
content=error_response(ErrorCode.BAD_REQUEST, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"审核同步失败: {exc}"),
)
@router.post("/experiment-sync/{sync_id}/rollback")
async def rollback_experiment_sync(
sync_id: int,
request: ExperimentSyncRollbackRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""回滚已应用的实验同步。"""
service = ExperimentSyncService(db)
try:
data = await service.rollback_sync(
sync_id=sync_id,
tenant_id=tenant_id,
reviewer_id=current_user.id,
rollback_notes=request.rollback_notes,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=400,
content=error_response(ErrorCode.BAD_REQUEST, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"回滚同步失败: {exc}"),
)
@router.post("/cost-optimization/analyze")
async def analyze_cost_optimization(
request: CostOptimizationAnalyzeRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""生成并持久化成本优化建议。"""
_ = current_user
service = CostOptimizationService(db)
try:
data = await service.analyze_cost_optimization(
truth_node_id=request.truth_node_id,
tenant_id=tenant_id,
period=request.period,
project_id=request.project_id,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=400,
content=error_response(ErrorCode.BAD_REQUEST, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"成本优化分析失败: {exc}"),
)
@router.get("/context/projects/{project_id}/truth-nodes")
async def list_project_truth_nodes(
project_id: int,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""获取项目上下文中的宪章列表,用于前端选择器。"""
_ = current_user
result = await db.execute(
select(TruthNode).where(
and_(
TruthNode.tenant_id == tenant_id,
TruthNode.project_id == project_id,
)
).order_by(TruthNode.created_at.desc())
)
nodes = result.scalars().all()
return success_response(
data={
"items": [
{
"id": n.id,
"title": n.title,
"status": n.status,
"node_type": n.node_type,
}
for n in nodes
]
}
)
@router.get("/context/projects/{project_id}/experiments")
async def list_project_experiments(
project_id: int,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""获取项目上下文中的实验列表(基于 linked_context_nodes 关联)。"""
_ = current_user
truth_ids_result = await db.execute(
select(TruthNode.id).where(
and_(
TruthNode.tenant_id == tenant_id,
TruthNode.project_id == project_id,
)
)
)
truth_ids = {int(row[0]) for row in truth_ids_result.all()}
if not truth_ids:
return success_response(data={"items": []})
exp_result = await db.execute(
select(Experiment)
.where(Experiment.tenant_id == tenant_id)
.order_by(Experiment.created_at.desc())
)
experiments = exp_result.scalars().all()
def _linked_ids(raw) -> set[int]:
ids = set()
if isinstance(raw, list):
for x in raw:
try:
ids.add(int(x))
except (TypeError, ValueError):
continue
return ids
items = []
for e in experiments:
if e.project_id is not None:
if int(e.project_id) != int(project_id):
continue
items.append(
{
"id": e.id,
"title": e.title or f"Experiment {e.id}",
"status": e.status,
"target_metric": e.target_metric,
}
)
continue
# 迁移兼容:project_id 为空时,回退到 linked_context_nodes 交叉判断。
if _linked_ids(e.linked_context_nodes).isdisjoint(truth_ids):
continue
items.append(
{
"id": e.id,
"title": e.title or f"Experiment {e.id}",
"status": e.status,
"target_metric": e.target_metric,
}
)
return success_response(data={"items": items})
__all__ = ["router"]

View File

@ -0,0 +1,11 @@
from __future__ import annotations
from fastapi import APIRouter
from . import routes_context, routes_experiment_sync, routes_lifecycle, routes_match_cost
router = APIRouter(prefix="/model-capability", tags=["model-capability"])
for _module in [routes_match_cost, routes_lifecycle, routes_experiment_sync, routes_context]:
router.include_router(_module.router)
__all__ = ["router"]

View File

@ -0,0 +1,7 @@
from __future__ import annotations
from .shared import AsyncSession, ModelCapabilityService, get_ai_service
def _build_service(db: AsyncSession) -> ModelCapabilityService:
return ModelCapabilityService(db=db, ai_service=get_ai_service())

View File

@ -0,0 +1,131 @@
from __future__ import annotations
from .shared import *
@router.post("/cost-optimization/analyze")
async def analyze_cost_optimization(
request: CostOptimizationAnalyzeRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""生成并持久化成本优化建议。"""
_ = current_user
service = CostOptimizationService(db)
try:
data = await service.analyze_cost_optimization(
truth_node_id=request.truth_node_id,
tenant_id=tenant_id,
period=request.period,
project_id=request.project_id,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=400,
content=error_response(ErrorCode.BAD_REQUEST, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"成本优化分析失败: {exc}"),
)
@router.get("/context/projects/{project_id}/truth-nodes")
async def list_project_truth_nodes(
project_id: int,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""获取项目上下文中的宪章列表,用于前端选择器。"""
_ = current_user
result = await db.execute(
select(TruthNode).where(
and_(
TruthNode.tenant_id == tenant_id,
TruthNode.project_id == project_id,
)
).order_by(TruthNode.created_at.desc())
)
nodes = result.scalars().all()
return success_response(
data={
"items": [
{
"id": n.id,
"title": n.title,
"status": n.status,
"node_type": n.node_type,
}
for n in nodes
]
}
)
@router.get("/context/projects/{project_id}/experiments")
async def list_project_experiments(
project_id: int,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""获取项目上下文中的实验列表(基于 linked_context_nodes 关联)。"""
_ = current_user
truth_ids_result = await db.execute(
select(TruthNode.id).where(
and_(
TruthNode.tenant_id == tenant_id,
TruthNode.project_id == project_id,
)
)
)
truth_ids = {int(row[0]) for row in truth_ids_result.all()}
if not truth_ids:
return success_response(data={"items": []})
exp_result = await db.execute(
select(Experiment)
.where(Experiment.tenant_id == tenant_id)
.order_by(Experiment.created_at.desc())
)
experiments = exp_result.scalars().all()
def _linked_ids(raw) -> set[int]:
ids = set()
if isinstance(raw, list):
for x in raw:
try:
ids.add(int(x))
except (TypeError, ValueError):
continue
return ids
items = []
for e in experiments:
if e.project_id is not None:
if int(e.project_id) != int(project_id):
continue
items.append(
{
"id": e.id,
"title": e.title or f"Experiment {e.id}",
"status": e.status,
"target_metric": e.target_metric,
}
)
continue
# 迁移兼容:project_id 为空时,回退到 linked_context_nodes 交叉判断。
if _linked_ids(e.linked_context_nodes).isdisjoint(truth_ids):
continue
items.append(
{
"id": e.id,
"title": e.title or f"Experiment {e.id}",
"status": e.status,
"target_metric": e.target_metric,
}
)
return success_response(data={"items": items})

View File

@ -0,0 +1,118 @@
from __future__ import annotations
from .shared import *
@router.post("/experiment-sync")
async def create_experiment_sync(
request: ExperimentSyncRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""创建实验结果同步记录,满足阈值时自动同步。"""
_ = current_user
service = ExperimentSyncService(db)
try:
data = await service.auto_sync_experiment_result(
experiment_id=request.experiment_id,
truth_node_id=request.truth_node_id,
tenant_id=tenant_id,
project_id=request.project_id,
sync_type=request.sync_type,
auto_apply_threshold=request.auto_apply_threshold,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=404,
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"创建同步失败: {exc}"),
)
@router.get("/experiment-sync")
async def list_experiment_sync_records(
truth_node_id: Optional[int] = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""查询实验同步记录。"""
_ = current_user
service = ExperimentSyncService(db)
try:
data = await service.list_sync_records(
tenant_id=tenant_id,
truth_node_id=truth_node_id,
limit=limit,
)
return success_response(data=data)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询同步记录失败: {exc}"),
)
@router.post("/experiment-sync/{sync_id}/review")
async def review_experiment_sync(
sync_id: int,
request: ExperimentSyncReviewRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""人工审核实验同步记录。"""
service = ExperimentSyncService(db)
try:
data = await service.review_sync(
sync_id=sync_id,
tenant_id=tenant_id,
reviewer_id=current_user.id,
action=request.action,
review_notes=request.review_notes,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=400,
content=error_response(ErrorCode.BAD_REQUEST, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"审核同步失败: {exc}"),
)
@router.post("/experiment-sync/{sync_id}/rollback")
async def rollback_experiment_sync(
sync_id: int,
request: ExperimentSyncRollbackRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""回滚已应用的实验同步。"""
service = ExperimentSyncService(db)
try:
data = await service.rollback_sync(
sync_id=sync_id,
tenant_id=tenant_id,
reviewer_id=current_user.id,
rollback_notes=request.rollback_notes,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=400,
content=error_response(ErrorCode.BAD_REQUEST, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"回滚同步失败: {exc}"),
)

View File

@ -0,0 +1,80 @@
from __future__ import annotations
from .shared import *
@router.post("/lifecycle/assess-impact")
async def assess_model_update_impact(
request: LifecycleImpactRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""评估模型升级对现有需求集合的影响。"""
_ = current_user
service = ModelLifecycleService(db)
try:
data = await service.assess_model_update_impact(
old_model_version_id=request.old_model_version_id,
new_model_version_id=request.new_model_version_id,
tenant_id=tenant_id,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=404,
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"影响评估失败: {exc}"),
)
@router.get("/lifecycle/impacts")
async def list_lifecycle_impacts(
limit: int = Query(default=50, ge=1, le=200),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""列出模型生命周期影响评估记录。"""
_ = current_user
service = ModelLifecycleService(db)
try:
data = await service.list_impacts(tenant_id=tenant_id, limit=limit)
return success_response(data=data)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询影响评估失败: {exc}"),
)
@router.post("/lifecycle/{impact_id}/migration-tasks")
async def create_migration_tasks(
impact_id: int,
request: GenerateMigrationTasksRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""基于影响评估自动生成迁移任务建议。"""
_ = current_user
service = ModelLifecycleService(db)
try:
data = await service.generate_migration_tasks(
impact_id=impact_id,
tenant_id=tenant_id,
max_tasks=request.max_tasks,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=404,
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"生成迁移任务失败: {exc}"),
)

View File

@ -0,0 +1,131 @@
from __future__ import annotations
from .shared import *
from .helpers import _build_service
@router.post("/validate-match")
async def validate_model_requirement_match(
request: ValidateMatchRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""需求-模型匹配校验"""
_ = current_user
service = _build_service(db)
try:
data = await service.validate_model_requirement_match(
truth_node_id=request.truth_node_id,
model_version_id=request.model_version_id,
tenant_id=tenant_id,
project_id=request.project_id,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=404,
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"匹配校验失败: {exc}"),
)
@router.get("/capability-gaps")
async def list_capability_gaps(
status: Optional[str] = Query(default=None),
severity: Optional[str] = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""列出能力差距预警"""
_ = current_user
service = _build_service(db)
try:
data = await service.list_capability_gaps(
tenant_id=tenant_id,
status=status,
severity=severity,
limit=limit,
)
return success_response(data=data)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询能力差距失败: {exc}"),
)
@router.get("/cost-tracking/{truth_node_id}")
async def get_cost_tracking(
truth_node_id: int,
period: str = Query(default="weekly", pattern="^(daily|weekly|monthly|project)$"),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""查询成本跟踪与 ROI"""
_ = current_user
service = _build_service(db)
try:
data = await service.get_cost_tracking(
truth_node_id=truth_node_id,
period=period,
tenant_id=tenant_id,
)
return success_response(data=data)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询成本跟踪失败: {exc}"),
)
@router.get("/cost-optimization/{truth_node_id}")
async def get_cost_optimization(
truth_node_id: int,
period: str = Query(default="weekly", pattern="^(daily|weekly|monthly|project)$"),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""查询成本优化建议(必要时自动触发一次计算)"""
_ = current_user
service = _build_service(db)
try:
tracking = await service.get_cost_tracking(
truth_node_id=truth_node_id,
period=period,
tenant_id=tenant_id,
)
optimization = tracking.get("optimization_suggestions") or {}
if not optimization:
computed = await service.track_cost_and_roi(
truth_node_id=truth_node_id,
period=period,
tenant_id=tenant_id,
)
optimization = computed.get("optimization_suggestions", {})
tracking = await service.get_cost_tracking(
truth_node_id=truth_node_id,
period=period,
tenant_id=tenant_id,
)
return success_response(
data={
"truth_node_id": truth_node_id,
"period": period,
"optimization_suggestions": optimization,
"roi_score": tracking.get("roi_score"),
"actual_cost_usd": tracking.get("actual_cost_usd", 0.0),
}
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询成本优化失败: {exc}"),
)

View File

@ -0,0 +1,29 @@
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, Query
from fastapi.responses import JSONResponse
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ...core.response import ErrorCode, error_response, success_response
from ...database import get_db
from ...dependencies import get_current_user, get_tenant_id
from ...models import Experiment, TruthNode, User
from ...schemas.model_capability import (
CostOptimizationAnalyzeRequest,
ExperimentSyncRequest,
ExperimentSyncReviewRequest,
ExperimentSyncRollbackRequest,
GenerateMigrationTasksRequest,
LifecycleImpactRequest,
ValidateMatchRequest,
)
from ...services.ai_service import get_ai_service
from ...services.cost_optimization_service import CostOptimizationService
from ...services.experiment_sync_service import ExperimentSyncService
from ...services.model_capability_service import ModelCapabilityService
from ...services.model_lifecycle_service import ModelLifecycleService
router = APIRouter(prefix="/model-capability", tags=["model-capability"])

View File

@ -1,686 +1,5 @@
"""项目管理 API。"""
from __future__ import annotations from __future__ import annotations
import json from .projects_api import router
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query __all__ = ["router"]
from pydantic import BaseModel, Field
from sqlalchemy import and_, delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..dependencies import get_current_user, get_tenant_id, require_roles
from ..models import (
AIOrchestrationRun,
AIWorkerExecution,
Budget,
ConversationSession,
CostTracking,
Experiment,
OperationExecutionLedger,
OperationLog,
Project,
ProjectSignalLink,
Signal,
SignalSourceRegistry,
Task,
User,
UserProject,
WorkItem,
)
from ..services.ai_pmo_kernel_service import AIPMOKernelService
from ..services.context_cache import context_cache_service
from ..services.operation_log_service import log_operation
from ..services.project_member_service import (
ProjectMemberAssignmentError,
notify_member_assignment_failure,
replace_project_members,
resolve_project_member_ids,
)
from ..utils.sequence import ensure_project_id_sequence
from ..utils.title import compact_title
router = APIRouter(prefix="/projects", tags=["projects"])
DONE_STATUSES = {"done", "completed", "closed", "resolved", "archived"}
def _as_dict(value: object) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {"text": value}
except (TypeError, ValueError):
return {"text": value}
return {}
class ProjectCreateRequest(BaseModel):
title: str
description: Optional[str] = None
status: str = "active"
domain_id: Optional[int] = None
assigned_role: Optional[str] = None
member_user_ids: list[int] = Field(default_factory=list)
class ProjectUpdateRequest(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
domain_id: Optional[int] = None
assigned_role: Optional[str] = None
class LinkSignalRequest(BaseModel):
link_type: Optional[str] = "related"
relevance_score: Optional[float] = 1.0
class ProjectMembersAssignRequest(BaseModel):
member_user_ids: list[int] = Field(default_factory=list)
def _serialize_project_row(project: Project) -> dict:
return {
"id": str(project.id),
"title": project.title or project.name,
"description": project.description,
"status": project.status,
"domain_id": str(project.domain_id) if project.domain_id is not None else None,
"assigned_role": project.assigned_role,
"signal_count": int(project.signal_count or 0),
"created_at": project.created_at.isoformat() if project.created_at else None,
"updated_at": project.updated_at.isoformat() if project.updated_at else None,
}
async def _build_project_summary(
db: AsyncSession,
*,
tenant_id: int,
project: Project,
) -> dict:
base = _serialize_project_row(project)
signal_count_result = await db.execute(
select(func.count(ProjectSignalLink.id)).where(
and_(
ProjectSignalLink.tenant_id == tenant_id,
ProjectSignalLink.project_id == int(project.id),
)
)
)
signal_count = int(signal_count_result.scalar() or 0)
base["signal_count"] = signal_count
task_rows = (
await db.execute(
select(Task).where(
and_(
Task.tenant_id == tenant_id,
Task.project_id == int(project.id),
)
)
)
).scalars().all()
total_tasks = len(task_rows)
completed_tasks = 0
in_progress_tasks = 0
pending_tasks = 0
task_items: list[dict] = []
for task in task_rows:
status_value = (task.status or "").lower()
if status_value in DONE_STATUSES:
completed_tasks += 1
elif status_value in {"in_progress", "review", "blocked"}:
in_progress_tasks += 1
else:
pending_tasks += 1
task_items.append(
{
"task_name": task.title,
"type": "task",
"status": task.status,
"signal_id": str(task.truth_node_id) if task.truth_node_id is not None else "",
}
)
base["progress"] = round((completed_tasks / total_tasks) * 100, 2) if total_tasks else 0.0
base["total_tasks"] = total_tasks
base["completed_tasks"] = completed_tasks
base["in_progress_tasks"] = in_progress_tasks
base["pending_tasks"] = pending_tasks
base["tasks"] = task_items
return base
async def _fetch_project_member_user_ids(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
) -> list[int]:
rows = await db.execute(
select(UserProject.user_id).where(
and_(
UserProject.tenant_id == int(tenant_id),
UserProject.project_id == int(project_id),
)
)
)
return sorted(int(user_id) for user_id in rows.scalars().all() if user_id is not None)
async def _get_project_or_404(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
) -> Project:
project = (
await db.execute(
select(Project).where(
and_(
Project.tenant_id == tenant_id,
Project.id == project_id,
)
)
)
).scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
async def _cleanup_project_dependencies(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
) -> None:
task_ids_subquery = (
select(Task.id)
.where(
and_(
Task.tenant_id == tenant_id,
Task.project_id == project_id,
)
)
.scalar_subquery()
)
# 先断开任务子依赖,避免删除 tasks 时触发约束错误。
await db.execute(
update(CostTracking)
.where(
and_(
CostTracking.tenant_id == tenant_id,
CostTracking.task_id.in_(task_ids_subquery),
)
)
.values(task_id=None)
)
# 非空外键:必须先删依赖记录。
await db.execute(
delete(AIWorkerExecution).where(
and_(
AIWorkerExecution.tenant_id == tenant_id,
AIWorkerExecution.project_id == project_id,
)
)
)
await db.execute(
delete(AIOrchestrationRun).where(
and_(
AIOrchestrationRun.tenant_id == tenant_id,
AIOrchestrationRun.project_id == project_id,
)
)
)
await db.execute(
delete(SignalSourceRegistry).where(
and_(
SignalSourceRegistry.tenant_id == tenant_id,
SignalSourceRegistry.project_id == project_id,
)
)
)
await db.execute(
delete(ProjectSignalLink).where(
and_(
ProjectSignalLink.tenant_id == tenant_id,
ProjectSignalLink.project_id == project_id,
)
)
)
await db.execute(
delete(Task).where(
and_(
Task.tenant_id == tenant_id,
Task.project_id == project_id,
)
)
)
await db.execute(
delete(UserProject).where(
and_(
UserProject.tenant_id == tenant_id,
UserProject.project_id == project_id,
)
)
)
# 可空外键:保留记录,将 project 解绑为 null。
await db.execute(
update(WorkItem)
.where(
and_(
WorkItem.tenant_id == tenant_id,
WorkItem.project_id == project_id,
)
)
.values(project_id=None)
)
await db.execute(
update(OperationLog)
.where(
and_(
OperationLog.tenant_id == tenant_id,
OperationLog.project_id == project_id,
)
)
.values(project_id=None)
)
await db.execute(
update(OperationExecutionLedger)
.where(
and_(
OperationExecutionLedger.tenant_id == tenant_id,
OperationExecutionLedger.project_id == project_id,
)
)
.values(project_id=None)
)
await db.execute(
update(Experiment)
.where(
and_(
Experiment.tenant_id == tenant_id,
Experiment.project_id == project_id,
)
)
.values(project_id=None)
)
await db.execute(
update(Budget)
.where(
and_(
Budget.tenant_id == tenant_id,
Budget.project_id == project_id,
)
)
.values(project_id=None)
)
await db.execute(
update(ConversationSession)
.where(
and_(
ConversationSession.tenant_id == tenant_id,
ConversationSession.current_project_id == project_id,
)
)
.values(current_project_id=None)
)
@router.get("/")
async def list_projects(
summary: Optional[int] = Query(default=0),
status: Optional[str] = Query(default=None),
assigned_role: Optional[str] = Query(default=None),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
query = select(Project).where(Project.tenant_id == tenant_id)
if status:
query = query.where(Project.status == status)
if assigned_role:
query = query.where(Project.assigned_role == assigned_role)
query = query.order_by(Project.updated_at.desc(), Project.id.desc())
projects = (await db.execute(query)).scalars().all()
if int(summary or 0) == 1:
rows = [
await _build_project_summary(
db,
tenant_id=tenant_id,
project=project,
)
for project in projects
]
return {"projects": rows}
return [_serialize_project_row(project) for project in projects]
@router.get("/{project_id}")
async def get_project(
project_id: int,
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
return _serialize_project_row(project)
@router.post("/")
async def create_project(
body: ProjectCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
):
tenant_id = int(current_user.tenant_id)
title = compact_title(body.title, max_len=24)
if not title:
raise HTTPException(status_code=400, detail="title required")
try:
member_user_ids = await resolve_project_member_ids(
db,
tenant_id=tenant_id,
owner_id=int(current_user.id),
assigned_role=body.assigned_role,
requested_member_user_ids=body.member_user_ids,
require_requested_members=False,
include_owner=True,
)
except ProjectMemberAssignmentError as exc:
await notify_member_assignment_failure(
db,
tenant_id=tenant_id,
actor_user_id=int(current_user.id),
project_title=title,
reason=str(exc),
interface_path="/projects/",
commit=True,
)
raise HTTPException(status_code=400, detail=str(exc)) from exc
await ensure_project_id_sequence(db)
project = Project(
tenant_id=tenant_id,
name=title,
title=title,
description=body.description,
status=body.status or "active",
domain_id=body.domain_id,
assigned_role=body.assigned_role,
owner_id=int(current_user.id),
signal_count=0,
)
db.add(project)
try:
await db.flush()
await replace_project_members(
db,
tenant_id=tenant_id,
project_id=int(project.id),
member_user_ids=member_user_ids,
)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=int(project.id),
entity_type="project",
entity_id=str(project.id),
action="project.create",
before_data=None,
after_data={
"title": project.title,
"status": project.status,
"member_user_ids": member_user_ids,
},
interface_path="/projects/",
caller="user",
)
await db.commit()
except Exception:
await db.rollback()
raise
await db.refresh(project)
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id))
return _serialize_project_row(project)
@router.put("/{project_id}/members")
async def assign_project_members(
project_id: int,
body: ProjectMembersAssignRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
):
tenant_id = int(current_user.tenant_id)
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
before_member_user_ids = await _fetch_project_member_user_ids(
db,
tenant_id=tenant_id,
project_id=int(project.id),
)
try:
member_user_ids = await resolve_project_member_ids(
db,
tenant_id=tenant_id,
owner_id=None,
assigned_role=project.assigned_role,
requested_member_user_ids=body.member_user_ids,
require_requested_members=True,
include_owner=False,
)
await replace_project_members(
db,
tenant_id=tenant_id,
project_id=int(project.id),
member_user_ids=member_user_ids,
)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=int(project.id),
entity_type="project",
entity_id=str(project.id),
action="project.members.assign",
before_data={"member_user_ids": before_member_user_ids},
after_data={"member_user_ids": member_user_ids},
interface_path=f"/projects/{project_id}/members",
caller="user",
)
await db.commit()
except ProjectMemberAssignmentError as exc:
await db.rollback()
await notify_member_assignment_failure(
db,
tenant_id=tenant_id,
actor_user_id=int(current_user.id),
project_id=int(project.id),
project_title=project.title or project.name,
reason=str(exc),
interface_path=f"/projects/{project_id}/members",
commit=True,
)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception:
await db.rollback()
raise
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id))
return {
"project_id": str(project.id),
"member_user_ids": [str(user_id) for user_id in member_user_ids],
}
@router.patch("/{project_id}")
async def update_project(
project_id: int,
body: ProjectUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
):
tenant_id = int(current_user.tenant_id)
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
if body.title is not None:
title = compact_title(body.title, max_len=24)
if not title:
raise HTTPException(status_code=400, detail="title cannot be empty")
project.title = title
project.name = title
if body.description is not None:
project.description = body.description
if body.status is not None:
project.status = body.status
if body.domain_id is not None:
project.domain_id = body.domain_id
if body.assigned_role is not None:
project.assigned_role = body.assigned_role
await db.commit()
await db.refresh(project)
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id))
return _serialize_project_row(project)
@router.delete("/{project_id}")
async def delete_project(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
):
tenant_id = int(current_user.tenant_id)
_ = current_user
try:
await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
await _cleanup_project_dependencies(
db,
tenant_id=tenant_id,
project_id=project_id,
)
await db.execute(
delete(Project).where(
and_(
Project.tenant_id == tenant_id,
Project.id == project_id,
)
)
)
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="Project has related records and cannot be deleted") from exc
except Exception:
await db.rollback()
raise
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id)
return {"message": "Project deleted"}
@router.post("/{project_id}/signals/{signal_id}")
async def link_signal_to_project(
project_id: int,
signal_id: int,
body: LinkSignalRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
):
tenant_id = int(current_user.tenant_id)
await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
signal = (
await db.execute(
select(Signal).where(
and_(
Signal.tenant_id == tenant_id,
Signal.id == signal_id,
)
)
)
).scalar_one_or_none()
if not signal:
raise HTTPException(status_code=404, detail="Signal not found")
existing = (
await db.execute(
select(ProjectSignalLink).where(
and_(
ProjectSignalLink.tenant_id == tenant_id,
ProjectSignalLink.project_id == project_id,
ProjectSignalLink.signal_id == signal_id,
)
)
)
).scalar_one_or_none()
if existing:
return {
"id": str(existing.id),
"project_id": str(existing.project_id),
"signal_id": str(existing.signal_id),
"link_type": existing.link_type,
"relevance_score": float(existing.relevance_score or 0.0),
"created_at": existing.created_at.isoformat() if existing.created_at else None,
}
link = ProjectSignalLink(
tenant_id=tenant_id,
project_id=project_id,
signal_id=signal_id,
link_type=body.link_type or "related",
relevance_score=float(body.relevance_score or 1.0),
)
db.add(link)
await db.commit()
await db.refresh(link)
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
return {
"id": str(link.id),
"project_id": str(link.project_id),
"signal_id": str(link.signal_id),
"link_type": link.link_type,
"relevance_score": float(link.relevance_score or 0.0),
"created_at": link.created_at.isoformat() if link.created_at else None,
}
@router.delete("/{project_id}/signals/{signal_id}")
async def unlink_signal_from_project(
project_id: int,
signal_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
):
tenant_id = int(current_user.tenant_id)
_ = current_user
await db.execute(
delete(ProjectSignalLink).where(
and_(
ProjectSignalLink.tenant_id == tenant_id,
ProjectSignalLink.project_id == project_id,
ProjectSignalLink.signal_id == signal_id,
)
)
)
await db.commit()
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
return {"message": "Signal unlinked"}

View File

@ -0,0 +1,11 @@
from __future__ import annotations
from fastapi import APIRouter
from . import routes_list, routes_mutation, routes_signals
router = APIRouter(prefix="/projects", tags=["projects"])
for _module in [routes_list, routes_mutation, routes_signals]:
router.include_router(_module.router)
__all__ = ["router"]

View File

@ -0,0 +1,266 @@
from __future__ import annotations
from .shared import *
def _as_dict(value: object) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {"text": value}
except (TypeError, ValueError):
return {"text": value}
return {}
def _serialize_project_row(project: Project) -> dict:
return {
"id": str(project.id),
"title": project.title or project.name,
"description": project.description,
"status": project.status,
"domain_id": str(project.domain_id) if project.domain_id is not None else None,
"assigned_role": project.assigned_role,
"signal_count": int(project.signal_count or 0),
"created_at": project.created_at.isoformat() if project.created_at else None,
"updated_at": project.updated_at.isoformat() if project.updated_at else None,
}
async def _build_project_summary(
db: AsyncSession,
*,
tenant_id: int,
project: Project,
) -> dict:
base = _serialize_project_row(project)
signal_count_result = await db.execute(
select(func.count(ProjectSignalLink.id)).where(
and_(
ProjectSignalLink.tenant_id == tenant_id,
ProjectSignalLink.project_id == int(project.id),
)
)
)
signal_count = int(signal_count_result.scalar() or 0)
base["signal_count"] = signal_count
task_rows = (
await db.execute(
select(Task).where(
and_(
Task.tenant_id == tenant_id,
Task.project_id == int(project.id),
)
)
)
).scalars().all()
total_tasks = len(task_rows)
completed_tasks = 0
in_progress_tasks = 0
pending_tasks = 0
task_items: list[dict] = []
for task in task_rows:
status_value = (task.status or "").lower()
if status_value in DONE_STATUSES:
completed_tasks += 1
elif status_value in {"in_progress", "review", "blocked"}:
in_progress_tasks += 1
else:
pending_tasks += 1
task_items.append(
{
"task_name": task.title,
"type": "task",
"status": task.status,
"signal_id": str(task.truth_node_id) if task.truth_node_id is not None else "",
}
)
base["progress"] = round((completed_tasks / total_tasks) * 100, 2) if total_tasks else 0.0
base["total_tasks"] = total_tasks
base["completed_tasks"] = completed_tasks
base["in_progress_tasks"] = in_progress_tasks
base["pending_tasks"] = pending_tasks
base["tasks"] = task_items
return base
async def _fetch_project_member_user_ids(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
) -> list[int]:
rows = await db.execute(
select(UserProject.user_id).where(
and_(
UserProject.tenant_id == int(tenant_id),
UserProject.project_id == int(project_id),
)
)
)
return sorted(int(user_id) for user_id in rows.scalars().all() if user_id is not None)
async def _get_project_or_404(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
) -> Project:
project = (
await db.execute(
select(Project).where(
and_(
Project.tenant_id == tenant_id,
Project.id == project_id,
)
)
)
).scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
async def _cleanup_project_dependencies(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
) -> None:
task_ids_subquery = (
select(Task.id)
.where(
and_(
Task.tenant_id == tenant_id,
Task.project_id == project_id,
)
)
.scalar_subquery()
)
# 先断开任务子依赖,避免删除 tasks 时触发约束错误。
await db.execute(
update(CostTracking)
.where(
and_(
CostTracking.tenant_id == tenant_id,
CostTracking.task_id.in_(task_ids_subquery),
)
)
.values(task_id=None)
)
# 非空外键:必须先删依赖记录。
await db.execute(
delete(AIWorkerExecution).where(
and_(
AIWorkerExecution.tenant_id == tenant_id,
AIWorkerExecution.project_id == project_id,
)
)
)
await db.execute(
delete(AIOrchestrationRun).where(
and_(
AIOrchestrationRun.tenant_id == tenant_id,
AIOrchestrationRun.project_id == project_id,
)
)
)
await db.execute(
delete(SignalSourceRegistry).where(
and_(
SignalSourceRegistry.tenant_id == tenant_id,
SignalSourceRegistry.project_id == project_id,
)
)
)
await db.execute(
delete(ProjectSignalLink).where(
and_(
ProjectSignalLink.tenant_id == tenant_id,
ProjectSignalLink.project_id == project_id,
)
)
)
await db.execute(
delete(Task).where(
and_(
Task.tenant_id == tenant_id,
Task.project_id == project_id,
)
)
)
await db.execute(
delete(UserProject).where(
and_(
UserProject.tenant_id == tenant_id,
UserProject.project_id == project_id,
)
)
)
# 可空外键:保留记录,将 project 解绑为 null。
await db.execute(
update(WorkItem)
.where(
and_(
WorkItem.tenant_id == tenant_id,
WorkItem.project_id == project_id,
)
)
.values(project_id=None)
)
await db.execute(
update(OperationLog)
.where(
and_(
OperationLog.tenant_id == tenant_id,
OperationLog.project_id == project_id,
)
)
.values(project_id=None)
)
await db.execute(
update(OperationExecutionLedger)
.where(
and_(
OperationExecutionLedger.tenant_id == tenant_id,
OperationExecutionLedger.project_id == project_id,
)
)
.values(project_id=None)
)
await db.execute(
update(Experiment)
.where(
and_(
Experiment.tenant_id == tenant_id,
Experiment.project_id == project_id,
)
)
.values(project_id=None)
)
await db.execute(
update(Budget)
.where(
and_(
Budget.tenant_id == tenant_id,
Budget.project_id == project_id,
)
)
.values(project_id=None)
)
await db.execute(
update(ConversationSession)
.where(
and_(
ConversationSession.tenant_id == tenant_id,
ConversationSession.current_project_id == project_id,
)
)
.values(current_project_id=None)
)

View File

@ -0,0 +1,43 @@
from __future__ import annotations
from .shared import *
from .helpers import _build_project_summary, _get_project_or_404
@router.get("/")
async def list_projects(
summary: Optional[int] = Query(default=0),
status: Optional[str] = Query(default=None),
assigned_role: Optional[str] = Query(default=None),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
query = select(Project).where(Project.tenant_id == tenant_id)
if status:
query = query.where(Project.status == status)
if assigned_role:
query = query.where(Project.assigned_role == assigned_role)
query = query.order_by(Project.updated_at.desc(), Project.id.desc())
projects = (await db.execute(query)).scalars().all()
if int(summary or 0) == 1:
rows = [
await _build_project_summary(
db,
tenant_id=tenant_id,
project=project,
)
for project in projects
]
return {"projects": rows}
return [_serialize_project_row(project) for project in projects]
@router.get("/{project_id}")
async def get_project(
project_id: int,
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
return _serialize_project_row(project)

View File

@ -0,0 +1,217 @@
from __future__ import annotations
from .shared import *
from .helpers import _cleanup_project_dependencies, _fetch_project_member_user_ids, _get_project_or_404
from .schemas import ProjectCreateRequest, ProjectMembersAssignRequest, ProjectUpdateRequest
@router.post("/")
async def create_project(
body: ProjectCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
):
tenant_id = int(current_user.tenant_id)
title = compact_title(body.title, max_len=24)
if not title:
raise HTTPException(status_code=400, detail="title required")
try:
member_user_ids = await resolve_project_member_ids(
db,
tenant_id=tenant_id,
owner_id=int(current_user.id),
assigned_role=body.assigned_role,
requested_member_user_ids=body.member_user_ids,
require_requested_members=False,
include_owner=True,
)
except ProjectMemberAssignmentError as exc:
await notify_member_assignment_failure(
db,
tenant_id=tenant_id,
actor_user_id=int(current_user.id),
project_title=title,
reason=str(exc),
interface_path="/projects/",
commit=True,
)
raise HTTPException(status_code=400, detail=str(exc)) from exc
await ensure_project_id_sequence(db)
project = Project(
tenant_id=tenant_id,
name=title,
title=title,
description=body.description,
status=body.status or "active",
domain_id=body.domain_id,
assigned_role=body.assigned_role,
owner_id=int(current_user.id),
signal_count=0,
)
db.add(project)
try:
await db.flush()
await replace_project_members(
db,
tenant_id=tenant_id,
project_id=int(project.id),
member_user_ids=member_user_ids,
)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=int(project.id),
entity_type="project",
entity_id=str(project.id),
action="project.create",
before_data=None,
after_data={
"title": project.title,
"status": project.status,
"member_user_ids": member_user_ids,
},
interface_path="/projects/",
caller="user",
)
await db.commit()
except Exception:
await db.rollback()
raise
await db.refresh(project)
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id))
return _serialize_project_row(project)
@router.put("/{project_id}/members")
async def assign_project_members(
project_id: int,
body: ProjectMembersAssignRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
):
tenant_id = int(current_user.tenant_id)
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
before_member_user_ids = await _fetch_project_member_user_ids(
db,
tenant_id=tenant_id,
project_id=int(project.id),
)
try:
member_user_ids = await resolve_project_member_ids(
db,
tenant_id=tenant_id,
owner_id=None,
assigned_role=project.assigned_role,
requested_member_user_ids=body.member_user_ids,
require_requested_members=True,
include_owner=False,
)
await replace_project_members(
db,
tenant_id=tenant_id,
project_id=int(project.id),
member_user_ids=member_user_ids,
)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=int(project.id),
entity_type="project",
entity_id=str(project.id),
action="project.members.assign",
before_data={"member_user_ids": before_member_user_ids},
after_data={"member_user_ids": member_user_ids},
interface_path=f"/projects/{project_id}/members",
caller="user",
)
await db.commit()
except ProjectMemberAssignmentError as exc:
await db.rollback()
await notify_member_assignment_failure(
db,
tenant_id=tenant_id,
actor_user_id=int(current_user.id),
project_id=int(project.id),
project_title=project.title or project.name,
reason=str(exc),
interface_path=f"/projects/{project_id}/members",
commit=True,
)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception:
await db.rollback()
raise
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id))
return {
"project_id": str(project.id),
"member_user_ids": [str(user_id) for user_id in member_user_ids],
}
@router.patch("/{project_id}")
async def update_project(
project_id: int,
body: ProjectUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
):
tenant_id = int(current_user.tenant_id)
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
if body.title is not None:
title = compact_title(body.title, max_len=24)
if not title:
raise HTTPException(status_code=400, detail="title cannot be empty")
project.title = title
project.name = title
if body.description is not None:
project.description = body.description
if body.status is not None:
project.status = body.status
if body.domain_id is not None:
project.domain_id = body.domain_id
if body.assigned_role is not None:
project.assigned_role = body.assigned_role
await db.commit()
await db.refresh(project)
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id))
return _serialize_project_row(project)
@router.delete("/{project_id}")
async def delete_project(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
):
tenant_id = int(current_user.tenant_id)
_ = current_user
try:
await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
await _cleanup_project_dependencies(
db,
tenant_id=tenant_id,
project_id=project_id,
)
await db.execute(
delete(Project).where(
and_(
Project.tenant_id == tenant_id,
Project.id == project_id,
)
)
)
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="Project has related records and cannot be deleted") from exc
except Exception:
await db.rollback()
raise
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id)
return {"message": "Project deleted"}

View File

@ -0,0 +1,93 @@
from __future__ import annotations
from .shared import *
from .helpers import _as_dict, _get_project_or_404
from .schemas import LinkSignalRequest
@router.post("/{project_id}/signals/{signal_id}")
async def link_signal_to_project(
project_id: int,
signal_id: int,
body: LinkSignalRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
):
tenant_id = int(current_user.tenant_id)
await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
signal = (
await db.execute(
select(Signal).where(
and_(
Signal.tenant_id == tenant_id,
Signal.id == signal_id,
)
)
)
).scalar_one_or_none()
if not signal:
raise HTTPException(status_code=404, detail="Signal not found")
existing = (
await db.execute(
select(ProjectSignalLink).where(
and_(
ProjectSignalLink.tenant_id == tenant_id,
ProjectSignalLink.project_id == project_id,
ProjectSignalLink.signal_id == signal_id,
)
)
)
).scalar_one_or_none()
if existing:
return {
"id": str(existing.id),
"project_id": str(existing.project_id),
"signal_id": str(existing.signal_id),
"link_type": existing.link_type,
"relevance_score": float(existing.relevance_score or 0.0),
"created_at": existing.created_at.isoformat() if existing.created_at else None,
}
link = ProjectSignalLink(
tenant_id=tenant_id,
project_id=project_id,
signal_id=signal_id,
link_type=body.link_type or "related",
relevance_score=float(body.relevance_score or 1.0),
)
db.add(link)
await db.commit()
await db.refresh(link)
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
return {
"id": str(link.id),
"project_id": str(link.project_id),
"signal_id": str(link.signal_id),
"link_type": link.link_type,
"relevance_score": float(link.relevance_score or 0.0),
"created_at": link.created_at.isoformat() if link.created_at else None,
}
@router.delete("/{project_id}/signals/{signal_id}")
async def unlink_signal_from_project(
project_id: int,
signal_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
):
tenant_id = int(current_user.tenant_id)
_ = current_user
await db.execute(
delete(ProjectSignalLink).where(
and_(
ProjectSignalLink.tenant_id == tenant_id,
ProjectSignalLink.project_id == project_id,
ProjectSignalLink.signal_id == signal_id,
)
)
)
await db.commit()
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
return {"message": "Signal unlinked"}

View File

@ -0,0 +1,26 @@
from __future__ import annotations
from .shared import BaseModel, Field, Optional
class ProjectCreateRequest(BaseModel):
title: str
description: Optional[str] = None
status: str = "active"
domain_id: Optional[int] = None
assigned_role: Optional[str] = None
member_user_ids: list[int] = Field(default_factory=list)
class ProjectUpdateRequest(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
domain_id: Optional[int] = None
assigned_role: Optional[str] = None
class LinkSignalRequest(BaseModel):
link_type: Optional[str] = "related"
relevance_score: Optional[float] = 1.0
class ProjectMembersAssignRequest(BaseModel):
member_user_ids: list[int] = Field(default_factory=list)

View File

@ -0,0 +1,46 @@
from __future__ import annotations
import json
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import and_, delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ...database import get_db
from ...dependencies import get_current_user, get_tenant_id, require_roles
from ...models import (
AIOrchestrationRun,
AIWorkerExecution,
Budget,
ConversationSession,
CostTracking,
Experiment,
OperationExecutionLedger,
OperationLog,
Project,
ProjectSignalLink,
Signal,
SignalSourceRegistry,
Task,
User,
UserProject,
WorkItem,
)
from ...services.ai_pmo_kernel_service import AIPMOKernelService
from ...services.context_cache import context_cache_service
from ...services.operation_log_service import log_operation
from ...services.project_member_service import (
ProjectMemberAssignmentError,
notify_member_assignment_failure,
replace_project_members,
resolve_project_member_ids,
)
from ...utils.sequence import ensure_project_id_sequence
from ...utils.title import compact_title
router = APIRouter(prefix="/projects", tags=["projects"])
DONE_STATUSES = {"done", "completed", "closed", "resolved", "archived"}

View File

@ -1,570 +1,5 @@
"""Adaptive RAG API。"""
from __future__ import annotations from __future__ import annotations
import asyncio from .rag_api import router
import io
import re
from datetime import datetime
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile __all__ = ["router"]
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.response import success_response
from ..database import get_db
from ..dependencies.auth import get_current_user, require_roles
from ..dependencies.tenant import get_tenant_id
from ..models import User
from ..schemas.rag import (
EntityResolutionRequest,
KnowledgeDocCreateRequest,
KnowledgeURLCreateRequest,
MultiStepSearchRequest,
QAExportQuery,
QAFeedbackCreateRequest,
QAListQuery,
QASessionListQuery,
QASessionRenameRequest,
RAGAskRequest,
SymbolSearchRequest,
SymbolSyncRequest,
)
from ..services.adaptive_rag_service import AdaptiveRAGService
from ..services.symbol_index_service import SymbolIndexService
router = APIRouter(prefix="/rag", tags=["Adaptive RAG"])
def _resolve_tenant_id(user: User, fallback_tenant_id: int) -> int:
try:
return int(getattr(user, "tenant_id") or fallback_tenant_id or 1)
except Exception:
return int(fallback_tenant_id or 1)
def _safe_decode_bytes(raw: bytes) -> str:
for encoding in ("utf-8", "utf-8-sig", "gb18030", "latin-1"):
try:
return raw.decode(encoding)
except Exception:
continue
return raw.decode("utf-8", errors="ignore")
def _extract_text_from_upload(filename: str, raw: bytes) -> tuple[str, str]:
name = str(filename or "uploaded.txt")
suffix = name.rsplit(".", 1)[-1].lower() if "." in name else "txt"
if suffix in {"txt", "md", "markdown", "csv", "json", "py", "ts", "tsx", "js", "jsx"}:
return ("md" if suffix in {"md", "markdown"} else "txt", _safe_decode_bytes(raw))
if suffix == "pdf":
try:
from pypdf import PdfReader # type: ignore
except Exception as exc:
raise HTTPException(status_code=400, detail=f"PDF 解析依赖缺失: {exc}") from exc
reader = PdfReader(io.BytesIO(raw))
parts: list[str] = []
for idx, page in enumerate(reader.pages, start=1):
page_text = (page.extract_text() or "").strip()
if not page_text:
continue
# 写入页码标记,向量分块时会保留 page_number 元数据,便于引用来源页码。
parts.append(f"[[PAGE:{idx}]]\n{page_text}")
text = "\n\n".join(parts)
return ("pdf", text)
if suffix in {"docx", "doc"}:
try:
from docx import Document # type: ignore
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Word 解析依赖缺失: {exc}") from exc
doc = Document(io.BytesIO(raw))
text = "\n".join([p.text for p in doc.paragraphs if p.text])
return ("word", text)
raise HTTPException(status_code=400, detail=f"不支持的文件类型: .{suffix}")
@router.post("/ask")
async def rag_ask(
request: RAGAskRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
result = await service.answer_question(
question=request.question,
asked_by=int(current_user.id),
asked_via=request.asked_via,
project_id=request.project_id,
source_chat_id=request.source_chat_id,
session_id=request.session_id,
force_new_session=bool(request.new_session),
thinking_mode=request.thinking_mode,
action_mode=request.action_mode,
sync_requirement=bool(request.sync_requirement),
)
return success_response(result)
@router.get("/manifest")
async def rag_manifest(
project_id: int | None = Query(default=None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
data = await service.get_project_manifest(project_id=project_id)
return success_response(data)
@router.get("/features/catalog")
async def rag_feature_catalog(
query: str | None = Query(default=None),
limit: int = Query(default=20, ge=1, le=200),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
data = await service.get_feature_catalog(query=query, limit=limit)
return success_response(data)
@router.post("/entity-resolution")
async def rag_entity_resolution(
request: EntityResolutionRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
data = await service.entity_resolution(
question=request.question,
project_id=request.project_id,
limit=request.limit,
)
return success_response(data)
@router.post("/multi-step-search")
async def rag_multi_step_search(
request: MultiStepSearchRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
data = await service.multi_step_search(
question=request.question,
project_id=request.project_id,
source_chat_id=request.source_chat_id,
session_state=None,
)
return success_response(data)
@router.post("/symbols/sync")
async def rag_symbols_sync(
request: SymbolSyncRequest,
current_user: User = Depends(require_roles("ADMIN", "BOSS")),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
data = await service.sync_codebase_symbols(
base_dir=request.base_dir,
max_files=request.max_files,
force_reindex=request.force_reindex,
)
return success_response(data)
@router.post("/symbols/search")
async def rag_symbols_search(
request: SymbolSearchRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
data = await service.search_symbols(
query=request.query,
language=request.language,
symbol_type=request.symbol_type,
limit=request.limit,
)
return success_response(data)
@router.get("/symbols/overview")
async def rag_symbols_overview(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
data = await service.get_overview(top_n_files=10, auto_sync_if_empty=False)
return success_response(data)
@router.get("/symbols/{symbol_id}")
async def rag_symbol_detail(
symbol_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
try:
data = await service.get_symbol_detail(symbol_id=symbol_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return success_response(data)
@router.get("/knowledge-base/docs")
async def list_knowledge_docs(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
return success_response(await service.list_knowledge_docs())
@router.post("/knowledge-base/docs")
async def create_knowledge_doc(
request: KnowledgeDocCreateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
result = await service.create_knowledge_doc(
title=request.title,
file_type=request.file_type,
raw_content=request.content,
uploaded_by=int(current_user.id),
source_type=request.source_type,
file_url=request.file_url,
project_id=request.project_id,
)
return success_response(result)
@router.post("/knowledge-base/upload")
async def upload_knowledge_doc(
file: UploadFile = File(...),
project_id: int | None = Form(default=None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
raw = await file.read()
if not raw:
raise HTTPException(status_code=400, detail="上传文件为空")
if len(raw) > 15 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件过大,限制 15MB")
file_type, text = _extract_text_from_upload(file.filename or "uploaded.txt", raw)
if not str(text or "").strip():
raise HTTPException(status_code=400, detail="无法提取有效文本内容")
effective_tenant_id = _resolve_tenant_id(current_user, tenant_id)
service = AdaptiveRAGService(db, tenant_id=effective_tenant_id)
doc = await service.create_knowledge_doc_placeholder(
title=(file.filename or "uploaded.txt")[:255],
file_type=file_type,
uploaded_by=int(current_user.id),
source_type="file",
file_url=None,
)
asyncio.create_task(
AdaptiveRAGService.process_knowledge_doc_content_async(
tenant_id=effective_tenant_id,
doc_id=int(doc.id),
raw_content=text,
project_id=project_id,
)
)
return success_response(service._serialize_doc(doc))
@router.post("/knowledge-base/urls")
async def create_knowledge_url(
request: KnowledgeURLCreateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
url = str(request.url or "").strip()
if not re.match(r"^https?://", url, flags=re.IGNORECASE):
raise HTTPException(status_code=400, detail="URL 必须以 http:// 或 https:// 开头")
effective_tenant_id = _resolve_tenant_id(current_user, tenant_id)
service = AdaptiveRAGService(db, tenant_id=effective_tenant_id)
doc = await service.create_knowledge_doc_placeholder(
title=(request.title or url)[:255],
file_type="url",
uploaded_by=int(current_user.id),
source_type="url",
file_url=url,
)
asyncio.create_task(
AdaptiveRAGService.refresh_url_doc_async(
tenant_id=effective_tenant_id,
doc_id=int(doc.id),
project_id=request.project_id,
)
)
return success_response(service._serialize_doc(doc))
@router.post("/knowledge-base/docs/{doc_id}/refresh")
async def refresh_knowledge_doc(
doc_id: int,
project_id: int | None = Query(default=None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = _resolve_tenant_id(current_user, tenant_id)
service = AdaptiveRAGService(db, tenant_id=effective_tenant_id)
try:
doc = await service.get_knowledge_doc(doc_id=doc_id)
if str(doc.get("source_type") or "").lower() != "url":
raise HTTPException(status_code=400, detail="仅 URL 类型条目支持 refresh")
marked = await service.mark_doc_processing(doc_id=doc_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
asyncio.create_task(
AdaptiveRAGService.refresh_url_doc_async(
tenant_id=effective_tenant_id,
doc_id=doc_id,
project_id=project_id,
)
)
return success_response(marked)
@router.delete("/knowledge-base/docs/{doc_id}")
async def delete_knowledge_doc(
doc_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
ok = await service.delete_knowledge_doc(doc_id=doc_id)
if not ok:
raise HTTPException(status_code=404, detail="文档不存在")
return success_response({"deleted": True, "doc_id": doc_id})
@router.post("/qa-records/{qa_id}/feedback")
async def submit_qa_feedback(
qa_id: int,
request: QAFeedbackCreateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
try:
result = await service.submit_feedback(
qa_id=qa_id,
rating=request.rating,
helpfulness=request.helpfulness,
error_types=request.error_types,
correct_answer=request.correct_answer,
submitted_by=int(current_user.id),
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return success_response(result)
@router.get("/qa-records")
async def list_qa_records(
project_id: int | None = Query(default=None),
date_from: datetime | None = Query(default=None),
date_to: datetime | None = Query(default=None),
rating: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
query = QAListQuery(
project_id=project_id,
date_from=date_from,
date_to=date_to,
rating=rating,
page=page,
page_size=page_size,
)
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
result = await service.list_qa_records(
project_id=query.project_id,
date_from=query.date_from,
date_to=query.date_to,
rating=query.rating,
page=query.page,
page_size=query.page_size,
)
return success_response(result)
@router.get("/qa-sessions")
async def list_qa_sessions(
date_from: datetime | None = Query(default=None),
date_to: datetime | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
query = QASessionListQuery(
date_from=date_from,
date_to=date_to,
page=page,
page_size=page_size,
)
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
result = await service.list_qa_sessions(
user_id=int(current_user.id),
date_from=query.date_from,
date_to=query.date_to,
page=query.page,
page_size=query.page_size,
)
return success_response(result)
@router.get("/qa-sessions/{session_id}")
async def qa_session_detail(
session_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
try:
result = await service.get_qa_session_detail(session_id=session_id, user_id=int(current_user.id))
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return success_response(result)
@router.get("/qa-sessions/{session_id}/state")
async def qa_session_state_replay(
session_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
try:
result = await service.get_session_state_replay(
session_id=session_id,
user_id=int(current_user.id),
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return success_response(result)
@router.patch("/qa-sessions/{session_id}/title")
async def rename_qa_session(
session_id: int,
request: QASessionRenameRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
try:
result = await service.rename_qa_session(
session_id=session_id,
user_id=int(current_user.id),
title=request.title,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return success_response(result)
@router.delete("/qa-sessions/{session_id}")
async def delete_qa_session(
session_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
deleted = await service.delete_qa_session(session_id=session_id, user_id=int(current_user.id))
if not deleted:
raise HTTPException(status_code=404, detail="qa session 不存在")
return success_response({"deleted": True, "session_id": session_id})
@router.get("/qa-records/stats")
async def qa_records_stats(
project_id: int | None = Query(default=None),
date_from: datetime | None = Query(default=None),
date_to: datetime | None = Query(default=None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
result = await service.get_qa_stats(
project_id=project_id,
date_from=date_from,
date_to=date_to,
)
return success_response(result)
@router.get("/qa-records/export")
async def export_qa_records_jsonl(
include_unrated: bool = Query(default=False),
project_id: int | None = Query(default=None),
rating: str | None = Query(default=None),
domain: str | None = Query(default=None),
date_from: datetime | None = Query(default=None),
date_to: datetime | None = Query(default=None),
current_user: User = Depends(require_roles("ADMIN", "BOSS")),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
query = QAExportQuery(
include_unrated=include_unrated,
project_id=project_id,
rating=rating,
domain=domain,
date_from=date_from,
date_to=date_to,
)
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
payload = await service.export_qa_jsonl(
include_unrated=query.include_unrated,
project_id=query.project_id,
rating=query.rating,
domain=query.domain,
date_from=query.date_from,
date_to=query.date_to,
)
return Response(content=payload, media_type="application/x-ndjson")

View File

@ -0,0 +1,11 @@
from __future__ import annotations
from fastapi import APIRouter
from . import routes_knowledge, routes_qa, routes_query
router = APIRouter(prefix="/rag", tags=["Adaptive RAG"])
for _module in [routes_query, routes_knowledge, routes_qa]:
router.include_router(_module.router)
__all__ = ["router"]

View File

@ -0,0 +1,52 @@
from __future__ import annotations
from .shared import *
def _resolve_tenant_id(user: User, fallback_tenant_id: int) -> int:
try:
return int(getattr(user, "tenant_id") or fallback_tenant_id or 1)
except Exception:
return int(fallback_tenant_id or 1)
def _safe_decode_bytes(raw: bytes) -> str:
for encoding in ("utf-8", "utf-8-sig", "gb18030", "latin-1"):
try:
return raw.decode(encoding)
except Exception:
continue
return raw.decode("utf-8", errors="ignore")
def _extract_text_from_upload(filename: str, raw: bytes) -> tuple[str, str]:
name = str(filename or "uploaded.txt")
suffix = name.rsplit(".", 1)[-1].lower() if "." in name else "txt"
if suffix in {"txt", "md", "markdown", "csv", "json", "py", "ts", "tsx", "js", "jsx"}:
return ("md" if suffix in {"md", "markdown"} else "txt", _safe_decode_bytes(raw))
if suffix == "pdf":
try:
from pypdf import PdfReader # type: ignore
except Exception as exc:
raise HTTPException(status_code=400, detail=f"PDF 解析依赖缺失: {exc}") from exc
reader = PdfReader(io.BytesIO(raw))
parts: list[str] = []
for idx, page in enumerate(reader.pages, start=1):
page_text = (page.extract_text() or "").strip()
if not page_text:
continue
# 写入页码标记,向量分块时会保留 page_number 元数据,便于引用来源页码。
parts.append(f"[[PAGE:{idx}]]\n{page_text}")
text = "\n\n".join(parts)
return ("pdf", text)
if suffix in {"docx", "doc"}:
try:
from docx import Document # type: ignore
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Word 解析依赖缺失: {exc}") from exc
doc = Document(io.BytesIO(raw))
text = "\n".join([p.text for p in doc.paragraphs if p.text])
return ("word", text)
raise HTTPException(status_code=400, detail=f"不支持的文件类型: .{suffix}")

View File

@ -0,0 +1,141 @@
from __future__ import annotations
from .shared import *
from .helpers import _extract_text_from_upload, _resolve_tenant_id
@router.get("/knowledge-base/docs")
async def list_knowledge_docs(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
return success_response(await service.list_knowledge_docs())
@router.post("/knowledge-base/docs")
async def create_knowledge_doc(
request: KnowledgeDocCreateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
result = await service.create_knowledge_doc(
title=request.title,
file_type=request.file_type,
raw_content=request.content,
uploaded_by=int(current_user.id),
source_type=request.source_type,
file_url=request.file_url,
project_id=request.project_id,
)
return success_response(result)
@router.post("/knowledge-base/upload")
async def upload_knowledge_doc(
file: UploadFile = File(...),
project_id: int | None = Form(default=None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
raw = await file.read()
if not raw:
raise HTTPException(status_code=400, detail="上传文件为空")
if len(raw) > 15 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件过大,限制 15MB")
file_type, text = _extract_text_from_upload(file.filename or "uploaded.txt", raw)
if not str(text or "").strip():
raise HTTPException(status_code=400, detail="无法提取有效文本内容")
effective_tenant_id = _resolve_tenant_id(current_user, tenant_id)
service = AdaptiveRAGService(db, tenant_id=effective_tenant_id)
doc = await service.create_knowledge_doc_placeholder(
title=(file.filename or "uploaded.txt")[:255],
file_type=file_type,
uploaded_by=int(current_user.id),
source_type="file",
file_url=None,
)
asyncio.create_task(
AdaptiveRAGService.process_knowledge_doc_content_async(
tenant_id=effective_tenant_id,
doc_id=int(doc.id),
raw_content=text,
project_id=project_id,
)
)
return success_response(service._serialize_doc(doc))
@router.post("/knowledge-base/urls")
async def create_knowledge_url(
request: KnowledgeURLCreateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
url = str(request.url or "").strip()
if not re.match(r"^https?://", url, flags=re.IGNORECASE):
raise HTTPException(status_code=400, detail="URL 必须以 http:// 或 https:// 开头")
effective_tenant_id = _resolve_tenant_id(current_user, tenant_id)
service = AdaptiveRAGService(db, tenant_id=effective_tenant_id)
doc = await service.create_knowledge_doc_placeholder(
title=(request.title or url)[:255],
file_type="url",
uploaded_by=int(current_user.id),
source_type="url",
file_url=url,
)
asyncio.create_task(
AdaptiveRAGService.refresh_url_doc_async(
tenant_id=effective_tenant_id,
doc_id=int(doc.id),
project_id=request.project_id,
)
)
return success_response(service._serialize_doc(doc))
@router.post("/knowledge-base/docs/{doc_id}/refresh")
async def refresh_knowledge_doc(
doc_id: int,
project_id: int | None = Query(default=None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = _resolve_tenant_id(current_user, tenant_id)
service = AdaptiveRAGService(db, tenant_id=effective_tenant_id)
try:
doc = await service.get_knowledge_doc(doc_id=doc_id)
if str(doc.get("source_type") or "").lower() != "url":
raise HTTPException(status_code=400, detail="仅 URL 类型条目支持 refresh")
marked = await service.mark_doc_processing(doc_id=doc_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
asyncio.create_task(
AdaptiveRAGService.refresh_url_doc_async(
tenant_id=effective_tenant_id,
doc_id=doc_id,
project_id=project_id,
)
)
return success_response(marked)
@router.delete("/knowledge-base/docs/{doc_id}")
async def delete_knowledge_doc(
doc_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
ok = await service.delete_knowledge_doc(doc_id=doc_id)
if not ok:
raise HTTPException(status_code=404, detail="文档不存在")
return success_response({"deleted": True, "doc_id": doc_id})

View File

@ -0,0 +1,198 @@
from __future__ import annotations
from .shared import *
from .helpers import _resolve_tenant_id
@router.post("/qa-records/{qa_id}/feedback")
async def submit_qa_feedback(
qa_id: int,
request: QAFeedbackCreateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
try:
result = await service.submit_feedback(
qa_id=qa_id,
rating=request.rating,
helpfulness=request.helpfulness,
error_types=request.error_types,
correct_answer=request.correct_answer,
submitted_by=int(current_user.id),
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return success_response(result)
@router.get("/qa-records")
async def list_qa_records(
project_id: int | None = Query(default=None),
date_from: datetime | None = Query(default=None),
date_to: datetime | None = Query(default=None),
rating: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
query = QAListQuery(
project_id=project_id,
date_from=date_from,
date_to=date_to,
rating=rating,
page=page,
page_size=page_size,
)
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
result = await service.list_qa_records(
project_id=query.project_id,
date_from=query.date_from,
date_to=query.date_to,
rating=query.rating,
page=query.page,
page_size=query.page_size,
)
return success_response(result)
@router.get("/qa-sessions")
async def list_qa_sessions(
date_from: datetime | None = Query(default=None),
date_to: datetime | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
query = QASessionListQuery(
date_from=date_from,
date_to=date_to,
page=page,
page_size=page_size,
)
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
result = await service.list_qa_sessions(
user_id=int(current_user.id),
date_from=query.date_from,
date_to=query.date_to,
page=query.page,
page_size=query.page_size,
)
return success_response(result)
@router.get("/qa-sessions/{session_id}")
async def qa_session_detail(
session_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
try:
result = await service.get_qa_session_detail(session_id=session_id, user_id=int(current_user.id))
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return success_response(result)
@router.get("/qa-sessions/{session_id}/state")
async def qa_session_state_replay(
session_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
try:
result = await service.get_session_state_replay(
session_id=session_id,
user_id=int(current_user.id),
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return success_response(result)
@router.patch("/qa-sessions/{session_id}/title")
async def rename_qa_session(
session_id: int,
request: QASessionRenameRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
try:
result = await service.rename_qa_session(
session_id=session_id,
user_id=int(current_user.id),
title=request.title,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return success_response(result)
@router.delete("/qa-sessions/{session_id}")
async def delete_qa_session(
session_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
deleted = await service.delete_qa_session(session_id=session_id, user_id=int(current_user.id))
if not deleted:
raise HTTPException(status_code=404, detail="qa session 不存在")
return success_response({"deleted": True, "session_id": session_id})
@router.get("/qa-records/stats")
async def qa_records_stats(
project_id: int | None = Query(default=None),
date_from: datetime | None = Query(default=None),
date_to: datetime | None = Query(default=None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
result = await service.get_qa_stats(
project_id=project_id,
date_from=date_from,
date_to=date_to,
)
return success_response(result)
@router.get("/qa-records/export")
async def export_qa_records_jsonl(
include_unrated: bool = Query(default=False),
project_id: int | None = Query(default=None),
rating: str | None = Query(default=None),
domain: str | None = Query(default=None),
date_from: datetime | None = Query(default=None),
date_to: datetime | None = Query(default=None),
current_user: User = Depends(require_roles("ADMIN", "BOSS")),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
query = QAExportQuery(
include_unrated=include_unrated,
project_id=project_id,
rating=rating,
domain=domain,
date_from=date_from,
date_to=date_to,
)
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
payload = await service.export_qa_jsonl(
include_unrated=query.include_unrated,
project_id=query.project_id,
rating=query.rating,
domain=query.domain,
date_from=query.date_from,
date_to=query.date_to,
)
return Response(content=payload, media_type="application/x-ndjson")

View File

@ -0,0 +1,136 @@
from __future__ import annotations
from .shared import *
from .helpers import _resolve_tenant_id
@router.post("/ask")
async def rag_ask(
request: RAGAskRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
result = await service.answer_question(
question=request.question,
asked_by=int(current_user.id),
asked_via=request.asked_via,
project_id=request.project_id,
source_chat_id=request.source_chat_id,
session_id=request.session_id,
force_new_session=bool(request.new_session),
thinking_mode=request.thinking_mode,
action_mode=request.action_mode,
sync_requirement=bool(request.sync_requirement),
)
return success_response(result)
@router.get("/manifest")
async def rag_manifest(
project_id: int | None = Query(default=None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
data = await service.get_project_manifest(project_id=project_id)
return success_response(data)
@router.get("/features/catalog")
async def rag_feature_catalog(
query: str | None = Query(default=None),
limit: int = Query(default=20, ge=1, le=200),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
data = await service.get_feature_catalog(query=query, limit=limit)
return success_response(data)
@router.post("/entity-resolution")
async def rag_entity_resolution(
request: EntityResolutionRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
data = await service.entity_resolution(
question=request.question,
project_id=request.project_id,
limit=request.limit,
)
return success_response(data)
@router.post("/multi-step-search")
async def rag_multi_step_search(
request: MultiStepSearchRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
data = await service.multi_step_search(
question=request.question,
project_id=request.project_id,
source_chat_id=request.source_chat_id,
session_state=None,
)
return success_response(data)
@router.post("/symbols/sync")
async def rag_symbols_sync(
request: SymbolSyncRequest,
current_user: User = Depends(require_roles("ADMIN", "BOSS")),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
data = await service.sync_codebase_symbols(
base_dir=request.base_dir,
max_files=request.max_files,
force_reindex=request.force_reindex,
)
return success_response(data)
@router.post("/symbols/search")
async def rag_symbols_search(
request: SymbolSearchRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
data = await service.search_symbols(
query=request.query,
language=request.language,
symbol_type=request.symbol_type,
limit=request.limit,
)
return success_response(data)
@router.get("/symbols/overview")
async def rag_symbols_overview(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
data = await service.get_overview(top_n_files=10, auto_sync_if_empty=False)
return success_response(data)
@router.get("/symbols/{symbol_id}")
async def rag_symbol_detail(
symbol_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
try:
data = await service.get_symbol_detail(symbol_id=symbol_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return success_response(data)

View File

@ -0,0 +1,33 @@
from __future__ import annotations
import asyncio
import io
import re
from datetime import datetime
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from ...core.response import success_response
from ...database import get_db
from ...dependencies.auth import get_current_user, require_roles
from ...dependencies.tenant import get_tenant_id
from ...models import User
from ...schemas.rag import (
EntityResolutionRequest,
KnowledgeDocCreateRequest,
KnowledgeURLCreateRequest,
MultiStepSearchRequest,
QAExportQuery,
QAFeedbackCreateRequest,
QAListQuery,
QASessionListQuery,
QASessionRenameRequest,
RAGAskRequest,
SymbolSearchRequest,
SymbolSyncRequest,
)
from ...services.adaptive_rag_service import AdaptiveRAGService
from ...services.symbol_index_service import SymbolIndexService
router = APIRouter(prefix="/rag", tags=["Adaptive RAG"])

View File

@ -1,120 +1,24 @@
"""食谱系统信号源 API """食谱系统信号源 API。"""
from __future__ import annotations
功能:
1. 接收食谱系统的 webhook 请求
2. 将食谱识别需求转换为 PMFlow 信号
3. 自动关联到指定项目(食谱系统项目)
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any
import json import json
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.logging import get_logger
from ..database import get_db from ..database import get_db
from ..dependencies.tenant import get_tenant_id from ..dependencies.tenant import get_tenant_id
from ..core.logging import get_logger from ..models import ProjectSignalLink, Signal
from ..models import Signal, ProjectSignalLink, Project from .signal_sources_models import RecipeBatchRequest, RecipeRecognitionRequest, RecipeRecognitionResponse
from .signal_sources_utils import resolve_target_project_id
router = APIRouter(prefix="/signal-sources", tags=["Signal Sources"]) router = APIRouter(prefix="/signal-sources", tags=["Signal Sources"])
logger = get_logger(__name__) logger = get_logger(__name__)
RECIPE_PROJECT_KEYWORDS = ("食谱", "recipe", "sstc")
async def _resolve_target_project_id(
*,
db: AsyncSession,
tenant_id: int,
project_id: Optional[int],
) -> int:
"""解析目标项目:优先显式 project_id,其次按关键字自动匹配。"""
from sqlalchemy import select, or_
if project_id is not None:
result = await db.execute(
select(Project.id).where(
Project.tenant_id == tenant_id,
Project.id == int(project_id),
).limit(1)
)
found = result.scalar_one_or_none()
if found is None:
raise HTTPException(status_code=404, detail=f"project_id={project_id} 不存在或无权限")
return int(found)
keyword_conditions = []
for keyword in RECIPE_PROJECT_KEYWORDS:
keyword_conditions.extend([
Project.title.ilike(f"%{keyword}%"),
Project.name.ilike(f"%{keyword}%"),
])
match_result = await db.execute(
select(Project.id).where(
Project.tenant_id == tenant_id,
or_(*keyword_conditions),
).limit(1)
)
matched = match_result.scalar_one_or_none()
if matched is not None:
return int(matched)
# 兜底:若租户仅存在一个活跃项目,自动绑定,避免“已调用但未入库”。
active_result = await db.execute(
select(Project.id).where(
Project.tenant_id == tenant_id,
Project.status == "active",
).limit(2)
)
active_ids = list(active_result.scalars().all())
if len(active_ids) == 1:
return int(active_ids[0])
raise HTTPException(
status_code=422,
detail="未找到目标项目,请在请求中显式传入 project_id(或确保项目名包含 食谱/recipe/sstc)",
)
# ============================================================================
# 食谱系统信号源 Webhook
# ============================================================================
class RecipeRecognitionRequest(BaseModel):
"""食谱识别请求"""
# 食谱信息
food_name: str = Field(..., description="食物名称")
food_description: Optional[str] = Field(None, description="食物描述")
image_url: Optional[str] = Field(None, description="图片URL")
category: Optional[str] = Field(None, description="分类")
cuisine: Optional[str] = Field(None, description="菜系")
# 营养信息(如果来源系统已分析)
calories: Optional[float] = Field(None, description="热量(千卡)")
protein: Optional[float] = Field(None, description="蛋白质(克)")
fat: Optional[float] = Field(None, description="脂肪(克)")
carbs: Optional[float] = Field(None, description="碳水化合物(克)")
fiber: Optional[float] = Field(None, description="膳食纤维(克)")
# 意图
intent: str = Field(
default="analyze",
description="意图:analyze-热量分析, save-保存食谱, recommend-推荐菜谱"
)
# 用户信息(可选)
user_name: Optional[str] = Field(None, description="用户名")
class RecipeRecognitionResponse(BaseModel):
"""响应"""
success: bool
signal_id: Optional[int] = None
project_id: Optional[int] = None
message: str
details: Optional[Dict[str, Any]] = None
@router.post("/recipe/webhook", response_model=RecipeRecognitionResponse, summary="食谱系统 Webhook") @router.post("/recipe/webhook", response_model=RecipeRecognitionResponse, summary="食谱系统 Webhook")
async def recipe_webhook( async def recipe_webhook(
@ -123,47 +27,18 @@ async def recipe_webhook(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
): ):
""" """接收食谱系统的识别请求。"""
接收食谱系统的识别请求
用法:
- 食谱系统完成拍照识别后,调用此 API 将需求推送到 PMFlow
- PMFlow 会自动创建信号并关联到食谱系统项目
请求示例:
```json
{
"food_name": "红烧肉",
"food_description": "一块红烧肉,约200克,肥瘦相间",
"image_url": "https://example.com/image.jpg",
"category": "肉类",
"cuisine": "中式",
"calories": 350,
"protein": 20,
"fat": 25,
"carbs": 10,
"fiber": 0,
"intent": "analyze",
"user_name": "张三"
}
```
"""
try: try:
# 1. 查找目标项目 target_project_id = await resolve_target_project_id(
target_project_id = await _resolve_target_project_id(
db=db, db=db,
tenant_id=tenant_id, tenant_id=tenant_id,
project_id=project_id, project_id=project_id,
) )
# 2. 构建信号内容 content_parts = [f"食物:{request.food_name}"]
content_parts = []
content_parts.append(f"食物:{request.food_name}")
if request.food_description: if request.food_description:
content_parts.append(f"描述:{request.food_description}") content_parts.append(f"描述:{request.food_description}")
# 营养信息
if request.calories or request.protein or request.fat or request.carbs or request.fiber: if request.calories or request.protein or request.fat or request.carbs or request.fiber:
nutrition = [] nutrition = []
if request.calories: if request.calories:
@ -186,16 +61,14 @@ async def recipe_webhook(
content = "\n".join(content_parts) content = "\n".join(content_parts)
# 3. 构建标题
intent_text = { intent_text = {
"analyze": "热量分析", "analyze": "热量分析",
"save": "保存食谱", "save": "保存食谱",
"recommend": "推荐菜谱" "recommend": "推荐菜谱",
}.get(request.intent, request.intent) }.get(request.intent, request.intent)
title = f"🍽️ 食谱{intent_text}:{request.food_name}" title = f"🍽️ 食谱{intent_text}:{request.food_name}"
# 4. 构建摘要
summary = f"食物:{request.food_name}" summary = f"食物:{request.food_name}"
if request.calories: if request.calories:
summary += f"\n热量:{request.calories} kcal" summary += f"\n热量:{request.calories} kcal"
@ -203,7 +76,6 @@ async def recipe_webhook(
summary += f"\n分类:{request.category}" summary += f"\n分类:{request.category}"
summary += f"\n意图:{intent_text}" summary += f"\n意图:{intent_text}"
# 5. 构建信号元数据
signal_metadata = { signal_metadata = {
"source": "recipe_system", "source": "recipe_system",
"intent": request.intent, "intent": request.intent,
@ -220,7 +92,6 @@ async def recipe_webhook(
"user_name": request.user_name, "user_name": request.user_name,
} }
# 6. 创建信号
signal = Signal( signal = Signal(
tenant_id=tenant_id, tenant_id=tenant_id,
source="recipe_system", source="recipe_system",
@ -236,9 +107,8 @@ async def recipe_webhook(
) )
db.add(signal) db.add(signal)
await db.flush() # 获取 signal.id await db.flush()
# 7. 关联到项目
link = ProjectSignalLink( link = ProjectSignalLink(
tenant_id=tenant_id, tenant_id=tenant_id,
project_id=target_project_id, project_id=target_project_id,
@ -259,22 +129,22 @@ async def recipe_webhook(
"project_id": target_project_id, "project_id": target_project_id,
"title": title, "title": title,
"status": signal.status, "status": signal.status,
} },
) )
except HTTPException: except HTTPException:
await db.rollback() await db.rollback()
raise raise
except Exception as e: except Exception as exc:
await db.rollback() await db.rollback()
logger.exception( logger.exception(
"recipe_webhook_create_signal_failed", "recipe_webhook_create_signal_failed",
tenant_id=tenant_id, tenant_id=tenant_id,
project_id=project_id, project_id=project_id,
food_name=request.food_name, food_name=request.food_name,
error=str(e), error=str(exc),
) )
raise HTTPException(status_code=500, detail=f"创建信号失败: {str(e)}") raise HTTPException(status_code=500, detail=f"创建信号失败: {str(exc)}")
@router.get("/recipe/status/{signal_id}", summary="查询食谱信号状态") @router.get("/recipe/status/{signal_id}", summary="查询食谱信号状态")
@ -284,8 +154,6 @@ async def get_recipe_signal_status(
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
): ):
"""查询食谱识别信号的处理状态""" """查询食谱识别信号的处理状态"""
from sqlalchemy import select
result = await db.execute( result = await db.execute(
select(Signal).where( select(Signal).where(
Signal.id == signal_id, Signal.id == signal_id,
@ -307,15 +175,6 @@ async def get_recipe_signal_status(
} }
# ============================================================================
# 批量导入食谱记录
# ============================================================================
class RecipeBatchRequest(BaseModel):
"""批量导入请求"""
recipes: list[RecipeRecognitionRequest] = Field(..., description="食谱列表")
@router.post("/recipe/batch", response_model=RecipeRecognitionResponse, summary="批量导入食谱") @router.post("/recipe/batch", response_model=RecipeRecognitionResponse, summary="批量导入食谱")
async def recipe_batch_import( async def recipe_batch_import(
request: RecipeBatchRequest, request: RecipeBatchRequest,
@ -325,7 +184,7 @@ async def recipe_batch_import(
): ):
"""批量导入食谱记录,每个都会创建信号""" """批量导入食谱记录,每个都会创建信号"""
try: try:
target_project_id = await _resolve_target_project_id( target_project_id = await resolve_target_project_id(
db=db, db=db,
tenant_id=tenant_id, tenant_id=tenant_id,
project_id=project_id, project_id=project_id,
@ -333,7 +192,6 @@ async def recipe_batch_import(
signal_ids = [] signal_ids = []
for recipe in request.recipes: for recipe in request.recipes:
# 构建内容
content = f"食物:{recipe.food_name}" content = f"食物:{recipe.food_name}"
if recipe.calories: if recipe.calories:
content += f"\n热量:{recipe.calories} kcal" content += f"\n热量:{recipe.calories} kcal"
@ -348,7 +206,7 @@ async def recipe_batch_import(
"protein": recipe.protein, "protein": recipe.protein,
"fat": recipe.fat, "fat": recipe.fat,
"carbs": recipe.carbs, "carbs": recipe.carbs,
} },
} }
signal = Signal( signal = Signal(
@ -361,13 +219,12 @@ async def recipe_batch_import(
priority="low", priority="low",
impact_score=0.3, impact_score=0.3,
status="待处理", status="待处理",
processed=True, # 批量导入的视为已处理 processed=True,
strategic_analysis=json.dumps(metadata), strategic_analysis=json.dumps(metadata),
) )
db.add(signal) db.add(signal)
await db.flush() await db.flush()
# 关联项目
link = ProjectSignalLink( link = ProjectSignalLink(
tenant_id=tenant_id, tenant_id=tenant_id,
project_id=target_project_id, project_id=target_project_id,
@ -380,22 +237,22 @@ async def recipe_batch_import(
return RecipeRecognitionResponse( return RecipeRecognitionResponse(
success=True, success=True,
signal_id=len(signal_ids), # 返回数量 signal_id=len(signal_ids),
project_id=target_project_id, project_id=target_project_id,
message=f"成功导入 {len(signal_ids)} 条食谱记录", message=f"成功导入 {len(signal_ids)} 条食谱记录",
details={"signal_count": len(signal_ids)} details={"signal_count": len(signal_ids)},
) )
except HTTPException: except HTTPException:
await db.rollback() await db.rollback()
raise raise
except Exception as e: except Exception as exc:
await db.rollback() await db.rollback()
logger.exception( logger.exception(
"recipe_batch_import_failed", "recipe_batch_import_failed",
tenant_id=tenant_id, tenant_id=tenant_id,
project_id=project_id, project_id=project_id,
recipe_count=len(request.recipes), recipe_count=len(request.recipes),
error=str(e), error=str(exc),
) )
raise HTTPException(status_code=500, detail=f"批量导入失败: {str(e)}") raise HTTPException(status_code=500, detail=f"批量导入失败: {str(exc)}")

View File

@ -0,0 +1,46 @@
"""Schemas for signal source APIs."""
from __future__ import annotations
from typing import Any, Optional
from pydantic import BaseModel, Field
class RecipeRecognitionRequest(BaseModel):
"""食谱识别请求"""
food_name: str = Field(..., description="食物名称")
food_description: Optional[str] = Field(None, description="食物描述")
image_url: Optional[str] = Field(None, description="图片URL")
category: Optional[str] = Field(None, description="分类")
cuisine: Optional[str] = Field(None, description="菜系")
calories: Optional[float] = Field(None, description="热量(千卡)")
protein: Optional[float] = Field(None, description="蛋白质(克)")
fat: Optional[float] = Field(None, description="脂肪(克)")
carbs: Optional[float] = Field(None, description="碳水化合物(克)")
fiber: Optional[float] = Field(None, description="膳食纤维(克)")
intent: str = Field(
default="analyze",
description="意图:analyze-热量分析, save-保存食谱, recommend-推荐菜谱",
)
user_name: Optional[str] = Field(None, description="用户名")
class RecipeRecognitionResponse(BaseModel):
"""响应"""
success: bool
signal_id: Optional[int] = None
project_id: Optional[int] = None
message: str
details: Optional[dict[str, Any]] = None
class RecipeBatchRequest(BaseModel):
"""批量导入请求"""
recipes: list[RecipeRecognitionRequest] = Field(..., description="食谱列表")

View File

@ -0,0 +1,67 @@
"""Shared helper functions for signal source APIs."""
from __future__ import annotations
from typing import Optional
from fastapi import HTTPException
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Project
RECIPE_PROJECT_KEYWORDS = ("食谱", "recipe", "sstc")
async def resolve_target_project_id(
*,
db: AsyncSession,
tenant_id: int,
project_id: Optional[int],
) -> int:
"""解析目标项目:优先显式 project_id,其次按关键字自动匹配。"""
if project_id is not None:
result = await db.execute(
select(Project.id).where(
Project.tenant_id == tenant_id,
Project.id == int(project_id),
).limit(1)
)
found = result.scalar_one_or_none()
if found is None:
raise HTTPException(status_code=404, detail=f"project_id={project_id} 不存在或无权限")
return int(found)
keyword_conditions = []
for keyword in RECIPE_PROJECT_KEYWORDS:
keyword_conditions.extend(
[
Project.title.ilike(f"%{keyword}%"),
Project.name.ilike(f"%{keyword}%"),
]
)
match_result = await db.execute(
select(Project.id).where(
Project.tenant_id == tenant_id,
or_(*keyword_conditions),
).limit(1)
)
matched = match_result.scalar_one_or_none()
if matched is not None:
return int(matched)
active_result = await db.execute(
select(Project.id).where(
Project.tenant_id == tenant_id,
Project.status == "active",
).limit(2)
)
active_ids = list(active_result.scalars().all())
if len(active_ids) == 1:
return int(active_ids[0])
raise HTTPException(
status_code=422,
detail="未找到目标项目,请在请求中显式传入 project_id(或确保项目名包含 食谱/recipe/sstc)",
)

View File

@ -1,462 +1,7 @@
"""信号相关 API 路由""" """信号相关 API 路由。"""
import json from __future__ import annotations
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query from .signals_api import router
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from ..database import get_db __all__ = ["router"]
from ..dependencies.tenant import get_tenant_id
from ..models import Signal, SignalFeedback, Project, ProjectSignalLink
from ..schemas import (
SignalCreate,
SignalPriority,
classify_signal_priority,
SignalFeedbackCreate,
SignalFeedbackResponse,
)
from ..services.fetcher import fetch_hn_stories
from ..services.analyzer import analyze_impact
from ..services.llm import llm_service
from ..services.plane import plane_service
from ..services.lark import LarkService
router = APIRouter(prefix="/signals", tags=["signals"])
lark_service = LarkService()
@router.get("/")
async def list_signals(
roles: Optional[str] = Query(None, description="逗号分隔的角色列表,如 'frontend,backend'"),
db: AsyncSession = Depends(get_db),
):
"""获取信号列表,支持按角色过滤"""
result = await db.execute(select(Signal).order_by(Signal.impact_score.desc()))
signals = result.scalars().all()
if roles:
role_list = [r.strip() for r in roles.split(",")]
filtered_signals = []
for signal in signals:
tasks = []
if signal.tasks_generated:
try:
tasks = json.loads(signal.tasks_generated)
except Exception:
tasks = []
filtered_tasks = [t for t in tasks if t.get("type") in role_list]
if filtered_tasks:
signal_dict = {
"id": str(signal.id),
"source": signal.source,
"content": signal.content,
"impact_score": signal.impact_score,
"priority": signal.priority,
"processed": signal.processed,
"strategic_analysis": signal.strategic_analysis,
"tasks_generated": signal.tasks_generated,
"tasks_filtered": filtered_tasks,
"created_at": signal.created_at.isoformat() if signal.created_at else None,
}
filtered_signals.append(signal_dict)
return filtered_signals
return signals
@router.post("/", response_model=dict)
async def create_signal(signal: SignalCreate, db: AsyncSession = Depends(get_db)):
new_signal = Signal(
source=signal.source,
content=signal.content,
impact_score=signal.impact_score,
)
db.add(new_signal)
await db.commit()
await db.refresh(new_signal)
return {"id": str(new_signal.id), "status": "received"}
@router.post("/ingest", response_model=dict)
async def ingest_signal(
signal: SignalCreate,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""接入外部信号,按优先级处理"""
initial_priority = signal.priority or classify_signal_priority(signal.impact_score)
new_signal = Signal(
source=signal.source,
content=signal.content,
impact_score=signal.impact_score,
priority=initial_priority.value,
)
db.add(new_signal)
await db.commit()
await db.refresh(new_signal)
if initial_priority == SignalPriority.LOW:
return {
"id": str(new_signal.id),
"priority": initial_priority.value,
"status": "recorded",
"message": "Low priority signal recorded without processing",
}
async def process_signal_internal():
try:
relevant_context = await llm_service.find_relevant_context(
signal_content=signal.content,
db_session=db,
top_k=3,
min_similarity=0.3,
tenant_id=tenant_id,
)
strategy_result = await llm_service.evaluate_with_voting_async(
signal_content=signal.content,
relevant_context=relevant_context,
impact_score=signal.impact_score,
db=db,
tenant_id=tenant_id,
)
except RuntimeError:
if initial_priority == SignalPriority.CRITICAL:
raise HTTPException(status_code=403, detail="预算超限,AI 调用被阻止(hard limit)")
return {
"id": str(new_signal.id),
"priority": initial_priority.value,
"status": "blocked",
"processing_mode": "background",
"message": "预算超限,AI 调用被阻止(hard limit)",
}
is_strategic = strategy_result.get("is_strategic", False)
decision_proposal = strategy_result.get("decision_proposal", {})
tasks = strategy_result.get("tasks", [])
confidence = strategy_result.get("confidence", 0.0)
final_priority = classify_signal_priority(signal.impact_score, confidence)
new_signal.priority = final_priority.value
sync_result = None
notification_sent = False
if final_priority in [SignalPriority.CRITICAL, SignalPriority.HIGH]:
if is_strategic and tasks:
sync_result = await plane_service.sync_tasks(
tasks, is_urgent=(final_priority == SignalPriority.CRITICAL)
)
default_chat_id = "oc_1234567890"
await lark_service.send_strategic_alert(
default_chat_id, signal.content, decision_proposal, tasks
)
notification_sent = True
new_signal.impact_score = 1.0
analysis_data = {
"decision_proposal": decision_proposal,
"competitiveness_radar": strategy_result.get("competitiveness_radar"),
"detected_tech_stacks": strategy_result.get("detected_tech_stacks", []),
"related_knowledge_nodes": strategy_result.get("related_knowledge_nodes", []),
"rag_context_used": len(relevant_context) if relevant_context else 0,
"rag_context_sources": [ctx["id"] for ctx in (relevant_context or [])],
}
if "_voting" in strategy_result:
voting_info = strategy_result["_voting"]
analysis_data["voting"] = {
"enabled": voting_info.get("enabled"),
"models_used": voting_info.get("models_used"),
"strategic_votes": voting_info.get("strategic_votes"),
"total_votes": voting_info.get("total_votes"),
"unanimous": voting_info.get("unanimous"),
}
new_signal.strategic_analysis = json.dumps(analysis_data)
new_signal.tasks_generated = json.dumps(tasks)
new_signal.processed = True
await db.commit()
await db.refresh(new_signal)
return {
"id": str(new_signal.id),
"priority": final_priority.value,
"is_strategic": is_strategic,
"confidence": confidence,
"decision_proposal": decision_proposal,
"rag_context_used": len(relevant_context) if relevant_context else 0,
"voting": strategy_result.get("_voting"),
"tasks_generated": len(tasks),
"sync_result": sync_result,
"notification_sent": notification_sent,
}
if initial_priority == SignalPriority.CRITICAL:
result = await process_signal_internal()
result["processing_mode"] = "synchronous"
return result
else:
background_tasks.add_task(process_signal_internal)
return {
"id": str(new_signal.id),
"priority": initial_priority.value,
"status": "queued",
"processing_mode": "background",
"message": f"{initial_priority.value.upper()} priority signal queued for processing",
}
@router.post("/fetch", response_model=dict)
async def trigger_signal_fetch(
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""手动触发抓取并分析信号"""
def _stop_ollama():
import subprocess
import signal
import os
killed = False
try:
result = subprocess.run(
["pgrep", "-f", "ollama"],
capture_output=True,
text=True,
)
if result.returncode == 0:
for pid in result.stdout.strip().split("\n"):
if pid:
try:
os.kill(int(pid), signal.SIGTERM)
killed = True
except ProcessLookupError:
pass
except Exception as e:
print(f"[AI] pgrep failed: {e}")
if not killed:
try:
result = subprocess.run(
["pkill", "-9", "-f", "ollama"],
capture_output=True,
timeout=5,
)
killed = result.returncode == 0
except Exception as e:
print(f"[AI] pkill failed: {e}")
if killed:
print("[AI] Ollama stopped - analysis complete")
raw_stories = await fetch_hn_stories(limit=30)
new_signals_count = 0
processed_count = 0
for story in raw_stories:
result = await db.execute(select(Signal).where(Signal.content == story["content"]))
if result.scalars().first():
continue
impact = analyze_impact(story["content"])
if impact > 0.4:
initial_priority = classify_signal_priority(impact)
new_signal = Signal(
source=story["source"],
content=story["content"],
impact_score=impact,
priority=initial_priority.value,
)
db.add(new_signal)
await db.commit()
await db.refresh(new_signal)
new_signals_count += 1
if initial_priority == SignalPriority.LOW:
continue
async def process_fetched():
nonlocal processed_count
try:
relevant_context = await llm_service.find_relevant_context(
signal_content=story["content"],
db_session=db,
top_k=3,
min_similarity=0.3,
tenant_id=tenant_id,
)
strategy_result = await llm_service.evaluate_with_voting_async(
signal_content=story["content"],
relevant_context=relevant_context,
impact_score=impact,
db=db,
tenant_id=tenant_id,
)
except RuntimeError:
return
is_strategic = strategy_result.get("is_strategic", False)
decision_proposal = strategy_result.get("decision_proposal", {})
confidence = strategy_result.get("confidence", 0.0)
final_priority = classify_signal_priority(impact, confidence)
new_signal.priority = final_priority.value
analysis_data = {
"decision_proposal": decision_proposal,
"competitiveness_radar": strategy_result.get("competitiveness_radar"),
"detected_tech_stacks": strategy_result.get("detected_tech_stacks", []),
"related_knowledge_nodes": strategy_result.get(
"related_knowledge_nodes", []
),
"rag_context_used": len(relevant_context) if relevant_context else 0,
"rag_context_sources": [ctx["id"] for ctx in (relevant_context or [])],
"confidence": confidence,
"is_strategic": is_strategic,
}
if "_voting" in strategy_result:
vi = strategy_result["_voting"]
analysis_data["voting"] = {
"enabled": vi.get("enabled"),
"models_used": vi.get("models_used"),
"strategic_votes": vi.get("strategic_votes"),
"total_votes": vi.get("total_votes"),
"unanimous": vi.get("unanimous"),
}
new_signal.strategic_analysis = json.dumps(analysis_data)
new_signal.tasks_generated = json.dumps(strategy_result.get("tasks", []))
new_signal.processed = True
await db.commit()
processed_count += 1
if initial_priority in [SignalPriority.MEDIUM, SignalPriority.HIGH]:
background_tasks.add_task(process_fetched)
else:
await process_fetched()
_stop_ollama()
return {
"status": "success",
"new_signals_count": new_signals_count,
"processed_count": processed_count,
"message": f"Fetched {new_signals_count} signals, {processed_count} analyzed",
}
@router.get("/{signal_id}/projects")
async def get_signal_projects(
signal_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取信号关联的项目"""
try:
sid = int(signal_id)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid signal_id")
signal_result = await db.execute(select(Signal).where(Signal.id == sid))
if not signal_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Signal not found")
result = await db.execute(
select(Project, ProjectSignalLink)
.join(ProjectSignalLink, Project.id == ProjectSignalLink.project_id)
.where(ProjectSignalLink.signal_id == sid)
)
rows = result.all()
return [
{
"id": str(p.id),
"title": p.title,
"status": p.status,
"assigned_role": p.assigned_role,
"link_type": link.link_type,
"relevance_score": link.relevance_score,
}
for p, link in rows
]
@router.post("/{signal_id}/feedback", response_model=SignalFeedbackResponse)
async def submit_signal_feedback(
signal_id: int,
feedback: SignalFeedbackCreate,
db: AsyncSession = Depends(get_db),
):
"""提交信号反馈"""
result = await db.execute(select(Signal).where(Signal.id == signal_id))
signal = result.scalar_one_or_none()
if not signal:
raise HTTPException(status_code=404, detail="Signal not found")
ai_predicted_strategic = False
ai_confidence = 0.0
if signal.strategic_analysis:
try:
analysis = json.loads(signal.strategic_analysis)
ai_predicted_strategic = analysis.get("is_strategic", signal.impact_score >= 0.9)
ai_confidence = analysis.get("confidence", 0.0)
except Exception:
ai_predicted_strategic = signal.impact_score >= 0.9
new_feedback = SignalFeedback(
signal_id=signal_id,
was_strategic=feedback.was_strategic,
reason=feedback.reason,
user_id=feedback.user_id,
ai_predicted_strategic=ai_predicted_strategic,
ai_confidence=ai_confidence,
)
db.add(new_feedback)
await db.commit()
await db.refresh(new_feedback)
is_correct = ai_predicted_strategic == feedback.was_strategic
return SignalFeedbackResponse(
id=new_feedback.id,
signal_id=new_feedback.signal_id,
was_strategic=new_feedback.was_strategic,
ai_predicted_strategic=new_feedback.ai_predicted_strategic,
ai_confidence=new_feedback.ai_confidence,
is_correct=is_correct,
)
@router.get("/{signal_id}/feedbacks", response_model=List[SignalFeedbackResponse])
async def get_signal_feedbacks(
signal_id: int,
db: AsyncSession = Depends(get_db),
):
"""获取某信号的全部反馈"""
result = await db.execute(
select(SignalFeedback)
.where(SignalFeedback.signal_id == signal_id)
.order_by(SignalFeedback.created_at.desc())
)
feedbacks = result.scalars().all()
return [
SignalFeedbackResponse(
id=f.id,
signal_id=f.signal_id,
was_strategic=f.was_strategic,
ai_predicted_strategic=f.ai_predicted_strategic,
ai_confidence=f.ai_confidence or 0.0,
is_correct=f.ai_predicted_strategic == f.was_strategic,
)
for f in feedbacks
]

View File

@ -0,0 +1,16 @@
"""Router assembly for signal APIs."""
from __future__ import annotations
from fastapi import APIRouter
from .feedback import router as feedback_router
from .ingest_fetch import router as ingest_fetch_router
from .list_create import router as list_create_router
router = APIRouter(prefix="/signals", tags=["signals"])
router.include_router(list_create_router)
router.include_router(ingest_fetch_router)
router.include_router(feedback_router)
__all__ = ["router"]

View File

@ -0,0 +1,88 @@
"""Signal feedback endpoints."""
from __future__ import annotations
import json
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from ...database import get_db
from ...models import Signal, SignalFeedback
from ...schemas import SignalFeedbackCreate, SignalFeedbackResponse
router = APIRouter()
@router.post("/{signal_id}/feedback", response_model=SignalFeedbackResponse)
async def submit_signal_feedback(
signal_id: int,
feedback: SignalFeedbackCreate,
db: AsyncSession = Depends(get_db),
):
"""提交信号反馈"""
result = await db.execute(select(Signal).where(Signal.id == signal_id))
signal = result.scalar_one_or_none()
if not signal:
raise HTTPException(status_code=404, detail="Signal not found")
ai_predicted_strategic = False
ai_confidence = 0.0
if signal.strategic_analysis:
try:
analysis = json.loads(signal.strategic_analysis)
ai_predicted_strategic = analysis.get("is_strategic", signal.impact_score >= 0.9)
ai_confidence = analysis.get("confidence", 0.0)
except Exception:
ai_predicted_strategic = signal.impact_score >= 0.9
new_feedback = SignalFeedback(
signal_id=signal_id,
was_strategic=feedback.was_strategic,
reason=feedback.reason,
user_id=feedback.user_id,
ai_predicted_strategic=ai_predicted_strategic,
ai_confidence=ai_confidence,
)
db.add(new_feedback)
await db.commit()
await db.refresh(new_feedback)
is_correct = ai_predicted_strategic == feedback.was_strategic
return SignalFeedbackResponse(
id=new_feedback.id,
signal_id=new_feedback.signal_id,
was_strategic=new_feedback.was_strategic,
ai_predicted_strategic=new_feedback.ai_predicted_strategic,
ai_confidence=new_feedback.ai_confidence,
is_correct=is_correct,
)
@router.get("/{signal_id}/feedbacks", response_model=List[SignalFeedbackResponse])
async def get_signal_feedbacks(
signal_id: int,
db: AsyncSession = Depends(get_db),
):
"""获取某信号的全部反馈"""
result = await db.execute(
select(SignalFeedback)
.where(SignalFeedback.signal_id == signal_id)
.order_by(SignalFeedback.created_at.desc())
)
feedbacks = result.scalars().all()
return [
SignalFeedbackResponse(
id=feedback.id,
signal_id=feedback.signal_id,
was_strategic=feedback.was_strategic,
ai_predicted_strategic=feedback.ai_predicted_strategic,
ai_confidence=feedback.ai_confidence or 0.0,
is_correct=feedback.ai_predicted_strategic == feedback.was_strategic,
)
for feedback in feedbacks
]

View File

@ -0,0 +1,297 @@
"""Signal ingest/fetch endpoints."""
from __future__ import annotations
import json
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from ...database import get_db
from ...dependencies.tenant import get_tenant_id
from ...models import Signal
from ...schemas import SignalCreate, SignalPriority, classify_signal_priority
from ...services.analyzer import analyze_impact
from ...services.fetcher import fetch_hn_stories
from ...services.lark import LarkService
from ...services.llm import llm_service
from ...services.plane import plane_service
router = APIRouter()
lark_service = LarkService()
@router.post("/ingest", response_model=dict)
async def ingest_signal(
signal: SignalCreate,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""接入外部信号,按优先级处理"""
initial_priority = signal.priority or classify_signal_priority(signal.impact_score)
new_signal = Signal(
source=signal.source,
content=signal.content,
impact_score=signal.impact_score,
priority=initial_priority.value,
)
db.add(new_signal)
await db.commit()
await db.refresh(new_signal)
if initial_priority == SignalPriority.LOW:
return {
"id": str(new_signal.id),
"priority": initial_priority.value,
"status": "recorded",
"message": "Low priority signal recorded without processing",
}
async def process_signal_internal():
try:
relevant_context = await llm_service.find_relevant_context(
signal_content=signal.content,
db_session=db,
top_k=3,
min_similarity=0.3,
tenant_id=tenant_id,
)
strategy_result = await llm_service.evaluate_with_voting_async(
signal_content=signal.content,
relevant_context=relevant_context,
impact_score=signal.impact_score,
db=db,
tenant_id=tenant_id,
)
except RuntimeError:
if initial_priority == SignalPriority.CRITICAL:
raise HTTPException(status_code=403, detail="预算超限,AI 调用被阻止(hard limit)")
return {
"id": str(new_signal.id),
"priority": initial_priority.value,
"status": "blocked",
"processing_mode": "background",
"message": "预算超限,AI 调用被阻止(hard limit)",
}
is_strategic = strategy_result.get("is_strategic", False)
decision_proposal = strategy_result.get("decision_proposal", {})
tasks = strategy_result.get("tasks", [])
confidence = strategy_result.get("confidence", 0.0)
final_priority = classify_signal_priority(signal.impact_score, confidence)
new_signal.priority = final_priority.value
sync_result = None
notification_sent = False
if final_priority in [SignalPriority.CRITICAL, SignalPriority.HIGH]:
if is_strategic and tasks:
sync_result = await plane_service.sync_tasks(
tasks,
is_urgent=(final_priority == SignalPriority.CRITICAL),
)
default_chat_id = "oc_1234567890"
await lark_service.send_strategic_alert(
default_chat_id,
signal.content,
decision_proposal,
tasks,
)
notification_sent = True
new_signal.impact_score = 1.0
analysis_data = {
"decision_proposal": decision_proposal,
"competitiveness_radar": strategy_result.get("competitiveness_radar"),
"detected_tech_stacks": strategy_result.get("detected_tech_stacks", []),
"related_knowledge_nodes": strategy_result.get("related_knowledge_nodes", []),
"rag_context_used": len(relevant_context) if relevant_context else 0,
"rag_context_sources": [ctx["id"] for ctx in (relevant_context or [])],
}
if "_voting" in strategy_result:
voting_info = strategy_result["_voting"]
analysis_data["voting"] = {
"enabled": voting_info.get("enabled"),
"models_used": voting_info.get("models_used"),
"strategic_votes": voting_info.get("strategic_votes"),
"total_votes": voting_info.get("total_votes"),
"unanimous": voting_info.get("unanimous"),
}
new_signal.strategic_analysis = json.dumps(analysis_data)
new_signal.tasks_generated = json.dumps(tasks)
new_signal.processed = True
await db.commit()
await db.refresh(new_signal)
return {
"id": str(new_signal.id),
"priority": final_priority.value,
"is_strategic": is_strategic,
"confidence": confidence,
"decision_proposal": decision_proposal,
"rag_context_used": len(relevant_context) if relevant_context else 0,
"voting": strategy_result.get("_voting"),
"tasks_generated": len(tasks),
"sync_result": sync_result,
"notification_sent": notification_sent,
}
if initial_priority == SignalPriority.CRITICAL:
result = await process_signal_internal()
result["processing_mode"] = "synchronous"
return result
background_tasks.add_task(process_signal_internal)
return {
"id": str(new_signal.id),
"priority": initial_priority.value,
"status": "queued",
"processing_mode": "background",
"message": f"{initial_priority.value.upper()} priority signal queued for processing",
}
@router.post("/fetch", response_model=dict)
async def trigger_signal_fetch(
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""手动触发抓取并分析信号"""
def _stop_ollama():
import os
import signal
import subprocess
killed = False
try:
result = subprocess.run(
["pgrep", "-f", "ollama"],
capture_output=True,
text=True,
)
if result.returncode == 0:
for pid in result.stdout.strip().split("\n"):
if pid:
try:
os.kill(int(pid), signal.SIGTERM)
killed = True
except ProcessLookupError:
pass
except Exception as exc:
print(f"[AI] pgrep failed: {exc}")
if not killed:
try:
result = subprocess.run(
["pkill", "-9", "-f", "ollama"],
capture_output=True,
timeout=5,
)
killed = result.returncode == 0
except Exception as exc:
print(f"[AI] pkill failed: {exc}")
if killed:
print("[AI] Ollama stopped - analysis complete")
raw_stories = await fetch_hn_stories(limit=30)
new_signals_count = 0
processed_count = 0
for story in raw_stories:
result = await db.execute(select(Signal).where(Signal.content == story["content"]))
if result.scalars().first():
continue
impact = analyze_impact(story["content"])
if impact > 0.4:
initial_priority = classify_signal_priority(impact)
new_signal = Signal(
source=story["source"],
content=story["content"],
impact_score=impact,
priority=initial_priority.value,
)
db.add(new_signal)
await db.commit()
await db.refresh(new_signal)
new_signals_count += 1
if initial_priority == SignalPriority.LOW:
continue
async def process_fetched():
nonlocal processed_count
try:
relevant_context = await llm_service.find_relevant_context(
signal_content=story["content"],
db_session=db,
top_k=3,
min_similarity=0.3,
tenant_id=tenant_id,
)
strategy_result = await llm_service.evaluate_with_voting_async(
signal_content=story["content"],
relevant_context=relevant_context,
impact_score=impact,
db=db,
tenant_id=tenant_id,
)
except RuntimeError:
return
is_strategic = strategy_result.get("is_strategic", False)
decision_proposal = strategy_result.get("decision_proposal", {})
confidence = strategy_result.get("confidence", 0.0)
final_priority = classify_signal_priority(impact, confidence)
new_signal.priority = final_priority.value
analysis_data = {
"decision_proposal": decision_proposal,
"competitiveness_radar": strategy_result.get("competitiveness_radar"),
"detected_tech_stacks": strategy_result.get("detected_tech_stacks", []),
"related_knowledge_nodes": strategy_result.get("related_knowledge_nodes", []),
"rag_context_used": len(relevant_context) if relevant_context else 0,
"rag_context_sources": [ctx["id"] for ctx in (relevant_context or [])],
"confidence": confidence,
"is_strategic": is_strategic,
}
if "_voting" in strategy_result:
vi = strategy_result["_voting"]
analysis_data["voting"] = {
"enabled": vi.get("enabled"),
"models_used": vi.get("models_used"),
"strategic_votes": vi.get("strategic_votes"),
"total_votes": vi.get("total_votes"),
"unanimous": vi.get("unanimous"),
}
new_signal.strategic_analysis = json.dumps(analysis_data)
new_signal.tasks_generated = json.dumps(strategy_result.get("tasks", []))
new_signal.processed = True
await db.commit()
processed_count += 1
if initial_priority in [SignalPriority.MEDIUM, SignalPriority.HIGH]:
background_tasks.add_task(process_fetched)
else:
await process_fetched()
_stop_ollama()
return {
"status": "success",
"new_signals_count": new_signals_count,
"processed_count": processed_count,
"message": f"Fetched {new_signals_count} signals, {processed_count} analyzed",
}

View File

@ -0,0 +1,106 @@
"""Signal list/create/project-link endpoints."""
from __future__ import annotations
import json
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from ...database import get_db
from ...models import Project, ProjectSignalLink, Signal
from ...schemas import SignalCreate
router = APIRouter()
@router.get("/")
async def list_signals(
roles: Optional[str] = Query(None, description="逗号分隔的角色列表,如 'frontend,backend'"),
db: AsyncSession = Depends(get_db),
):
"""获取信号列表,支持按角色过滤"""
result = await db.execute(select(Signal).order_by(Signal.impact_score.desc()))
signals = result.scalars().all()
if roles:
role_list = [r.strip() for r in roles.split(",")]
filtered_signals = []
for signal in signals:
tasks = []
if signal.tasks_generated:
try:
tasks = json.loads(signal.tasks_generated)
except Exception:
tasks = []
filtered_tasks = [task for task in tasks if task.get("type") in role_list]
if filtered_tasks:
signal_dict = {
"id": str(signal.id),
"source": signal.source,
"content": signal.content,
"impact_score": signal.impact_score,
"priority": signal.priority,
"processed": signal.processed,
"strategic_analysis": signal.strategic_analysis,
"tasks_generated": signal.tasks_generated,
"tasks_filtered": filtered_tasks,
"created_at": signal.created_at.isoformat() if signal.created_at else None,
}
filtered_signals.append(signal_dict)
return filtered_signals
return signals
@router.post("/", response_model=dict)
async def create_signal(signal: SignalCreate, db: AsyncSession = Depends(get_db)):
new_signal = Signal(
source=signal.source,
content=signal.content,
impact_score=signal.impact_score,
)
db.add(new_signal)
await db.commit()
await db.refresh(new_signal)
return {"id": str(new_signal.id), "status": "received"}
@router.get("/{signal_id}/projects")
async def get_signal_projects(
signal_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取信号关联的项目"""
try:
sid = int(signal_id)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid signal_id")
signal_result = await db.execute(select(Signal).where(Signal.id == sid))
if not signal_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Signal not found")
result = await db.execute(
select(Project, ProjectSignalLink)
.join(ProjectSignalLink, Project.id == ProjectSignalLink.project_id)
.where(ProjectSignalLink.signal_id == sid)
)
rows = result.all()
return [
{
"id": str(project.id),
"title": project.title,
"status": project.status,
"assigned_role": project.assigned_role,
"link_type": link.link_type,
"relevance_score": link.relevance_score,
}
for project, link in rows
]

View File

@ -2,73 +2,31 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import and_, delete, func, select from sqlalchemy import and_, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db from ..database import get_db
from ..dependencies import get_current_user, get_tenant_id, require_roles from ..dependencies import get_current_user, get_tenant_id, require_roles
from ..models import SkillTag, User, UserSkillBinding from ..models import SkillTag, User, UserSkillBinding
from ..services.ai_pmo_kernel_service import AIPMOKernelService
from ..services import skill_service from ..services import skill_service
from ..services.ai_pmo_kernel_service import AIPMOKernelService
from ..services.auth import has_any_role from ..services.auth import has_any_role
from ..utils.timezone import now_cn_naive from ..utils.timezone import now_cn_naive
from .skill_tags_contracts import (
SkillTagCreateRequest,
SkillTagUpdateRequest,
UserSkillCreateRequest,
UserSkillUpdateRequest,
to_tag_payload,
)
router = APIRouter(tags=["skill-tags"]) router = APIRouter(tags=["skill-tags"])
class SkillTagCreateRequest(BaseModel):
name: str
slug: str
category: Optional[str] = None
description: Optional[str] = None
sort_order: int = 0
class SkillTagUpdateRequest(BaseModel):
name: Optional[str] = None
category: Optional[str] = None
description: Optional[str] = None
sort_order: Optional[int] = None
class UserSkillCreateRequest(BaseModel):
skill_tag_id: int
level: str = "beginner"
proficiency: int = Field(default=50, ge=0, le=100)
quality_score: float = 0.0
efficiency_score: float = 0.0
class UserSkillUpdateRequest(BaseModel):
level: Optional[str] = None
proficiency: Optional[int] = Field(default=None, ge=0, le=100)
quality_score: Optional[float] = None
efficiency_score: Optional[float] = None
def _to_tag_payload(tag: SkillTag) -> dict:
return {
"id": int(tag.id),
"tenant_id": int(tag.tenant_id),
"name": tag.name,
"slug": tag.slug,
"category": tag.category,
"description": tag.description,
"is_system": bool(tag.is_system),
"sort_order": int(tag.sort_order or 0),
"created_at": tag.created_at.isoformat() if tag.created_at else None,
"updated_at": tag.updated_at.isoformat() if tag.updated_at else None,
}
@router.get("/skill-tags") @router.get("/skill-tags")
async def list_skill_tags( async def list_skill_tags(
category: Optional[str] = Query(None), category: str | None = Query(None),
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@ -87,9 +45,9 @@ async def list_skill_tags(
) )
count_map = {int(skill_id): int(total) for skill_id, total in count_rows.all()} count_map = {int(skill_id): int(total) for skill_id, total in count_rows.all()}
items = [] items = []
for t in tags: for tag in tags:
payload = _to_tag_payload(t) payload = to_tag_payload(tag)
payload["linked_user_count"] = count_map.get(int(t.id), 0) payload["linked_user_count"] = count_map.get(int(tag.id), 0)
items.append(payload) items.append(payload)
return {"items": items, "total": len(items)} return {"items": items, "total": len(items)}
@ -114,7 +72,7 @@ async def create_skill_tag(
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return _to_tag_payload(tag) return to_tag_payload(tag)
@router.put("/skill-tags/{tag_id}") @router.put("/skill-tags/{tag_id}")
@ -130,7 +88,6 @@ async def update_skill_tag(
if not tag: if not tag:
raise HTTPException(status_code=404, detail="Skill tag not found") raise HTTPException(status_code=404, detail="Skill tag not found")
# 系统标签仅允许改分类,禁止改名。
if tag.is_system and body.name is not None and body.name != tag.name: if tag.is_system and body.name is not None and body.name != tag.name:
raise HTTPException(status_code=400, detail="System tag name cannot be changed") raise HTTPException(status_code=400, detail="System tag name cannot be changed")
@ -145,7 +102,7 @@ async def update_skill_tag(
tag.updated_at = now_cn_naive() tag.updated_at = now_cn_naive()
await db.commit() await db.commit()
await db.refresh(tag) await db.refresh(tag)
return _to_tag_payload(tag) return to_tag_payload(tag)
@router.delete("/skill-tags/{tag_id}") @router.delete("/skill-tags/{tag_id}")
@ -258,10 +215,7 @@ async def update_user_skill(
if is_self and not is_manager: if is_self and not is_manager:
if body.quality_score is not None or body.efficiency_score is not None: if body.quality_score is not None or body.efficiency_score is not None:
raise HTTPException( raise HTTPException(status_code=403, detail="Members can only update own proficiency")
status_code=403,
detail="Members can only update own proficiency",
)
row = ( row = (
await db.execute( await db.execute(

View File

@ -0,0 +1,54 @@
"""Request contracts and serializers for skill-tags API."""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, Field
from ..models import SkillTag
class SkillTagCreateRequest(BaseModel):
name: str
slug: str
category: Optional[str] = None
description: Optional[str] = None
sort_order: int = 0
class SkillTagUpdateRequest(BaseModel):
name: Optional[str] = None
category: Optional[str] = None
description: Optional[str] = None
sort_order: Optional[int] = None
class UserSkillCreateRequest(BaseModel):
skill_tag_id: int
level: str = "beginner"
proficiency: int = Field(default=50, ge=0, le=100)
quality_score: float = 0.0
efficiency_score: float = 0.0
class UserSkillUpdateRequest(BaseModel):
level: Optional[str] = None
proficiency: Optional[int] = Field(default=None, ge=0, le=100)
quality_score: Optional[float] = None
efficiency_score: Optional[float] = None
def to_tag_payload(tag: SkillTag) -> dict:
return {
"id": int(tag.id),
"tenant_id": int(tag.tenant_id),
"name": tag.name,
"slug": tag.slug,
"category": tag.category,
"description": tag.description,
"is_system": bool(tag.is_system),
"sort_order": int(tag.sort_order or 0),
"created_at": tag.created_at.isoformat() if tag.created_at else None,
"updated_at": tag.updated_at.isoformat() if tag.updated_at else None,
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,49 @@
from __future__ import annotations
from fastapi import APIRouter
from . import (
ai_routing,
charters_ai_alignment,
charters_detail_update,
charters_list_create,
overview,
releases_list_create,
releases_status_generate,
releases_verification,
reviews_archive,
reviews_list_create,
reviews_patch_convert,
signals_archive,
signals_attachment,
signals_list,
tasks_ai_breakdown,
tasks_board_status,
tasks_create,
tasks_recommend_optimize,
tasks_update_delete,
)
router = APIRouter(tags=["sstc"])
for _module in [
ai_routing,
overview,
signals_list,
signals_attachment,
signals_archive,
charters_list_create,
charters_detail_update,
charters_ai_alignment,
tasks_create,
tasks_ai_breakdown,
tasks_board_status,
tasks_update_delete,
tasks_recommend_optimize,
releases_list_create,
releases_status_generate,
releases_verification,
reviews_list_create,
reviews_patch_convert,
reviews_archive,
]:
router.include_router(_module.router)

View File

@ -0,0 +1,130 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.charter_seed_utils import _safe_threshold
from ...services.sstc.entity_lookup import _ensure_project
from ...services.sstc.feishu_routing import _extract_feishu_routing_config
from ...services.sstc.signal_core import _parse_project_id
router = APIRouter(tags=["sstc"])
class AIRoutingConfigUpdateRequest(BaseModel):
cold_start_enabled: Optional[bool] = None
signal_auto_enter_threshold: Optional[float] = None
project_auto_create_threshold: Optional[float] = None
project_uncertain_threshold: Optional[float] = None
def _routing_payload(data: dict[str, Any]) -> dict[str, Any]:
return {
"cold_start_enabled": bool(data["cold_start_enabled"]),
"signal_auto_enter_threshold": round(float(data["signal_auto_enter_threshold"]), 2),
"project_auto_create_threshold": round(float(data["project_auto_create_threshold"]), 2),
"project_uncertain_threshold": round(float(data["project_uncertain_threshold"]), 2),
"tips": {
"cold_start_enabled": "开启后,所有飞书消息进入待处理,不会自动入库或自动建项。",
"signal_auto_enter_threshold": "建议范围 0.75-0.90,数值越高越保守。",
"project_auto_create_threshold": "建议范围 0.80-0.95,数值越高越严格。",
"project_uncertain_threshold": "低于该值视为低置信度,进入人工待处理。",
},
}
@router.get("/projects/{project_id}/ai-routing-config")
async def get_ai_routing_config(
project_id: str,
current_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
):
tenant_id = int(current_user.tenant_id)
pid = _parse_project_id(project_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
integration = (
await db.execute(
select(Integration).where(
and_(
Integration.tenant_id == tenant_id,
Integration.type == "feishu",
)
)
)
).scalar_one_or_none()
return success_response(_routing_payload(_extract_feishu_routing_config(integration)))
@router.put("/projects/{project_id}/ai-routing-config")
async def update_ai_routing_config(
project_id: str,
request: AIRoutingConfigUpdateRequest,
current_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
):
tenant_id = int(current_user.tenant_id)
pid = _parse_project_id(project_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
integration = (
await db.execute(
select(Integration).where(
and_(
Integration.tenant_id == tenant_id,
Integration.type == "feishu",
)
)
)
).scalar_one_or_none()
if integration is None:
integration = Integration(
tenant_id=tenant_id,
type="feishu",
config={},
is_active=True,
)
db.add(integration)
await db.flush()
current = _extract_feishu_routing_config(integration)
config = dict(current["raw"])
if request.cold_start_enabled is not None:
enabled = bool(request.cold_start_enabled)
config["cold_start_enabled"] = enabled
if request.signal_auto_enter_threshold is not None:
config["signal_auto_enter_threshold"] = _safe_threshold(
request.signal_auto_enter_threshold,
current["signal_auto_enter_threshold"],
)
if request.project_auto_create_threshold is not None:
config["project_auto_create_threshold"] = _safe_threshold(
request.project_auto_create_threshold,
current["project_auto_create_threshold"],
)
if request.project_uncertain_threshold is not None:
config["project_uncertain_threshold"] = _safe_threshold(
request.project_uncertain_threshold,
current["project_uncertain_threshold"],
)
project_threshold = _safe_threshold(
config.get("project_auto_create_threshold"),
current["project_auto_create_threshold"],
)
uncertain_threshold = min(
project_threshold,
_safe_threshold(
config.get("project_uncertain_threshold"),
current["project_uncertain_threshold"],
),
)
config["project_auto_create_threshold"] = project_threshold
config["project_uncertain_threshold"] = uncertain_threshold
integration.config = config
await db.commit()
await db.refresh(integration)
return success_response(_routing_payload(_extract_feishu_routing_config(integration)))

View File

@ -0,0 +1,256 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.charter_prefill_builder import _build_ai_first_charter_content
from ...services.sstc.charter_prefill_utils import _has_complete_module_data, _merge_prefill_content
from ...services.sstc.charter_seed_utils import _extract_charter_signal_ids
from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project
from ...services.sstc.signal_core import _as_dict, _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
router = APIRouter(tags=["sstc"])
class CharterAIPrefillRequest(BaseModel):
force: bool = False
raw_text: Optional[str] = None
@router.post("/projects/{project_id}/charters/{charter_id}/ai-prefill")
async def ai_prefill_charter(
project_id: str,
charter_id: str,
body: CharterAIPrefillRequest = Body(default_factory=CharterAIPrefillRequest),
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
before_content = _as_dict(charter.content)
before_status = str(charter.status or "")
if not body.force and _has_complete_module_data(before_content):
return success_response(
{
"id": str(charter.id),
"title": charter.title,
"content": before_content,
"status": charter.status,
"version": charter.version,
"prefilled": False,
"skipped": True,
}
)
signal_ids = _extract_charter_signal_ids(before_content, charter.signal_id)
signal_rows: list[Signal] = []
if signal_ids:
signal_rows = (
await db.execute(
select(Signal).where(
and_(
Signal.tenant_id == tenant_id,
Signal.id.in_(signal_ids),
)
)
)
).scalars().all()
raw_text = str(body.raw_text or before_content.get("raw_source_text") or "").strip()
if not signal_rows and not raw_text:
raise HTTPException(status_code=400, detail="未找到可用于预填的信号源或原始文本")
generated = await _build_ai_first_charter_content(
db=db,
tenant_id=tenant_id,
project_id=pid,
signal_rows=signal_rows,
signal_ids=signal_ids,
raw_text=raw_text,
)
merged = _merge_prefill_content(
before_content, generated, overwrite_existing=bool(body.force)
)
merged["signal_ids"] = signal_ids
if raw_text:
merged["raw_source_text"] = raw_text
charter.content = merged
charter.status = "draft"
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="charter",
entity_id=str(cid),
action="ai_prefill",
before_data={"content": before_content, "status": before_status},
after_data={"content": merged, "status": "draft"},
)
await db.commit()
await db.refresh(charter)
_sync_pmflow_rag_async(
tenant_id=tenant_id,
record_type="charter",
record_id=int(charter.id),
project_id=pid,
title=charter.title,
content=_as_dict(charter.content),
)
return success_response(
{
"id": str(charter.id),
"title": charter.title,
"content": _as_dict(charter.content),
"status": charter.status,
"version": charter.version,
"prefilled": True,
"skipped": False,
}
)
@router.post("/projects/{project_id}/charters/{charter_id}/alignment/start")
async def start_alignment(
project_id: str,
charter_id: str,
body: AlignmentStartRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
before = {"status": charter.status}
charter.status = "aligning"
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="charter",
entity_id=str(cid),
action="start_alignment",
before_data=before,
after_data={"status": charter.status, "note": body.note},
)
await db.commit()
return success_response({"id": str(cid), "status": charter.status})
@router.post("/projects/{project_id}/charters/{charter_id}/alignment/confirm")
async def confirm_alignment(
project_id: str,
charter_id: str,
body: AlignmentConfirmRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
row_result = await db.execute(
select(AlignmentRecord).where(
and_(
AlignmentRecord.truth_node_id == cid,
AlignmentRecord.user_id == int(current_user.id),
AlignmentRecord.tenant_id == tenant_id,
)
)
)
row = row_result.scalar_one_or_none()
if row is None:
row = AlignmentRecord(
truth_node_id=cid,
user_id=int(current_user.id),
alignment_score=body.score,
is_confirmed=body.confirmed,
comments=body.comments,
tenant_id=tenant_id,
)
db.add(row)
else:
row.alignment_score = body.score
row.is_confirmed = body.confirmed
row.comments = body.comments
member_ids = await svc.get_project_member_ids(pid)
confirmed_map: set[int] = set()
rows_result = await db.execute(
select(AlignmentRecord).where(
and_(
AlignmentRecord.truth_node_id == cid,
AlignmentRecord.tenant_id == tenant_id,
AlignmentRecord.is_confirmed.is_(True),
)
)
)
for r in rows_result.scalars().all():
confirmed_map.add(int(r.user_id))
all_confirmed = set(member_ids).issubset(confirmed_map) if member_ids else False
if all_confirmed:
charter.status = "aligned"
elif (charter.status or "").lower() not in {"aligned"}:
charter.status = "aligning"
if all_confirmed:
content = _as_dict(charter.content)
signal_ids = [sid for sid in content.get("signal_ids", []) if isinstance(sid, int)]
if charter.signal_id and int(charter.signal_id) not in signal_ids:
signal_ids.append(int(charter.signal_id))
if signal_ids:
signal_rows = (
await db.execute(
select(Signal).where(
and_(
Signal.tenant_id == tenant_id,
Signal.id.in_(signal_ids),
)
)
)
).scalars().all()
for signal in signal_rows:
signal.status = "已关闭"
signal.processed = True
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="alignment",
entity_id=f"{cid}:{current_user.id}",
action="confirm",
after_data={
"score": body.score,
"confirmed": body.confirmed,
"all_confirmed": all_confirmed,
"charter_status": charter.status,
},
)
await db.commit()
return success_response(
{
"charter_id": str(cid),
"charter_status": charter.status,
"all_confirmed": all_confirmed,
"confirmed_count": len(confirmed_map),
"member_count": len(member_ids),
}
)

View File

@ -0,0 +1,176 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.charter_data import _build_charter_summary_payload, _load_signal_rows, _select_source_signal, _serialize_alignment_record, _serialize_charter_signal, _serialize_charter_task, _serialize_source_signal
from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project
from ...services.sstc.feishu_routing import _backfill_feishu_sender_names
from ...services.sstc.governance import _require_manager
from ...services.sstc.signal_core import _as_dict, _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
router = APIRouter(tags=["sstc"])
@router.get("/projects/{project_id}/charters/{charter_id}")
async def get_charter_detail(
project_id: str,
charter_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
content = _as_dict(charter.content)
signal_ids = [sid for sid in content.get("signal_ids", []) if isinstance(sid, int)]
signal_rows = await _load_signal_rows(
db,
tenant_id=tenant_id,
signal_ids=signal_ids,
)
if signal_rows:
await _backfill_feishu_sender_names(
db,
tenant_id=tenant_id,
signals=signal_rows,
)
task_result = await db.execute(
select(Task).where(
and_(
Task.truth_node_id == cid,
Task.project_id == pid,
Task.tenant_id == tenant_id,
)
)
)
tasks = list(task_result.scalars().all())
align_result = await db.execute(
select(AlignmentRecord).where(
and_(
AlignmentRecord.truth_node_id == cid,
AlignmentRecord.tenant_id == tenant_id,
)
)
)
alignments = list(align_result.scalars().all())
source_signal = _select_source_signal(charter, signal_rows)
return success_response(
{
"id": str(charter.id),
"title": charter.title,
"content": content,
"signal_ids": signal_ids,
"signals": [_serialize_charter_signal(signal) for signal in signal_rows],
"source_signal": _serialize_source_signal(source_signal) if source_signal else None,
"tasks": [_serialize_charter_task(task) for task in tasks],
"alignment_records": [_serialize_alignment_record(record) for record in alignments],
"status": charter.status,
"version": charter.version,
}
)
@router.patch("/projects/{project_id}/charters/{charter_id}")
async def update_charter(
project_id: str,
charter_id: str,
body: CharterUpdateRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
before = {
"title": charter.title,
"content": _as_dict(charter.content),
"version": charter.version,
"status": charter.status,
}
aligned = (charter.status or "").lower() in ALIGNED_STATUSES
if aligned and body.force_new_version:
new_node = TruthNode(
node_type="charter",
parent_id=int(charter.id),
project_id=pid,
created_by=int(current_user.id),
title=compact_title((body.title or charter.title), max_len=24),
content=body.content if body.content is not None else _as_dict(charter.content),
version=int(charter.version or 1) + 1,
status="draft",
tenant_id=tenant_id,
)
db.add(new_node)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="charter",
entity_id=str(charter.id),
action="new_version",
before_data=before,
after_data={"new_charter_id": "pending"},
)
await db.commit()
await db.refresh(new_node)
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=pid)
_sync_pmflow_rag_async(
tenant_id=tenant_id,
record_type="charter",
record_id=int(new_node.id),
project_id=pid,
title=new_node.title,
content=_as_dict(new_node.content),
)
new_payload = _build_charter_summary_payload(new_node)
new_payload["new_version_created"] = True
return success_response(new_payload)
if body.title is not None:
charter.title = compact_title(body.title, max_len=24)
if body.content is not None:
charter.content = body.content
charter.status = "draft"
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="charter",
entity_id=str(charter.id),
action="update",
before_data=before,
after_data={
"title": charter.title,
"content": _as_dict(charter.content),
"version": charter.version,
"status": charter.status,
},
)
await db.commit()
await db.refresh(charter)
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=pid)
_sync_pmflow_rag_async(
tenant_id=tenant_id,
record_type="charter",
record_id=int(charter.id),
project_id=pid,
title=charter.title,
content=_as_dict(charter.content),
)
update_payload = _build_charter_summary_payload(charter)
update_payload["new_version_created"] = False
return success_response(update_payload)

View File

@ -0,0 +1,146 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.charter_data import _build_charter_summary_payload, _fetch_valid_project_signal_ids, _load_signal_rows
from ...services.sstc.charter_prefill_builder import _build_ai_first_charter_content
from ...services.sstc.charter_prefill_utils import _has_non_empty_module_data
from ...services.sstc.entity_lookup import _ensure_project
from ...services.sstc.governance import _require_manager
from ...services.sstc.signal_core import _as_dict, _parse_project_id, _sync_pmflow_rag_async
from ...services.sstc.signal_filters import _collect_locked_signal_conflicts
router = APIRouter(tags=["sstc"])
class CharterCreateRequest(BaseModel):
title: str
content: dict[str, Any] | None = None
signal_ids: list[int]
skip_ai_prefill: bool = False
@router.get("/projects/{project_id}/charters")
async def list_charters(
project_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
await _ensure_project(svc, pid)
charters = await svc.get_charters(pid)
payload = [
{
"id": str(c.id),
"title": c.title,
"content": _as_dict(c.content),
"signal_ids": _as_dict(c.content).get("signal_ids", []),
"status": c.status,
"version": c.version,
"parent_id": str(c.parent_id) if c.parent_id else None,
"created_at": c.created_at.isoformat() if c.created_at else None,
}
for c in charters
]
return success_response(payload)
@router.post("/projects/{project_id}/charters")
async def create_charter(
project_id: str,
body: CharterCreateRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
if not body.signal_ids:
raise HTTPException(status_code=400, detail="宪章必须关联至少一个信号")
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
valid_signal_ids = await _fetch_valid_project_signal_ids(
db,
tenant_id=tenant_id,
project_id=pid,
signal_ids=body.signal_ids,
)
if set(body.signal_ids) - valid_signal_ids:
raise HTTPException(status_code=400, detail="部分信号未关联到当前项目")
signal_ids = sorted(set(body.signal_ids))
existing_charters = await svc.get_charters(pid)
conflict_signal_ids, conflict_charters = _collect_locked_signal_conflicts(
charters=existing_charters,
candidate_signal_ids=signal_ids,
)
if conflict_signal_ids:
raise HTTPException(
status_code=409,
detail={
"message": _SIGNAL_LOCK_REASON,
"conflict_signal_ids": conflict_signal_ids,
"conflict_charters": conflict_charters,
},
)
signal_rows = await _load_signal_rows(
db,
tenant_id=tenant_id,
signal_ids=signal_ids,
)
content = dict(body.content or {})
raw_text = str(content.get("raw_source_text") or "").strip()
if not body.skip_ai_prefill and not _has_non_empty_module_data(content):
ai_prefill = await _build_ai_first_charter_content(
db=db,
tenant_id=tenant_id,
project_id=pid,
signal_rows=list(signal_rows),
signal_ids=signal_ids,
raw_text=raw_text,
)
content.update(ai_prefill)
content["signal_ids"] = signal_ids
primary_signal_id = content["signal_ids"][0] if content["signal_ids"] else None
charter = TruthNode(
node_type="charter",
parent_id=None,
project_id=pid,
signal_id=primary_signal_id,
created_by=int(current_user.id),
title=compact_title(body.title, max_len=24),
content=content,
version=1,
status="draft",
tenant_id=tenant_id,
)
db.add(charter)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="charter",
entity_id="new",
action="create",
after_data={"title": charter.title, "signal_ids": content["signal_ids"]},
)
for signal in signal_rows:
signal.status = "已关联宪章"
signal.processed = False
await db.commit()
await db.refresh(charter)
_sync_pmflow_rag_async(
tenant_id=tenant_id,
record_type="charter",
record_id=int(charter.id),
project_id=pid,
title=charter.title,
content=_as_dict(charter.content),
)
return success_response(_build_charter_summary_payload(charter))

View File

@ -0,0 +1,82 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.entity_lookup import _ensure_project
from ...services.sstc.governance import _build_governance_snapshot
from ...services.sstc.signal_core import _parse_project_id
router = APIRouter(tags=["sstc"])
@router.get("/projects/{project_id}/sstc/overview")
async def get_board_overview(
project_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
await _ensure_project(svc, pid)
overview = await svc.build_overview(pid)
stages = await svc.build_stages(pid)
risks = await svc.build_risks(pid)
return success_response(
{
"overview": overview,
"stages": stages,
"risks": risks,
}
)
@router.get("/projects/{project_id}/sstc/stages")
async def get_stages(
project_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
await _ensure_project(svc, pid)
return success_response(await svc.build_stages(pid))
@router.get("/projects/{project_id}/sstc/risks")
async def get_risks(
project_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
await _ensure_project(svc, pid)
return success_response(await svc.build_risks(pid))
@router.get("/projects/{project_id}/sstc/governance")
async def get_governance_snapshot(
project_id: str,
charter_id: Optional[int] = Query(default=None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
data = await _build_governance_snapshot(
db,
tenant_id=tenant_id,
project_id=pid,
charter_id=charter_id,
)
return success_response(data)
@router.get("/projects/{project_id}/sstc-chain")
async def get_sstc_chain(
project_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
await _ensure_project(svc, pid)
return success_response(await svc.build_chain(pid))

View File

@ -0,0 +1,123 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project
from ...services.sstc.governance import _build_governance_snapshot, _require_manager
from ...services.sstc.signal_core import _as_dict, _parse_project_id
router = APIRouter(tags=["sstc"])
@router.get("/projects/{project_id}/releases")
async def list_releases(
project_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
await _ensure_project(svc, pid)
releases = await svc.get_releases(pid)
return success_response(
[
{
"id": str(r.id),
"title": r.title,
"status": r.status,
"content": _as_dict(r.content),
"version": r.version,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in releases
]
)
@router.post("/projects/{project_id}/releases")
async def create_release_plan(
project_id: str,
body: ReleaseCreateRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
charter = await _ensure_charter(
db,
tenant_id=tenant_id,
project_id=pid,
charter_id=int(body.charter_id),
)
if not body.task_ids:
raise HTTPException(status_code=400, detail="发布计划必须绑定至少一个任务")
task_result = await db.execute(
select(Task).where(
and_(
Task.id.in_(body.task_ids),
Task.project_id == pid,
Task.tenant_id == tenant_id,
)
)
)
tasks = list(task_result.scalars().all())
if len(tasks) != len(body.task_ids):
raise HTTPException(status_code=400, detail="部分任务不存在")
not_done = [t for t in tasks if (t.status or "").lower() not in DONE_STATUSES]
if not_done:
raise HTTPException(status_code=409, detail="发布计划仅允许绑定已完成任务")
governance = await _build_governance_snapshot(
db,
tenant_id=tenant_id,
project_id=pid,
charter_id=int(charter.id),
)
content = {
"charter_id": int(charter.id),
"task_ids": [int(t.id) for t in tasks],
"planned_at": body.planned_at,
"verification_items": [],
"verification_done": False,
"verification_progress": {"done": 0, "total": 0},
"governance_snapshot": governance,
}
release_node = TruthNode(
tenant_id=tenant_id,
node_type="release",
parent_id=int(charter.id),
project_id=pid,
created_by=int(current_user.id),
title=compact_title(body.title, max_len=24),
content=content,
version=1,
status="pending",
)
db.add(release_node)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="release",
entity_id="new",
action="create",
after_data={"title": release_node.title, "task_ids": body.task_ids},
)
await db.commit()
await db.refresh(release_node)
return success_response(
{
"id": str(release_node.id),
"title": release_node.title,
"status": release_node.status,
"content": _as_dict(release_node.content),
}
)

View File

@ -0,0 +1,107 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project, _ensure_release
from ...services.sstc.governance import _assert_release_transition_allowed, _require_manager
from ...services.sstc.signal_core import _as_dict, _parse_node_id, _parse_project_id
router = APIRouter(tags=["sstc"])
@router.patch("/projects/{project_id}/releases/{release_node_id}/status")
async def patch_release_status(
project_id: str,
release_node_id: str,
body: ReleaseStatusPatchRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
rid = _parse_node_id(release_node_id, "release_node_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
release_node = await _ensure_release(
db, tenant_id=tenant_id, project_id=pid, release_node_id=rid
)
await _assert_release_transition_allowed(
db,
tenant_id=tenant_id,
project_id=pid,
release_node=release_node,
target_status=body.status,
)
before = {"status": release_node.status}
release_node.status = body.status
content = _as_dict(release_node.content)
content["status_note"] = body.note
content["last_status_changed_at"] = now_cn_aware().isoformat()
release_node.content = content
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="release",
entity_id=str(rid),
action="status_change",
before_data=before,
after_data={"status": body.status, "note": body.note},
)
await db.commit()
return success_response({"id": str(release_node.id), "status": release_node.status})
@router.post("/projects/{project_id}/releases/{release_node_id}/verification/generate")
async def generate_release_verification(
project_id: str,
release_node_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
rid = _parse_node_id(release_node_id, "release_node_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
release_node = await _ensure_release(
db, tenant_id=tenant_id, project_id=pid, release_node_id=rid
)
content = _as_dict(release_node.content)
charter_id = int(content.get("charter_id") or 0)
charter = await _ensure_charter(
db,
tenant_id=tenant_id,
project_id=pid,
charter_id=charter_id,
)
charter_content = _as_dict(charter.content)
success_metrics = charter_content.get("success_metrics") or charter_content.get("core_metrics") or []
if not success_metrics:
success_metrics = ["功能验收通过", "核心流程无阻塞", "关键性能指标达标"]
items = [
{
"id": f"check-{i+1}",
"title": str(metric),
"expected": "达到预期",
"status": "pending",
"required": True,
"source": "charter_success_metric",
"verified_by": None,
"verified_at": None,
"note": None,
}
for i, metric in enumerate(success_metrics)
]
content["verification_items"] = items
content["verification_progress"] = {"done": 0, "total": len(items)}
content["verification_done"] = False
release_node.content = content
await db.commit()
return success_response({"release_id": str(release_node.id), "verification_items": items})

View File

@ -0,0 +1,103 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.entity_lookup import _ensure_project, _ensure_release
from ...services.sstc.governance import _require_manager
from ...services.sstc.signal_core import _as_dict, _parse_node_id, _parse_project_id
router = APIRouter(tags=["sstc"])
@router.patch("/projects/{project_id}/releases/{release_node_id}/verification/items/{item_id}")
async def patch_release_verification_item(
project_id: str,
release_node_id: str,
item_id: str,
body: ReleaseVerificationItemPatchRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
rid = _parse_node_id(release_node_id, "release_node_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
release_node = await _ensure_release(
db, tenant_id=tenant_id, project_id=pid, release_node_id=rid
)
content = _as_dict(release_node.content)
items = content.get("verification_items")
if not isinstance(items, list) or len(items) == 0:
raise HTTPException(status_code=404, detail="验证清单不存在,请先生成")
target = None
for item in items:
if str(item.get("id")) == item_id:
target = item
break
if target is None:
raise HTTPException(status_code=404, detail="验证项不存在")
target["status"] = body.status
if body.status == "done":
target["verified_by"] = str(current_user.id)
target["verified_at"] = now_cn_aware().isoformat()
else:
target["verified_by"] = None
target["verified_at"] = None
target["note"] = body.note
done_count = len([item for item in items if str(item.get("status") or "").lower() == "done"])
content["verification_items"] = items
content["verification_progress"] = {"done": done_count, "total": len(items)}
content["verification_done"] = done_count == len(items) and len(items) > 0
release_node.content = content
await db.commit()
return success_response(
{
"release_id": str(release_node.id),
"item_id": item_id,
"verification_progress": content["verification_progress"],
"verification_done": content["verification_done"],
}
)
@router.post("/projects/{project_id}/releases/{release_node_id}/verification/complete")
async def complete_release_verification(
project_id: str,
release_node_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
rid = _parse_node_id(release_node_id, "release_node_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
release_node = await _ensure_release(
db, tenant_id=tenant_id, project_id=pid, release_node_id=rid
)
status_raw = str(release_node.status or "").lower()
if status_raw not in {"online", "releasing"}:
raise HTTPException(status_code=409, detail="仅上线中的发布计划可完成验证")
content = _as_dict(release_node.content)
items = content.get("verification_items", [])
if not isinstance(items, list) or len(items) == 0:
raise HTTPException(status_code=409, detail="验证清单为空,请先生成并完成验证项")
incomplete = [item for item in items if str(item.get("status") or "").lower() != "done"]
if incomplete:
raise HTTPException(status_code=409, detail="仍有未完成验证项,请先逐项完成")
now_str = now_cn_aware().isoformat()
content["verification_items"] = items
content["verification_done"] = True
content["verification_progress"] = {"done": len(items), "total": len(items)}
content["verified_at"] = now_str
release_node.content = content
release_node.status = "verified"
await db.commit()
return success_response({"id": str(release_node.id), "status": release_node.status})

View File

@ -0,0 +1,60 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.entity_lookup import _ensure_project, _ensure_review
from ...services.sstc.governance import _require_manager
from ...services.sstc.signal_core import _as_dict, _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
router = APIRouter(tags=["sstc"])
@router.post("/projects/{project_id}/reviews/{review_node_id}/archive")
async def archive_review(
project_id: str,
review_node_id: str,
body: ReviewArchiveRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
rid = _parse_node_id(review_node_id, "review_node_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
review_node = await _ensure_review(
db,
tenant_id=tenant_id,
project_id=pid,
review_node_id=rid,
)
before = {"status": review_node.status}
review_node.status = "archived" if body.archive else "draft"
content = _as_dict(review_node.content)
content["archive_note"] = body.note
content["archived_at"] = now_cn_aware().isoformat() if body.archive else None
review_node.content = content
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="review",
entity_id=str(rid),
action="archive" if body.archive else "unarchive",
before_data=before,
after_data={"status": review_node.status},
)
await db.commit()
_sync_pmflow_rag_async(
tenant_id=tenant_id,
record_type="review",
record_id=int(review_node.id),
project_id=pid,
title=review_node.title,
content=_as_dict(review_node.content),
)
return success_response({"id": str(review_node.id), "status": review_node.status})

View File

@ -0,0 +1,104 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.entity_lookup import _ensure_project, _ensure_release
from ...services.sstc.governance import _require_manager
from ...services.sstc.review_utils import _normalize_review_content
from ...services.sstc.signal_core import _as_dict, _parse_project_id, _sync_pmflow_rag_async
router = APIRouter(tags=["sstc"])
@router.get("/projects/{project_id}/reviews")
async def list_reviews(
project_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
await _ensure_project(svc, pid)
reviews = await svc.get_reviews(pid)
return success_response(
[
{
"id": str(r.id),
"title": r.title,
"status": r.status,
"content": _as_dict(r.content),
"version": r.version,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in reviews
]
)
@router.post("/projects/{project_id}/reviews")
async def create_review(
project_id: str,
body: ReviewCreateRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
release_node = await _ensure_release(
db,
tenant_id=tenant_id,
project_id=pid,
release_node_id=int(body.release_node_id),
)
if (release_node.status or "").lower() not in RELEASE_VERIFIED_STATUSES:
raise HTTPException(status_code=409, detail="发布未验证,复盘环节未解锁")
normalized_content = _normalize_review_content(body.content)
if not normalized_content.get("summary"):
normalized_content["summary"] = "AI 自动生成:请补充目标达成、核心问题与改进项。"
normalized_content["release_node_id"] = int(release_node.id)
normalized_content["charter_id"] = int(release_node.parent_id) if release_node.parent_id else None
review_node = TruthNode(
tenant_id=tenant_id,
node_type="review",
parent_id=int(release_node.id),
project_id=pid,
created_by=int(current_user.id),
title=compact_title(body.title, max_len=24),
content=normalized_content,
version=1,
status="draft",
)
db.add(review_node)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="review",
entity_id="new",
action="create",
after_data={"title": review_node.title, "release_node_id": body.release_node_id},
)
await db.commit()
await db.refresh(review_node)
_sync_pmflow_rag_async(
tenant_id=tenant_id,
record_type="review",
record_id=int(review_node.id),
project_id=pid,
title=review_node.title,
content=_as_dict(review_node.content),
)
return success_response(
{
"id": str(review_node.id),
"title": review_node.title,
"status": review_node.status,
"content": _as_dict(review_node.content),
}
)

View File

@ -0,0 +1,215 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project, _ensure_release, _ensure_review, _parse_due_date
from ...services.sstc.governance import _assert_charter_aligned, _require_manager
from ...services.sstc.review_utils import _normalize_review_content
from ...services.sstc.signal_core import _as_dict, _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
router = APIRouter(tags=["sstc"])
@router.patch("/projects/{project_id}/reviews/{review_node_id}/content")
async def patch_review_content(
project_id: str,
review_node_id: str,
body: ReviewContentPatchRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
rid = _parse_node_id(review_node_id, "review_node_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
review_node = await _ensure_review(
db,
tenant_id=tenant_id,
project_id=pid,
review_node_id=rid,
)
existing = _as_dict(review_node.content)
merged = dict(existing)
merged.update(body.content or {})
normalized = _normalize_review_content(merged)
normalized["release_node_id"] = existing.get("release_node_id")
normalized["charter_id"] = existing.get("charter_id")
review_node.content = normalized
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="review",
entity_id=str(rid),
action="update_content",
after_data={
"improvement_count": len(normalized.get("improvements") or []),
"summary_len": len(str(normalized.get("summary") or "")),
},
)
await db.commit()
_sync_pmflow_rag_async(
tenant_id=tenant_id,
record_type="review",
record_id=int(review_node.id),
project_id=pid,
title=review_node.title,
content=_as_dict(review_node.content),
)
return success_response(
{
"id": str(review_node.id),
"status": review_node.status,
"content": _as_dict(review_node.content),
}
)
@router.post("/projects/{project_id}/reviews/{review_node_id}/improvements/convert-task")
async def convert_review_improvement_to_task(
project_id: str,
review_node_id: str,
body: ReviewImprovementToTaskRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
rid = _parse_node_id(review_node_id, "review_node_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
review_node = await _ensure_review(
db,
tenant_id=tenant_id,
project_id=pid,
review_node_id=rid,
)
review_content = _normalize_review_content(_as_dict(review_node.content))
improvements = review_content.get("improvements") or []
if body.improvement_index >= len(improvements):
raise HTTPException(status_code=400, detail="改进项索引超出范围")
item = improvements[body.improvement_index]
if not isinstance(item, dict):
raise HTTPException(status_code=400, detail="改进项数据无效")
release_node_id = int(review_node.parent_id or review_content.get("release_node_id") or 0)
if release_node_id <= 0:
raise HTTPException(status_code=409, detail="复盘未关联发布计划,无法生成任务")
release_node = await _ensure_release(
db,
tenant_id=tenant_id,
project_id=pid,
release_node_id=release_node_id,
)
release_content = _as_dict(release_node.content)
charter_id = (
int(body.charter_id)
if body.charter_id
else int(release_content.get("charter_id") or release_node.parent_id or 0)
)
if charter_id <= 0:
raise HTTPException(status_code=409, detail="无法定位宪章,改进项无法转任务")
charter = await _ensure_charter(
db,
tenant_id=tenant_id,
project_id=pid,
charter_id=charter_id,
)
_assert_charter_aligned(charter)
title = compact_title(
body.title or str(item.get("title") or "").strip() or "复盘改进项任务",
max_len=32,
)
if not title:
raise HTTPException(status_code=400, detail="改进项标题为空")
desc_parts = [
str(body.description or "").strip(),
str(item.get("description") or "").strip(),
f"来源复盘: {review_node.title}",
]
description = "\n".join([part for part in desc_parts if part])
due_date = _parse_due_date(body.due_date)
task = await persist_task(
db,
tenant_id=tenant_id,
truth_node_id=charter_id,
project_id=pid,
draft=build_task_create_draft(
title=title,
description=description or None,
assignee_id=item.get("owner_id"),
required_skill_tag_ids=[],
status="todo",
priority=body.priority,
estimated_hours=body.estimated_hours,
due_date=due_date,
),
actor_user_id=int(current_user.id),
action="create_from_review_improvement",
remark="sstc.review_improvement_to_task",
commit=False,
refresh_workload=False,
)
item["task_id"] = int(task.id)
item["status"] = "converted"
review_content["improvements"] = improvements
review_content["release_node_id"] = release_node_id
review_content["charter_id"] = charter_id
review_node.content = review_content
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="task",
entity_id=str(task.id),
action="create_from_review_improvement",
after_data={
"review_id": rid,
"improvement_index": body.improvement_index,
"charter_id": charter_id,
},
)
await db.commit()
await db.refresh(task)
_sync_pmflow_rag_async(
tenant_id=tenant_id,
record_type="task",
record_id=int(task.id),
project_id=pid,
title=task.title,
content=task.description or "",
)
_sync_pmflow_rag_async(
tenant_id=tenant_id,
record_type="review",
record_id=int(review_node.id),
project_id=pid,
title=review_node.title,
content=_as_dict(review_node.content),
)
return success_response(
{
"task": {
"id": str(task.id),
"title": task.title,
"status": task.status,
"priority": task.priority,
"charter_id": str(charter_id),
},
"review_id": str(review_node.id),
"improvement_index": body.improvement_index,
}
)

View File

@ -0,0 +1,56 @@
from __future__ import annotations
from datetime import datetime
from io import BytesIO
from typing import Any, Optional
from uuid import uuid4
from fastapi import APIRouter, BackgroundTasks, Body, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy import and_, delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from ...core.response import success_response
from ...database import get_db
from ...dependencies import get_current_user, require_project_member_or_manager
from ...models import (
AICallLog,
AlignmentRecord,
Integration,
ModelRequirementBinding,
ModelVersion,
ProjectSignalLink,
Signal,
Task,
TruthNode,
User,
UserProject,
)
from ...schemas.sstc import (
AlignmentConfirmRequest,
AlignmentStartRequest,
CharterUpdateRequest,
ReleaseCreateRequest,
ReleaseStatusPatchRequest,
ReleaseVerificationItemPatchRequest,
ReviewArchiveRequest,
ReviewContentPatchRequest,
ReviewCreateRequest,
ReviewImprovementToTaskRequest,
TaskStatusPatchRequest,
WorkloadOptimizeRequest,
)
from ...services import skill_score_service, workload_service
from ...services.ai_pmo_kernel_service import AIPMOKernelService
from ...services.ai_service import AIService
from ...services.auth import has_any_role
from ...services.budget_enforcement_service import BudgetEnforcementService
from ...services.feishu_integration import get_feishu_service
from ...services.llm import llm_service
from ...services.operation_log_service import log_operation
from ...services.skill_service import recommend_personnel
from ...services.sstc_service import ALIGNED_STATUSES, DONE_STATUSES, RELEASE_VERIFIED_STATUSES, SSTCService
from ...services.task_persistence import build_task_create_draft, persist_task
from ...utils.title import compact_title
from ...utils.timezone import CN_TZ, now_cn_aware, now_cn_naive

View File

@ -0,0 +1,47 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.entity_lookup import _ensure_project
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id
router = APIRouter(tags=["sstc"])
@router.post("/projects/{project_id}/signals/{signal_id}/archive")
async def archive_signal(
project_id: str,
signal_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
sid = _parse_node_id(signal_id, "signal_id")
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
await _ensure_project(svc, pid)
result = await db.execute(
select(Signal).where(
and_(Signal.id == sid, Signal.tenant_id == int(current_user.tenant_id))
)
)
signal = result.scalar_one_or_none()
if not signal:
raise HTTPException(status_code=404, detail="Signal not found")
before = {"processed": signal.processed}
signal.processed = True
signal.status = "已关闭"
await log_operation(
db,
tenant_id=int(current_user.tenant_id),
user_id=int(current_user.id),
project_id=pid,
entity_type="signal",
entity_id=str(sid),
action="archive",
before_data=before,
after_data={"processed": True},
)
await db.commit()
return success_response({"id": str(signal.id), "status": "已关闭"})

View File

@ -0,0 +1,191 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.entity_lookup import _ensure_project
from ...services.sstc.signal_core import _as_dict, _build_signal_attachment_download_url, _error_json, _extract_signal_attachments, _parse_node_id, _parse_project_id, _resolve_local_attachment_path, _signal_attachment_storage_root
router = APIRouter(tags=["sstc"])
@router.post("/projects/{project_id}/signals/{signal_id}/attachments")
async def upload_signal_attachment(
project_id: str,
signal_id: str,
file: UploadFile | None = File(default=None),
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
try:
pid = _parse_project_id(project_id)
except HTTPException:
return _error_json(status_code=400, message="project_id 不合法")
try:
sid = _parse_node_id(signal_id, "signal_id")
except HTTPException:
return _error_json(status_code=400, message="signal_id 不合法")
if file is None:
return _error_json(status_code=400, message="请选择要上传的附件")
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
project = await svc.get_project(pid)
if project is None:
return _error_json(status_code=404, message="项目不存在")
signals = await svc.get_signals(pid)
signal = next((item for item in signals if int(item.id) == sid), None)
if signal is None:
return _error_json(status_code=404, message="信号不存在或不属于当前项目")
original_file_name = Path(str(file.filename or "")).name.strip()
if not original_file_name:
return _error_json(status_code=400, message="文件名不能为空")
extension = Path(original_file_name).suffix.lower()
attachment_type = _SIGNAL_ATTACHMENT_ALLOWED_EXTENSIONS.get(extension)
if attachment_type is None:
return _error_json(status_code=400, message="仅支持 jpg/png/gif/pdf/doc/docx/zip 文件")
mime_type = str(file.content_type or "").strip().lower() or None
if mime_type and mime_type not in _SIGNAL_ATTACHMENT_ALLOWED_MIME_TYPES:
return _error_json(status_code=400, message="文件类型不支持,请上传 jpg/png/gif/pdf/doc/docx/zip")
try:
content = await file.read()
except Exception:
return _error_json(status_code=500, message="读取上传文件失败")
if not content:
return _error_json(status_code=400, message="上传文件为空")
if len(content) > _SIGNAL_ATTACHMENT_MAX_BYTES:
return _error_json(status_code=400, message="单文件大小不能超过 20MB")
attachment_id = uuid4().hex
relative_dir = Path(f"tenant-{tenant_id}") / f"project-{pid}" / f"signal-{sid}"
storage_root = _signal_attachment_storage_root()
save_dir = storage_root / relative_dir
save_dir.mkdir(parents=True, exist_ok=True)
saved_file_name = f"{attachment_id}{extension}"
saved_path = save_dir / saved_file_name
try:
saved_path.write_bytes(content)
except Exception:
return _error_json(status_code=500, message="保存附件失败,请稍后重试")
file_key = f"local://{(relative_dir / saved_file_name).as_posix()}"
attachment_payload = {
"id": attachment_id,
"attachment_id": attachment_id,
"attachment_type": attachment_type,
"file_name": original_file_name,
"mime_type": mime_type or "application/octet-stream",
"file_size": len(content),
"file_key": file_key,
"image_key": None,
"media_id": None,
"source": "manual_upload",
"occurred_at": now_cn_aware().isoformat(),
"download_url": _build_signal_attachment_download_url(pid, sid, attachment_id),
}
raw_payload = _as_dict(signal.strategic_analysis)
source_context = raw_payload.get("source_context")
if not isinstance(source_context, dict):
source_context = {}
attachments = source_context.get("attachments")
if not isinstance(attachments, list):
attachments = []
attachments.append(attachment_payload)
source_context["attachments"] = attachments
raw_payload["source_context"] = source_context
signal.strategic_analysis = json.dumps(raw_payload, ensure_ascii=False)
try:
await db.commit()
await db.refresh(signal)
except Exception:
await db.rollback()
try:
saved_path.unlink()
except Exception:
pass
return _error_json(status_code=500, message="上传附件失败,请稍后重试")
return success_response(attachment_payload)
@router.get("/projects/{project_id}/signals/{signal_id}/attachments/{attachment_id}/download")
async def download_signal_attachment(
project_id: str,
signal_id: str,
attachment_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
sid = _parse_node_id(signal_id, "signal_id")
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
signals = await svc.get_signals(pid)
signal = next((item for item in signals if int(item.id) == sid), None)
if signal is None:
raise HTTPException(status_code=404, detail="Signal not found")
attachments = _extract_signal_attachments(signal)
target = next(
(
item
for item in attachments
if str(item.get("attachment_id") or "").strip() == attachment_id
or str(item.get("id") or "").strip() == attachment_id
),
None,
)
if target is None:
raise HTTPException(status_code=404, detail="Attachment not found")
local_path = _resolve_local_attachment_path(str(target.get("file_key") or ""))
if local_path is not None:
if not local_path.exists() or not local_path.is_file():
raise HTTPException(status_code=404, detail="附件文件不存在")
try:
content = local_path.read_bytes()
except Exception as exc:
raise HTTPException(status_code=500, detail=f"附件读取失败: {str(exc)}") from exc
if not content:
raise HTTPException(status_code=500, detail="附件内容为空")
file_name = str(target.get("file_name") or f"attachment-{attachment_id}").strip().replace('"', "'")
media_type = str(target.get("mime_type") or "application/octet-stream")
return StreamingResponse(
BytesIO(content),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{file_name}"'},
)
feishu_service = get_feishu_service(db)
await feishu_service.load_config(tenant_id)
if not feishu_service.is_configured():
raise HTTPException(status_code=503, detail="飞书集成未配置,暂无法下载附件")
try:
payload = await feishu_service.download_attachment_bytes(target)
except ValueError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"附件下载失败: {str(exc)}") from exc
file_name = str(payload.get("file_name") or target.get("file_name") or f"attachment-{attachment_id}").strip()
file_name = file_name.replace('"', "'")
media_type = str(payload.get("content_type") or target.get("mime_type") or "application/octet-stream")
content = payload.get("content")
if not isinstance(content, (bytes, bytearray)):
raise HTTPException(status_code=500, detail="附件内容为空")
return StreamingResponse(
BytesIO(bytes(content)),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{file_name}"'},
)

View File

@ -0,0 +1,145 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.entity_lookup import _ensure_project
from ...services.sstc.feishu_routing import _backfill_feishu_sender_names
from ...services.sstc.signal_core import _as_dict, _extract_signal_attachments, _normalize_planner_status, _normalize_signal_status_code, _parse_project_id, _signal_ai_analysis, _signal_display_type, _signal_project_scope, _signal_source_context, _signal_status_label_from_code
from ...services.sstc.signal_filters import _build_signal_created_at_range, _extract_charter_related_signal_ids, _match_signal_filters, _normalize_signal_status_filter, _signal_proposer_name, _signal_title_text
router = APIRouter(tags=["sstc"])
@router.get("/projects/{project_id}/signals")
async def list_signals(
project_id: str,
status: Optional[str] = Query(default=None, description="pending/linked/closed/archived"),
keyword: Optional[str] = Query(default=None, description="按信号标题/内容/ID/提报人/关联宪章搜索"),
source: Optional[str] = Query(default=None, description="来源过滤"),
priority: Optional[str] = Query(default=None, description="优先级过滤"),
created_from: Optional[str] = Query(default=None, description="创建时间起始(北京时间 YYYY-MM-DD)"),
created_to: Optional[str] = Query(default=None, description="创建时间结束(北京时间 YYYY-MM-DD)"),
limit: int = Query(default=10, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
project = await _ensure_project(svc, pid)
project_creator_name = "-"
if project and getattr(project, "owner_id", None):
owner = (
await db.execute(
select(User.name).where(
and_(
User.tenant_id == tenant_id,
User.id == int(project.owner_id),
)
).limit(1)
)
).scalars().first()
if owner:
project_creator_name = str(owner)
signals = await svc.get_signals(pid)
await _backfill_feishu_sender_names(
db,
tenant_id=tenant_id,
signals=signals,
)
charters = await svc.get_charters(pid)
linked_signal_states: dict[int, str] = {}
linked_signal_charter_name: dict[int, str] = {}
for c in charters:
signal_ids = _extract_charter_related_signal_ids(c)
charter_status = (c.status or "").lower()
state = (
_SIGNAL_STATUS_CODE_CLOSED
if charter_status in {"aligned", "active", "online", "archived", "已对齐", "已上线", "已归档"}
else _SIGNAL_STATUS_CODE_LINKED
)
for sid in signal_ids:
if linked_signal_states.get(sid) == _SIGNAL_STATUS_CODE_CLOSED:
continue
linked_signal_states[sid] = state
linked_signal_charter_name[sid] = c.title
normalized_status_filter = _normalize_signal_status_filter(status)
created_from_utc, created_to_utc = _build_signal_created_at_range(
created_from=created_from,
created_to=created_to,
)
rows = []
for s in signals:
strategic_payload = _as_dict(s.strategic_analysis)
ai_analysis = _signal_ai_analysis(s)
source_context = _signal_source_context(s)
project_scope = _signal_project_scope(s)
proposer_name = _signal_proposer_name(s)
title_text = _signal_title_text(s)
normalized_status = str(s.status or "").strip()
sid = int(s.id)
status_code = _normalize_signal_status_code(
linked_state=linked_signal_states.get(sid),
signal_status=normalized_status,
processed=bool(s.processed),
)
row_status = _signal_status_label_from_code(status_code, normalized_status)
locked_by_charter = sid in linked_signal_states or ("关联" in normalized_status)
can_create_charter = not locked_by_charter
charter_lock_reason = None if can_create_charter else _SIGNAL_LOCK_REASON
row = {
"id": str(s.id),
"title": title_text[:80] or "未命名信号",
"content": s.content,
"priority": s.priority,
"signal_type": _signal_display_type(s),
"source": s.source,
"project_creator_name": project_creator_name,
"proposer_name": proposer_name,
"proposer_open_id": source_context.get("sender_open_id"),
"project_scope": project_scope,
"scope_reason": ai_analysis.get("reason"),
"target_project_hints": ai_analysis.get("target_project_hints", []),
"status": row_status,
"status_code": status_code,
"can_create_charter": can_create_charter,
"charter_lock_reason": charter_lock_reason,
"charter_name": linked_signal_charter_name.get(sid),
"created_at": s.created_at.isoformat() if s.created_at else None,
"attachments": _extract_signal_attachments(s),
"strategic_analysis": strategic_payload,
"planner_status": _normalize_planner_status(getattr(s, "planner_status", None)),
"planner_analyzed_at": (
s.planner_analyzed_at.isoformat() if getattr(s, "planner_analyzed_at", None) else None
),
"planner_assignee_user_id": (
str(s.planner_assignee_user_id) if getattr(s, "planner_assignee_user_id", None) is not None else None
),
}
created_at_dt = s.created_at
if created_at_dt is not None and created_at_dt.tzinfo is not None:
created_at_dt = created_at_dt.astimezone(CN_TZ).replace(tzinfo=None)
if not _match_signal_filters(
row=row,
status_filter=normalized_status_filter,
keyword=keyword,
source=source,
priority=priority,
created_from_utc=created_from_utc,
created_to_utc=created_to_utc,
created_at_dt=created_at_dt,
):
continue
rows.append(row)
total = len(rows)
paged_rows = rows[offset : offset + limit]
return success_response(
{
"items": paged_rows,
"total": total,
"limit": limit,
"offset": offset,
"has_more": (offset + len(paged_rows)) < total,
}
)

View File

@ -0,0 +1,121 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project
from ...services.sstc.governance import _assert_charter_aligned, _require_manager
from ...services.sstc.signal_core import _as_dict, _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
router = APIRouter(tags=["sstc"])
@router.post("/projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown")
async def ai_breakdown_tasks(
project_id: str,
charter_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
_assert_charter_aligned(charter)
prompt = f"""请基于以下需求宪章拆解 5-8 条任务,返回 JSON 数组:
[
{{"task_name":"", "type":"backend/frontend/ai/devops", "estimated_hours": 4.0, "priority":"P0/P1/P2"}}
]
宪章标题:{charter.title}
宪章内容:{_as_dict(charter.content)}
"""
try:
rows = await llm_service.generate_task_breakdown_async(
prompt,
db=db,
tenant_id=tenant_id,
project_id=pid,
)
except RuntimeError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"AI 拆解失败: {exc}") from exc
created = []
prompt_tokens = max(len(prompt) // 4, 1)
completion_tokens = 0
for item in rows or []:
title = compact_title((item.get("task_name") or item.get("title") or ""), max_len=32)
if not title:
continue
priority = str(item.get("priority") or "P1").lower()
mapped_priority = "high" if priority == "p0" else "medium" if priority == "p1" else "low"
task = await persist_task(
db,
tenant_id=tenant_id,
truth_node_id=cid,
project_id=pid,
draft=build_task_create_draft(
title=title,
description=item.get("description"),
required_skill_tag_ids=[],
priority=mapped_priority,
status="todo",
estimated_hours=float(item.get("estimated_hours") or 0) or None,
),
actor_user_id=int(current_user.id),
action="task.create",
remark="sstc.ai_breakdown",
commit=False,
refresh_workload=False,
)
created.append(task)
completion_tokens += max(len(title) // 2, 1)
db.add(
AICallLog(
tenant_id=tenant_id,
user_id=int(current_user.id),
function_name="sstc_ai_breakdown",
prompt=prompt,
response=f"created_count={len(created)}",
tokens_used=prompt_tokens + completion_tokens,
cost=((prompt_tokens * 0.00015 + completion_tokens * 0.0006) / 1000),
status="success",
)
)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="task",
entity_id=str(cid),
action="ai_breakdown",
after_data={"created_count": len(created)},
)
await db.commit()
for task in created:
_sync_pmflow_rag_async(
tenant_id=tenant_id,
record_type="task",
record_id=int(task.id),
project_id=pid,
title=task.title,
content=task.description or "",
)
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
return success_response(
{
"created_count": len(created),
"items": [
{"id": str(t.id), "title": t.title, "priority": t.priority, "status": t.status}
for t in created
],
}
)

View File

@ -0,0 +1,126 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.entity_lookup import _ensure_project, _ensure_task
from ...services.sstc.governance import _is_manager
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
router = APIRouter(tags=["sstc"])
@router.get("/projects/{project_id}/tasks/board")
async def get_task_board(
project_id: str,
assignee_id: Optional[int] = Query(default=None),
priority: Optional[str] = Query(default=None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
result = await db.execute(
select(Task).where(
and_(
Task.project_id == pid,
Task.tenant_id == tenant_id,
)
)
)
tasks = list(result.scalars().all())
if assignee_id is not None:
tasks = [t for t in tasks if t.assignee_id == assignee_id]
if priority:
tasks = [t for t in tasks if (t.priority or "").lower() == priority.lower()]
rows = []
for t in tasks:
risk_count = 0
if (t.status or "").lower() == "blocked":
risk_count += 1
if t.due_date and t.due_date < now_cn_aware().replace(tzinfo=None) and (t.status or "").lower() not in DONE_STATUSES:
risk_count += 1
rows.append(
{
"id": str(t.id),
"title": t.title,
"description": t.description,
"status": t.status,
"priority": t.priority,
"assignee_id": str(t.assignee_id) if t.assignee_id else None,
"due_date": t.due_date.isoformat() if t.due_date else None,
"estimated_hours": float(t.estimated_hours) if t.estimated_hours else None,
"risk_count": risk_count,
"truth_node_id": str(t.truth_node_id),
}
)
return success_response(rows)
@router.patch("/projects/{project_id}/tasks/{task_id}/status")
async def patch_task_status(
project_id: str,
task_id: str,
body: TaskStatusPatchRequest,
background_tasks: BackgroundTasks,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
tid = _parse_node_id(task_id, "task_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid)
if not _is_manager(current_user) and int(task.assignee_id or 0) != int(current_user.id):
raise HTTPException(status_code=403, detail="仅负责人或任务执行人可更新状态")
before_status = (task.status or "").lower()
before = {"status": task.status}
task.status = body.status
if body.status in DONE_STATUSES:
task.completed_at = now_cn_naive()
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="task",
entity_id=str(tid),
action="status_change",
before_data=before,
after_data={"status": body.status},
)
if task.assignee_id:
await workload_service.refresh_user_assigned_hours_from_tasks(
db,
tenant_id=tenant_id,
user_id=int(task.assignee_id),
)
else:
await db.commit()
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
if task.assignee_id:
await context_cache_service.invalidate_user_context(
tenant_id=tenant_id,
user_id=int(task.assignee_id),
)
became_done = (body.status or "").lower() in DONE_STATUSES and before_status not in DONE_STATUSES
if became_done and task.assignee_id:
background_tasks.add_task(
skill_score_service.process_task_completion,
task_id=int(task.id),
tenant_id=tenant_id,
operator_user_id=int(current_user.id),
)
_sync_pmflow_rag_async(
tenant_id=tenant_id,
record_type="task",
record_id=int(task.id),
project_id=pid,
title=task.title,
content=(task.description or "") + f"\n状态: {task.status}",
)
return success_response({"id": str(task.id), "status": task.status})

View File

@ -0,0 +1,84 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project, _parse_due_date
from ...services.sstc.governance import _assert_charter_aligned, _require_manager
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
router = APIRouter(tags=["sstc"])
class TaskCreateRequest(BaseModel):
title: str
description: Optional[str] = None
required_skill_tag_ids: Optional[list[int]] = None
priority: str = "medium"
estimated_hours: Optional[float] = None
due_date: Optional[str] = None
@router.post("/projects/{project_id}/charters/{charter_id}/tasks")
async def create_task(
project_id: str,
charter_id: str,
body: TaskCreateRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
_assert_charter_aligned(charter)
due_date = _parse_due_date(body.due_date)
task = await persist_task(
db,
tenant_id=tenant_id,
truth_node_id=cid,
project_id=pid,
draft=build_task_create_draft(
title=body.title,
description=body.description,
required_skill_tag_ids=body.required_skill_tag_ids or [],
priority=body.priority,
status="todo",
estimated_hours=body.estimated_hours,
due_date=due_date,
),
actor_user_id=int(current_user.id),
action="task.create",
remark="sstc.create_task",
commit=False,
refresh_workload=False,
)
await db.commit()
await db.refresh(task)
if task.assignee_id is not None:
await workload_service.refresh_user_assigned_hours_from_tasks(
db,
tenant_id=tenant_id,
user_id=int(task.assignee_id),
)
_sync_pmflow_rag_async(
tenant_id=tenant_id,
record_type="task",
record_id=int(task.id),
project_id=pid,
title=task.title,
content=task.description or "",
)
return success_response(
{
"id": str(task.id),
"title": task.title,
"status": task.status,
"priority": task.priority,
"charter_id": str(cid),
}
)

View File

@ -0,0 +1,131 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.entity_lookup import _ensure_project, _ensure_task
from ...services.sstc.governance import _require_manager
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id
router = APIRouter(tags=["sstc"])
@router.post("/projects/{project_id}/tasks/{task_id}/recommend-assignee")
async def recommend_assignee_for_task(
project_id: str,
task_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
tid = _parse_node_id(task_id, "task_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid)
required_skills = [str(x) for x in (task.required_skill_tag_ids or [])]
recommendations = await recommend_personnel(
db=db,
tenant_id=tenant_id,
required_skills=required_skills,
task_description=task.description or task.title,
task_id=str(task.id),
limit=5,
)
project_user_ids_result = await db.execute(
select(UserProject.user_id).where(
and_(
UserProject.tenant_id == tenant_id,
UserProject.project_id == pid,
)
)
)
project_user_ids = {int(uid) for uid in project_user_ids_result.scalars().all()}
active_users_result = await db.execute(
select(User.id).where(
and_(
User.tenant_id == tenant_id,
User.status == "active",
User.is_active.is_(True),
)
)
)
active_user_ids = {int(uid) for uid in active_users_result.scalars().all()}
filtered = []
for item in recommendations.get("recommendations", []):
try:
uid = int(item.get("user_id"))
except (TypeError, ValueError):
continue
if uid in project_user_ids and uid in active_user_ids:
filtered.append(item)
recommendations["recommendations"] = filtered
recommendations["total_count"] = len(filtered)
return success_response(recommendations)
@router.post("/projects/{project_id}/tasks/optimize-workload")
async def optimize_task_workload(
project_id: str,
body: WorkloadOptimizeRequest = Body(default_factory=WorkloadOptimizeRequest),
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
proposals = await workload_service.build_ai_optimize_preview(
db,
tenant_id=tenant_id,
project_id=pid,
max_moves=body.max_moves,
unassigned_only=body.mode == "unassigned",
)
if not proposals:
return success_response(
{
"updated": 0,
"message": "no tasks to optimize",
"mode": body.mode,
}
)
optimize_result = await workload_service.apply_ai_optimize_plan(
db,
tenant_id=tenant_id,
proposals=proposals,
)
updated = int(optimize_result.get("applied_count") or 0)
affected_users = int(optimize_result.get("affected_users") or 0)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="task",
entity_id=str(pid),
action="optimize_workload",
after_data={
"updated": updated,
"affected_users": affected_users,
"proposals": len(proposals),
"mode": body.mode,
},
)
await db.commit()
return success_response(
{
"updated": updated,
"affected_users": affected_users,
"proposals": len(proposals),
"mode": body.mode,
}
)

View File

@ -0,0 +1,164 @@
from __future__ import annotations
from .shared import *
from ...services.sstc.entity_lookup import _ensure_project, _ensure_task, _parse_due_date
from ...services.sstc.governance import _require_manager
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
router = APIRouter(tags=["sstc"])
class TaskUpdateRequest(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
assignee_id: Optional[int] = None
required_skill_tag_ids: Optional[list[int]] = None
priority: Optional[str] = None
estimated_hours: Optional[float] = None
due_date: Optional[str] = None
@router.patch("/projects/{project_id}/tasks/{task_id}")
async def patch_task(
project_id: str,
task_id: str,
body: TaskUpdateRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
tid = _parse_node_id(task_id, "task_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid)
before = {
"title": task.title,
"description": task.description,
"assignee_id": task.assignee_id,
"priority": task.priority,
"estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None,
"due_date": task.due_date.isoformat() if task.due_date else None,
}
old_assignee_id = int(task.assignee_id) if task.assignee_id else None
if body.title is not None:
task.title = compact_title(body.title, max_len=32)
if body.description is not None:
task.description = body.description
if body.assignee_id is not None:
task.assignee_id = body.assignee_id
if body.required_skill_tag_ids is not None:
task.required_skill_tag_ids = body.required_skill_tag_ids
if body.priority is not None:
task.priority = body.priority
if body.estimated_hours is not None:
task.estimated_hours = body.estimated_hours
if body.due_date is not None:
task.due_date = _parse_due_date(body.due_date)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="task",
entity_id=str(tid),
action="update",
before_data=before,
after_data={
"title": task.title,
"description": task.description,
"assignee_id": task.assignee_id,
"priority": task.priority,
"estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None,
"due_date": task.due_date.isoformat() if task.due_date else None,
},
)
touched_user_ids: set[int] = set()
if old_assignee_id:
touched_user_ids.add(old_assignee_id)
if task.assignee_id:
touched_user_ids.add(int(task.assignee_id))
if touched_user_ids:
for uid in touched_user_ids:
await workload_service.refresh_user_assigned_hours_from_tasks(
db,
tenant_id=tenant_id,
user_id=uid,
)
else:
await db.commit()
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
for uid in touched_user_ids:
await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=uid)
_sync_pmflow_rag_async(
tenant_id=tenant_id,
record_type="task",
record_id=int(task.id),
project_id=pid,
title=task.title,
content=task.description or "",
)
return success_response({"id": str(task.id), "status": task.status})
@router.delete("/projects/{project_id}/tasks/{task_id}")
async def delete_task(
project_id: str,
task_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
tid = _parse_node_id(task_id, "task_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid)
assignee_id = int(task.assignee_id) if task.assignee_id else None
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="task",
entity_id=str(tid),
action="delete",
before_data={
"assignee_id": assignee_id,
"estimated_hours": float(task.estimated_hours) if task.estimated_hours else None,
"status": task.status,
},
)
await db.execute(
delete(Task).where(
and_(
Task.id == tid,
Task.project_id == pid,
Task.tenant_id == tenant_id,
)
)
)
if assignee_id:
await workload_service.refresh_user_assigned_hours_from_tasks(
db,
tenant_id=tenant_id,
user_id=assignee_id,
)
else:
await db.commit()
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
if assignee_id:
await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=assignee_id)
return success_response({"id": str(tid), "deleted": True})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
from __future__ import annotations
from fastapi import APIRouter
from . import routes_bulk, routes_import, routes_mutation, routes_query
router = APIRouter(prefix="/users", tags=["users"])
for _module in [routes_query, routes_mutation, routes_bulk, routes_import]:
router.include_router(_module.router)
__all__ = ["router"]

View File

@ -0,0 +1,64 @@
from __future__ import annotations
from .shared import *
def _enum_value(value: Any) -> Any:
return value.value if hasattr(value, "value") else value
def _parse_role_values(raw: Any) -> list[str]:
if raw is None:
return []
if isinstance(raw, (list, tuple, set)):
values = [str(_enum_value(item)).strip().upper() for item in raw]
else:
values = [token.strip().upper() for token in str(_enum_value(raw)).split(",")]
deduped: list[str] = []
for value in values:
if value and value not in deduped:
deduped.append(value)
return deduped
def _normalize_role_input(raw: Any, *, allow_empty: bool = False) -> list[str]:
values = _parse_role_values(raw)
if not values and not allow_empty:
raise ValueError("role required")
invalid = [value for value in values if value not in VALID_ROLE_VALUES]
if invalid:
raise ValueError(f"Invalid role: {','.join(invalid)}")
return values
def _pack_roles(raw: Any) -> str:
return ",".join(_normalize_role_input(raw))
def _has_any_role(raw: Any, *roles: str) -> bool:
values = set(_parse_role_values(raw))
expected = {str(role).upper() for role in roles}
return bool(values.intersection(expected))
def _build_role_filter(column: Any, roles: list[str]) -> Any:
filters = []
for role in roles:
filters.extend(
[
column == role,
column.like(f"{role},%"),
column.like(f"%,{role},%"),
column.like(f"%,{role}"),
]
)
return or_(*filters)
def _parse_int_list(raw: Optional[str]) -> list[int]:
if not raw:
return []
ids: list[int] = []
for part in raw.split(","):
token = part.strip()
if not token:
continue
try:
ids.append(int(token))
except ValueError:
continue
return ids

View File

@ -0,0 +1,267 @@
from __future__ import annotations
from .shared import *
from .helpers_roles import _normalize_role_input, _pack_roles
def _translate_user_integrity_error(exc: IntegrityError) -> str:
message = str(getattr(exc, "orig", exc)).lower()
if "users.username" in message or "username" in message:
return "用户名已存在"
if "users.email" in message or "email" in message:
return "邮箱已存在"
return "用户数据冲突,请检查用户名或邮箱是否重复"
async def _collect_user_enrichment(
db: AsyncSession,
tenant_id: int,
users: list[User],
) -> tuple[dict[int, float], dict[int, list[str]], dict[int, list[int]]]:
if not users:
return {}, {}, {}
user_ids = [int(u.id) for u in users]
workloads_result = await db.execute(
select(UserWorkload).where(
and_(
UserWorkload.tenant_id == tenant_id,
UserWorkload.user_id.in_(user_ids),
)
)
)
workload_map: dict[int, float] = {
int(w.user_id): float(w.workload_ratio or 0.0)
for w in workloads_result.scalars().all()
}
skill_rows = await db.execute(
select(UserSkillBinding.user_id, SkillTag.name, UserSkillBinding.proficiency)
.join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id)
.where(
and_(
UserSkillBinding.tenant_id == tenant_id,
UserSkillBinding.user_id.in_(user_ids),
)
)
.order_by(UserSkillBinding.user_id, UserSkillBinding.proficiency.desc())
)
top_skills: dict[int, list[str]] = {}
for uid, skill_name, _prof in skill_rows.all():
key = int(uid)
top_skills.setdefault(key, [])
if len(top_skills[key]) < 3:
top_skills[key].append(skill_name)
project_rows = await db.execute(
select(UserProject.user_id, UserProject.project_id).where(
and_(
UserProject.tenant_id == tenant_id,
UserProject.user_id.in_(user_ids),
)
)
)
project_map: dict[int, list[int]] = {}
for uid, pid in project_rows.all():
project_map.setdefault(int(uid), []).append(int(pid))
return workload_map, top_skills, project_map
def _serialize_user(
user: User,
workload_map: dict[int, float] | None = None,
top_skills: dict[int, list[str]] | None = None,
project_map: dict[int, list[int]] | None = None,
) -> dict:
payload = user.to_dict()
uid = int(user.id)
payload["workload_ratio"] = (workload_map or {}).get(uid, float(user.workload or 0))
payload["top_skills"] = (top_skills or {}).get(uid, [])
payload["project_ids"] = (project_map or {}).get(uid, [])
return payload
async def _replace_user_projects(
db: AsyncSession,
tenant_id: int,
user_id: int,
project_ids: list[int],
) -> None:
existing_rows = await db.execute(
select(UserProject.project_id).where(
and_(
UserProject.tenant_id == tenant_id,
UserProject.user_id == user_id,
)
)
)
existing_project_ids = {int(pid) for pid in existing_rows.scalars().all() if pid is not None}
await db.execute(
delete(UserProject).where(
and_(
UserProject.tenant_id == tenant_id,
UserProject.user_id == user_id,
)
)
)
unique_project_ids = sorted(set(project_ids))
for pid in unique_project_ids:
db.add(UserProject(tenant_id=tenant_id, user_id=user_id, project_id=pid))
# bulk delete(UserProject) 不会触发 ORM 对象事件,这里手动注册缓存失效意图。
cache_payload = db.sync_session.info.get("_pmflow_cache_invalidation")
if not isinstance(cache_payload, dict):
cache_payload = {
"tenant_ids": set(),
"invalidate_system": False,
"invalidate_project_list": False,
"invalidate_signal_pending": False,
"user_ids": set(),
}
db.sync_session.info["_pmflow_cache_invalidation"] = cache_payload
cache_payload["tenant_ids"].add(int(tenant_id))
cache_payload["invalidate_system"] = True
cache_payload["invalidate_project_list"] = True
cache_payload["user_ids"].add(int(user_id))
affected_project_ids = sorted(existing_project_ids.union({int(pid) for pid in unique_project_ids}))
for project_id in affected_project_ids:
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id)
async def _create_user_with_defaults(
db: AsyncSession,
tenant_id: int,
user_data: UserCreate,
actor_id: int,
*,
commit: bool,
) -> User:
username = user_data.username.strip()
if not username:
raise ValueError("用户名不能为空")
existing_username = await db.execute(
select(User).where(
and_(
User.tenant_id == tenant_id,
User.username == username,
)
)
)
if existing_username.scalar_one_or_none():
raise ValueError("用户名已存在")
if user_data.email:
existing_email = await db.execute(
select(User).where(
and_(
User.tenant_id == tenant_id,
User.email == user_data.email.strip(),
)
)
)
if existing_email.scalar_one_or_none():
raise ValueError("邮箱已存在")
user = User(
tenant_id=tenant_id,
username=username,
password_hash=AuthService.hash_password(user_data.password),
name=(user_data.name or username).strip(),
email=(user_data.email or f"{username}@placeholder.local").strip(),
role=_pack_roles(user_data.role),
assigned_domains=user_data.assigned_domains or [],
workload=0,
status=UserStatus.ACTIVE.value,
is_active=True,
)
db.add(user)
await db.flush()
db.add(
UserWorkload(
tenant_id=tenant_id,
user_id=int(user.id),
current_assigned_hours=0.0,
max_available_hours_per_week=40.0,
workload_ratio=0.0,
)
)
await skill_service.init_user_skill_bindings(
db,
tenant_id=tenant_id,
user_id=int(user.id),
include_system_only=True,
commit=False,
)
if user_data.project_ids:
await _replace_user_projects(
db,
tenant_id=tenant_id,
user_id=int(user.id),
project_ids=user_data.project_ids,
)
await log_operation(
db,
tenant_id=tenant_id,
user_id=actor_id,
project_id=None,
entity_type="user",
entity_id=str(user.id),
action="create",
before_data=None,
after_data=user.to_dict(),
)
if commit:
await db.commit()
await db.refresh(user)
return user
def _normalize_import_row(raw: dict[str, Any]) -> dict[str, Any]:
lower_map = {str(k).strip().lower(): v for k, v in raw.items()}
return {
"username": str(lower_map.get("username", "")).strip(),
"password": str(lower_map.get("password", "")).strip(),
"name": str(lower_map.get("name", "")).strip(),
"email": str(lower_map.get("email", "")).strip() or None,
"role": str(lower_map.get("role", "FE")).strip().upper(),
"project_ids": _parse_int_list(str(lower_map.get("project_ids", ""))),
}
async def _iter_import_rows(file: UploadFile) -> list[dict[str, Any]]:
filename = (file.filename or "").lower()
content = await file.read()
if filename.endswith(".csv"):
text = content.decode("utf-8-sig")
reader = csv.DictReader(StringIO(text))
return [dict(row) for row in reader]
if filename.endswith(".xlsx") or filename.endswith(".xls"):
try:
from openpyxl import load_workbook # type: ignore
except Exception as exc:
raise HTTPException(
status_code=400,
detail=f"Excel import requires openpyxl: {exc}",
)
wb = load_workbook(filename=BytesIO(content), read_only=True)
sheet = wb.active
rows = list(sheet.iter_rows(values_only=True))
if not rows:
return []
headers = [str(v).strip() if v is not None else "" for v in rows[0]]
data_rows: list[dict[str, Any]] = []
for row in rows[1:]:
item = {
headers[idx]: row[idx]
for idx in range(min(len(headers), len(row)))
if headers[idx]
}
data_rows.append(item)
return data_rows
raise HTTPException(status_code=400, detail="Only .csv/.xlsx/.xls are supported")

View File

@ -0,0 +1,242 @@
from __future__ import annotations
from .shared import *
from .helpers_roles import _normalize_role_input, _pack_roles
from .helpers_user import _replace_user_projects, _serialize_user
from .schemas import BulkAssignUsersRequest, BulkDeleteUsersRequest
@router.delete("/{user_id}")
async def delete_user(
user_id: int,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
"""删除用户(有未完成任务时禁止)。"""
if int(current_user.id) == user_id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
result = await db.execute(
select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id))
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
incomplete_count_result = await db.execute(
select(func.count(Task.id)).where(
and_(
Task.tenant_id == tenant_id,
Task.assignee_id == user_id,
func.lower(Task.status).notin_(COMPLETED_TASK_STATUSES),
)
)
)
incomplete_count = int(incomplete_count_result.scalar() or 0)
if incomplete_count > 0:
raise HTTPException(
status_code=400,
detail=f"User has {incomplete_count} incomplete tasks",
)
before_data = user.to_dict()
await AIPMOKernelService.invalidate_world_model_cache_for_user(
db=db,
tenant_id=tenant_id,
user_id=user_id,
)
await db.execute(
delete(UserSkillBinding).where(
and_(
UserSkillBinding.tenant_id == tenant_id,
UserSkillBinding.user_id == user_id,
)
)
)
await db.execute(
delete(UserWorkload).where(
and_(
UserWorkload.tenant_id == tenant_id,
UserWorkload.user_id == user_id,
)
)
)
await db.execute(
delete(UserProject).where(
and_(
UserProject.tenant_id == tenant_id,
UserProject.user_id == user_id,
)
)
)
await db.delete(user)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=None,
entity_type="user",
entity_id=str(user_id),
action="delete",
before_data=before_data,
after_data=None,
)
await db.commit()
return {"message": "User deleted successfully"}
@router.post("/bulk-delete")
async def bulk_delete_users(
body: BulkDeleteUsersRequest,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
"""批量删除用户(带未完成任务保护)。"""
if not body.user_ids:
raise HTTPException(status_code=400, detail="user_ids required")
deleted: list[int] = []
skipped: list[dict[str, Any]] = []
for user_id in sorted(set(body.user_ids)):
if int(current_user.id) == user_id:
skipped.append({"user_id": user_id, "reason": "Cannot delete yourself"})
continue
user = (
await db.execute(
select(User).where(
and_(
User.tenant_id == tenant_id,
User.id == user_id,
)
)
)
).scalar_one_or_none()
if not user:
skipped.append({"user_id": user_id, "reason": "User not found"})
continue
incomplete_count_result = await db.execute(
select(func.count(Task.id)).where(
and_(
Task.tenant_id == tenant_id,
Task.assignee_id == user_id,
func.lower(Task.status).notin_(COMPLETED_TASK_STATUSES),
)
)
)
incomplete_count = int(incomplete_count_result.scalar() or 0)
if incomplete_count > 0:
skipped.append(
{
"user_id": user_id,
"reason": f"User has {incomplete_count} incomplete tasks",
}
)
continue
await AIPMOKernelService.invalidate_world_model_cache_for_user(
db=db,
tenant_id=tenant_id,
user_id=user_id,
)
await db.execute(
delete(UserSkillBinding).where(
and_(
UserSkillBinding.tenant_id == tenant_id,
UserSkillBinding.user_id == user_id,
)
)
)
await db.execute(
delete(UserWorkload).where(
and_(
UserWorkload.tenant_id == tenant_id,
UserWorkload.user_id == user_id,
)
)
)
await db.execute(
delete(UserProject).where(
and_(
UserProject.tenant_id == tenant_id,
UserProject.user_id == user_id,
)
)
)
await db.delete(user)
deleted.append(user_id)
await db.commit()
return {"deleted_count": len(deleted), "deleted_user_ids": deleted, "skipped": skipped}
@router.post("/bulk-assign")
async def bulk_assign_users(
body: BulkAssignUsersRequest,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
"""批量分配项目/角色。"""
user_ids = sorted(set(body.user_ids or []))
if not user_ids:
raise HTTPException(status_code=400, detail="user_ids required")
if body.role is None and body.project_ids is None:
raise HTTPException(status_code=400, detail="role or project_ids required")
updated = 0
skipped: list[dict[str, Any]] = []
for user_id in user_ids:
user = (
await db.execute(
select(User).where(
and_(
User.tenant_id == tenant_id,
User.id == user_id,
)
)
)
).scalar_one_or_none()
if not user:
skipped.append({"user_id": user_id, "reason": "User not found"})
continue
before_data = user.to_dict()
if body.role is not None:
if not _has_any_role(_enum_value(current_user.role), "BOSS"):
skipped.append({"user_id": user_id, "reason": "Only BOSS can bulk assign roles"})
continue
try:
user.role = _pack_roles(body.role)
except ValueError as exc:
skipped.append({"user_id": user_id, "reason": str(exc)})
continue
if body.project_ids is not None:
await _replace_user_projects(
db,
tenant_id=tenant_id,
user_id=user_id,
project_ids=body.project_ids,
)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=None,
entity_type="user",
entity_id=str(user_id),
action="bulk_assign",
before_data=before_data,
after_data=user.to_dict(),
remark=f"role={body.role}, projects={body.project_ids}",
)
updated += 1
await db.commit()
return {"updated_count": updated, "skipped": skipped}

Some files were not shown because too many files have changed in this diff Show More