Summarize backend API updates
This commit is contained in:
parent
b7fc357027
commit
739e6947ea
36
CLAUDE.md
36
CLAUDE.md
|
|
@ -120,6 +120,42 @@ backend/
|
|||
- 新增:`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_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/`)
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -2,158 +2,22 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Optional
|
||||
from typing import Optional
|
||||
|
||||
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 ..core.response import success_response
|
||||
from ..database import get_db
|
||||
from ..dependencies.auth import get_current_user
|
||||
from ..models import (
|
||||
AIProcessingLog,
|
||||
FeishuItem,
|
||||
ProjectSignalLink,
|
||||
ProjectSuggestion,
|
||||
Signal,
|
||||
User,
|
||||
WorkItem,
|
||||
)
|
||||
|
||||
from ..models import AIProcessingLog, User
|
||||
from .ai_logs_utils import apply_project_filter, serialize_log_detail, serialize_log_row
|
||||
|
||||
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("")
|
||||
async def list_ai_logs(
|
||||
project_id: Optional[int] = Query(default=None, ge=1),
|
||||
|
|
@ -171,12 +35,12 @@ async def list_ai_logs(
|
|||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
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)
|
||||
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)
|
||||
|
||||
total = int((await db.execute(total_stmt)).scalar() or 0)
|
||||
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(
|
||||
{
|
||||
"items": items,
|
||||
|
|
@ -218,9 +82,7 @@ async def get_ai_log_stats(
|
|||
func.sum(
|
||||
case(
|
||||
(
|
||||
AIProcessingLog.routing_decision.in_(
|
||||
["auto_create_project", "auto_enter_signal"]
|
||||
),
|
||||
AIProcessingLog.routing_decision.in_(["auto_create_project", "auto_enter_signal"]),
|
||||
1,
|
||||
),
|
||||
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()
|
||||
return success_response(
|
||||
{
|
||||
|
|
@ -296,7 +158,7 @@ async def list_execute_reply_guard_alerts(
|
|||
return success_response(
|
||||
{
|
||||
"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())
|
||||
.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()
|
||||
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}")
|
||||
|
|
@ -338,4 +200,4 @@ async def get_ai_log_detail(
|
|||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="ai_log_not_found")
|
||||
return success_response(_serialize_log_detail(row))
|
||||
return success_response(serialize_log_detail(row))
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
@ -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": [],
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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
|
|
@ -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"]
|
||||
|
|
@ -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)]
|
||||
|
||||
|
|
@ -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)}"
|
||||
)
|
||||
|
||||
|
|
@ -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)}"
|
||||
)
|
||||
|
||||
|
|
@ -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)}")
|
||||
|
||||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -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 # 需要前端检测
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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 "禁用"}'
|
||||
}
|
||||
|
||||
|
|
@ -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]
|
||||
|
||||
|
|
@ -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"])
|
||||
|
|
@ -1,381 +1,7 @@
|
|||
"""
|
||||
PMFlow 人员能力图谱 API 端点
|
||||
"""PMFlow 人员能力图谱 API 端点。"""
|
||||
|
||||
- 技能标签 CRUD
|
||||
- 用户技能绑定
|
||||
- 团队负载看板
|
||||
- 用户个人负载查询
|
||||
- 人员推荐
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from .skill_api import router
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
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),
|
||||
)
|
||||
__all__ = ["router"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
|
|
@ -1,376 +1,7 @@
|
|||
"""Feishu Integration API - 飞书集成接口。"""
|
||||
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
|
||||
"""Feishu API router compatibility entrypoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
router = APIRouter(prefix="/feishu", tags=["Feishu Integration"])
|
||||
from .feishu_api import 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)}"
|
||||
)
|
||||
__all__ = ["router"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
"""Feishu API module package."""
|
||||
|
||||
from .router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
|
|
@ -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)}")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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(),
|
||||
}
|
||||
|
|
@ -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)}")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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="卡片内容")
|
||||
|
|
@ -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)}")
|
||||
|
|
@ -1,491 +1,5 @@
|
|||
"""模型能力矩阵 API"""
|
||||
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"])
|
||||
|
||||
|
||||
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})
|
||||
from .model_capability_api import router
|
||||
|
||||
__all__ = ["router"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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())
|
||||
|
||||
|
|
@ -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})
|
||||
|
||||
|
|
@ -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}"),
|
||||
)
|
||||
|
||||
|
|
@ -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}"),
|
||||
)
|
||||
|
||||
|
|
@ -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}"),
|
||||
)
|
||||
|
||||
|
|
@ -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"])
|
||||
|
|
@ -1,686 +1,5 @@
|
|||
"""项目管理 API。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
from .projects_api import router
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
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"}
|
||||
__all__ = ["router"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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"}
|
||||
|
||||
|
|
@ -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"}
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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"}
|
||||
|
|
@ -1,570 +1,5 @@
|
|||
"""Adaptive RAG API。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import re
|
||||
from datetime import datetime
|
||||
from .rag_api import router
|
||||
|
||||
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"])
|
||||
|
||||
|
||||
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")
|
||||
__all__ = ["router"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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}")
|
||||
|
||||
|
|
@ -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})
|
||||
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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"])
|
||||
|
|
@ -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
|
||||
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 ..dependencies.tenant import get_tenant_id
|
||||
from ..core.logging import get_logger
|
||||
from ..models import Signal, ProjectSignalLink, Project
|
||||
from ..models import ProjectSignalLink, Signal
|
||||
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"])
|
||||
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")
|
||||
async def recipe_webhook(
|
||||
|
|
@ -123,47 +27,18 @@ async def recipe_webhook(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
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:
|
||||
# 1. 查找目标项目
|
||||
target_project_id = await _resolve_target_project_id(
|
||||
target_project_id = await resolve_target_project_id(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
# 2. 构建信号内容
|
||||
content_parts = []
|
||||
content_parts.append(f"食物:{request.food_name}")
|
||||
|
||||
|
||||
content_parts = [f"食物:{request.food_name}"]
|
||||
if 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:
|
||||
nutrition = []
|
||||
if request.calories:
|
||||
|
|
@ -177,33 +52,30 @@ async def recipe_webhook(
|
|||
if request.fiber:
|
||||
nutrition.append(f"纤维 {request.fiber}g")
|
||||
content_parts.append("营养:" + ", ".join(nutrition))
|
||||
|
||||
|
||||
if request.category:
|
||||
content_parts.append(f"分类:{request.category}")
|
||||
if request.cuisine:
|
||||
content_parts.append(f"菜系:{request.cuisine}")
|
||||
content_parts.append(f"意图:{request.intent}")
|
||||
|
||||
|
||||
content = "\n".join(content_parts)
|
||||
|
||||
# 3. 构建标题
|
||||
|
||||
intent_text = {
|
||||
"analyze": "热量分析",
|
||||
"save": "保存食谱",
|
||||
"recommend": "推荐菜谱"
|
||||
"recommend": "推荐菜谱",
|
||||
}.get(request.intent, request.intent)
|
||||
|
||||
|
||||
title = f"🍽️ 食谱{intent_text}:{request.food_name}"
|
||||
|
||||
# 4. 构建摘要
|
||||
|
||||
summary = f"食物:{request.food_name}"
|
||||
if request.calories:
|
||||
summary += f"\n热量:{request.calories} kcal"
|
||||
if request.category:
|
||||
summary += f"\n分类:{request.category}"
|
||||
summary += f"\n意图:{intent_text}"
|
||||
|
||||
# 5. 构建信号元数据
|
||||
|
||||
signal_metadata = {
|
||||
"source": "recipe_system",
|
||||
"intent": request.intent,
|
||||
|
|
@ -219,8 +91,7 @@ async def recipe_webhook(
|
|||
"image_url": request.image_url,
|
||||
"user_name": request.user_name,
|
||||
}
|
||||
|
||||
# 6. 创建信号
|
||||
|
||||
signal = Signal(
|
||||
tenant_id=tenant_id,
|
||||
source="recipe_system",
|
||||
|
|
@ -234,21 +105,20 @@ async def recipe_webhook(
|
|||
processed=False,
|
||||
strategic_analysis=json.dumps(signal_metadata, ensure_ascii=False),
|
||||
)
|
||||
|
||||
|
||||
db.add(signal)
|
||||
await db.flush() # 获取 signal.id
|
||||
|
||||
# 7. 关联到项目
|
||||
await db.flush()
|
||||
|
||||
link = ProjectSignalLink(
|
||||
tenant_id=tenant_id,
|
||||
project_id=target_project_id,
|
||||
signal_id=signal.id,
|
||||
)
|
||||
db.add(link)
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(signal)
|
||||
|
||||
|
||||
return RecipeRecognitionResponse(
|
||||
success=True,
|
||||
signal_id=signal.id,
|
||||
|
|
@ -259,22 +129,22 @@ async def recipe_webhook(
|
|||
"project_id": target_project_id,
|
||||
"title": title,
|
||||
"status": signal.status,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
logger.exception(
|
||||
"recipe_webhook_create_signal_failed",
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
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="查询食谱信号状态")
|
||||
|
|
@ -284,8 +154,6 @@ async def get_recipe_signal_status(
|
|||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
"""查询食谱识别信号的处理状态"""
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(Signal).where(
|
||||
Signal.id == signal_id,
|
||||
|
|
@ -294,10 +162,10 @@ async def get_recipe_signal_status(
|
|||
)
|
||||
)
|
||||
signal = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if not signal:
|
||||
raise HTTPException(status_code=404, detail="信号不存在")
|
||||
|
||||
|
||||
return {
|
||||
"signal_id": signal.id,
|
||||
"title": signal.title,
|
||||
|
|
@ -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="批量导入食谱")
|
||||
async def recipe_batch_import(
|
||||
request: RecipeBatchRequest,
|
||||
|
|
@ -325,21 +184,20 @@ async def recipe_batch_import(
|
|||
):
|
||||
"""批量导入食谱记录,每个都会创建信号"""
|
||||
try:
|
||||
target_project_id = await _resolve_target_project_id(
|
||||
target_project_id = await resolve_target_project_id(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
|
||||
signal_ids = []
|
||||
for recipe in request.recipes:
|
||||
# 构建内容
|
||||
content = f"食物:{recipe.food_name}"
|
||||
if recipe.calories:
|
||||
content += f"\n热量:{recipe.calories} kcal"
|
||||
|
||||
|
||||
title = f"🍽️ 食谱记录:{recipe.food_name}"
|
||||
|
||||
|
||||
metadata = {
|
||||
"source": "recipe_system",
|
||||
"intent": recipe.intent,
|
||||
|
|
@ -348,9 +206,9 @@ async def recipe_batch_import(
|
|||
"protein": recipe.protein,
|
||||
"fat": recipe.fat,
|
||||
"carbs": recipe.carbs,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
signal = Signal(
|
||||
tenant_id=tenant_id,
|
||||
source="recipe_system",
|
||||
|
|
@ -361,13 +219,12 @@ async def recipe_batch_import(
|
|||
priority="low",
|
||||
impact_score=0.3,
|
||||
status="待处理",
|
||||
processed=True, # 批量导入的视为已处理
|
||||
processed=True,
|
||||
strategic_analysis=json.dumps(metadata),
|
||||
)
|
||||
db.add(signal)
|
||||
await db.flush()
|
||||
|
||||
# 关联项目
|
||||
|
||||
link = ProjectSignalLink(
|
||||
tenant_id=tenant_id,
|
||||
project_id=target_project_id,
|
||||
|
|
@ -375,27 +232,27 @@ async def recipe_batch_import(
|
|||
)
|
||||
db.add(link)
|
||||
signal_ids.append(signal.id)
|
||||
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
return RecipeRecognitionResponse(
|
||||
success=True,
|
||||
signal_id=len(signal_ids), # 返回数量
|
||||
signal_id=len(signal_ids),
|
||||
project_id=target_project_id,
|
||||
message=f"成功导入 {len(signal_ids)} 条食谱记录",
|
||||
details={"signal_count": len(signal_ids)}
|
||||
details={"signal_count": len(signal_ids)},
|
||||
)
|
||||
|
||||
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
logger.exception(
|
||||
"recipe_batch_import_failed",
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
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)}")
|
||||
|
|
|
|||
|
|
@ -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="食谱列表")
|
||||
|
|
@ -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)",
|
||||
)
|
||||
|
|
@ -1,462 +1,7 @@
|
|||
"""信号相关 API 路由"""
|
||||
"""信号相关 API 路由。"""
|
||||
|
||||
import json
|
||||
from typing import List, Optional
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from .signals_api import router
|
||||
|
||||
from ..database import get_db
|
||||
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
|
||||
]
|
||||
__all__ = ["router"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
]
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
|
|
@ -2,73 +2,31 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
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 SkillTag, User, UserSkillBinding
|
||||
from ..services.ai_pmo_kernel_service import AIPMOKernelService
|
||||
from ..services import skill_service
|
||||
from ..services.ai_pmo_kernel_service import AIPMOKernelService
|
||||
from ..services.auth import has_any_role
|
||||
|
||||
from ..utils.timezone import now_cn_naive
|
||||
from .skill_tags_contracts import (
|
||||
SkillTagCreateRequest,
|
||||
SkillTagUpdateRequest,
|
||||
UserSkillCreateRequest,
|
||||
UserSkillUpdateRequest,
|
||||
to_tag_payload,
|
||||
)
|
||||
|
||||
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")
|
||||
async def list_skill_tags(
|
||||
category: Optional[str] = Query(None),
|
||||
category: str | None = Query(None),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(get_current_user),
|
||||
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()}
|
||||
items = []
|
||||
for t in tags:
|
||||
payload = _to_tag_payload(t)
|
||||
payload["linked_user_count"] = count_map.get(int(t.id), 0)
|
||||
for tag in tags:
|
||||
payload = to_tag_payload(tag)
|
||||
payload["linked_user_count"] = count_map.get(int(tag.id), 0)
|
||||
items.append(payload)
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
|
@ -114,7 +72,7 @@ async def create_skill_tag(
|
|||
)
|
||||
except ValueError as 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}")
|
||||
|
|
@ -130,7 +88,6 @@ async def update_skill_tag(
|
|||
if not tag:
|
||||
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:
|
||||
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()
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
return _to_tag_payload(tag)
|
||||
return to_tag_payload(tag)
|
||||
|
||||
|
||||
@router.delete("/skill-tags/{tag_id}")
|
||||
|
|
@ -258,10 +215,7 @@ async def update_user_skill(
|
|||
|
||||
if is_self and not is_manager:
|
||||
if body.quality_score is not None or body.efficiency_score is not None:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Members can only update own proficiency",
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="Members can only update own proficiency")
|
||||
|
||||
row = (
|
||||
await db.execute(
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
@ -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)))
|
||||
|
||||
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -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})
|
||||
|
||||
|
|
@ -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})
|
||||
|
||||
|
|
@ -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})
|
||||
|
||||
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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": "已关闭"})
|
||||
|
||||
|
|
@ -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}"'},
|
||||
)
|
||||
|
||||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -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
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -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})
|
||||
|
||||
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -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
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
@ -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
Loading…
Reference in New Issue