PMFlow/backend/app/services/task_brief_service.py

429 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""增量任务包管理服务。"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from uuid import uuid4
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Integration
from ..utils.timezone import now_cn_naive
INTEGRATION_TYPE = "task_briefs"
DEFAULT_TEMPLATE_ID = "tpl_default_signal_planner"
DEFAULT_TEMPLATE_NAME = "Signal Planner 默认模板"
DEFAULT_TEMPLATE_DESCRIPTION = "系统初始化模板(可按需修改)"
DEFAULT_TEMPLATE_FALLBACK = """# Task Brief
## Meta
- Generated At: {{GENERATED_AT}}
- Base Ref: {{BASE_REF}}
- Head Ref: {{HEAD_REF}}
## Changed Files
{{CHANGED_FILES}}
## Scope
{{SCOPE_SUMMARY}}
## Risks
{{RISKS}}
"""
class TaskBriefService:
@staticmethod
def _safe_dict(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
try:
# 深拷贝,避免浅拷贝触发 JSON 嵌套对象原地修改导致的“提交不落库”。
return json.loads(json.dumps(value))
except Exception:
return dict(value)
if isinstance(value, str):
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
return {}
@staticmethod
def _project_root() -> Path:
return Path(__file__).resolve().parents[3]
@classmethod
def _load_default_template_content(cls) -> str:
template_path = cls._project_root() / "docs/architecture/TASK_BRIEF_TEMPLATE.md"
if template_path.exists():
try:
return template_path.read_text(encoding="utf-8").strip() or DEFAULT_TEMPLATE_FALLBACK
except Exception:
return DEFAULT_TEMPLATE_FALLBACK
return DEFAULT_TEMPLATE_FALLBACK
@classmethod
def _build_default_template(cls) -> dict[str, Any]:
now_text = now_cn_naive().isoformat()
return {
"id": DEFAULT_TEMPLATE_ID,
"name": DEFAULT_TEMPLATE_NAME,
"description": DEFAULT_TEMPLATE_DESCRIPTION,
"content": cls._load_default_template_content(),
"updated_at": now_text,
"updated_by": 0,
"created_at": now_text,
}
@staticmethod
def _extract_markdown_section_lines(content: str, *, heading: str) -> list[str]:
target = f"## {heading}".lower()
lines = content.splitlines()
in_section = False
collected: list[str] = []
for raw in lines:
line = raw.strip()
if line.lower().startswith("## "):
if line.lower() == target:
in_section = True
continue
if in_section:
break
if in_section and line:
collected.append(line)
return collected
@staticmethod
def _extract_markdown_meta_value(content: str, *, key: str) -> str | None:
prefix = f"- {key}:"
for raw in content.splitlines():
line = raw.strip()
if line.startswith(prefix):
return line.split(":", 1)[1].strip() or None
return None
@classmethod
def _normalize_generated_at(cls, raw_generated: str | None, fallback: datetime) -> str:
if not raw_generated:
return fallback.isoformat()
for fmt in ("%Y-%m-%d %H:%M:%S %z",):
try:
return datetime.strptime(raw_generated, fmt).isoformat()
except Exception:
pass
try:
return datetime.fromisoformat(raw_generated).isoformat()
except Exception:
return raw_generated
@classmethod
def _load_seed_history(cls) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
docs_dir = cls._project_root() / "docs/task_briefs"
if not docs_dir.exists():
return [], []
briefs: list[dict[str, Any]] = []
runs: list[dict[str, Any]] = []
for file_path in sorted(docs_dir.glob("task_brief_*.md")):
try:
content = file_path.read_text(encoding="utf-8")
except Exception:
continue
if not content.strip():
continue
stem = "".join(ch for ch in file_path.stem if ch.isalnum() or ch in {"_", "-"})
brief_id = f"brief_seed_{stem}"[:64]
run_id = f"run_seed_{stem}"[:64]
fallback_time = datetime.fromtimestamp(file_path.stat().st_mtime)
generated_at = cls._normalize_generated_at(
cls._extract_markdown_meta_value(content, key="Generated At"),
fallback=fallback_time,
)
title = "导入任务包"
for line in content.splitlines():
if line.strip().startswith("# "):
title = line.strip()[2:].strip() or title
break
changed_section = cls._extract_markdown_section_lines(content, heading="Changed Files")
changed_files = [line[2:].strip() for line in changed_section if line.startswith("- ") and line[2:].strip()]
scope_section = cls._extract_markdown_section_lines(content, heading="Scope")
scope_items = [line[2:].strip() if line.startswith("- ") else line for line in scope_section]
scope_summary = ";".join([item for item in scope_items if item][:2]).strip()
if not scope_summary:
scope_summary = "从 docs/task_briefs 自动导入"
run = {
"id": run_id,
"brief_id": brief_id,
"title": title,
"template_id": DEFAULT_TEMPLATE_ID,
"template_name": DEFAULT_TEMPLATE_NAME,
"generated_at": generated_at,
"generated_by": 0,
"status": "imported",
"changed_files": changed_files,
"scope_summary": scope_summary,
}
brief = {
"id": brief_id,
"title": title,
"status": "ready",
"created_at": generated_at,
"updated_at": generated_at,
"latest_run_id": run_id,
"template_id": DEFAULT_TEMPLATE_ID,
"template_name": DEFAULT_TEMPLATE_NAME,
"summary": scope_summary,
"content": content.strip(),
"changed_files": changed_files,
}
runs.append(run)
briefs.append(brief)
return briefs, runs
@classmethod
async def _get_or_init_bundle(cls, db: AsyncSession, *, tenant_id: int) -> tuple[Integration, dict[str, Any]]:
row = (
await db.execute(
select(Integration).where(
and_(
Integration.tenant_id == int(tenant_id),
Integration.type == INTEGRATION_TYPE,
)
)
)
).scalar_one_or_none()
if row is None:
row = Integration(
tenant_id=int(tenant_id),
type=INTEGRATION_TYPE,
is_active=True,
config={
"templates": [],
"runs": [],
"briefs": [],
},
)
db.add(row)
await db.flush()
bundle = cls._safe_dict(row.config)
changed = False
for key in ("templates", "runs", "briefs"):
if not isinstance(bundle.get(key), list):
bundle[key] = []
changed = True
existing_templates = [item for item in bundle.get("templates", []) if isinstance(item, dict)]
if not existing_templates:
bundle["templates"] = [cls._build_default_template()]
changed = True
existing_briefs = [item for item in bundle.get("briefs", []) if isinstance(item, dict)]
existing_runs = [item for item in bundle.get("runs", []) if isinstance(item, dict)]
if not existing_briefs and not existing_runs:
seed_briefs, seed_runs = cls._load_seed_history()
if seed_briefs and seed_runs:
bundle["briefs"] = seed_briefs
bundle["runs"] = seed_runs
changed = True
if changed:
row.config = bundle
await db.flush()
return row, bundle
@staticmethod
def _sort_by_time_desc(items: list[dict[str, Any]], key: str = "created_at") -> list[dict[str, Any]]:
return sorted(items, key=lambda x: str(x.get(key) or ""), reverse=True)
@classmethod
async def list_center(cls, db: AsyncSession, *, tenant_id: int) -> list[dict[str, Any]]:
_, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id)
briefs = [dict(item) for item in bundle.get("briefs", []) if isinstance(item, dict)]
return cls._sort_by_time_desc(briefs, key="created_at")
@classmethod
async def list_templates(cls, db: AsyncSession, *, tenant_id: int) -> list[dict[str, Any]]:
_, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id)
templates = [dict(item) for item in bundle.get("templates", []) if isinstance(item, dict)]
return cls._sort_by_time_desc(templates, key="updated_at")
@classmethod
async def create_template(
cls,
db: AsyncSession,
*,
tenant_id: int,
operator_user_id: int,
name: str,
content: str,
description: Optional[str] = None,
) -> dict[str, Any]:
row, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id)
now_text = now_cn_naive().isoformat()
item = {
"id": f"tpl_{uuid4().hex[:12]}",
"name": str(name or "").strip()[:120] or "默认模板",
"description": str(description or "").strip()[:300] or None,
"content": str(content or "").strip(),
"updated_at": now_text,
"updated_by": int(operator_user_id),
"created_at": now_text,
}
templates = bundle.get("templates") if isinstance(bundle.get("templates"), list) else []
templates.append(item)
bundle["templates"] = templates
row.config = bundle
await db.flush()
return item
@classmethod
async def update_template(
cls,
db: AsyncSession,
*,
tenant_id: int,
operator_user_id: int,
template_id: str,
name: Optional[str] = None,
content: Optional[str] = None,
description: Optional[str] = None,
) -> dict[str, Any] | None:
row, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id)
templates = bundle.get("templates") if isinstance(bundle.get("templates"), list) else []
target = None
for item in templates:
if not isinstance(item, dict):
continue
if str(item.get("id") or "") == str(template_id or ""):
target = item
break
if target is None:
return None
if name is not None:
target["name"] = str(name or "").strip()[:120] or target.get("name") or "默认模板"
if content is not None:
target["content"] = str(content or "").strip()
if description is not None:
target["description"] = str(description or "").strip()[:300] or None
target["updated_at"] = now_cn_naive().isoformat()
target["updated_by"] = int(operator_user_id)
bundle["templates"] = templates
row.config = bundle
await db.flush()
return dict(target)
@classmethod
async def list_runs(cls, db: AsyncSession, *, tenant_id: int) -> list[dict[str, Any]]:
_, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id)
runs = [dict(item) for item in bundle.get("runs", []) if isinstance(item, dict)]
return cls._sort_by_time_desc(runs, key="generated_at")
@classmethod
async def generate_brief(
cls,
db: AsyncSession,
*,
tenant_id: int,
operator_user_id: int,
title: str,
template_id: Optional[str],
changed_files: list[str],
scope_summary: Optional[str],
) -> dict[str, Any]:
row, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id)
now_text = now_cn_naive().isoformat()
templates = bundle.get("templates") if isinstance(bundle.get("templates"), list) else []
template_obj = None
if template_id:
for item in templates:
if isinstance(item, dict) and str(item.get("id") or "") == str(template_id):
template_obj = item
break
if template_obj is None:
template_obj = next((item for item in templates if isinstance(item, dict)), None)
brief_id = f"brief_{uuid4().hex[:12]}"
run_id = f"run_{uuid4().hex[:12]}"
content = str((template_obj or {}).get("content") or "").strip()
file_lines = [str(path).strip() for path in changed_files if str(path).strip()]
summary = str(scope_summary or "").strip() or f"共 {len(file_lines)} 个文件发生变更"
run = {
"id": run_id,
"brief_id": brief_id,
"title": str(title or "").strip()[:160] or "增量任务包",
"template_id": str((template_obj or {}).get("id") or "") or None,
"template_name": str((template_obj or {}).get("name") or "") or None,
"generated_at": now_text,
"generated_by": int(operator_user_id),
"status": "completed",
"changed_files": file_lines,
"scope_summary": summary,
}
brief = {
"id": brief_id,
"title": run["title"],
"status": "ready",
"created_at": now_text,
"updated_at": now_text,
"latest_run_id": run_id,
"template_id": run["template_id"],
"template_name": run["template_name"],
"summary": summary,
"content": content,
"changed_files": file_lines,
}
runs = bundle.get("runs") if isinstance(bundle.get("runs"), list) else []
briefs = bundle.get("briefs") if isinstance(bundle.get("briefs"), list) else []
runs.append(run)
briefs = [item for item in briefs if isinstance(item, dict) and str(item.get("id") or "") != brief_id]
briefs.append(brief)
bundle["runs"] = runs
bundle["briefs"] = briefs
row.config = bundle
await db.flush()
return {"run": run, "brief": brief}
@classmethod
async def get_contract_sync_status(cls, db: AsyncSession, *, tenant_id: int) -> dict[str, Any]:
_ = db
_ = tenant_id
root = Path(__file__).resolve().parents[3]
contract_files = [
root / "docs/architecture/MASTER_CONTRACT.md",
root / "docs/architecture/API_CONTRACTS.md",
root / "docs/architecture/DATAFLOW.md",
]
statuses = []
for file_path in contract_files:
exists = file_path.exists()
statuses.append(
{
"file": str(file_path.relative_to(root)),
"exists": exists,
"updated_at": (
datetime.fromtimestamp(file_path.stat().st_mtime).isoformat()
if exists
else None
),
}
)
all_synced = all(bool(item.get("exists")) for item in statuses)
return {
"all_synced": all_synced,
"files": statuses,
}