PMFlow/backend/app/services/task_brief_service.py

730 lines
26 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
import subprocess
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}}
"""
LOW_TEMPLATE_ID = "tpl_low_model_execution"
LOW_TEMPLATE_NAME = "Low Model 执行模板(收敛)"
LOW_TEMPLATE_DESCRIPTION = "面向 low 模型:聚焦确定性执行、最小改动、可回归。"
LOW_TEMPLATE_CONTENT = """# Task Brief(Low Model 执行版)
## Meta
- Generated At: {{GENERATED_AT}}
- Base Ref: {{BASE_REF}}
- Head Ref: {{HEAD_REF}}
## Goal
- 任务目标:{{SCOPE_SUMMARY}}
- 执行原则:只做最小必要改动,不扩散需求边界。
## Changed Files
{{CHANGED_FILES}}
## Atomic Actions
1. 明确本次唯一目标与目标验收标准(1-2 条)。
2. 逐文件执行最小改动,禁止顺手重构。
3. 写前后保持接口字段兼容,避免破坏已有调用。
4. 每次改动后进行快速验证并记录结果。
## Must-Run Verification
- Backend: `python3 -m py_compile backend/app`
- Frontend: `npm run build` + `npx tsc --noEmit`
- 必须给出失败项与修复动作,禁止只报“已测试”。
## Escalation Gate (Upgrade to High Model)
- 跨模块文件超过 5 个;
- 涉及数据库 schema / 事务边界调整;
- 涉及前后端契约新增或破坏性变更;
- 无法在当前信息下保证零回归。
## Risks
{{RISKS}}
"""
HIGH_TEMPLATE_ID = "tpl_high_model_architect"
HIGH_TEMPLATE_NAME = "High Model 架构模板(跨栈)"
HIGH_TEMPLATE_DESCRIPTION = "面向 high 模型:跨栈影响评估、契约矩阵、回滚策略。"
HIGH_TEMPLATE_CONTENT = """# Task Brief(High Model 架构版)
## Meta
- Generated At: {{GENERATED_AT}}
- Base Ref: {{BASE_REF}}
- Head Ref: {{HEAD_REF}}
## Strategic Goal
- 业务目标:{{SCOPE_SUMMARY}}
- 决策边界:优先保障可回滚、契约兼容、线上稳定性。
## Changed Files
{{CHANGED_FILES}}
## Cross-Stack Impact Matrix
| 领域 | 影响描述 | 契约同步动作 | 验证方式 |
| --- | --- | --- | --- |
| Backend API | | | |
| Frontend Type | | | |
| DB/Data | | | |
| Async/Job | | | |
## Controlled Write Plan
- 所有数据库写入必须经受控接口(HTTP 或 `internal://`);
- 不允许直连 SQL 路径作为最终执行方案;
- 需给出接口化落地路径与调用顺序。
## Rollback Plan
1. 回滚触发条件(明确监控阈值)。
2. 数据层回滚策略(兼容/补偿/回放)。
3. 应用层回滚策略(开关/降级/流量切换)。
## Verification Gate
- 编译、单测、关键链路回归全部通过;
- 前后端契约文档同步完成;
- 风险项有 owner 与截止时间。
## Risks
{{RISKS}}
"""
HIGH_CONTRACT_TEMPLATE_ID = "tpl_high_model_contract_guard"
HIGH_CONTRACT_TEMPLATE_NAME = "High Model 契约审计模板"
HIGH_CONTRACT_TEMPLATE_DESCRIPTION = "面向 high 模型:前后端契约覆盖率审计与缺口修复。"
HIGH_CONTRACT_TEMPLATE_CONTENT = """# Task Brief(High Model 契约审计版)
## Meta
- Generated At: {{GENERATED_AT}}
- Base Ref: {{BASE_REF}}
- Head Ref: {{HEAD_REF}}
## Audit Scope
- 目标范围:{{SCOPE_SUMMARY}}
## Changed Files
{{CHANGED_FILES}}
## Contract Coverage Checklist
- [ ] 后端新增/变更 API 已在契约文档登记;
- [ ] 前端调用与后端路由一一对应;
- [ ] 响应包络保持 `{code, message, data}`;
- [ ] 关键类型(TS/Pydantic)字段一致;
- [ ] 历史兼容策略(向后兼容或版本化)已说明。
## Gap Register
| Gap | Severity | Owner | Fix Plan | ETA |
| --- | --- | --- | --- | --- |
| | | | | |
## Delivery Artifacts
- 契约差异摘要(新增/删除/变更字段)
- 影响接口清单与调用方映射
- 回归测试结果与剩余风险
## Risks
{{RISKS}}
"""
class TaskBriefService:
@classmethod
def _project_root_candidates(cls) -> list[Path]:
service_file = Path(__file__).resolve()
cwd = Path.cwd().resolve()
raw_candidates = [
service_file.parents[3],
service_file.parents[2],
cwd,
cwd.parent,
]
deduped: list[Path] = []
seen: set[str] = set()
for path in raw_candidates:
key = str(path)
if key in seen:
continue
seen.add(key)
deduped.append(path)
return deduped
@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 {}
@classmethod
def _project_root(cls) -> Path:
for root in cls._project_root_candidates():
if (root / "docs" / "architecture").exists():
return root
return cls._project_root_candidates()[0]
@staticmethod
def _normalize_template_text(content: str) -> str:
return "\n".join(line.rstrip() for line in str(content or "").strip().splitlines()).strip()
@classmethod
def _is_default_template_fallback(cls, content: str) -> bool:
return cls._normalize_template_text(content) == cls._normalize_template_text(DEFAULT_TEMPLATE_FALLBACK)
@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 _resolve_git_ref(cls, ref: str) -> str:
try:
output = subprocess.check_output(
["git", "rev-parse", "--short", ref],
cwd=str(cls._project_root()),
stderr=subprocess.DEVNULL,
text=True,
timeout=1.5,
).strip()
return output or "-"
except Exception:
return "-"
@staticmethod
def _to_markdown_list(items: list[str], *, empty_text: str) -> str:
cleaned = [str(item).strip() for item in items if str(item).strip()]
if not cleaned:
return f"- {empty_text}"
return "\n".join(f"- {item}" for item in cleaned)
@classmethod
def _render_template_content(
cls,
*,
template_content: str,
generated_at: str,
base_ref: str,
head_ref: str,
changed_files: list[str],
scope_summary: str,
risks: list[str],
) -> str:
content = str(template_content or "").strip() or cls._load_default_template_content()
replacements = {
"{{GENERATED_AT}}": str(generated_at or "-"),
"{{BASE_REF}}": str(base_ref or "-"),
"{{HEAD_REF}}": str(head_ref or "-"),
"{{CHANGED_FILES}}": cls._to_markdown_list(changed_files, empty_text="(none)"),
"{{SCOPE_SUMMARY}}": str(scope_summary or "-"),
"{{RISKS}}": cls._to_markdown_list(risks, empty_text="待补充风险评估"),
}
rendered = content
for token, value in replacements.items():
rendered = rendered.replace(token, value)
return rendered.strip()
@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 _build_template_item(
*,
template_id: str,
name: str,
description: str,
content: str,
now_text: str,
) -> dict[str, Any]:
return {
"id": template_id,
"name": name,
"description": description,
"content": content,
"updated_at": now_text,
"updated_by": 0,
"created_at": now_text,
}
@classmethod
def _build_system_templates(cls) -> list[dict[str, Any]]:
now_text = now_cn_naive().isoformat()
return [
cls._build_default_template(),
cls._build_template_item(
template_id=LOW_TEMPLATE_ID,
name=LOW_TEMPLATE_NAME,
description=LOW_TEMPLATE_DESCRIPTION,
content=LOW_TEMPLATE_CONTENT,
now_text=now_text,
),
cls._build_template_item(
template_id=HIGH_TEMPLATE_ID,
name=HIGH_TEMPLATE_NAME,
description=HIGH_TEMPLATE_DESCRIPTION,
content=HIGH_TEMPLATE_CONTENT,
now_text=now_text,
),
cls._build_template_item(
template_id=HIGH_CONTRACT_TEMPLATE_ID,
name=HIGH_CONTRACT_TEMPLATE_NAME,
description=HIGH_CONTRACT_TEMPLATE_DESCRIPTION,
content=HIGH_CONTRACT_TEMPLATE_CONTENT,
now_text=now_text,
),
]
@classmethod
def _ensure_system_templates(
cls,
templates: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], bool]:
merged = [item for item in templates if isinstance(item, dict)]
known_ids = {str(item.get("id") or "").strip() for item in merged if str(item.get("id") or "").strip()}
changed = False
for preset in cls._build_system_templates():
template_id = str(preset.get("id") or "").strip()
if not template_id or template_id in known_ids:
continue
merged.append(preset)
known_ids.add(template_id)
changed = True
return merged, changed
@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_system_templates()
existing_templates = [item for item in bundle["templates"] if isinstance(item, dict)]
changed = True
else:
upgraded_default_template = False
latest_default_content = cls._load_default_template_content()
for item in existing_templates:
if str(item.get("id") or "") != DEFAULT_TEMPLATE_ID:
continue
current_content = str(item.get("content") or "").strip()
if current_content and not cls._is_default_template_fallback(current_content):
break
item["name"] = str(item.get("name") or "").strip() or DEFAULT_TEMPLATE_NAME
item["description"] = str(item.get("description") or "").strip() or DEFAULT_TEMPLATE_DESCRIPTION
item["content"] = latest_default_content
item["updated_at"] = now_cn_naive().isoformat()
if item.get("updated_by") is None:
item["updated_by"] = 0
upgraded_default_template = True
break
if upgraded_default_template:
bundle["templates"] = existing_templates
changed = True
existing_templates, added_presets = cls._ensure_system_templates(existing_templates)
if added_presets:
bundle["templates"] = existing_templates
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]}"
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)} 个文件发生变更"
risks = (
[
"跨文件改动可能引发契约/类型不一致,请补充联动核对。",
"合入前需完成 build/lint/test 并记录结果。",
]
if file_lines
else ["未提供变更文件列表,风险评估信息不足,请人工补充。"]
)
content = cls._render_template_content(
template_content=str((template_obj or {}).get("content") or "").strip(),
generated_at=now_text,
base_ref=cls._resolve_git_ref("HEAD~1"),
head_ref=cls._resolve_git_ref("HEAD"),
changed_files=file_lines,
scope_summary=summary,
risks=risks,
)
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,
}