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