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