3935 lines
134 KiB
Python
3935 lines
134 KiB
Python
"""
|
||
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.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)
|
||
_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)
|
||
_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})
|