PMFlow/backend/app/api/sstc.py

3938 lines
135 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
SSTC 唯一事实链路 API
覆盖模块:
- 项目概览/链路导航/风险预警
- 信号源
- 需求宪章(含对齐)
- 任务拆解与执行
- 发布验证
- 复盘沉淀
"""
from __future__ import annotations
import asyncio
import json
import re
from datetime import datetime, timedelta, timezone
from io import BytesIO
from pathlib import Path
from typing import Any, Optional
from uuid import uuid4
from fastapi import APIRouter, BackgroundTasks, Body, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
from sqlalchemy import and_, delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.response import error_response, success_response
from ..database import get_db
from ..models import (
AICallLog,
AlignmentRecord,
Integration,
ModelRequirementBinding,
ModelVersion,
ProjectSignalLink,
Signal,
Task,
TruthNode,
User,
UserProject,
)
from ..schemas.sstc import (
AlignmentConfirmRequest,
AlignmentStartRequest,
CharterUpdateRequest,
ReleaseVerificationItemPatchRequest,
ReleaseCreateRequest,
ReleaseStatusPatchRequest,
ReviewArchiveRequest,
ReviewContentPatchRequest,
ReviewCreateRequest,
ReviewImprovementToTaskRequest,
WorkloadOptimizeRequest,
TaskStatusPatchRequest,
)
from ..services.llm import llm_service
from ..services.ai_service import AIService
from ..services.ai_pmo_kernel_service import AIPMOKernelService
from ..services.operation_log_service import log_operation
from ..services.auth import has_any_role
from ..services.skill_service import recommend_personnel
from ..services import skill_score_service
from ..services.sstc_service import (
ALIGNED_STATUSES,
DONE_STATUSES,
RELEASE_VERIFIED_STATUSES,
SSTCService,
)
from ..services import workload_service
from ..services.budget_enforcement_service import BudgetEnforcementService
from ..services.feishu_integration import get_feishu_service
from ..services.adaptive_rag_service import AdaptiveRAGService
from ..services.context_cache import context_cache_service
from ..services.task_persistence import build_task_create_draft, persist_task
from ..dependencies import get_current_user, require_project_member_or_manager
from ..utils.title import compact_title
router = APIRouter(tags=["sstc"])
_FEISHU_PLACEHOLDER_RE = re.compile(r"^飞书用户\(([^)]+)\)$")
_SIGNAL_PREFIX_RE = re.compile(r"^\[(.*?)\]\s*:\s*")
DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD = 0.80
DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD = 0.85
DEFAULT_PROJECT_UNCERTAIN_THRESHOLD = 0.60
_SIGNAL_STATUS_CODE_PENDING = "pending"
_SIGNAL_STATUS_CODE_LINKED = "linked"
_SIGNAL_STATUS_CODE_CLOSED = "closed"
_SIGNAL_STATUS_LABEL_BY_CODE = {
_SIGNAL_STATUS_CODE_PENDING: "待处理",
_SIGNAL_STATUS_CODE_LINKED: "已关联宪章",
_SIGNAL_STATUS_CODE_CLOSED: "已关闭",
}
_SIGNAL_STATUS_FILTER_ALIAS = {
"pending": _SIGNAL_STATUS_CODE_PENDING,
"待处理": _SIGNAL_STATUS_CODE_PENDING,
"linked": _SIGNAL_STATUS_CODE_LINKED,
"已关联宪章": _SIGNAL_STATUS_CODE_LINKED,
"closed": _SIGNAL_STATUS_CODE_CLOSED,
"archived": _SIGNAL_STATUS_CODE_CLOSED,
"已关闭": _SIGNAL_STATUS_CODE_CLOSED,
"已归档": _SIGNAL_STATUS_CODE_CLOSED,
}
_SIGNAL_LOCK_REASON = "信号已关联宪章,禁止重复新建"
_BEIJING_UTC_OFFSET = timedelta(hours=8)
_ALLOWED_ATTACHMENT_TYPES = {"image", "file", "media", "document"}
_SIGNAL_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024
_SIGNAL_ATTACHMENT_ALLOWED_EXTENSIONS = {
".jpg": "image",
".jpeg": "image",
".png": "image",
".gif": "image",
".pdf": "document",
".doc": "document",
".docx": "document",
".zip": "file",
}
_SIGNAL_ATTACHMENT_ALLOWED_MIME_TYPES = {
"image/jpeg",
"image/png",
"image/gif",
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/zip",
"application/x-zip-compressed",
"multipart/x-zip",
"application/octet-stream",
}
_CHARTER_REQUIRED_FIELDS: dict[str, tuple[str, ...]] = {
"module_1": ("problem_statement", "goal_statement", "kpis"),
"module_2": ("in_scope", "out_of_scope", "alignment_qa"),
"module_3": ("primary_model", "fallback_model", "capability_table", "dependency_map"),
"module_4": ("milestones", "role_effort_estimates", "capability_owner_assignment"),
"module_5": ("risk_register", "trigger_rules", "execution_rule"),
"module_6": ("attachment_metadata", "attachment_summary", "search_tags"),
}
RELEASE_STATUS_TRANSITIONS: dict[str, set[str]] = {
"pending": {"releasing", "online"},
"releasing": {"online", "rolled_back"},
"online": {"rolled_back"},
"rolled_back": {"releasing"},
"verified": set(),
}
def _sync_pmflow_rag_async(
*,
tenant_id: int,
record_type: str,
record_id: int,
project_id: int,
title: str,
content: dict[str, Any] | str | None,
) -> None:
try:
if isinstance(content, str):
payload = content
else:
payload = json.dumps(content or {}, ensure_ascii=False)
asyncio.create_task(
AdaptiveRAGService.upsert_pmflow_record_async(
tenant_id=tenant_id,
record_type=record_type,
record_id=record_id,
project_id=project_id,
title=title,
content=payload,
)
)
except Exception:
return
def _parse_project_id(project_id: str) -> int:
try:
return int(project_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid project_id") from exc
def _parse_node_id(node_id: str, label: str) -> int:
try:
return int(node_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Invalid {label}") from exc
def _as_dict(value: Any) -> dict:
if isinstance(value, dict):
return value
if isinstance(value, str):
import json
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {"text": value}
except (TypeError, ValueError):
return {"text": value}
return {}
def _signal_ai_analysis(signal: Signal) -> dict:
payload = _as_dict(signal.strategic_analysis)
ai_payload = payload.get("ai_analysis")
return ai_payload if isinstance(ai_payload, dict) else {}
def _signal_project_scope(signal: Signal) -> Optional[str]:
scope = str(_signal_ai_analysis(signal).get("requirement_project_scope") or "").strip().lower()
if scope in {"new_project", "existing_project", "pending_classification", "non_requirement"}:
return scope
return None
def _signal_display_type(signal: Signal) -> str:
scope = _signal_project_scope(signal)
if scope:
return scope
return "pending_classification"
def _signal_source_context(signal: Signal) -> dict:
payload = _as_dict(signal.strategic_analysis)
source_context = payload.get("source_context")
return source_context if isinstance(source_context, dict) else {}
def _safe_int(value: Any) -> Optional[int]:
try:
return int(str(value).strip())
except (TypeError, ValueError, AttributeError):
return None
def _signal_attachment_storage_root() -> Path:
root = Path(__file__).resolve().parents[2] / "storage" / "signal_attachments"
root.mkdir(parents=True, exist_ok=True)
return root
def _build_signal_attachment_download_url(project_id: int, signal_id: int, attachment_id: str) -> str:
return f"/projects/{project_id}/signals/{signal_id}/attachments/{attachment_id}/download"
def _resolve_local_attachment_path(file_key: str) -> Optional[Path]:
raw = str(file_key or "").strip()
if not raw.startswith("local://"):
return None
relative = raw.removeprefix("local://").strip().lstrip("/")
if not relative:
return None
storage_root = _signal_attachment_storage_root().resolve()
candidate = (storage_root / relative).resolve()
try:
candidate.relative_to(storage_root)
except ValueError:
return None
return candidate
def _error_json(status_code: int, message: str) -> JSONResponse:
return JSONResponse(status_code=status_code, content=error_response(status_code, message))
def _normalize_attachment_type(value: Any) -> str:
raw = str(value or "").strip().lower()
alias = {
"img": "image",
"picture": "image",
"attachment": "file",
"doc": "document",
"docs": "document",
}
mapped = alias.get(raw, raw)
return mapped if mapped in _ALLOWED_ATTACHMENT_TYPES else "file"
def _normalize_signal_attachment(item: Any, *, index: int) -> Optional[dict[str, Any]]:
if not isinstance(item, dict):
return None
attachment_type = _normalize_attachment_type(item.get("attachment_type") or item.get("type"))
attachment_id = (
str(item.get("attachment_id") or item.get("id") or item.get("file_key") or item.get("image_key") or item.get("media_id") or "")
.strip()
)
if not attachment_id:
attachment_id = f"attachment-{index + 1}"
file_name = (
str(item.get("file_name") or item.get("name") or item.get("title") or "").strip()
or f"{attachment_type}-{attachment_id}"
)
file_size = _safe_int(item.get("file_size") or item.get("size"))
mime_type = str(item.get("mime_type") or item.get("mime") or "").strip() or None
occurred_at = str(item.get("occurred_at") or "").strip() or None
download_url = str(item.get("download_url") or item.get("url") or "").strip() or None
file_key = str(item.get("file_key") or "").strip() or None
image_key = str(item.get("image_key") or "").strip() or None
media_id = str(item.get("media_id") or "").strip() or None
source = str(item.get("source") or "").strip() or None
if not any([file_key, image_key, media_id, download_url]):
return None
return {
"id": attachment_id,
"attachment_id": attachment_id,
"attachment_type": attachment_type,
"file_name": file_name,
"mime_type": mime_type,
"file_size": file_size,
"file_key": file_key,
"image_key": image_key,
"media_id": media_id,
"source": source,
"occurred_at": occurred_at,
"download_url": download_url,
}
def _extract_source_attachments(source_context: dict[str, Any]) -> list[dict[str, Any]]:
raw_items = source_context.get("attachments")
if not isinstance(raw_items, list):
return []
items: list[dict[str, Any]] = []
seen_ids: set[str] = set()
for idx, raw in enumerate(raw_items):
normalized = _normalize_signal_attachment(raw, index=idx)
if normalized is None:
continue
aid = str(normalized.get("attachment_id") or "").strip()
if not aid or aid in seen_ids:
continue
seen_ids.add(aid)
items.append(normalized)
return items
def _extract_signal_attachments(signal: Signal) -> list[dict[str, Any]]:
source_context = _signal_source_context(signal)
return _extract_source_attachments(source_context)
def _normalize_signal_status_code(
*,
linked_state: Optional[str],
signal_status: Optional[str],
processed: bool,
) -> str:
if linked_state in {_SIGNAL_STATUS_CODE_LINKED, _SIGNAL_STATUS_CODE_CLOSED}:
return linked_state
raw = str(signal_status or "").strip().lower()
if "关联" in raw or raw == _SIGNAL_STATUS_CODE_LINKED:
return _SIGNAL_STATUS_CODE_LINKED
if "关闭" in raw or "归档" in raw or raw in {"closed", "archived"}:
return _SIGNAL_STATUS_CODE_CLOSED
if processed:
return _SIGNAL_STATUS_CODE_CLOSED
return _SIGNAL_STATUS_CODE_PENDING
def _signal_status_label_from_code(status_code: str, fallback_status: Optional[str] = None) -> str:
fallback = str(fallback_status or "").strip()
normalized_code = str(status_code or "").strip().lower()
if (
normalized_code == _SIGNAL_STATUS_CODE_PENDING
and fallback
and all(token not in fallback for token in ["关联", "关闭", "归档"])
):
return fallback
label = _SIGNAL_STATUS_LABEL_BY_CODE.get(normalized_code)
if label:
return label
return fallback or _SIGNAL_STATUS_LABEL_BY_CODE[_SIGNAL_STATUS_CODE_PENDING]
def _extract_charter_related_signal_ids(charter: TruthNode) -> list[int]:
payload = _as_dict(charter.content)
signal_ids: list[int] = []
raw_ids = payload.get("signal_ids")
if isinstance(raw_ids, list):
for raw_id in raw_ids:
sid = _safe_int(raw_id)
if sid is not None:
signal_ids.append(sid)
charter_signal_id = _safe_int(charter.signal_id)
if charter_signal_id is not None:
signal_ids.append(charter_signal_id)
return sorted(set(signal_ids))
def _collect_locked_signal_conflicts(
*,
charters: list[TruthNode],
candidate_signal_ids: list[int],
) -> tuple[list[int], dict[int, list[dict[str, Any]]]]:
candidate_set = set(int(sid) for sid in candidate_signal_ids)
conflicts: dict[int, list[dict[str, Any]]] = {}
for charter in charters:
charter_signal_ids = _extract_charter_related_signal_ids(charter)
if not charter_signal_ids:
continue
for sid in charter_signal_ids:
if sid not in candidate_set:
continue
conflicts.setdefault(sid, []).append(
{
"charter_id": int(charter.id),
"charter_title": str(charter.title or "").strip() or f"宪章#{int(charter.id)}",
}
)
conflict_ids = sorted(conflicts.keys())
return conflict_ids, conflicts
def _parse_beijing_day_start(raw: str, *, label: str) -> datetime:
text = str(raw or "").strip()
if not text:
raise HTTPException(status_code=400, detail=f"{label} 不能为空")
if "T" in text:
text = text.split("T")[0]
try:
local_day = datetime.strptime(text, "%Y-%m-%d")
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Invalid {label}") from exc
return local_day - _BEIJING_UTC_OFFSET
def _build_signal_created_at_range(
*,
created_from: Optional[str],
created_to: Optional[str],
) -> tuple[Optional[datetime], Optional[datetime]]:
start_utc = _parse_beijing_day_start(created_from, label="created_from") if created_from else None
end_utc = None
if created_to:
end_day_start_utc = _parse_beijing_day_start(created_to, label="created_to")
end_utc = end_day_start_utc + timedelta(days=1)
if start_utc and end_utc and start_utc >= end_utc:
raise HTTPException(status_code=400, detail="created_from 不能晚于 created_to")
return start_utc, end_utc
def _normalize_signal_status_filter(value: Optional[str]) -> Optional[str]:
raw = str(value or "").strip()
if not raw:
return None
return _SIGNAL_STATUS_FILTER_ALIAS.get(raw.lower(), _SIGNAL_STATUS_FILTER_ALIAS.get(raw, raw.lower()))
def _match_signal_filters(
*,
row: dict[str, Any],
status_filter: Optional[str],
keyword: Optional[str],
source: Optional[str],
priority: Optional[str],
created_from_utc: Optional[datetime],
created_to_utc: Optional[datetime],
created_at_dt: Optional[datetime],
) -> bool:
if status_filter:
if str(row.get("status_code") or "").strip().lower() != str(status_filter).strip().lower():
return False
keyword_text = str(keyword or "").strip().lower()
if keyword_text:
haystack = " ".join(
[
str(row.get("id") or ""),
str(row.get("title") or ""),
str(row.get("content") or ""),
str(row.get("proposer_name") or ""),
str(row.get("charter_name") or ""),
]
).lower()
if keyword_text not in haystack:
return False
source_text = str(source or "").strip().lower()
if source_text and source_text != "all":
row_source = str(row.get("source") or "").strip().lower()
if row_source != source_text:
return False
priority_text = str(priority or "").strip().lower()
if priority_text and priority_text != "all":
row_priority = str(row.get("priority") or "").strip().lower()
if row_priority != priority_text:
return False
if created_from_utc or created_to_utc:
if created_at_dt is None:
return False
if created_from_utc and created_at_dt < created_from_utc:
return False
if created_to_utc and created_at_dt >= created_to_utc:
return False
return True
def _strip_signal_sender_prefix(content: str) -> str:
text = str(content or "")
return _SIGNAL_PREFIX_RE.sub("", text, count=1).strip() or text
def _extract_sender_from_content(content: str) -> Optional[str]:
text = str(content or "")
matched = _SIGNAL_PREFIX_RE.match(text)
if not matched:
return None
sender = (matched.group(1) or "").strip()
return sender or None
def _signal_title_text(signal: Signal) -> str:
return _strip_signal_sender_prefix(str(signal.content or ""))
def _signal_proposer_name(signal: Signal) -> str:
source_context = _signal_source_context(signal)
sender = str(source_context.get("sender") or "").strip()
if sender:
return sender
sender_from_content = _extract_sender_from_content(str(signal.content or ""))
return sender_from_content or "未知用户"
def _replace_signal_sender_in_content(content: str, sender_name: str) -> str:
text = str(content or "")
if not text:
return text
if _SIGNAL_PREFIX_RE.match(text):
return _SIGNAL_PREFIX_RE.sub(f"[{sender_name}]: ", text, count=1)
return f"[{sender_name}]: {text}"
def _safe_threshold(value: Any, default: float) -> float:
try:
number = float(value)
except (TypeError, ValueError):
number = float(default)
return min(1.0, max(0.0, number))
def _parse_json_object_text(raw: str) -> dict[str, Any]:
text = str(raw or "").strip()
if not text:
return {}
if text.startswith("```"):
text = text.strip("`")
if text.lower().startswith("json"):
text = text[4:].strip()
try:
parsed = json.loads(text)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
def _seed_text_snapshot(seed: dict[str, Any]) -> str:
pieces: list[str] = []
for value in seed.values():
if isinstance(value, str):
pieces.append(value)
elif isinstance(value, (list, tuple)):
pieces.extend([str(item) for item in value if item is not None])
return "\n".join([part for part in pieces if str(part).strip()]).strip()
def _needs_seed_localize_zh(seed: dict[str, Any]) -> bool:
text = _seed_text_snapshot(seed)
if not text:
return False
zh_chars = len(re.findall(r"[\u4e00-\u9fff]", text))
latin_chars = len(re.findall(r"[A-Za-z]", text))
return zh_chars < 20 and latin_chars > 40
async def _localize_seed_to_zh(
*,
db: AsyncSession,
tenant_id: int,
project_id: int,
seed: dict[str, Any],
) -> dict[str, Any]:
if not seed:
return seed
prompt = (
"将下列 JSON 草稿的自然语言内容翻译为简体中文。"
"要求:仅返回 JSON;键名与结构保持不变;模型名/专有技术名词可保留英文。\n\n"
f"{json.dumps(seed, ensure_ascii=False)}"
)
try:
raw = await llm_service.generate(
prompt,
function_name="sstc_charter_localize_zh",
db=db,
tenant_id=tenant_id,
project_id=project_id,
)
localized = _parse_json_object_text(raw)
return localized if localized else seed
except Exception:
return seed
def _to_string_list(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, str):
lines = [line.strip(" -\t") for line in value.replace("\r", "").split("\n")]
return [line for line in lines if line]
if isinstance(value, (list, tuple)):
output: list[str] = []
for item in value:
text = str(item or "").strip(" -\t")
if text:
output.append(text)
return output
text = str(value).strip()
return [text] if text else []
def _to_bullet_text(items: list[str]) -> str:
cleaned = [str(item or "").strip() for item in items if str(item or "").strip()]
if not cleaned:
return ""
return "\n".join([f"- {item}" for item in cleaned])
def _extract_charter_signal_ids(content: dict[str, Any], charter_signal_id: Optional[int] = None) -> list[int]:
signal_ids: list[int] = []
raw_ids = content.get("signal_ids")
if isinstance(raw_ids, list):
for sid in raw_ids:
try:
signal_ids.append(int(sid))
except Exception:
continue
if charter_signal_id:
try:
cid = int(charter_signal_id)
if cid not in signal_ids:
signal_ids.append(cid)
except Exception:
pass
return sorted(set(signal_ids))
def _build_signal_context_text(
signal_rows: list[Signal],
extra_text: str = "",
raw_text: str = "",
) -> str:
parts: list[str] = []
for idx, signal in enumerate(signal_rows, start=1):
title = _signal_title_text(signal) or f"信号{idx}"
parts.append(
"\n".join(
[
f"[信号{idx}] 标题:{title}",
f"来源:{signal.source or '-'}",
f"优先级:{signal.priority or '-'}",
f"内容:{str(signal.content or '').strip()}",
]
).strip()
)
extra_blocks: list[str] = []
for block in (extra_text, raw_text):
text = str(block or "").strip()
if text and text not in extra_blocks:
extra_blocks.append(text)
for text in extra_blocks:
parts.append(f"[补充上下文]\n{text}")
return "\n\n".join([part for part in parts if part]).strip()
def _has_non_empty_module_data(content: dict[str, Any]) -> bool:
modules = content.get("modules")
if not isinstance(modules, dict):
return False
for module in modules.values():
if not isinstance(module, dict):
continue
for value in module.values():
if str(value or "").strip():
return True
return False
def _has_complete_module_data(content: dict[str, Any]) -> bool:
modules = content.get("modules")
if not isinstance(modules, dict):
return False
for module_key, required_fields in _CHARTER_REQUIRED_FIELDS.items():
module_payload = modules.get(module_key)
if not isinstance(module_payload, dict):
return False
for field_key in required_fields:
if not str(module_payload.get(field_key) or "").strip():
return False
return True
def _merge_prefill_content(
existing: dict[str, Any], incoming: dict[str, Any], *, overwrite_existing: bool
) -> dict[str, Any]:
merged: dict[str, Any] = dict(existing)
for key, value in incoming.items():
if overwrite_existing or key not in merged:
merged[key] = value
continue
current = merged.get(key)
if isinstance(current, dict) and isinstance(value, dict):
merged[key] = _merge_prefill_content(
current, value, overwrite_existing=overwrite_existing
)
continue
if isinstance(current, list):
if not current and value:
merged[key] = value
continue
if current is None or not str(current).strip():
merged[key] = value
return merged
def _default_ai_validations(module_name: str) -> str:
return _to_bullet_text(
[
f"校验「{module_name}」与信号源证据链一致",
"校验字段可验证、可执行、可追踪",
"发现冲突时自动标注并等待人工否决/确认",
]
)
def _default_escalation_trigger() -> str:
return _to_bullet_text(
[
"置信度低于 0.75",
"关键字段缺失或来源证据冲突",
"超过模型能力边界或预算/时限约束",
]
)
async def _generate_charter_seed(
*,
db: AsyncSession,
tenant_id: int,
project_id: int,
signal_context: str,
) -> dict[str, Any]:
if not signal_context.strip():
return {}
prompt = (
"你是 AI 项目经理,请基于输入信号生成可落地的宪章草稿。"
"所有自然语言字段必须使用简体中文,禁止英文句子;模型名称或技术名词可保留英文。"
"只输出 JSON,不要 markdown。\n"
"字段:\n"
"{\n"
' "summary": "一句话问题摘要",\n'
' "background": "背景与动机",\n'
' "objectives": ["目标1","目标2"],\n'
' "scope": "范围说明",\n'
' "in_scope": ["范围内事项1","范围内事项2"],\n'
' "out_of_scope": ["范围外事项1","范围外事项2"],\n'
' "success_metrics": ["指标1","指标2"],\n'
' "risks": ["风险1","风险2"],\n'
' "dependencies": ["依赖1","依赖2"],\n'
' "primary_model": "模型名",\n'
' "fallback_model": "备用模型名"\n'
"}\n\n"
f"信号输入:\n{signal_context[:6000]}"
)
try:
raw = await llm_service.generate(
prompt,
function_name="sstc_charter_prefill",
db=db,
tenant_id=tenant_id,
project_id=project_id,
)
parsed = _parse_json_object_text(raw)
if parsed:
if _needs_seed_localize_zh(parsed):
return await _localize_seed_to_zh(
db=db, tenant_id=tenant_id, project_id=project_id, seed=parsed
)
return parsed
except Exception:
pass
try:
ai_seed = await AIService().generate_charter_from_signal(
signal_context,
tenant_id=tenant_id,
db=db,
)
if isinstance(ai_seed, dict):
if _needs_seed_localize_zh(ai_seed):
return await _localize_seed_to_zh(
db=db, tenant_id=tenant_id, project_id=project_id, seed=ai_seed
)
return ai_seed
return {}
except Exception:
return {}
async def _build_ai_first_charter_content(
*,
db: AsyncSession,
tenant_id: int,
project_id: int,
signal_rows: list[Signal],
signal_ids: list[int],
raw_text: str = "",
) -> dict[str, Any]:
signal_context = _build_signal_context_text(signal_rows, raw_text=raw_text)
seed = await _generate_charter_seed(
db=db,
tenant_id=tenant_id,
project_id=project_id,
signal_context=signal_context,
)
summary = str(seed.get("summary") or "").strip()
background = str(seed.get("background") or "").strip()
objectives = _to_string_list(seed.get("objectives"))
scope = str(seed.get("scope") or "").strip()
in_scope_items = _to_string_list(seed.get("in_scope"))
out_of_scope_items = _to_string_list(seed.get("out_of_scope"))
success_metrics = _to_string_list(seed.get("success_metrics"))
risks = _to_string_list(seed.get("risks"))
dependencies = _to_string_list(seed.get("dependencies"))
primary_model = str(seed.get("primary_model") or "").strip()
fallback_model = str(seed.get("fallback_model") or "").strip()
if not summary:
summary = "目标用户在当前流程中存在效率损耗,需要通过 AI 驱动的流程优化提升交付质量与速度。"
if not background:
background = signal_context[:500] or "基于已绑定信号源自动生成。"
if not objectives:
objectives = ["缩短端到端交付周期", "提升关键路径质量稳定性", "降低跨角色协同成本"]
if not success_metrics:
success_metrics = ["需求到上线周期缩短 >= 30%", "关键任务按期交付率 >= 90%", "线上回滚率 <= 3%"]
if not in_scope_items:
in_scope_items = ["需求澄清与宪章生成自动化", "任务拆解与负责人推荐", "对齐状态自动追踪与留痕"]
if not out_of_scope_items:
out_of_scope_items = ["替代团队组织架构决策", "绕过人工否决机制自动上线", "非项目相关流程改造"]
if not risks:
risks = ["需求波动导致范围蔓延", "模型能力边界与输入复杂度不匹配", "关键成员负载过高导致延期"]
if not dependencies:
dependencies = ["FastAPI", "React", "LLM 推理服务", "项目知识库/日志系统"]
if not primary_model:
primary_model = "待 AI 评估后绑定"
if not fallback_model:
fallback_model = "同供应商低成本模型"
source_refs = [f"signal:{sid}" for sid in signal_ids]
now_iso = datetime.now(timezone.utc).isoformat()
validations_m1 = _default_ai_validations("模块1:触发与目标")
validations_m2 = _default_ai_validations("模块2:范围与对齐")
validations_m3 = _default_ai_validations("模块3:模型与依赖")
validations_m4 = _default_ai_validations("模块4:计划与资源")
validations_m5 = _default_ai_validations("模块5:风险与兜底")
validations_m6 = _default_ai_validations("模块6:附件与治理")
escalation = _default_escalation_trigger()
alignment_qa = _to_bullet_text(
[
f"核心用户与痛点:{summary}",
"上线时间与硬约束:由里程碑计划自动回填并持续校验",
f"明确不做项:{'; '.join(out_of_scope_items[:3])}",
f"关键依赖:{'; '.join(dependencies[:4])}",
f"核心风险与缓解:{'; '.join(risks[:3])}",
]
)
modules = {
"module_1": {
"problem_statement": summary,
"goal_statement": _to_bullet_text(objectives),
"kpis": _to_bullet_text(success_metrics),
"ai_validations": validations_m1,
"escalation_trigger": escalation,
},
"module_2": {
"in_scope": _to_bullet_text(in_scope_items),
"out_of_scope": _to_bullet_text(out_of_scope_items),
"alignment_qa": alignment_qa,
"ai_validations": validations_m2,
"escalation_trigger": escalation,
},
"module_3": {
"primary_model": primary_model,
"fallback_model": fallback_model,
"capability_table": _to_bullet_text(
[
"上下文窗口/并发/延迟由模型能力中心实时读取",
"按 P50/P90 工作负载自动估算 token 与成本",
"超边界输入自动触发替代模型建议",
]
),
"dependency_map": _to_bullet_text(dependencies),
"business_view": "以业务术语展示能力上限、成本区间与上线风险。",
"engineering_view": "以工程参数展示 context window、QPS、成本与错误预算。",
"ai_validations": validations_m3,
"escalation_trigger": escalation,
},
"module_4": {
"milestones": _to_bullet_text(
[
"需求冻结 -> 技术评审 -> 开发完成 -> QA 验收 -> 发布上线",
"关键依赖阻塞超过阈值自动重排计划",
]
),
"role_effort_estimates": _to_bullet_text(
[
"产品/PM:需求澄清与验收口径",
"前后端工程:核心功能开发与联调",
"AI 工程:模型评估、提示词与推理链路优化",
]
),
"capability_owner_assignment": "负责人由能力图谱自动推荐并可人工否决。",
"buffer_policy": "默认 15% 进度缓冲,关键路径超阈值触发自动预警。",
"ai_validations": validations_m4,
"escalation_trigger": escalation,
},
"module_5": {
"risk_register": _to_bullet_text(risks),
"trigger_rules": _to_bullet_text(
[
"进度偏差 > 15%",
"关键接口错误率连续超过阈值",
"模型调用成本超预算预警线",
]
),
"execution_rule": _to_bullet_text(
[
"触发后优先执行预设 fallback",
"自动通知责任人并写入审计日志",
"连续失败时升级到人工决策",
]
),
"ai_validations": validations_m5,
"escalation_trigger": escalation,
},
"module_6": {
"attachment_metadata": _to_bullet_text(
[
"原始信号记录",
"需求讨论记录",
"设计与评审文档",
"发布与复盘材料",
]
),
"attachment_summary": "AI 自动提炼附件决策结论并关联到对应模块字段。",
"search_tags": _to_bullet_text(["需求宪章", "AI First", "SSTC", "对齐记录"]),
"ai_validations": validations_m6,
"governance_rule": "附件仅作证据引用,核心事实字段以模块1-5为准。",
},
}
return {
"schema_version": "charter_ai_first_v1",
"auto_generated_by_ai": True,
"metadata": {
"status": "draft",
"confidence_score": 0.82 if seed else 0.72,
"last_validated_at": now_iso,
"evidence_refs": source_refs,
"escalation_reason": None,
},
"modules": modules,
"signal_ids": signal_ids,
"summary": summary,
"background": background,
"objectives": objectives,
"scope": scope,
"success_metrics": success_metrics,
"core_metrics": success_metrics,
"rationale": summary,
"decision": _to_bullet_text(objectives),
"in_scope": in_scope_items,
"out_of_scope": out_of_scope_items,
"risks": risks,
"raw_source_text": raw_text.strip() or None,
}
def _extract_feishu_routing_config(integration: Optional[Integration]) -> dict[str, Any]:
config: dict[str, Any] = {}
if integration is not None:
if isinstance(integration.config, dict):
config = integration.config
elif isinstance(integration.config, str):
try:
parsed = json.loads(integration.config)
if isinstance(parsed, dict):
config = parsed
except (TypeError, ValueError):
config = {}
cold_start_enabled = bool(config.get("cold_start_enabled"))
signal_threshold = _safe_threshold(
config.get("signal_auto_enter_threshold"),
DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD,
)
project_threshold = _safe_threshold(
config.get("project_auto_create_threshold"),
DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD,
)
uncertain_threshold = min(
project_threshold,
_safe_threshold(
config.get("project_uncertain_threshold"),
DEFAULT_PROJECT_UNCERTAIN_THRESHOLD,
),
)
return {
"raw": config,
"cold_start_enabled": cold_start_enabled,
"signal_auto_enter_threshold": signal_threshold,
"project_auto_create_threshold": project_threshold,
"project_uncertain_threshold": uncertain_threshold,
}
async def _backfill_feishu_sender_names(
db: AsyncSession,
*,
tenant_id: int,
signals: list[Signal],
) -> None:
pending: dict[int, dict[str, str]] = {}
for signal in signals:
if str(signal.source or "").lower() != "feishu":
continue
source_context = _signal_source_context(signal)
open_id = str(source_context.get("sender_open_id") or "").strip()
sender = _signal_proposer_name(signal).strip()
if not open_id or not sender:
continue
if _FEISHU_PLACEHOLDER_RE.match(sender):
pending[int(signal.id)] = {
"open_id": open_id,
"current_sender": sender,
}
if not pending:
return
service = get_feishu_service(db)
await service.load_config(tenant_id=tenant_id)
if not service.app_id or not service.app_secret:
return
resolved_name_by_open_id: dict[str, str] = {}
for open_id in sorted({meta["open_id"] for meta in pending.values()})[:20]:
try:
remote_name = await service._fetch_user_name_by_open_id(open_id) # noqa: SLF001
except Exception:
remote_name = None
normalized = (remote_name or "").strip()
if not normalized or _FEISHU_PLACEHOLDER_RE.match(normalized):
continue
resolved_name_by_open_id[open_id] = normalized
if not resolved_name_by_open_id:
return
changed = False
for signal in signals:
meta = pending.get(int(signal.id))
if not meta:
continue
sender_name = resolved_name_by_open_id.get(meta["open_id"])
if not sender_name:
continue
payload = _as_dict(signal.strategic_analysis)
source_context = payload.get("source_context")
if not isinstance(source_context, dict):
source_context = {}
source_context["sender"] = sender_name
source_context["sender_open_id"] = meta["open_id"]
payload["source_context"] = source_context
signal.strategic_analysis = json.dumps(payload, ensure_ascii=False)
signal.content = _replace_signal_sender_in_content(str(signal.content or ""), sender_name)
changed = True
if changed:
await db.commit()
def _parse_due_date(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid due_date") from exc
async def _ensure_project(
svc: SSTCService,
project_id: int,
):
project = await svc.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
async def _ensure_charter(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
charter_id: int,
) -> TruthNode:
result = await db.execute(
select(TruthNode).where(
and_(
TruthNode.id == charter_id,
TruthNode.project_id == project_id,
TruthNode.tenant_id == tenant_id,
TruthNode.node_type == "charter",
)
)
)
charter = result.scalar_one_or_none()
if not charter:
raise HTTPException(status_code=404, detail="Charter not found")
return charter
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)))
async def _ensure_task(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
task_id: int,
) -> Task:
result = await db.execute(
select(Task).where(
and_(
Task.id == task_id,
Task.project_id == project_id,
Task.tenant_id == tenant_id,
)
)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
async def _ensure_release(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
release_node_id: int,
) -> TruthNode:
result = await db.execute(
select(TruthNode).where(
and_(
TruthNode.id == release_node_id,
TruthNode.project_id == project_id,
TruthNode.tenant_id == tenant_id,
TruthNode.node_type == "release",
)
)
)
release_node = result.scalar_one_or_none()
if not release_node:
raise HTTPException(status_code=404, detail="Release plan not found")
return release_node
async def _ensure_review(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
review_node_id: int,
) -> TruthNode:
result = await db.execute(
select(TruthNode).where(
and_(
TruthNode.id == review_node_id,
TruthNode.project_id == project_id,
TruthNode.tenant_id == tenant_id,
TruthNode.node_type == "review",
)
)
)
review_node = result.scalar_one_or_none()
if not review_node:
raise HTTPException(status_code=404, detail="Review not found")
return review_node
def _normalize_review_content(raw: dict[str, Any] | None) -> dict[str, Any]:
payload = dict(raw or {})
summary = str(payload.get("summary") or "").strip()
goals = payload.get("goals")
if not isinstance(goals, list):
goals = []
what_went_well = payload.get("what_went_well")
if not isinstance(what_went_well, list):
what_went_well = []
root_causes = payload.get("root_causes")
if not isinstance(root_causes, list):
root_causes = []
improvements = payload.get("improvements")
if not isinstance(improvements, list):
improvements = []
normalized_improvements: list[dict[str, Any]] = []
def _optional_int(value: Any) -> int | None:
if value in (None, ""):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
for idx, item in enumerate(improvements):
if isinstance(item, str):
title = item.strip()
if not title:
continue
normalized_improvements.append(
{
"id": f"imp-{idx+1}",
"title": title,
"status": "pending",
"owner_id": None,
"due_date": None,
"task_id": None,
}
)
continue
if not isinstance(item, dict):
continue
title = str(item.get("title") or "").strip()
if not title:
continue
normalized_improvements.append(
{
"id": str(item.get("id") or f"imp-{idx+1}"),
"title": title,
"status": str(item.get("status") or "pending"),
"owner_id": _optional_int(item.get("owner_id")),
"due_date": item.get("due_date"),
"task_id": _optional_int(item.get("task_id")),
"description": str(item.get("description") or "").strip() or None,
}
)
return {
"summary": summary,
"goals": [str(x).strip() for x in goals if str(x).strip()],
"what_went_well": [str(x).strip() for x in what_went_well if str(x).strip()],
"root_causes": [str(x).strip() for x in root_causes if str(x).strip()],
"improvements": normalized_improvements,
"last_updated_at": datetime.now(timezone.utc).isoformat(),
}
async def _project_task_metrics(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
) -> dict[str, int]:
result = await db.execute(
select(Task.status).where(
and_(
Task.project_id == project_id,
Task.tenant_id == tenant_id,
)
)
)
statuses = [str(row[0] or "").lower() for row in result.all()]
total = len(statuses)
done = len([s for s in statuses if s in DONE_STATUSES])
return {"total": total, "done": done}
async def _build_governance_snapshot(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
charter_id: int | None = None,
) -> dict[str, Any]:
charter: TruthNode | None = None
if charter_id:
charter = (
await db.execute(
select(TruthNode).where(
and_(
TruthNode.id == charter_id,
TruthNode.project_id == project_id,
TruthNode.tenant_id == tenant_id,
TruthNode.node_type == "charter",
)
)
)
).scalar_one_or_none()
if charter is None:
charter = (
await db.execute(
select(TruthNode)
.where(
and_(
TruthNode.project_id == project_id,
TruthNode.tenant_id == tenant_id,
TruthNode.node_type == "charter",
)
)
.order_by(TruthNode.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
confidence_score = None
confidence_risk = False
charter_id_value = int(charter.id) if charter else None
if charter:
charter_content = _as_dict(charter.content)
metadata = charter_content.get("metadata")
if isinstance(metadata, dict):
raw_score = metadata.get("confidence_score")
try:
confidence_score = float(raw_score)
except (TypeError, ValueError):
confidence_score = None
confidence_risk = confidence_score is not None and confidence_score < 0.8
binding = None
if charter_id_value:
binding = (
await db.execute(
select(ModelRequirementBinding, ModelVersion)
.join(
ModelVersion,
ModelVersion.id == ModelRequirementBinding.model_version_id,
isouter=True,
)
.where(
and_(
ModelRequirementBinding.tenant_id == tenant_id,
ModelRequirementBinding.truth_node_id == charter_id_value,
)
)
.order_by(ModelRequirementBinding.created_at.desc())
.limit(1)
)
).first()
model_block = False
model_info: dict[str, Any] = {
"bound": False,
"compatibility_score": None,
"model_version_id": None,
"model_name": None,
"provider": None,
}
est_cost = 0.01
if binding:
binding_row, model_row = binding
score = int(binding_row.compatibility_score or 0)
model_block = score < 70
model_info = {
"bound": True,
"compatibility_score": score,
"model_version_id": int(binding_row.model_version_id),
"model_name": getattr(model_row, "name", None),
"provider": getattr(model_row, "provider", None),
}
try:
est_cost = max(float(binding_row.estimated_cost or 0.01), 0.01)
except (TypeError, ValueError):
est_cost = 0.01
budget_service = BudgetEnforcementService(db)
budget_check = await budget_service.check_budget_before_ai_call(
tenant_id=tenant_id,
estimated_cost_usd=est_cost,
project_id=project_id,
)
budget_block = not bool(budget_check.get("allowed", True))
intervention_required = confidence_risk or model_block or budget_block
blockers: list[str] = []
warnings: list[str] = []
if confidence_risk:
warnings.append("宪章 AI 置信度低于 0.80,建议补充关键事实后再推进。")
if model_block:
blockers.append("模型匹配分低于 70,禁止自动推进发布验证。")
elif not model_info["bound"]:
warnings.append("当前宪章未绑定模型版本,建议先完成模型能力矩阵校验。")
if budget_block:
blockers.append(f"预算管控阻断:{budget_check.get('reason') or 'budget_blocked'}")
elif str(budget_check.get("reason") or "") == "within_budget":
usage = budget_check.get("usage_percent")
if isinstance(usage, (int, float)) and float(usage) >= 80:
warnings.append("预算已接近阈值,建议执行成本优化方案。")
return {
"project_id": project_id,
"charter_id": charter_id_value,
"intervention_required": intervention_required,
"blockers": blockers,
"warnings": warnings,
"confidence": {
"score": confidence_score,
"risk": confidence_risk,
},
"model_binding": model_info,
"budget": budget_check,
}
async def _assert_release_transition_allowed(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
release_node: TruthNode,
target_status: str,
) -> None:
current = str(release_node.status or "pending").lower()
target = str(target_status or "").lower()
if target == "verified":
raise HTTPException(status_code=409, detail="请通过验证完成接口标记为已验证")
if target not in RELEASE_STATUS_TRANSITIONS.get(current, set()):
raise HTTPException(
status_code=409,
detail=f"非法状态流转:{current} -> {target}",
)
content = _as_dict(release_node.content)
if target in {"releasing", "online"}:
task_ids = [int(tid) for tid in (content.get("task_ids") or []) if str(tid).isdigit()]
if not task_ids:
raise HTTPException(status_code=409, detail="发布计划未绑定任务")
task_result = await db.execute(
select(Task.id, Task.status).where(
and_(
Task.tenant_id == tenant_id,
Task.project_id == project_id,
Task.id.in_(task_ids),
)
)
)
task_rows = list(task_result.all())
if len(task_rows) != len(task_ids):
raise HTTPException(status_code=409, detail="发布计划存在无效任务绑定")
not_done = [row for row in task_rows if str(row[1] 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=project_id,
charter_id=int(content.get("charter_id") or 0) or None,
)
if governance.get("blockers"):
raise HTTPException(
status_code=409,
detail=";".join([str(item) for item in governance.get("blockers", [])]),
)
verification_items = content.get("verification_items")
if not isinstance(verification_items, list) or len(verification_items) == 0:
raise HTTPException(status_code=409, detail="请先生成发布验证清单")
def _assert_charter_aligned(charter: TruthNode) -> None:
if (charter.status or "").lower() not in ALIGNED_STATUSES:
raise HTTPException(
status_code=409,
detail="宪章未对齐,任务环节未解锁",
)
def _is_manager(user: User) -> bool:
return has_any_role(str(user.role or ""), ["BOSS", "ADMIN"])
def _require_manager(user: User) -> None:
if not _is_manager(user):
raise HTTPException(status_code=403, detail="仅项目负责人或管理员可执行该操作")
@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))
@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:
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),
}
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(timezone.utc).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,
}
)
@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": datetime.now(timezone.utc).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}"'},
)
@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": "已关闭"})
class CharterCreateRequest(BaseModel):
title: str
content: dict[str, Any] | None = None
signal_ids: list[int]
skip_ai_prefill: bool = False
class CharterAIPrefillRequest(BaseModel):
force: bool = False
raw_text: Optional[str] = None
@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)
link_result = await db.execute(
select(ProjectSignalLink.signal_id).where(
and_(
ProjectSignalLink.project_id == pid,
ProjectSignalLink.signal_id.in_(body.signal_ids),
ProjectSignalLink.tenant_id == tenant_id,
)
)
)
valid_signal_ids = {int(v) for v in link_result.scalars().all()}
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 db.execute(
select(Signal).where(
and_(
Signal.tenant_id == tenant_id,
Signal.id.in_(signal_ids),
)
)
)
).scalars().all()
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(
{
"id": str(charter.id),
"title": charter.title,
"content": _as_dict(charter.content),
"signal_ids": _as_dict(charter.content).get("signal_ids", []),
"status": charter.status,
"version": charter.version,
}
)
@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 = []
if signal_ids:
signal_result = await db.execute(
select(Signal).where(
and_(Signal.id.in_(signal_ids), Signal.tenant_id == tenant_id)
)
)
signal_rows = list(signal_result.scalars().all())
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 = None
if charter.signal_id:
for signal in signal_rows:
if int(signal.id) == int(charter.signal_id):
source_signal = signal
break
if source_signal is None and signal_rows:
source_signal = signal_rows[0]
return success_response(
{
"id": str(charter.id),
"title": charter.title,
"content": content,
"signal_ids": signal_ids,
"signals": [
{
"id": str(s.id),
"source": s.source,
"signal_type": _signal_display_type(s),
"project_scope": _signal_project_scope(s),
"proposer_name": _signal_proposer_name(s),
"title": _signal_title_text(s)[:80] or "未命名信号",
"content": s.content,
"priority": s.priority,
"impact_score": s.impact_score,
"attachments": _extract_signal_attachments(s),
}
for s in signal_rows
],
"source_signal": (
{
"id": str(source_signal.id),
"title": _signal_title_text(source_signal)[:80] or "未命名信号",
"signal_type": _signal_display_type(source_signal),
"priority": source_signal.priority,
"content": source_signal.content,
"attachments": _extract_signal_attachments(source_signal),
}
if source_signal
else None
),
"tasks": [
{
"id": str(t.id),
"title": t.title,
"description": t.description,
"status": t.status,
"priority": t.priority,
"estimated_hours": float(t.estimated_hours) if t.estimated_hours else None,
"assignee_id": str(t.assignee_id) if t.assignee_id else None,
"due_date": t.due_date.isoformat() if t.due_date else None,
}
for t in tasks
],
"alignment_records": [
{
"user_id": str(r.user_id),
"score": r.alignment_score,
"confirmed": bool(r.is_confirmed),
"comments": r.comments,
}
for r 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),
)
return success_response(
{
"id": str(new_node.id),
"version": new_node.version,
"status": new_node.status,
"title": new_node.title,
"content": _as_dict(new_node.content),
"new_version_created": True,
}
)
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),
)
return success_response(
{
"id": str(charter.id),
"title": charter.title,
"content": _as_dict(charter.content),
"status": charter.status,
"version": charter.version,
"new_version_created": False,
}
)
@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),
}
)
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
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.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),
}
)
@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
],
}
)
@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 < datetime.now(timezone.utc).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 = datetime.now(timezone.utc).replace(tzinfo=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="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})
@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})
@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,
}
)
@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),
}
)
@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"] = datetime.now(timezone.utc).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})
@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"] = datetime.now(timezone.utc).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 = datetime.now(timezone.utc).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})
@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),
}
)
@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,
}
)
@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"] = datetime.now(timezone.utc).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})