429 lines
16 KiB
Python
429 lines
16 KiB
Python
"""增量任务包管理服务。"""
|
||
|
||
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,
|
||
}
|