--- name: pipeline-app-module version: 2.0.0 description: "产线生态系统 — 通用执行引擎、产线定义/运营/分销/交互的完整开发规范和部署指南。" trigger_conditions: - 用户要求开发或修改产线功能 - 涉及 pipeline_core/pipeline_ops/pipeline_dist/pipeline_service/pipeline_task 模块 - 需要添加新的产线类型、步骤处理器或分销功能 - 讨论产线架构设计(Hermes验证→固化→独立运行) tags: [pipeline, module, independent-app, distribution, execution-engine] --- # Pipeline 产线生态系统 ## 核心理念 **模块不等于应用的附属品**。模块就是模块,谁加载就是谁的: - pipeline-app 加载 → pipeline-app 的模块 - sage 加载 → sage 的模块 - 任何应用加载 → 该应用的模块 **Hermes Agent 是验证场,pipeline 是产品**: - Hermes Agent 中用 cron/delegate/terminal 跑通业务流程 - 验证通过后固化为产线步骤定义(pipeline_steps 表) - pipeline-service 自动调度执行,独立于 Hermes Agent ## 仓库总览 **⚠️ 所有 pipeline 仓库统一放在 `/d/ymq/pipeline/` 目录下,不要放根层级或 `~/repos/`。** | 仓库 | 路径 | 说明 | |------|------|------| | pipeline-app (伞仓) | `/d/ymq/pipeline/pipeline-app/` | 独立应用宿主源码,内含 `pipeline_core/` 目录(非独立仓库) | | **pipeline-app 测试** | **`apitest@120.48.168.15:/d/apitest/pipeline-app/`** | **tokentest 环境 (port 9090, pipeline.opencomputing.cn)** | | pipeline-sdlc | `/d/ymq/pipeline/pipeline-sdlc/` | 开发产线(SDLC:项目、迭代、测试、Bug、部署环境) | | pipeline-service | `/d/ymq/pipeline/pipeline-service/` | 通用产线执行引擎(v2.0) | | pipeline-task | `/d/ymq/pipeline/pipeline-task/` | 产线任务交互层 | | evaluate | `/d/ymq/pipeline/evaluate/` | 质量评估模块 | | showcase | `/d/ymq/pipeline/showcase/` | 展示平台(展示产线产出物) | **pipeline_core 不是独立仓库**,它是 pipeline-app 伞仓根目录下的子目录 `pipeline_core/`(含 `init/`, `json/`, `wwwroot/`, `models/`, `pipeline_core/` Python 包等)。测试服务器上 `pkgs/pipeline_core/` 也没有独立 `.git`,git 操作走父仓库 pipeline-app。 ## 模块架构 ### pipeline_core(产线定义 — 管理员) | 表 | 说明 | |----|------| | pipelines | 产线主表(name, description, status, owner_id) | | pipeline_steps | 步骤定义(pipeline_id, step_name, step_type, step_order, step_config — deps 存在 step_config JSON 中,非独立列) | | pipeline_versions | 发布版本(pipeline_id, version_no, snapshot_json, status) | | llm | 模型配置(name, provider, model_id, api_base, api_key, status) — llm_bridge.py 从此表读取 API 配置 | ### pipeline_ops(运营管理 — 运营人员) | 表 | 说明 | |----|------| | pipeline_pricing | 定价(pipeline_id, step_id, model_name, unit_price, pricing_type) | | pipeline_capacity | 供应量(pipeline_id, quota_type, quota_value, concurrent_limit) | | pipeline_usage_log | 使用记录(pipeline_id, user_id, step_id, usage_count, cost) | ### pipeline_dist(分销管理 — 分销商) | 表 | 说明 | |----|------| | distributors | 分销商(name, api_key, status, quota, org_id) | | distributor_pipeline | 分销-产线关联(distributor_id, pipeline_id, custom_price, enabled) | ### pipeline_service(执行引擎 — 核心) 通用产线执行引擎,不知道自己在跑 KTV 还是找飞机还是写小说。 | 表 | 说明 | |----|------| | pipeline_tasks | 任务主表(tenant_id 隔离) | | pipeline_task_steps | 步骤执行记录 | | pipeline_artifacts | 产物(input/output,支持版本) | **引擎工作原理:** 1. 提交 → 读取 pipeline_steps 表步骤定义 → 创建 task_steps 记录 → 启动执行 2. 执行循环 → 解析 DAG 依赖图 → 找可执行步骤 → 调用 handler → 存 artifact → 继续 3. 多租户 → 所有查询按 tenant_id 隔离 4. 人工干预 → 修改 artifact → 创建新版本 → BFS 级联重跑 **步骤处理器(可插拔):** ```python async def handler(tenant_id, task_id, step_name, input_data, config) -> dict: return output_data register_handler("llm_generate", handler) ``` 步骤定义中的 step_type 匹配 handler 注册名。新增产线 = 插步骤数据 + 注册 handler,引擎不改一行。 **对外接口(注册到 ServerEnv):** | 函数 | 说明 | |------|------| | pipeline_submit | 提交新任务 | | pipeline_role_submit | 主agent提交带 role 的角色任务(tenant_id=project_id,见 references/role-agent-question-loop.md) | | pipeline_list | 任务列表 | | pipeline_detail | 任务详情+步骤状态树 | | pipeline_node | 节点 artifact | | pipeline_modify | 修改+级联重跑 | | pipeline_pause/resume/cancel | 任务控制 | | pipeline_restart | 重启已完成/失败/取消任务,重置所有步骤为新版本 | ### pipeline_task(引擎交互层 — 底层 API,非 UI 模块) **⚠️ 架构关键:pipeline_task 是引擎层,不是 UI 模块。** 各产线类型(SDLC、KTV 等)各自拥有独立的 UI 模块(如 pipeline-sdlc),产线 UI 通过跨模块 `entire_url('/pipeline_task/api/...')` 调用 pipeline_task 的 API。不要把产线专属的 dashboard UI 放进 pipeline_task。 纯薄交互层,无数据表。通过 ServerEnv 调用 pipeline_service 的函数。 | dspy | 调用 | |------|------| | task_submit | pipeline_submit() | | task_list | pipeline_list() | | task_detail | pipeline_detail() | | task_node | pipeline_node() | | task_modify | pipeline_modify() | | task_control | pipeline_pause/resume/cancel() | | task_restart | pipeline_restart() | | human_complete | human_task_complete() | | human_list | human_task_list() | | approval_approve | approval_approve() | | approval_reject | approval_reject() | UI: index.ui(内嵌 Tabular 任务列表 + 搜索筛选) / task_list.ui / task_detail.ui(Jinja2 预渲染,人工任务表单/审批按钮/重启) / task_submit.ui / step_panel.ui(步骤产物查看 + 修改级联重跑) ## 宿主集成 任何应用只需: ```python from pipeline_core.init import load_pipeline_core from pipeline_ops.init import load_pipeline_ops from pipeline_dist.init import load_pipeline_dist from pipeline_service.init import load_pipeline_service from pipeline_task.init import load_pipeline_task load_pipeline_core() load_pipeline_ops() load_pipeline_dist() load_pipeline_service() load_pipeline_task() ``` ## pipeline-app 关键配置 ### conf/config.json(完整示例) ```json { "password_key": "<从服务器 conf/config.json 读取,各环境不同,不要硬编码>", "databases": { "pipeline": { "driver": "aiosqlor", "kwargs": { "host": "127.0.0.1", "port": 3306, "user": "hermes", "password": "", "db": "pipeline", "charset": "utf8mb4" } } }, "website": { "port": 9090, "paths": [ ["$[workdir]$/wwwroot", ""] ], "session_redis": { "url": "redis://127.0.0.1:6379/3" }, "processors": [ [".dspy", "dspy"], [".ui", "bui"], [".tmpl", "tmpl"] ], "indexes": ["index.ui", "index.html"] }, "SAGE_RBAC_DB": "pipeline" } ``` **配置要点(易错):** - `driver` 必须是 `"aiosqlor"`(不是 "aiomysql"),否则 sqlor 找不到匹配的 MySQL 子类 - **⚠️ PITFALL: `password_key` 各环境不同,不要硬编码。** 读取方式:`from appPublic.jsonConfig import getConfig; config = getConfig(".", {"workdir": "."}); key = config.password_key`。硬编码会导致密码验证失败 - **⚠️ PITFALL: 用户密码是 RC4 加密,不是 AES。** 加密函数是 `appPublic.rc4.password(s, key=key)`,不是 `aes_encode_b64`。`password_key` 仅用于 RC4 加密用户密码;DB 密码(database.kwargs.password)才用 AES - `password` 必须 AES 加密:`from appPublic.aes import aes_encode_b64; aes_encode_b64(password_key, plaintext)` — 这是 DB 连接密码,与用户密码不同 - `password_key` 字段必须存在,否则 sqlor 解密时报 NoneType 错误 - `SAGE_RBAC_DB` 设为 `"pipeline"`(本应用自己的数据库),不是 `"sage"` - `paths` 使用统一 wwwroot 目录 + 空前缀 `""`,不要用多个独立路径带模块前缀(会导致 url2file 解析失败) ### global_func.py 所有模块共用 pipeline 数据库,动态获取 dbname: ```python from ahserver.serverenv import ServerEnv DBNAME = "pipeline" def get_module_dbname(mname): """All modules share the pipeline database.""" return DBNAME def set_globalvariable(): g = ServerEnv() g.get_module_dbname = get_module_dbname ``` ## Support Files - `references/ktv-handler-implementation.md` — KTV 步骤处理器实现细节 - `references/eval-llm-integration.md` — 质量评估 LLM 集成模式 - **`references/cockpit-dspy-common-issues.md`** — cockpit dspy 常见崩溃:msg_id 变量未定义、LLM 超时丢失上下文等问题的根因和修复 - **`references/cockpit-executor-pipeline.md`** — Cockpit → Pipeline Executor 交互流程:正确的 `pipeline_submit → start_task → 步骤执行` 链路 vs 错误的「直接调 LLM」反模式 - **`references/quality-gate-pattern.md`** — 质量门控模式详细实现 - `references/interactive-decision-steps.md` — 交互式决策步骤 - `references/root-index-template.md` — 根入口页面模板 - `references/service-to-module-conversion.md` — 服务转模块指南 - `references/tokentest-deployment.md` — pipeline-app 前移到 tokentest 测试环境的完整部署步骤 - **`references/502-troubleshooting.md`** — Pipeline 502 问题完整排查指南:bricks symlink 缺失诊断、nginx 反向代理链路、部署完整性检查清单 - **`references/serverenv-vs-run_ns.md`** — DSPY调用的async函数必须用 request._run_ns 而非 ServerEnv() 的陷阱和修复 - **`references/gpu-service-architecture.md`** — GPU服务原子性架构:nginx反向代理、token网关、服务不互调、不上LLM(数据库、配置、脚本、验证) - **`references/pipeline-task-human-interaction.md`** — 人工任务交互流程:human_complete/approval dspy模式、Jinja2预加载、pipeline_restart - **`references/sqlor-dictobject-serialization.md`** — sqlor DictObject 序列化泄漏方法名的根因和修复 - `references/rbac-permission-cache.md` — RBAC 权限匹配规则(通配符 `/**`)、缓存 TTL、session-ticket 认证方式、init_data.py 表结构对齐检查 - `references/rbac-manual-registration.md` — load_path.py 无法执行时的兜底方案 - `references/vibevoice-asr-deployment.md` — VibeVoice-ASR-7B GPU 部署:ModelScope 下载、transformers 直连(无 Docker)、ahserver+LongTasks 模式、Whisper API 兼容映射 - **`references/dashboard-ui-pattern.md`** — 开发产线 Dashboard:快速提交表单、步骤树弹窗(Popup+Tree+apply_data)、Bug跨模块查询、产线下拉选项 - **`references/cockpit-intent-pattern.md`** — 驾驶舱意图识别+引导对话模式:LLM分类→缺失信息追问→动作路由分发,`_select_model` name/id 双匹配 - **`references/agent-loop-deliverables.md`** — Agent循环引擎+交付件仓库+Agent配置:pipeline_deliverables/pipeline_project_agents 表结构,agent_loop.py 循环逻辑,DB context 冲突注意事项 - **`references/role-agent-question-loop.md`** — v3.2.0 主agent+角色agent架构:tenant_id=project_id 约定、角色名归一化、问题回路表/env接口、DDL双仓库同步、ALTER迁移语句、踩坑记录 - **`references/workspace-directory-pattern.md`** — 工作目录模式:sd_projects.workspace_dir + pipeline_project_agents.deliverable_dir,Agent 交付文件路径的目录结构和代码模式 - **`references/cockpit-pattern.md`** — 驾驶舱模式:对话式输入、时间线+聊天双面板、urlwidget→DSPY动态内容渲染 - **`references/getwidgetbyid-no-default-pitfall.md`** — bricks.js getWidgetById 新版无默认 from_widget 导致脚本崩溃的根因、修复和验证脚本 - **`references/bricks-blank-page-troubleshooting.md`** — 页面空白/bricks.js 构造函数崩溃完整排查:实体目录 vs 软链接、源版 vs 编译版 bricks.js、浏览器缓存诊断法、git 恢复后文件缺失 ## 部署步骤 ### 1. 数据库准备 ```sql CREATE DATABASE pipeline CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ``` ### 2. 建表 从 sage 导出 RBAC 表结构,执行各模块的 DDL: ```bash # RBAC 表(从 sage 复制结构) mysqldump -u hermes -phermes123 --no-data sage users role permission rolepermission userrole organization orgtypes org_roles appcodes appcodes_kv > rbac_ddl.sql mysql -u hermes -phermes123 pipeline < rbac_ddl.sql # 业务表(各模块 DDL) for mod in pipeline_core pipeline_ops pipeline_dist pipeline-service; do mysql -u hermes -phermes123 pipeline < $mod/mysql.ddl.sql done ``` ### 3. 构建应用 ```bash bash build.sh # 创建 venv + 安装依赖 + 生成 DDL/CRUD ``` ### 4. 初始化数据 运行 init_data.py 脚本(创建管理员用户、角色、权限、appcodes): ```bash py3/bin/python init_data.py ``` ### 5. 配置 wwwroot(关键——缺失会导致 502) 创建统一 wwwroot,符号链接各模块。**以下两步缺一不可:** ```bash mkdir -p wwwroot # 【关键1】bricks 前端静态文件——必须是软链接 → pkgs/bricks/dist,绝不能是实体目录! # 实体目录会装 23KB 源版 bricks.js → 页面空白报 "bricks.App is not a constructor" # 前置:若 dist 不存在先 `cd pkgs/bricks && mkdir -p dist && cd bricks && bash build.sh` rm -rf wwwroot/bricks ln -sf "$cdir/pkgs/bricks/dist" "$cdir/wwwroot/bricks" # build.sh 第44行就是这么做的 # 【关键2】根入口页面——nginx location = / 代理到 /index.ui,缺失则根路径 500 # 内容必须是完整的主布局(Header+Sidebar+Content),从 git 历史恢复: # git show 3c9fdcf:wwwroot/index.ui > wwwroot/index.ui # 模块 wwwroot 符号链接(仅服务静态文件,不影响 Python 导入) ln -sf pipeline_core_repo/wwwroot wwwroot/pipeline_core ln -sf pipeline_ops_repo/wwwroot wwwroot/pipeline_ops ln -sf pipeline_dist_repo/wwwroot wwwroot/pipeline_dist ln -sf ~/repos/pipeline-task/wwwroot wwwroot/pipeline_task # ⚠️ Python 包必须放在 app 目录内才能 import! # wwwroot 符号链接只服务静态文件,不影响 Python 导入路径 cp -r ~/repos/pipeline-task/pipeline_task ./pipeline_task cp -r ~/repos/pipeline-service/pipeline_service ./pipeline_service ``` ### 6. 启动 ```bash bash start.sh ``` ### 7. 权限初始化(关键) 确保 `logined` 角色存在并注册所有路径: ```bash cd ~/test/pipeline-app py3/bin/python init_rbac.py # 或参考 rbac-permission-initialization-pattern 技能 ``` **注意**:pipeline-service 是纯后端模块,无前端路径需要注册。 ### 8. 部署验证(必须完整) **不要只测 localhost**,必须验证外部访问: ```bash # 1. 本地基础验证 curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:9090/ # 期望: 200 # 2. 登录页面 curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:9090/rbac/user/login.ui # 期望: 200 # 3. 登录后模块访问 curl -s -o /dev/null -w "%{http_code}" -u admin:admin123 http://127.0.0.1:9090/pipeline_core/index.ui # 期望: 200 # 4. 外部 HTTPS(必须验证!) curl -s -o /dev/null -w "%{http_code}" https://pipeline.opencomputing.cn/ # 期望: 200(不是 401) curl -s -o /dev/null -w "%{http_code}" -u admin:admin123 https://pipeline.opencomputing.cn/pipeline_task/index.ui # 期望: 200 ``` **常见401原因**:`logined` 角色不存在于 `role` 表 → 登录用户无权限。详见 `rbac-permission-initialization-pattern` 技能 Section 6。 ## 技术要点 - **sor.U() 只有2个参数(CRITICAL PITFALL)**:id 放在 data 字典中,绝不传第3个参数。常见复发模式是 `data.pop('id')` 取走 id 后把 `{'id': record_id}` 当作独立 WHERE 参数传入——sqlor 的 `U()` 不接受独立 WHERE 参数。 - **⚠️ PITFALL: sor.I 不是 Insert,是 Inspect(查表结构)!** `sor.I(table)` 返回表的字段描述,不带数据。插入数据用 `sor.C(table, data_dict)` —— C 才是 Create。`sor.I` 被错误当成 Insert 调用会造成 `takes 2 positional arguments but 3 were given`(因为 I 只接受 tablename 一个参数 + self)。记忆口诀:C=Create, R=Read, U=Update, D=Delete, I=Inspect。 ```python # WRONG — data.pop('id') + 3rd arg = TypeError: record_id = data.pop('id', None) await sor.U('pipelines', data, {'id': record_id}) # 3 args → TypeError! # CORRECT — merge id back into data: record_id = data.pop('id', None) await sor.U('pipelines', {**data, 'id': record_id}) # 2 args ✓ # Also wrong — separate data + where dicts: await sor.U('pipelines', {'status': 'published'}, {'id': pipeline_id}) # 3 args! # Correct: await sor.U('pipelines', {'id': pipeline_id, 'status': 'published'}) # 2 args ✓ ``` - **dspy 注入集有限,标准库不全**:globalEnv 注入 `json`/`DBPools`/`curDateString` 等全局名,但**不注入 os/uuid/base64** —— dspy 用到这些必须文件头显式 `import os`(顶层 import 合法)。裸名可用性以 grep `globalEnv.py` 的 `g.xxx =` 为准,不凭记忆 - **DBPools 动态获取 dbname**:禁硬编码,通过 global_func.py 映射 - **⚠️ PITFALL: DSPY 调用的 async 函数必须用 `request._run_ns`,不能用 `ServerEnv()`**: `ServerEnv()` 是模块级空单例,不随请求变化。`request._run_ns` 是 `processorResource` 按请求注入的上下文,包含 `entire_url`、`get_userorgid`、`get_module_dbname` 等。 所有从 `.dspy` 调用的 async 函数必须在第一行用 `env = request._run_ns`。用 `env = ServerEnv()` 会导致 `env.entire_url` 等为 None,触发 `TypeError: 'NoneType' object is not callable`(500错误)。 详细修复示例见 `references/serverenv-vs-run_ns.md`。 - **pipeline_service 仓库不存在**:`pipeline_service` 无公开仓库。`pipeline_app.py` 和 `ktv_adapter.py` 的导入必须用 `try/except ImportError` 包裹,优雅降级。 - **部署到 tokentest 不能用 `pipeline` 数据库**:`test` 用户无 CREATE DATABASE 权限,直接用 `sage` 库。详见 `references/tokentest-deployment.md`。 - **分销密钥生成**:distributor_generate_key.dspy 使用 secrets.token_hex(32) - **步骤处理器可插拔**:register_handler(step_type, fn),不同产线注册不同 handler - **多租户隔离**:tenant_id 贯穿 pipeline_tasks 所有查询 - **交互层极简**:pipeline_task 零数据表,JS 只写必要辅助函数,不写大段逻辑 - **pipeline_steps 表无 deps 列**:DAG 依赖关系存在 `step_config` JSON 中(`{"deps": ["step_a"], "desc": "..."}`),由 storage.py 提取到记录顶层 - **步骤树 UI 层级**:在 step_config 中添加 `parent_step` 字段控制 Tree widget 展示层级:`{"deps": [...], "parent_step": "step_a"}`。`parent_step` 与 `deps` 独立——deps 控制执行 DAG,parent_step 控制 UI 树。根节点 parent_step 为空字符串 - **跨模块 entire_url**:产线专属 UI(如 pipeline-sdlc)调用 pipeline_task API 须用绝对路径 `entire_url('/pipeline_task/api/task_list.dspy')`。模块内用相对路径,跨模块必须 `/模块名/api/...` - **步骤处理器文件命名**:按产线类型命名 `handlers_.py`,导出 `HANDLERS` 字典和 `register__handlers()` 函数,在 `load_pipeline_service()` 中调用注册 ### 步骤处理器开发模式 **PITFALL: 区分草稿代码与生产代码** - 草稿/骨架代码位于源仓库(如 `pipeline-app/app/ktv_adapter.py`) - 生产代码位于测试服务器 `apitest@120.48.168.15:/d/apitest/pipeline-app/app/ktv_adapter.py` - 修改前必须确认读取的是哪个版本,避免在草稿上浪费时间 新增产线 = 步骤数据入库 + handler 文件实现 + 注册。引擎代码不改一行。 ### 质量门控模式(Quality Gate Pattern) **适用场景**:关键步骤需要质量保障,自动评估-重试-人工介入闭环。 **架构设计**: ``` 执行 handler → 多维度评估 → 不达标自动重试(最多3次) → 仍不达标暂停等待人工决策 ``` **实现模式**: 1. **创建评估函数**(`app/eval_*.py`): ```python async def evaluate_xxx_quality(output: dict) -> Tuple[bool, float, str]: """评估函数返回 (passed, score, reason)""" # 多维度评估逻辑 # 返回: (是否通过, 0-1分数, 原因说明) return True, 0.85, "各维度评估良好" ``` 2. **包装 handler**: ```python def _make_quality_handler(original_handler, eval_func): async def wrapper(tenant_id, task_id, step_name, input_data, config): from app.quality_gate import with_quality_gate result = await with_quality_gate( task_id=task_id, step_name=step_name, version=1, handler=original_handler, evaluator=eval_func, tenant_id=tenant_id, input_data=input_data, config=config, ) return result return wrapper # 注册质量门控版本 quality_music_polling = _make_quality_handler(handle_music_polling, eval_music_quality) ``` 3. **注册到 KTV_HANDLERS**: ```python KTV_HANDLERS = { "music_polling": quality_music_polling, # 质量门控 ✓ "other_step": handle_other_step, # 普通 handler } ``` **多维度评估示例**: **视频质量(5维度)**: - 视觉质量 25% - ffprobe 分析分辨率/码率/帧率 - 运动流畅度 20% - ffprobe 帧率分析 - 语义一致性 30% - **已集成 llm_bridge**(关键帧base64 → LLM多模态分析prompt匹配度) - 角色一致性 15% - **已集成 llm_bridge**(参考图+关键帧 → LLM对比角色特征) - 时间连贯性 10% - 帧差分析 **音乐质量(7维度)**: - 节奏质量 15% - 响度范围分析 - 和弦进行 10% - 时长推断 - 听感质量 20% - loudnorm 响度/峰值分析 - 情绪-歌词匹配 20% - **已集成 llm_bridge**(歌词+音频情绪 → LLM情感匹配分析) - 指令匹配度 15% - **已集成 llm_bridge**(prompt+音频特征 → LLM指令匹配分析) - 伴奏质量 10% - Demucs 分离后评估 - 人声质量 10% - Demucs 分离后评估 **工具链**:ffprobe/ffmpeg(元数据), Demucs GPU(人声伴奏分离), llm_bridge(LLM文本/多模态评估) **PITFALL — eval 模块 LLM 调用异常回退**:llm_call 可能超时或返回非JSON。每个评估函数必须在 except 块中返回默认分数(如 0.8),保证产线不中断。详见 `references/eval-llm-integration.md`。 **Executor 集成**: `pipeline_service/executor.py` 需特殊处理 `QualityGatePausedError`,避免覆盖 WAITING 状态为 FAILED。 详细实现参考 `references/quality-gate-pattern.md` 和 `references/eval-llm-integration.md`。 **验证迁移完整性**:运行 `scripts/verify_ktv_pipeline.py` 检查所有 step_types 都有对应 handler,DAG 无悬空依赖。 **文件结构**: ```python # pipeline_service/handlers_ktv.py KTV_HANDLERS = { "audio_preparing": handle_audio_preparing, "demucs_separating": handle_demucs_separating, # ... } def register_ktv_handlers(): from .handler import register_handler for step_type, fn in KTV_HANDLERS.items(): register_handler(step_type, fn) ``` **Handler 签名**: ```python async def handler(tenant_id, task_id, step_name, input_data, config) -> dict: # input_data: {dep_step_name: {output_key: value}, "task_params": {...}} # config: step_config JSON from pipeline_steps table return output_data # 存入 artifact,下游步骤可通过 input_data 访问 ``` **input_data 中找上游产物**: ```python for dep_name, dep_output in input_data.items(): if isinstance(dep_output, dict): value = dep_output.get("key_name") if value: break ``` **LLM 调用桥接**(`pipeline_service/llm_bridge.py`): 后端优先级: 1. harnessed_agent.llm_chat(ServerEnv 加载时) 2. **数据库 `llm` 表** — 按 model name 查 api_base/api_key/model_id,有内存缓存 3. 环境变量 LLM_API_BASE / LLM_API_KEY / LLM_MODEL ```python from pipeline_service.llm_bridge import llm_call result = await llm_call(prompt, model="deepseek-v4-pro", temperature=0.7) ``` **质量评估模块 LLM 集成模式**(`app/eval_*.py`): 评估函数标准模式(视频/音乐质量评估): ```python async def evaluate_xxx(video_path: str, prompt: str, frames: list = None) -> Tuple[float, str]: """返回 (score 0-1, reason)""" if not prompt: return 0.5, "无 prompt,跳过评估" try: from pipeline_service.llm_bridge import llm_call # 1. 构建 prompt(要求 JSON 输出) llm_prompt = f"""分析以下内容: 描述:{prompt} 请评估:1. 维度1 2. 维度2 返回 JSON:{{"match_score": 0-10, "reason": "简要说明"}} """ # 2. 多模态:图像 base64 编码 frames_b64 = [] if frames: for frame_path in frames[:3]: # 最多3帧 if os.path.exists(frame_path): with open(frame_path, 'rb') as f: frames_b64.append(base64.b64encode(f.read()).decode('utf-8')) # 3. 调用 LLM result = await llm_call(llm_prompt) result = result.strip() # 4. 清理 markdown code block if result.startswith("```"): result = result.split("\n", 1)[1].rsplit("```", 1)[0] # 5. 解析 JSON import json data = json.loads(result) match_score = data.get("match_score", 8) reason = data.get("reason", "LLM 评估完成") # 6. 转换为 0-1 分数 score = min(1.0, match_score / 10.0) return score, f"评估结果: {reason}" except Exception as e: logger.warning(f"LLM 评估失败: {e}") return 0.8, "评估异常,使用默认分数" # 关键:回退默认分数保证产线不中断 ``` **已实现的评估函数**: - `eval_video.py`: semantic_consistency(关键帧+prompt→语义匹配)、character_consistency(参考图+关键帧→角色一致性) - `eval_music.py`: emotion_lyrics_match(歌词+音频情绪→情感匹配)、prompt_adherence(prompt+音频特征→指令匹配) **PITFALL**:每个评估函数的 except 块必须返回默认分数(如 0.8),否则 LLM 超时或 JSON 解析失败会导致产线中断。详见 `references/eval-llm-integration.md`。 ## GPU 远程执行模式(⚠️ 已废弃 — 请使用 Token Gateway) **SSH 直连 GPU 服务器已废弃**。正确架构:pipeline-app → token平台(Sage/llmage) → nginx → GPU服务。 详见 `references/gpu-service-architecture.md`。 旧模式(不要用): ```python GPU_HOST = "ymq@opencomputing.net" async def _run_gpu(cmd, timeout=600): ssh_cmd = f"ssh {GPU_HOST} '{cmd}'" # ❌ 绕过token平台 ``` 新模式: ```python async def call_gpu(model: str, payload: dict): return await http_post("https://token.opencomputing.cn/llmage/v1/chat/completions", json={"model": model, "messages": [{"role":"user","content":json.dumps(payload)}]}) ``` ## GPU 远程执行模式(历史参考) ```python GPU_HOST = "ymq@opencomputing.net" async def _run_gpu(cmd, timeout=600): ssh_cmd = f"ssh -o StrictHostKeyChecking=no {GPU_HOST} '{cmd}'" return await _run_local(ssh_cmd, timeout) async def _copy_to_gpu(local, remote): await _run_local(f"scp -o StrictHostKeyChecking=no '{local}' {GPU_HOST}:{remote}") ``` 详细实现参考 `references/ktv-handler-implementation.md`。 ## 产线管理页面布局模式(index.ui) 产线管理模块的 index.ui 采用三层定高 + 填充区布局: ``` VBox (height:100%, padding:0) ├── HBox Title (cheight:6, 定高) — 标题 + 副标题 ├── HBox (cheight:18, 定高) — 包裹 ResponsableBox │ └── ResponsableBox (三卡片 cheight:12) └── VScrollPanel (id=pipeline_core_content, css:filler, 填充剩余) ``` **关键点:** - 卡片和内容区是 root VBox 的**直接子节点**,不要额外包一层 VBox wrapper - 三卡片用 HBox 包裹以锁定高度(cheight:18),ResponsableBox 的 cheight:12 去不掉 - VScrollPanel 用 `css: "filler"` 自动填充剩余空间,内容内部滚动 - 卡片 bind:`target: "app.pipeline_core_content"`, `mode: "replace"` - 不要用 `-` prefix(closest 只搜祖先,搜不到兄弟节点) - **此布局是所有模块 index.ui 的统一标准**:pipeline_core、pipeline_sdlc 等模块的入口页必须使用相同结构(白底卡片 + SVG 图标 + VScrollPanel filler)。禁止使用暗色主题、emoji 图标或 `backgroundColor` 属性 ## UI 导航契约(强制 — 用户明确要求) 三层导航层次,统一交互,不混用: 1. **系统菜单 → TabPanel**:侧边栏 Menu 的系统菜单项(产线管理/开发产线/运营/分销/任务中心/审计日志/远程空间等),点击用 `this.open_tab({name,label,url,removable:true})` 在 `app.main_content`(TabPanel)打开为可切换标签页。参考 `index.ui` 侧边栏 `sidebar_menu` 的 script 写法。 2. **用户菜单 → 弹窗**:Header 右上角 👤 用户菜单(`userinfo.ui` 点击 `target:"Popup"` + `popup_options`),弹窗加载 `user_menu.ui`,**不进 TabPanel**。 3. **TabPanel 内容中的点击 → 弹窗**:TabPanel 打开的模块页面内,点击按钮/链接一律弹窗(PopupWindow / Popup),**不在 TabPanel 内再嵌套 tab,也不整页跳转**破坏 tab 结构。 **目的**:顶层用 TabPanel 保持多任务上下文;用户级/次级操作一律弹窗(临时、可关闭),不污染 tab 列表。写任何 .ui 的 bind/script 前先套这三条。 ## DSPY 职责边界(dspy 简单 + 复用 py) 用户原则:**dspy 要简单,多个 dspy 不能重复实现同一个功能,否则就写在 py 里。** 模式: 1. 重复逻辑抽到 .py 的统一函数(如 `workspace.py` 的 `get_workspace_dir`/`get_workspace_base`) 2. `load_xxx()` 里 `g.fn = fn` 注册到 ServerEnv(否则 dspy 调用 NameError 500) 3. dspy 里按函数名直接调用,只保留各自的业务逻辑 反面教材(已收敛):8 个 `workspace_*.dspy` 各自内联"读 workspace_base(params 兜底) + 查 current_project_id → sd_projects 算 ws_dir",同一逻辑重复 8 次;抽成 `get_workspace_dir(sor, uid)` 后,8 处改成一行 `ws_dir, _ = await get_workspace_dir(sor, uid)`(净删 161 行)。 **收敛后必须复查残留**:替换掉的代码块若定义了局部变量(如 `pid`),后续若还引用会 NameError/500;缺 `import json` 也会 500。改完 grep `pid`/`workspace_base`/`json.` 逐文件确认无残留 + 端到端 curl 冒烟。 ## Tabular 自定义 toolbar 按钮 + script bind 模式 在产线定义表加"发布"等自定义操作: **toolbar 定义(index.ui 中):** ```json "toolbar": { "tools": [ {"name": "publish", "label": "发布", "selected_row": true} ] } ``` **bind 写法(PITFALL)—— 用 `this` 不用 `getWidgetById`:** ```json "binds": [{ "wid": "self", "event": "publish", "actiontype": "script", "target": "self", "script": "var d=this.select_row.user_data;fetch('{{entire_url('../api/pipeline_publish.dspy')}}',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'pipeline_id='+encodeURIComponent(d.id)}).then(function(r){return r.json()}).then(function(r){if(r.success){this.render({})}else{bricks.show_error({title:'发布失败',message:r.message||'未知错误'})}}.bind(this))" }] ``` **两个关键 PITFALL:** 1. `target: "self"` 时 script 中 `this` 就是 Tabular 自身,**不要**用 `bricks.getWidgetById('xxx')` —— toolbar 按钮在 Tabular 内部,`getWidgetById` 从按钮向下搜索找不到祖先 Tabular,报 `Cannot read properties of undefined (reading 'dom_element')` 2. promise `.then()` 回调中 `this` 丢失,必须用 `.bind(this)` 才能调用 `this.render({})` ## 开发工作流(强制) **铁律:pipeline-app 测试 = tokentest,禁在本机另搭环境。** 1. 所有代码修改必须先在 `/d/ymq/pipeline/pipeline-app/` 或对应模块仓库(`/d/ymq/pipeline//`)完成 2. `git commit` 后立即 `git push` 3. **SSH 到 tokentest 同步部署**:`ssh apitest@120.48.168.15` → `cd /d/apitest/pipeline-app && git pull`(伞仓);各模块 `cd /d/apitest/pipeline-app/pkgs/ && git pull` 4. Python 代码变更后必须 `pip install --upgrade`:`cd /d/apitest/pipeline-app && source py3/bin/activate && pip install --upgrade /d/apitest/pipeline-app/pkgs/` 5. 重启:`pkill -9 -f pipeline_app && sleep 2 && cd /d/apitest/pipeline-app && bash start.sh` 6. 验证:`https://pipeline.opencomputing.cn/` ### 服务器直接修改回传流程(违规补救) 当发现测试服务器上有直接修改(`git status` 显示 modified/untracked),必须按以下流程补救: 1. **先 `git pull` 本地**确认拿到远程最新 2. **`scp` 服务器改动回本地**:`scp apitest@120.48.168.15:/d/apitest/pipeline-app/pkgs// /d/ymq/pipeline//` 3. **本地 `git add -A && git commit && git push`** 4. **服务器清理**:`rm -f` 冲突的 untracked 文件 → `git stash && git pull && git stash drop` 5. 验证 `git status --short` 为空 **常见脏模块清单**(每次部署后必须检查): ```bash ssh apitest@120.48.168.15 " for d in /d/apitest/pipeline-app/pkgs/*/; do [ -d \"\$d/.git\" ] || continue out=\$(cd \"\$d\" && git status --short) [ -n \"\$out\" ] && echo \"[DIRTY] \$(basename \$d)\" && echo \"\$out\" done " ## ⚠️ 工作流铁律(最高优先级 — 违反即误操作) 0. **任何代码变更前必须加载 `multi-agent-workflow` 技能**。禁止单 agent 开发,禁止直接 SSH/scp/sed 改服务器文件。必须走本地克隆 → Agent1 开发 → Agent2 审查 → Agent3 测试 → 审计的完整流水线。违反此条是本技能最严重的流程违规。 0.5. **智能体创建的重复仓库必须清理**。子代理(delegate_task)会在 `/d/ymq/` 根层级创建 `pipeline-app`、`pipeline-sdlc`、`pipeline-service`、`pipeline-task`、`pipeline_core`、`test/pipeline-app` 等同名克隆,与 `/d/ymq/pipeline/` 下的正确仓库重复。每次任务结束后检查并删除: ```bash rm -rf /d/ymq/pipeline-app /d/ymq/pipeline-sdlc /d/ymq/pipeline-service /d/ymq/pipeline-task /d/ymq/pipeline_core /d/ymq/test/pipeline-app ``` 正确仓库只有 `/d/ymq/pipeline/pipeline-app/`、`/d/ymq/pipeline/pipeline-sdlc/`、`/d/ymq/pipeline/pipeline-service/`、`/d/ymq/pipeline/pipeline-task/`、`/d/ymq/pipeline/evaluate/`、`/d/ymq/pipeline/showcase/`。 1. **严禁随意修改部署环境文件**。用户原话:「为什么以前各个模块都能用的界面,现在都不能用了,这些你都可以随便改来改去的吗?」—— 每次改动必须有根有据,先查后改,不准盲目创建文件、改 symlink、或覆盖远程文件。 2. **修复前四步确认**:(1) `git log` 看最近变更 (2) `ls -la` 确认文件现状 (3) 对比 `build.sh` 确认构建流程 (4) 用 `git show :` 找回丢失的文件。 3. **修改 → commit → push → 服务器 git pull → 重启 → 浏览器测**。不允许跳过任何一步。 4. **curl 200 ≠ 功能正常**。必须浏览器端完整链路验证。 5. **服务器文件改动前先告知用户**。不要静默 overwrite 远程文件(用户 blocked scp/sed 命令)。 ## 部署踩坑记录 **⚠️ 排查铁律:先确认原有状态,再动手修复。** 不要看到 502/500 就盲目创建文件、改 symlink、修改模块代码。先检查:(1) git log 看最近变更 (2) ls -la 确认 wwwroot 文件现状 (3) 对比 build.sh 确认构建流程 (4) 用 git show : 找回丢失的文件。在拿不准时,用 `cp` 备份原文件再改。 | 问题 | 原因 | 修复 | |------|------|------| | sqlor connect 报 "Not Implemented" | driver 名错误 | 用 `"aiosqlor"` 而非 `"aiomysql"` | | sqlor 报 NoneType.encode | 密码未加密或缺 password_key | config.json 加 `password_key` 字段,密码用 `aes_encode_b64(key, pwd)` 加密 | | url2file 返回 None,"invalid path" | paths 配置用独立目录+模块前缀 | 统一 wwwroot 目录,前缀用 `""` 空字符串 | | ImportError: cannot import name | CWD 模块目录遮蔽 pip 安装包 | 重命名为 `*_repo`,符号链接内层包 `*_repo/modname/ → modname/` | | load_path.py 报找不到 APP_ROOT | 不需要 APP_ROOT 探测 | 脚本在 app root 执行,cwd 就是根目录,用相对路径 | | load_path.py 执行失败 "找不到 set_role_perm.py" | 缺少 wrapper 脚本 | 在 app root 创建 `set_role_perm.py` wrapper(见下方) | | sor.U() 3参数报错 | 旧代码残留,常见复发模式是 `data.pop('id')` 后把 `{'id': record_id}` 当作独立 WHERE 参数传入——sqlor 的 `U()` 不接受独立 WHERE 参数 | id 放 data 字典,sor.U(table, data) 只传2参数。修改时必须同时更新3处副本:源码、`build/lib/`、`py3/lib/python3.10/site-packages/`。具体修复模式见上方「技术要点」sor.U() 代码示例 | | RBAC 查权限报错 | SAGE_RBAC_DB 指向 sage | 改为 `"pipeline"`(本应用自己的库) | | 根路径 `/` 返回500 | 缺少 `indexes` 配置 | config.json 加 `"indexes": ["index.ui", "index.html"]` | | bricks.js 401未授权 | 静态资源未注册 any 权限 | 用通配符 `/bricks/**` 注册到 `any` 角色。**SQL 风格 `/module/%` 通配符无效**(2026-08 实测:注册 `/pipeline-sdlc/%` 到 any 仍 401),必须用 `/**`;注册后 `redis-cli -n 3 FLUSHDB` + 重启才生效 | | **bricks.js:684 "Class extends value undefined" + "bricks.App is not a constructor",页面一片空白** | `wwwroot/bricks` 变成了**实体目录**(内含 23KB 源版 bricks.js),不是 build.sh 标准的软链接 `→ pkgs/bricks/dist`(424KB 编译版)。源版缺 `bricks.Layout` 等类定义,`bricks.App = class extends bricks.Layout` 直接崩 | `rm -rf wwwroot/bricks && ln -sf /d/apitest/pipeline-app/pkgs/bricks/dist wwwroot/bricks`。诊断:`ls -la wwwroot/bricks` 看有无 `->`;`wc -c` 对比 bricks.js 大小(编译版 ~424KB,源文件 ~23KB)。完整排查流程见 `references/bricks-blank-page-troubleshooting.md` | | 服务器已修好,用户仍报一模一样的前端错误 | 浏览器缓存了旧 bricks.js | 验证法:`curl -s https://域名/bricks/bricks.js -o /tmp/x && sed -n '<浏览器报错行号>p' /tmp/x` —— 服务器该行内容若与报错描述不符(如报错说 684 行是 `bricks.App = class extends` 但服务器 684 行是无害代码),就是缓存。让用户 Ctrl+Shift+R 强刷,不要反复改服务器 | | git clone/恢复后 git 跟踪文件磁盘缺失(如 wwwroot/index.ui),nginx 代理根路径 500 | clone 后工作区状态混乱:`git ls-files` 有记录但文件不在磁盘(git status 显示 ` D`) | `git ls-files ` 对比 `ls` 找出全部缺失文件 → `git checkout -- ` 逐个恢复。恢复后重启验证 | | 所有路径 401(含外部HTTPS) | `role` 表缺 `logined` 角色,登录用户无任何权限 | 创建 `logined` 角色(orgtypeid=*)+ 注册路径到该角色 | | set_role_perm.py 报 NotADirectoryError `/conf/config.json/conf/config.json` | getConfig() 接收**目录**路径(内部拼 `conf/config.json`),脚本传了完整文件路径 | 改为 `getConfig(ROOT_DIR, ...)` 只传目录,不拼 `conf/config.json` | | set_role_perm.py 报 `Table 'pipeline.role_path' doesn't exist` | 脚本用了不存在表名 `role_path`;正确 RBAC 表是 `permission`(存路径)+ `rolepermission`(关联角色) | 用 `permission` 表 INSERT 路径,`rolepermission` 表关联角色;一个 path 一条 permission,多个 rolepermission 行给不同角色授权 | | set_role_perm.py 报 `NoneType.get` / 连不上 DB | 脚本硬编码 `sqlorContext('sage')` 但独立应用数据库是 `pipeline`,config 中无 sage 的数据库配置 | 改为 `sqlorContext('pipeline')` 或用 `get_module_dbname()` 动态获取 | | Jinja2 ternary 报错 | .ui 文件用了不存在的过滤器 | 用 `if/else` 表达式或静态文本替代 | | pipeline_steps 插入失败 "Unknown column 'deps'" | 表无 deps 列,依赖关系存在 step_config JSON 中 | `step_config` 字段写入 `{"deps": [...], "desc": "..."}` 格式 JSON | | 整站 502 Bad Gateway(nginx返回) | `wwwroot/bricks` 符号链接缺失,`entire_url('/bricks/header.tmpl')` 返回404 → Bricks 渲染崩溃 → ahserver 500 → nginx 502 | `ln -sf /bricks/dist wwwroot/bricks`(build.sh 第3步明确执行此操作)。完整排查流程见 `references/502-troubleshooting.md` | | 根路径 `/` 返回500 "invalid path",但 index.ui 存在 | nginx `location = /` 已 proxy 到 `/index.ui`,但 `wwwroot/bricks` 缺失导致 header.tmpl 找不到,Bricks 模板渲染中 `path_call` 返回 None → `'NoneType' object has no attribute 'be_call'` | 恢复 bricks symlink 即可。**不是** rbac init.py 的问题,不要修改 rbac | | 侧边栏菜单消失 / 页面只显示空白内容块 | `wwwroot/index.ui` 丢失(被错误覆盖或删除) | 从 git 历史恢复:`git show 3c9fdcf:wwwroot/index.ui`。完整 index.ui 包含 Header + Sidebar Menu + VScrollPanel 布局,不是单个模块的内容页。**不要**直接复制 pipeline_core/index.ui 作为根 index | | build_step_graph 报 KeyError deps | storage.py 未从 step_config 提取 deps | `get_pipeline_steps()` 解析 step_config JSON 并注入 `d['deps'] = cfg.get('deps', [])` | | DSPY调用报 TypeError: NoneType not callable | async函数用了 `env = ServerEnv()` 而非 `env = request._run_ns` | 改为 `env = request._run_ns`,详见 `references/serverenv-vs-run_ns.md` | | **sqlor DictObject 序列化泄漏方法名** | `dir(rec)` 包含 `clear`/`copy`/`fromkeys` 等内置方法 | 用 `vars(rec)` + `if not callable(v)` 过滤 | | **storage.py 中 `init_task_steps` 报 `KeyError: 'step_name'`** | sqlor 行对象用字典访问 `rec['step_name']` 失败。sqlor 行只支持属性访问 `rec.step_name` | 改为 `getattr(rec, 'step_name', '')`。`get_task_steps` 中 `sor.R(table, conds, sort='x')` 三参数也需改为 `sqlExe` + `ORDER BY`。详见 `references/storage-py-pitfalls.md` | | 模块 .ui 页面 500 报函数未定义 | Python 包不在 app 目录内,`try/except ImportError` 静默跳过 | 将模块 Python 包复制/链接到 app 目录下(如 `pipeline-app/pipeline_task/`),wwwroot 符号链接 ≠ Python 导入路径 | | init_data.py INSERT 报 Unknown column 'del_flg' / 'ptype' / 'role' | sage RBAC 表列名与 init_data.py 不一致。permission 用 `permtype` 非 `ptype`,`description` 非 `title`;role 只有 `id,orgtypeid,name`(无 role/rolelabel);organization 无 `del_flg` | init_data.py 修改前先 `SHOW COLUMNS FROM ` 确认列名,逐表对齐 | | 登录后子页面 401(权限已写DB) | RBAC 权限缓存 TTL 300s,重启后才刷新;或 init_data.py 未找到 API 路径(glob 路径受 symlink 影响) | 权限变更后重启应用;用 `SELECT rp.roleid, p.path FROM rolepermission rp JOIN permission p ON rp.permid=p.id WHERE rp.roleid='owner.superuser'` 验证权限已写入 | | curl -u 测试全部返回 401 | 认证机制是 session-ticket(TktAuthentication),不支持 HTTP Basic Auth | 浏览器登录获取 session cookie,或用 `curl -c/-b` 先 POST 登录再请求 | | load_path.py 无法执行,引用 `~/repos/sage/` 路径 | 模块的 load_path.py 是为 Sage 写的,但部署在 pipeline-app 上,`set_role_perm.py` 和 `py3/bin/python` 路径都不对 | 不用 load_path.py,直接用 SQL 手动注册 `/**` 通配符权限。详见 `references/rbac-manual-registration.md` | | 新模块已部署(wwwroot + Python包 + 菜单)但页面 403 | RBAC 权限从未注册,permission 表无该模块路径 | 检查 `SELECT * FROM permission WHERE path LIKE '/模块名%'`,若为空则注册 `/**` 通配符到 superuser + logined 角色 | | **RC4 `password_encode` 加密后无法解密——API key 应明文存储** | `appPublic.rc4` 的 `password()` 和 `unpassword()` 不对称:加密时使用了随机 salt,解密时固定 salt 不匹配,导致 `password_encode(v)` 存储后 `password_decode` 无法还原。`llm` 表的 `api_key` 若经 RC4 加密则永久不可用 | **`add_llm.dspy` 和 `update_llm.dspy` 中移除 `password_encode` 调用**,api_key 明文存储。`_call_llm` 和 `llm_bridge.py` 中直接使用 `model_info.api_key`,不解密。已存在的 RC4 密文用 `UPDATE llm SET api_key='' WHERE api_key LIKE 'QUZV%'` 清除 | | **wwwroot/pipeline_task 是普通目录而非符号链接,新 API 文件 500 "invalid path"** | build.sh 未创建 symlink 或之前手动 mkdir 覆盖。所有模块 wwwroot 必须是 symlink 指向 pkgs/ 下对应模块的 wwwroot | `rm -rf wwwroot/pipeline_task && ln -sf pkgs/pipeline-task/wwwroot wwwroot/pipeline_task`。验证:`ls -la wwwroot/pipeline_task` 应显示 `->` 符号链接 | | curl 登录返回 "Password is required" | login DSPY 字段名是 `passwd` 不是 `password`。Form data 必须用 `passwd` 字段 | `curl -d "username=admin&passwd=xxx"` 而非 `password=` | | RBAC INSERT 报 `Unknown column 'permtype'` | pipeline-app 的 permission 表只有 8 列(id,name,description,ptype,parentid,path,title,icon),无 `permtype` 列 | 用 `DESCRIBE permission` 确认列名后对齐;INSERT 只写 id,name,ptype,parentid,path,title | | CRUD Tabular 新增/编辑 500 | `json/
.json` 中的 `new_data_url` 等 URL 与 xls2ui 实际生成的文件名不匹配。xls2ui 生成 `add_
.dspy` 但 JSON 可能写 `llm_create.dspy` | **改源 JSON,不手改生成文件。** 修正 `json/
.json` 中的 URL → 运行 `bash build.sh` 让 xls2ui 重建。xls2ui 命名规则:`add_.dspy` / `delete_.dspy` / `update_.dspy` / `get_.dspy` | | 重启后页面仍 401 但权限已写入 DB | RBAC 权限缓存在 Redis(db 3),TTL 300s | `redis-cli -n 3 FLUSHDB` 刷新缓存后重试 | | 新进程启动但页面行为异常 | 旧部署路径残留进程仍占用同一端口(两个 PID 同时 LISTEN) | `lsof -i :` 检查所有进程,kill 旧 PID 后单进程重启 | | 动态内容 urlwidget 加载失败或显示空白 | 用 Jinja2 .ui 模板通过 `{% %}` 预渲染数据,但 bui 处理器不支持服务端 Jinja2 | 创建 DSPY 端点返回 Bricks widget JSON,urlwidget 指向 `/api/xxx.dspy` 而非 `.ui` 文件 | | 用户密码验证始终失败 | password_key 硬编码错误值;或误用 AES 替代 RC4 编码 | 必须从服务器 config 读取 key:`getConfig(".", {"workdir": "."}).password_key`;用 `appPublic.rc4.password(s, key=key)` 编码 | | 根页面需要新菜单/卡片 | 根 `wwwroot/index.ui` 的 Menu widget 同时驱动侧栏和主页面卡片 | 在 Menu items 数组中添加新条目即可,自动生成侧栏菜单项+主页卡片 | | **Bricks 无独立输入组件** | Form 是唯一用户输入机制,不存在独立的 textarea/input widget | 聊天式 UI 用最小 Form(仅 textarea + hide 字段),自定义按钮放 Form 外,`type:\"submit\"` 按钮关联 Form |\n| **⚠️ .ui 文件 script 中用 `{{entire_url(\"\\\"/path\\\"\")}}` 导致 Jinja2 500** | `\\\"` 在 Jinja2 bui 处理器中被当作非法转义字符:`jinja2.exceptions.TemplateSyntaxError: unexpected char '\\\\'` | **script 内禁用 `entire_url()`**。用相对路径如 `/pipeline-sdlc/api/cockpit_chat.dspy`。`entire_url()` 仅在纯 JSON option 值(非 script 字符串内)中使用安全 |\n| **UiText 可脱离 Form 独立使用** | bricks.UiText 虽注册为 Form field type,但可通过 JS 动态创建:`new bricks.UiText({name:'x',placeholder:'...'})`,append 到容器 dom_element。用户明确拒绝 Form 做输入时使用此模式 | render 事件 bind 中创建:`var ut=new bricks.UiText({name:'cockpit_input',placeholder:'...'}); this.dom_element.appendChild(ut.dom_element); ut.handleInput();` | | **Browserbase session-ticket 不兼容** | Browserbase 后台每个 navigation 创建新 browser context,不保留 cookies;session-ticket 认证的页面始终 401 | 浏览器测试仅用于验证公开页面;需认证的页面通过 SSH 上的 curl 验证(先 `curl -c` 登录再 `-b` 请求),最终验证用真实浏览器 | | **Bricks Form 的 code 下拉框取值失败——`model_id` 始终为空** | `form_element.querySelector('[name=model_id]')` 找不到 Bricks code widget 渲染的 select 元素,因为 code widget 的 DOM 不设置标准 name 属性。`form_element.querySelector('select')` 也未必能找到 | **用 `document.querySelector` 直接从页面取**:`var el=document.querySelector('#model_selector select')||document.querySelector('select'); if(el)mid=el.value||'';`。不依赖 bricks.getWidgetById().form_element 子元素查询 | | **`_select_model` 按 id 查不到模型——回退到第一个** | 前端下拉框传的是模型 `name`(如 `qwen3.7-max`),但 `_select_model` 只查 `WHERE id=${lid}$` | 改为 `WHERE (id=${lid}$ OR name=${lid}$)` 同时匹配 id 和 name | | **sqlor `LIMIT ${max_msgs}$` → MySQL 1327 \"Undeclared variable\"** | sqlor `${param}$` 语法处理纯数字值时替换为 `$N$`,MySQL 将其视为 session 变量声明 | 改用 Python f-string 直接插值:`f\"SELECT ... LIMIT {max_msgs}\"`。不要在 `LIMIT` 子句中使用 sqlor 变量语法 | | **`pipeline_submit` undefined in DSPY — \"name 'pipeline_submit' is not defined\"** | `pipeline_service` 模块未安装到 Python 路径。`load_pipeline_service()` 的 `import` 静默失败返回空 stub,所有注册到 ServerEnv 的函数都不可用 | `cd pkgs/pipeline-service && ../../py3/bin/pip install -e .` 安装可编辑包。重启后日志应出现 `[pipeline_service] v3.1.0 loaded` | | **`pip --force-reinstall` 破坏 sqlor DBPools 类** | 强制重装 pipeline_service 可能触发 sqlor 依赖滚动更新,导致 `DBPools` 失去 `EventDispatcher` 父类——所有 DB 操作出现 `has no attribute 'bind'` / `NameError: EventDispatcher` | 手动恢复 sqlor/dbpools.py:(1) 加 `from appPublic.event_dispatcher import EventDispatcher` (2) `class DBPools(EventDispatcher):` (3) `__init__` 中加 `EventDispatcher.__init__(self)`。详见 `references/pip-force-reinstall-sqlor-corruption.md` | | **`_get_db()` 返回空 databases——executor 找不到步骤定义** | `DBPools()` 单例在某些模块加载时序下 `databases` 属性为空,导致 `sqlorContext('pipeline')` 连接失败或返回空结果。storage.py 和 executor.py 使用独立的 `_get_db()` 不走 DSPY 的 `get_sor_context` | 在 `_get_db()` 和 `_get_task_raw()` 中加检查:`if not db.databases: from appPublic.jsonConfig import getConfig; config = getConfig(); if config.databases: db.databases = config.databases` | | **Cockpit 直接调 `_call_llm` 绕过产线执行器** | 驾驶舱在 `new_task` handler 中直接调用 LLM 获取文字回复,没有走 `pipeline_submit → start_task → executor → 步骤执行 → 交付件` 的完整流程。用户看到的不是「产线驱动开发」而是「LLM 对话」 | cockpit 通过 `pipeline_submit()` 提交任务,executor 自动按步骤执行。不要在 cockpit 中直接调 `_call_llm` 做任务执行。详见 `references/cockpit-executor-pipeline.md` | | **DSPY `msg_id` UnboundLocalError on LLM timeout** | `async with DBPools().sqlorContext()` 块内 LLM 调用超时导致块提前退出,块内定义的 `msg_id` 未赋值,return 引用崩溃 | 在 `async with` 前初始化 `msg_id = ''` 和 `agent_reply = ''`。详见 `references/cockpit-dspy-common-issues.md` | | **dspy 端点 500 修完一层又爆一层(编译错 → 运行时 NameError)** | 多层错误:编译层 SyntaxError(f-string 引号被写成 `f\"...\"`,报 `unexpected character after line continuation character`)掩盖运行时层。pipeline-app globalEnv 只注入 `json`/`DBPools`/`curDateString` 等,**不注入 os/uuid/base64** —— dspy 用 `os.listdir` 必须文件头显式 `import os` | 日志行尾 `cost` 分层:~0.004s=编译失败,秒级=运行时错误(每修一层看新请求 except 是否露出下一层)。修 NameError 时全文件扫裸名一次修完。免登录冒烟:curl 该端点返回 401=编译已过到鉴权层,500=仍崩。实证 2026-08-04 cockpit_chat.dspy。详见 sage-dspy-development 技能 `references/dspy-file-corruption.md` §多层错误 | | **`new_task` 创建后 `start_agent` 找不到任务** | 任务创建用 `tenant_id=org_id`,查询用 `tenant_id=project_id`——两字段值不同 | v3.2 统一约定:**角色任务的 tenant_id=project_id**(role_agent_run 第一参数即 project_id,见 references/role-agent-question-loop.md)。主agent写任务和角色认领必须用同一个值 | | **`ROLE_PROMPT.format(...)` KeyError 崩溃——JSON 数据含花括号** | 用 `str.format()` 把任务 params(JSON 字符串,含大量 `{}`)注入 prompt,format 把 JSON 花括号当占位符解析 | LLM prompt 模板一律用哨兵占位符 `.replace('__ROLE__', role)`,不用 `.format()`。同理注入用户内容/待答问题的 prompt 也用 `.replace()` | | **角色agent认领不到任务(静默 idle)** | 前端/默认 role 写 `'developer'`,认领 WHERE 匹配规范名 `'develop'` | 角色名经 ROLE_ALIASES 归一化(agent_loop._normalize_role)。cockpit_agent.dspy 默认 role 改 `'develop'`;新加角色入口必须走归一化 | | **bricks.getWidgetById('id') 报 TypeError: Cannot read properties of undefined (reading 'dom_element')** | 新版 bricks.js(line 2680)的 `getWidgetById` 不再有默认 `from_widget` 兜底。脚本中只传一个参数时 `from_widget` 为 `undefined`,`get_by_id` 访问 `fromw.dom_element` 崩溃。(旧版 `getWidgetByIdOld` 有 `from_widget = bricks.Body` 默认值,新版移除了。) | **所有 `getWidgetById` 调用必须传第二参数**:搜全局树用 `bricks.getWidgetById('xxx', bricks.app)`;搜子树用合适的容器 widget;搜兄弟节点也必须从共同祖先(通常 `bricks.app`)向下搜,因为 `downward=true` 只看子树,`closest`(`-` prefix)只看祖先,都不覆盖兄弟。| | **新表只在 service 的 DDL 里,sdlc 部署后缺表** | pipeline-service 和 pipeline-sdlc 共用 pipeline 库(`get_module_dbname('pipeline-sdlc')` 返回 'pipeline'),但各仓库有自己的 mysql.ddl.sql,新表只加到一个仓库 | 新表建表语句必须同时写入两个仓库的 mysql.ddl.sql(如 pipeline_agent_questions)。存量库还需 ALTER 迁移(先 SHOW COLUMNS 幂等检查) | | **`aes_decode_b64` 导入失败——不在 rc4 模块** | DB 密码解码函数在 `appPublic.aes`,不在 `appPublic.rc4`(rc4 里是用户密码的 password/unpassword)。key 也不能用字面量 'password_key' | `from appPublic.aes import aes_decode_b64; pwd = aes_decode_b64(getConfig().password_key, encrypted)`。rc4=用户密码,aes=DB密码,两套函数别混 | | **`add_startup(coro())` → TypeError: 'coroutine' object is not callable** | `add_startup` 期望一个返回协程的可调用对象(函数),不是协程对象本身。`add_startup(_role_poller())` 调用了 async 函数,把协程对象传进去了 | **传函数引用而非调用结果**:`add_startup(_role_poller)`,不加括号。ahserver 会在启动时调用它生成协程 | | **on_startup handler 阻塞 aiohttp 启动——HTTP 000 / 永不监听端口** | aiohttp `on_startup` 信号会 `await` 每个注册的 handler。handler 里有 `while True: ... await asyncio.sleep(10)` 无限循环会永久阻塞服务器启动 | **on_startup handler 只能 spawn 后台任务,不能内联执行**:`async def _role_poller(app): asyncio.create_task(_poll_loop())`。_poll_loop 含无限循环,_role_poller spawn 后立即返回 | | **`_role_poller() takes 0 positional arguments but 1 was given`** | aiohttp 的 `app.on_startup.append(c)` 会给每个 handler 传入 `app` 实例。函数签名少了一个参数 | 签名必须是 `async def _role_poller(app):`,即使不实际使用 app 参数 | | **shell_exec 返回 [Errno 13] Permission denied** | 传入的 workdir(如 /d/pipeline/workspaces)在沙箱检查通过但目录本身不存在 → `create_subprocess_shell(cwd=...)` 打开失败 | 用 `_resolve_workdir()` 自动回落:检测 /d 可写性,不可写时用 /tmp/pipeline_workspaces。shell_exec 内部也对传入 workdir 做 `os.path.isdir` 检查,不存在时自动回落 | | **devops 路由执行 `Cloning: command not found`** | LLM 分类器的 description 字段是自然语言(\"Cloning into...\"),不是可执行 shell 命令;或消息含中文前缀\"执行命令:\" | cockpit_chat devops handler 直接用 `message_text` 作命令(`re.sub(r'^(执行命令[::]|运行[::]|帮我\\s*)', '', cmd)` 去前缀),不依赖分类器 description | | **新建 dspy 端点 500: `NameError: name 'dbname'/'uid' is not defined`** | 独立 `.dspy` 文件没有 `dbname`/`uid` 自动注入。每个 .dspy 是独立执行单元,不会继承其他文件的顶层变量 | dspy 端点 boilerplate 三行头:`uid = await get_user()` → `dbname = get_module_dbname('pipeline-sdlc')` → `async with DBPools().sqlorContext(dbname) as sor:`。参考 `cockpit_stats.dspy` 的写法 | ### 模块 load_path.py 实现模式 **模块的 `scripts/load_path.py`**(示例:pipeline_core/scripts/load_path.py): ```python #!/usr/bin/env python3 """Pipeline core module RBAC permission registration""" import subprocess MOD = "pipeline_core" PATHS_ANY = [ f"/{MOD}/pipeline.css", f"/{MOD}/pipeline.js", ] PATHS_LOGINED = [ f"/{MOD}/", f"/{MOD}/index.ui", f"/{MOD}/api/pipelines_create.dspy", f"/{MOD}/api/pipelines_update.dspy", # ... 其他 API ] def register_paths(): for path in PATHS_ANY: subprocess.run(["py3/bin/python", "set_role_perm.py", "any", path]) print(f" any: {path}") for path in PATHS_LOGINED: subprocess.run(["py3/bin/python", "set_role_perm.py", "logined", path]) print(f" logined: {path}") if __name__ == "__main__": print(f"=== {MOD} RBAC registration ===") register_paths() print("Done.") ``` **应用根目录的 `set_role_perm.py` wrapper**(~/test/pipeline-app/set_role_perm.py): ```python #!/usr/bin/env python3 """Wrapper script for RBAC permission registration. Called by modules' load_path.py scripts. Usage: py3/bin/python set_role_perm.py """ import sys import asyncio sys.path.insert(0, '.') from rbac.set_role_perms import set_role_perm as _set_role_perm from appPublic.jsonConfig import getConfig from sqlor.dbpools import DBPools async def main(role, path): parts = path.strip('/').split('/') module = parts[0] if parts else 'app' config = getConfig('.', {'workdir': '.'}) db = DBPools(config.databases) await _set_role_perm('pipeline', module, '*', role, path) if __name__ == '__main__': if len(sys.argv) != 3: print(f"Usage: {sys.argv[0]} ") sys.exit(1) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete(main(sys.argv[1], sys.argv[2])) loop.close() ``` **执行流程**: 1. 在 app root 执行:`py3/bin/python pipeline_core/scripts/load_path.py` 2. load_path.py 调用:`py3/bin/python set_role_perm.py logined /pipeline_core/index.ui` 3. wrapper 调用 `rbac.set_role_perms.set_role_perm()` 写入数据库 ## RBAC 权限注册(通配符模式) 用 `/**` 通配符一次覆盖模块所有路径,避免逐个注册: ```sql -- 1. 创建模块通配符权限 INSERT INTO permission (id, name, ptype, parentid, path, title) VALUES (getID(), '/pipeline_core/**', 'page', root_perm_id, '/pipeline_core/**', '/pipeline_core/**'); -- 2. 分配给 superuser 角色 INSERT INTO rolepermission (id, roleid, permid) VALUES (getID(), 'owner.superuser', perm_id); -- 3. 静态资源分配给 any 角色(免登录访问) -- /bricks/**, /i18n/** → any 角色 -- /rbac/userpassword_login.ui → any 角色 ``` **any 角色**:所有未登录用户的默认角色,注册静态资源和登录页面。 **logined 角色**:所有登录用户自动拥有,注册已登录通用页面。 ## 根入口页面(index.ui) 独立应用需要在 `wwwroot/index.ui` 创建主入口页面,包含: - 顶部导航栏(Menu 组件,链接各模块) - 模块卡片(ResponsableBox + VBox.card,点击进入各模块) - 用户按钮(右上角) 模板参考:`references/root-index-template.md` ## bricks 前端构建 ```bash cd ~/test/pipeline-app/pkgs/bricks/bricks && bash build.sh ``` 构建产物在 `pkgs/bricks/dist/`,符号链接 `wwwroot/bricks → pkgs/bricks/dist/`。 如果 `bricks.js` 404,检查 dist 目录是否已生成。 **⚠️ 铁律:`wwwroot/bricks` 必须是软链接指向 `pkgs/bricks/dist`,绝不能是实体目录**(build.sh 第43-44行 `rm -rf wwwroot/bricks && ln -sf pkgs/bricks/dist wwwroot/bricks`)。 实体目录里若放了 `pkgs/bricks/bricks/` 的**源文件** bricks.js(~23KB,缺 Layout/JsWidget 等类定义),前端直接崩: `bricks.js:684 Class extends value undefined` + `bricks.App is not a constructor` → 页面空白。 诊断三件套:`ls -la wwwroot/ | grep bricks`(有无 `->`)、`wc -c wwwroot/bricks/bricks.js`(编译版 ~424KB vs 源版 ~23KB)、对比用户报错行号与服务器该行内容是否吻合(不吻合=浏览器缓存旧文件)。 ## 模块目录结构(部署时) ``` ~/test/pipeline-app/ ├── app/pipeline_app.py # 启动入口 ├── conf/config.json # 配置(含加密密码) ├── wwwroot/ # 统一 wwwroot │ ├── pipeline_core → ../pipeline_core_repo/wwwroot │ ├── pipeline_ops → ../pipeline_ops_repo/wwwroot │ ├── pipeline_dist → ../pipeline_dist_repo/wwwroot │ ├── pipeline_task → ~/repos/pipeline-task/wwwroot │ └── bricks → ../bricks ├── pipeline_core → pipeline_core_repo/pipeline_core/ # Python 包 ├── pipeline_ops → pipeline_ops_repo/pipeline_ops/ ├── pipeline_dist → pipeline_dist_repo/pipeline_dist/ ├── pipeline-service → ~/repos/pipeline-service # 符号链接到 repos ├── pipeline-task → ~/repos/pipeline-task ├── pipeline_core_repo/ # 完整仓库(含 wwwroot, models, json) ├── pipeline_ops_repo/ ├── pipeline_dist_repo/ ├── init_data.py # 初始化脚本 ├── build.sh ├── start.sh └── stop.sh ``` **关键**:Python 导入路径是 `pipeline_core/`(符号链接到内层包),静态文件在 `wwwroot/pipeline_core/`(符号链接到 repo 的 wwwroot)。不要混淆。