--- name: sage-dspy-development description: "Sage DSPY file development patterns: exec-context rules, env registration, API patterns" version: 1.0.0 author: Hermes Agent platforms: [linux] metadata: hermes: tags: [dspy, sage, development, patterns, bricks] related_skills: [pre-commit-crud-check, rbac-permission-initialization-pattern] --- # Sage DSPY 开发规范 ## DSPY 执行机制(CRITICAL) ahserver 将 DSPY 代码包装为 async function 并 await 执行: ```python # ahserver 内部处理(baseProcessor.py line 234-243): txt = "async def myfunc(request,**ns):\n" + '\n'.join(lines) exec(txt, lenv, lenv) func = lenv['myfunc'] return await func(request, **lenv) ``` ### 关键影响 1. **必须使用 `return`** — DSPY 代码在 async function 内部执行,变量赋值不对外可见。必须用 `return` 返回值,不能用最后一行表达式: ```python # ✅ CORRECT async with db.sqlorContext(dbname) as sor: recs = await sor.sqlExe(sql, ns) return [dict(r) for r in recs] return [] # ❌ WRONG — result 变量在函数作用域内,外部不可见 async with db.sqlorContext(dbname) as sor: recs = await sor.sqlExe(sql, ns) result = [dict(r) for r in recs] result # ← 这一行在 async function 外面,但同级的 return [] 会使它不执行 ``` 2. **`async with` / `await` 正常工作** — 因为代码在 async function 内被 await 执行,同步调用 `exec()` 不会阻止异步操作。 3. **`NoneType` 错误 = 缺少 `return`** — 当看到 `return data type error, ` 时,90% 的情况是 DSPY 中忘记写 `return`。 4. **Python 函数隐式返回 None → Jinja2 崩溃** — 模块 Python 函数(非 DSPY)定义 SQL 但忘记 `sqlExe + return`,隐式返回 `None`。Jinja2 模板中 `{% for r in data %}` 对 `None` 迭代直接 500:`'NoneType' object is not iterable`。**检查 `async def` 内是否有 SQL 定义行但缺少 `recs = await sor.sqlExe()` 和 `return`**。 ### wwwroot 根级 DSPY(raw API 端点:`~/sage/wwwroot/xxx.dspy`) 根级 DSPY 与模块 DSPY 走**同一个 exec 包装**(`async def myfunc(request, **ns)`),**必须用 `return` 返回结果**。dspy 执行环境里**没有 `response` 对象**——`response.text = ...` / `response.content_type = ...` 直接 `NameError: name 'response' is not defined`,端点 500。 🔴 **实证(2026-08-04 sage 测试服务器)**:`/get_domain_info.dspy` 按 `response.text` 风格编写,每次请求都报 `NameError: name 'response' is not defined. Did you mean: 'Response'?`(栈底 `baseProcessor.py path_call → await func(request, **lenv)`,签名里没有 response)。**本技能旧版曾把 response.text 当作根级 DSPY 的标准写法,那是错的,已修正。** ```python # wwwroot/tenant_brand_data.dspy — 生产验证过的根级 raw JSON API(参考实现) userorgid = await get_userorgid() env = request._run_ns async with get_sor_context(env, 'sage') as sor: orgs = await sor.sqlExe('SELECT orgname, iconid FROM organization WHERE id = ${orgid}$', {'orgid': userorgid}) org = orgs[0] if orgs else None return json.dumps({"orgname": org.orgname if org else '', "iconid": org.iconid if org else ''}, ensure_ascii=False) ``` **根级 DSPY 特征**: - 返回规则与模块 DSPY 相同:**必须 `return` 字符串**;`response.text` 不可用 - `json` / `get_sor_context` / `request` / `DBPools` 均为预置注入名,无需 import - 读取请求域名:`request.headers.get('X-Forwarded-Host') or request.host`(nginx 透传 Host/X-Forwarded-Host;带端口时用 `host.split(':')[0]` 去掉) - 匿名端点需在 load_path.py 注册 RBAC `any`(如 `/get_domain_info.dspy any`),否则未登录访问 403 - **Pitfall**:远程写入根级 DSPY 时 Shell 转义会破坏 `${host}$` sqlor 占位符语法——使用 Python base64 编码→SSH→解码写入可避免此问题 ### 模块 DSPY(`~/sage/wwwroot/module/api/xxx.dspy`) 模块 DSPY 通过模块路由执行,使用**委托模式**: ### 允许(模块 DSPY) - `import json` — 唯一允许的 import - `async with get_sor_context(env, 'dbname') as sor:` — 标准数据库访问 - `env.xxx()` — 调用 ServerEnv 上注册的函数 - 字符串拼接:`'hello ' + str(x)` — **不能用 f-string** ### 禁止(模块 DSPY) - **f-string** — `{` `}` 在 exec 环境中与模板语法冲突,导致 SyntaxError - **其他 import** — os, sys、`from sqlor.dbpools import DBPools` 等全部禁止(模块 DSPY 必须用 `get_sor_context`) - **裸模块变量** — `PROVIDERS` 等模块级变量在 DSPY exec 中不生效 ### DSPY 模板 ```python # xxx.dspy — API 端点 import json env = request._run_ns async with get_sor_context(env, 'modulename') as sor: result = await env.api_func(sor, params_kw) return json.dumps(result, ensure_ascii=False) ``` ```python # xxx.dspy — 带异常处理 import json env = request._run_ns try: async with get_sor_context(env, 'modulename') as sor: result = await env.api_func(sor, params_kw) return json.dumps(result, ensure_ascii=False) except Exception as e: exception('xxx error: ' + str(e)) return json.dumps({'status': 'error', 'message': str(e)}, ensure_ascii=False) ``` ## 模块变量注册 DSPY 访问不到模块级变量(如 `PROVIDERS`),必须在 `load_module()` 中注册到 ServerEnv: ```python # init.py def load_xxx(): env = ServerEnv() env.PROVIDERS = PROVIDERS # DSPY 中通过 env.PROVIDERS 访问 env.api_func = api_func # DSPY 中通过 env.api_func() 调用 env.j2_helper = j2_helper # Jinja2 模板中 {% set x = j2_helper(request) %} ``` ### 🔴 新增 API 函数的三步注册(缺第三步 = 静默失效) 给模块新增一个供 DSPY 或其他模块调用的函数,必须完成三步,**第三步最常被漏**: 1. **core.py**:类方法 `async def my_func(self, ...)` 2. **__init__.py**:模块级 wrapper `async def my_func(...): return await get_manager().my_func(...)` 3. **__init__.py 的 `load_xxx()` 函数体内**:`env.my_func = my_func` ← 最容易漏 **漏第三步的症状是完全静默的**:py_compile 通过、wrapper 存在、无 NameError。若调用方用 `getattr(env, 'my_func', None)` 容错写法,拿到 None 直接跳过,功能不生效且零报错。实证 2026-08-04:product_management 加了 `sync_llm_product` wrapper 但漏了 `env.sync_llm_product = sync_llm_product`,llmage 上架联动 hook 会静默 no-op;靠 ad-hoc 验证脚本断言注册字面量存在才抓到。**提交前验证必须 grep 注册行**(`env. = ` 在 load_xxx 内),py_compile/AST 查不出 wiring 缺失。 **跨模块 env 契约**(A 模块的 dspy 调 B 模块注册的函数,如 llmage dspy 调 product_management 的 `sync_llm_product`): - 调用方:`fn = getattr(env, 'my_func', None)` + `if fn:` 容错 —— B 未加载/未注册时主流程不崩,只降级 - 被调方:完成上面三步注册 - 两侧分属两个 repo/两个 commit,review 时必须成对检查(调用点存在 ⇔ 注册存在) ## 业务逻辑分离 DSPY 文件只做路由代理,业务逻辑放在 Python 模块中: ```python # module/api.py — 业务逻辑(可以自由 import) from .providers import WechatGateway async def check_payment(sor, params_kw): # 复杂的业务逻辑 pass # wwwroot/api/check.dspy — 路由代理 import json env = request._run_ns async with get_sor_context(env, 'module') as sor: result = await env.check_payment(sor, params_kw) return json.dumps(result, ensure_ascii=False) ``` ## CRUD 自动生成冲突(CRITICAL) **Symptom**: 访问 `/table_list/index.ui` 返回 403,但权限已注册。同样的 `json/` + `wwwroot/` 并存模式在其他表(documents、knowledge_bases 等)正常工作。 **Root Cause**: `json/.json` 存在时,Sage/ahserver 框架自动生成 CRUD 路由(`/table_list/`)。手写 `wwwroot/
_list/` 及其 `.dspy` 文件与自动路由的 handler 命名冲突 → 框架返回 403 或 500 "invalid path"。 **Detection**: 检查是否同时存在 `json/
.json` 和 `wwwroot/
_list/` 目录。 **Fix**: 二选一,不能两者并存: - **方案 A**(推荐): 删除手写目录,用 `json/
.json` 的自动 CRUD。框架自动生成列表页和 CRUD 端点。 - **方案 B**: 删除 `json/
.json`,手写完整的 DSPY + UI。 **Pitfall**: 不要在已有 `json/
.json` 的模块中手写 `
_list/` 的 `.dspy` 文件。框架的自动 CRUD 已经是完整实现,手写文件只会产生冲突。 **ragserver 实例**: `json/tags.json` 已存在,又创建了 `wwwroot/tags_list/index.ui` + data/add/update/delete.dspy → 403 "invalid path"。删掉手写 `tags_list/` 后恢复正常。 ## 常见错误 | 错误 | 原因 | 修复 | |------|------|------| | `NameError: name 'PROVIDERS' is not defined` | 模块变量未注册 | `env.PROVIDERS = PROVIDERS` 注册后 `env.PROVIDERS` | | `NameError: name 'response' is not defined` | 根级 DSPY 用了 `response.text` —— exec 环境无 response 对象 | 改为 `return json.dumps(...)`,见「wwwroot 根级 DSPY」节 | | `SyntaxError: '{' was never closed` | f-string 花括号冲突 | 改用 `'text ' + str(x)` | | `unexpected character after line continuation character (, line N)` | dspy 源码被写入转义引号 `f\"...\"`(整个文件编译失败→每次请求 500)。`, line N` = 源文件第 N−1 行 | 去掉 `\"` 转义改回 `"`;同批常伴 `'\\\\n'.join`(字面反斜杠n,编译能过但拼出字面文本),一并 grep 修掉。详见 `references/dspy-file-corruption.md` | | 修完编译错误后 500 变 `NameError: name 'os' is not defined`(或 uuid/base64 等) | **多层错误**:编译层错误掩盖运行时错误,修一层露一层。exec 环境注入集**因宿主而异**——pipeline-app 的 globalEnv 只注入 `json`(`g.json=json`),**不注入 os/uuid/base64/aiohttp**。日志行 `cost` 可分层:~毫秒级(0.004s)= 编译失败,秒级(2.6s)= 编译已过、运行时错误 | dspy 文件头显式 `import os` / `import json` / `import aiohttp`(顶层 import 合法,aiohttp 类 dspy 本来就靠 import)。修前先 grep 目标宿主 `globalEnv.py`(`g.xxx =`)与 `processorResource.py`(`y_env.xxx`)确认注入集,不凭记忆假设。实证 2026-08-04 cockpit_chat.dspy:先修 f-string 转义(cost 0.004s),再补 import json/os(cost 2.6s 才暴露)。详见 `references/dspy-file-corruption.md` §多层错误 | | 修完部署后无法判断是否生效,又不想打扰用户 | 带登录态才能端到端测;匿名 curl 可做分层冒烟 | 未登录 curl 该端点:**500 = dspy 仍在编译/运行层崩;401 need login = 已编译执行到鉴权层,编译问题已修复**(dspy 每请求编译,无需重启)。服务器本地也可 `compile()` 验证语法 | | `NameError: name 'get_sor_context' is not defined` | DSPY exec 环境无此函数。**注意:是否可用取决于宿主是否注册,不是单纯 Sage/非 Sage 之分** —— ahserver 宿主(如 pipeline SDLC 仪表盘)也注册了它 | 先 grep 同仓库其他 .dspy:有别的文件用了 `get_sor_context` 说明宿主提供,直接用 `get_sor_context(request._run_ns, 'dbname')`;否则用 `DBPools().sqlorContext()`(standalone 模式) | | `AttributeError: 'function' object has no attribute 'uuid4'` | DSPY 命名空间中 `uuid` 被 `globalEnv.py` 设为 `g.uuid = getID`(函数),不是 Python 的 `uuid` 模块。`uuid.uuid4()` 会报此错 | 改用预置的 `getID(N)`。**DSPY 禁止 `import uuid`**——用 `import uuid` 临时覆盖在部分宿主可行但不是正确做法。`getID(32)` 生成 32 字符 nanoid 字符串,用于主键 ID。Python 模块(init.py 等)正常用 `uuid.uuid4().hex` 无此问题 | | `KeyError: 'subject'` | 参数名不一致 | 使用 `.get()` 或统一参数名 | | `sor.sqlExe('INSERT ...') 数据丢失` | sqlExe 在 CRUD context 中不自动提交 | 改用 `sor.C('table', {...})` — CRUD 方法自动 commit | | 写入成功但紧随的回读/同步函数说记录不存在或状态是旧的 | 回读调用写在 `async with sqlorContext()` 块**内部**——块退出才提交,回读用自己的连接读到旧值 | 把回读/同步调用移到 `async with` 块**外面**,见「async with 块内回读 = 脏读」节 | | Shell 破坏 `${param}$` 语法 | SSH heredoc 中 `${host}$` 被 shell 解释 | 用 Python base64 编码→SSH→解码写入,避免 shell 转义 | | `sor.sqlExe` 重复 ORDER BY | ns 的 `sort` 参数会生成 ORDER BY,与 SQL 显式 ORDER BY 冲突 | 二选一:用 ns `sort` 或 SQL 显式 `ORDER BY`,不能同时。去 ns 的 `sort` 参数,改 SQL 加 `ORDER BY` | | `return data type error, ` | DSPY 使用了 **JSON 格式** `{"python": {"import":..., "call":...}}` | 必须改用 **Python 脚本格式** | ### 🔴 ID 长度铁律(所有模块) **DSPY ID 生成铁律:用 `uuid()` 无参调用,禁 `import uuid`。** DSPY 执行环境中 `uuid` 是 `getID` 函数的别名(`globalEnv.py` 设 `g.uuid = getID`),`uuid()` 生成 nanoid 字符串(默认 21 字符),**不是 Python `uuid` 模块**。`uuid.uuid4()` 会报 `AttributeError: 'function' object has no attribute 'uuid4'`。 Python 模块(init.py 等)用 `uuid.uuid4().hex`(32 字符 hex,禁 `[:16]` / `[:12]` 截断)。 **部署前验表**: ```sql SELECT TABLE_NAME, COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='' AND COLUMN_NAME='id'; ``` 不是 `varchar(32)` → `ALTER TABLE t MODIFY id VARCHAR(32)`。 **代码扫描(提交前)**: ```bash grep -rn 'uuid\.uuid4()\.hex\[:1[0-9]\]' # Python 截断 → 改 .hex grep -rn 'import uuid' *.dspy # DSPY 禁 import uuid grep -rn 'getID(' *.dspy # 应改用 uuid() ``` 实证(2026-08-07 ragserver):`save_tags.dspy` / `create_kb.dspy` / `new_tree_item.dspy` 3 个 DSPY 文件违规 `import uuid`,均改为 `uuid()`;`init.py` 5 处 `[:16]` → `.hex`。rag 库 29 张表 id 列均为 `varchar(32)`。 ### DSPY 格式铁律(CRITICAL) **永远不要用 JSON 格式 DSPY。全部使用 Python 脚本格式。** JSON 格式 `{"python": {"import": "module", "call": "func"}}` 有两个致命问题: 1. 函数返回 `None` 时直接爆 `return data type error, `,而 Python 脚本格式的 `json.dumps([])` 永远不出此错 2. 错误栈不暴露真实异常原因,导致在多轮调试中走错方向(改 Python 函数、改 SQL、改 try/except) **正确格式(只用这种):** ```python # xxx.dspy — Python 脚本格式 import json data = await api_func_name(request) return json.dumps(data, ensure_ascii=False) ``` ### DSPY 调试铁律 **当错误栈中出现 `.dspy` 文件路径时,先打开那个 DSPY 文件检查格式和内容,再查 Python 代码。** 不要从 `init.py`→`dashboards.py`→`etl.py`→SQL 逐层排查——栈已经精确告诉你哪个文件出的问题。 典型教训:`/sage_datamart/api/currency_stats.dspy return data type error, ` 反复出现 3 次 → 一直在改 Python 代码(加 try/except、修 SQL 字段名、改数据库连接)→ 实际上就是 `currency_stats.dspy` 用了 JSON 格式而其他 11 个 DSPY 都是 Python 脚本格式 → 改为 Python 脚本格式一行代码解决。 ### sor.C() vs sor.sqlExe('INSERT') — 提交行为差异(CRITICAL) ```python # ❌ WRONG — sqlExe INSERT 不自动提交,连接关闭后数据丢失 await sor.sqlExe('INSERT INTO permission (id, path) VALUES ("${id}$", "${path}$")', {'id': perm_id, 'path': '/tokentest.opencomputing.cn/**'}) # ✓ CORRECT — sor.C() 是 CRUD 方法,自动提交 await sor.C('permission', {'id': perm_id, 'path': '/tokentest.opencomputing.cn/**'}) await sor.C('rolepermission', {'id': getID(), 'roleid': 'any', 'permid': perm_id}) ``` **检测方法**:INSERT 后立即查询同表——`sor.R('permission', {'path': ...})`。如果返回空但脚本无报错,说明未提交。使用 `sor.R()` 做幂等检查后再 `sor.C()` 是安全模式。 ### `async with` 块内回读/调同步函数 = 脏读(CRITICAL) sqlor context 块**退出时才提交**。在块内写入一行后,紧接着调用另一个函数(它开自己的连接回读同一行),读到的是**旧值**: ```python # ❌ WRONG — sync_llm_product 用另一连接读 llm,此时 UPDATE 还没提交 async with DBPools().sqlorContext(dbname) as sor: await sor.U('llm', {'id': record_id, 'status': 'published'}) await env.sync_llm_product(record_id) # 读到 status='unpublished' → 报"模型不在已上架列表" # ✅ CORRECT — 块退出(提交)之后再调用 async with DBPools().sqlorContext(dbname) as sor: await sor.U('llm', {'id': record_id, 'status': 'published'}) await env.sync_llm_product(record_id) ``` **实证 2026-08-04(llmage 上架联动 hook)**:hook 放在事务块内时,上架永远报 `导入后仍未找到产品(模型可能不在已上架列表)`,事后 SELECT 证明 llm.status 已更新为 published;把调用移到块外(llmage commit 89e6d7c)立即修复,同一模型重试即成功。 **症状签名**:写入本身成功(事后单独 SELECT 能验证新值),但紧随其后的同步/回读函数声称记录不存在或状态是旧的。遇到"写成功了但读不到"的矛盾,第一嫌疑是事务可见性,而不是同步函数本身的逻辑。 ## Standalone DSPY(非 Sage 模块,如 ragserver) 在 ahserver 独立应用中(非 Sage 模块系统),DSPY 用 `DBPools().sqlorContext()` 替代 `get_sor_context`: ```python # data query DSPY — 返回原始数据 ns = params_kw.copy() db = DBPools() dbname = get_module_dbname('rag') async with db.sqlorContext(dbname) as sor: recs = await sor.sqlExe("SELECT * FROM t WHERE id=${id}$", ns) return [dict(r) for r in recs] return [] ``` ```python # CRUD DSPY — 返回状态 ns = params_kw.copy() db = DBPools() dbname = get_module_dbname('rag') async with db.sqlorContext(dbname) as sor: await sor.sqlExe("DELETE FROM t WHERE id=${id}$", ns) return {"status": "SUCCEEDED"} return {"error": "failed"} ``` 关键点: - 独立应用中没有 Sage 的 `get_sor_context`,用 `DBPools()` 替代 - 独立应用中没有 `get_module_dbname`,直接用字符串 `'rag'` - `async with` 在 DSPY exec 环境中正常工作(代码被包装为 async def) - `${param}$` 占位符,不能用 `%s` - **必须使用 `return` 返回值**,不能依赖最后一行表达式 - **媒体 URL 必须用 `entire_url()` 包装**:`media_url = entire_url(safe_url(file_path))` 否则 VideoPlayer/Audio/Image 拿到相对路径无法播放 - **chunk metadata 位置信息**:入库时写入 `metadata` 列(JSON 含 `bbox`/`start_time`/`end_time`),检索时 `SELECT metadata FROM document_chunks` 并解析显示 ## 薄交互模块模式(pipeline_task 实例) 模块自身无数据表,通过共享 ServerEnv 单例调用依赖服务模块注册的函数(pipeline_task → pipeline_service 的 `pipeline_detail`、`pipeline_node`、`get_userorgid`)。宿主先加载 pipeline_service 再加载 pipeline_task。DSPY 直接以**裸名**调用,无需 import、无需 `env.` 前缀: ```python # wwwroot/api/task_tree.dspy tenant_id = (await get_userorgid()) or '0' result = await pipeline_detail(tenant_id, task_id) # 返回 JSON 字符串 data = json.loads(result) steps = data.get('task', {}).get('steps', []) return json.dumps(tree_nodes, ensure_ascii=False) ``` **为什么裸名可用**:`baseProcessor.py set_run_env` 构造 `run_ns = ServerEnv() 单例 + y_env + request + params_kw`,DSPY 经 `exec(txt, lenv, lenv)`(lenv=run_ns)执行 → run_ns 里的任何键都是裸名。init.py 里 `env.xxx = fn` 注册到同一单例,请求时 update 进 run_ns。 **裸 `json` 可用**:`globalEnv.py` 执行 `g.json = json` 注入同一单例,所以 DSPY 里**不写 `import json`** 也能用 `json.dumps/loads`(pipeline-task 全部 dspy 均无 import json 且在生产运行)。写 `import json` 也无害(仍是唯一允许的 import)。 **约定**:注册给 DSPY/Jinja2 用的函数必须是 **async**(DSPY 里 `await env_fn(...)`)。若 `env.xxx` 调用返回 NoneType/不可调用,检查:注册是否早于请求、是否同一 ServerEnv 单例、函数是否 async——见 `references/serverenv-dspy-pitfall.md`(有失败反例,也有本模式的成功实例)。 ## DSPY 运行时日志:info() / debug() 内建 dspy exec 环境内可直接调用 `info()` / `debug()` / `exception()`(ahserver 注入,无需 import),输出到 `logs/.log`,格式 `2026-08-03 18:35:28.860[ragserver][info][:6]消息`(`:N` 是 dspy 内部行号,可定位文件内位置)。 ```python info(f'[search_result] params_kw={params_kw}') info(f'[search_result] parsed: kb_id={kb_id!r} query={query!r} method={request.method} url={request.url}') ``` f-string 在独立应用(ragserver 等)的 dspy 中可用且已验证;模块 DSPY(Sage)仍禁 f-string,用 `info('xxx ' + str(params_kw))`。 **前台/后台不一致排查配方**(后端 curl 正常但用户页面异常时): 1. dspy 顶部加 `info()` 记录前台实际入参:`params_kw` + `request.method` + `request.url` 2. 本地改 → commit/push → 服务器 pull → 重启(dspy 改动需重启才生效) 3. 自己先 curl 触发一次,grep 日志确认日志链路通 4. 让用户复现一次,grep 日志看真实入参 5. **若用户操作完全没出现在日志里 → 请求根本没到这个实例**:查 nginx 路由(`cat /etc/nginx/sites-enabled/` 看 proxy_pass 端口),或用户浏览器加载的是缓存旧前端(grep 用户截图上的文案字面量是否在服务器 .ui/.js 文件里) ## Bricks Widget DSPY 前端交互坑(CRITICAL) ### .dspy vs .ui:模板语法不同 - **.ui 文件**:使用 Jinja 模板 `{{entire_url('./xxx.dspy')}}`——框架端渲染 - **.dspy 文件**:使用 Python 语法 `entire_url('./xxx.dspy')`——直接 Python 字符串 **实证 2026-08-05(ragserver kb rename)**:rename_kb_form.dspy 用了 `{{entire_url(...)}}`→ Form submit 的 urlwidget 解析出错误 URL → 403。 ```python # ❌ .dspy 中写 Jinja——{{ }} 被当作字面字符串,不解析 "options": {"url": "{{entire_url('./rename_kb.dspy')}}"} # ✅ .dspy 中写 Python——entire_url() 是预置函数,直接调用 "options": {"url": entire_url('./rename_kb.dspy')} ``` ### Handler dspy 返回 urlwidget:必须 json.dumps Bricks 的 urlwidget handler 期望**合法 JSON 字符串**做响应。从 handler dspy 返回 urlwidget widget 时必须用 `json.dumps()` 包装,不能返回裸 dict: ```python # ✅ CORRECT return json.dumps({"widgettype": "urlwidget", "options": {"url": entire_url('/rag/knowledge_bases_list/index.ui')}}, ensure_ascii=False) # ❌ WRONG — 裸 dict 不是合法 JSON 字符串,bricks 解析失败 return {"widgettype": "urlwidget", "options": {"url": entire_url('/rag/knowledge_bases_list/index.ui')}} ``` ### Python 字符串嵌入 JS script:json.dumps 转义 .dspy 生成的 JSON widget 树中包含 inline JS 脚本字符串。当 Python 变量(如 KB 名、文件名)嵌入 JS 字符串时: ```python # ❌ 危险——namestr 包含单引号/反斜杠时破坏 JS 语法 "script": "if(!confirm('确认删除「" + namestr + "」?'))return;" # ✅ 安全——json.dumps 自动转义特殊字符 safe_name = json.dumps(namestr, ensure_ascii=False) "script": "if(!confirm('确认删除「' + " + safe_name + " + '」?'))return;" ``` 编译不报错,但运行时 `new AsyncFunction(script)` 遇到非法 token 抛 `SyntaxError: Invalid or unexpected token`。 ### 嵌套点击事件:stopPropagation 卡片 VBox 有 click→导航到详情页,卡片内按钮(编辑/删除)也有 click→弹窗/删除。点击按钮时事件冒泡到卡片触发导航。修复:按钮绑定的 script 最前面加 `event.stopPropagation()`。 ```python # ❌ 点击删除同时触发卡片导航进 detail.ui "binds": [{"wid": "self", "event": "click", "actiontype": "script", "script": "if(!confirm('删除?'))return;fetch(...)"}] # ✅ 先阻止冒泡再执行操作 "binds": [{"wid": "self", "event": "click", "actiontype": "script", "script": "event.stopPropagation();if(!confirm('删除?'))return;fetch(...)"}] ``` 若按钮用 `actiontype: "urlwidget"`(不是 script),在前面加一个独立的 script bind: ```python "binds": [ {"wid": "self", "event": "click", "actiontype": "script", "script": "event.stopPropagation()"}, {"wid": "self", "event": "click", "actiontype": "urlwidget", ...} ] ``` ### PopupWindow 表单提交后 AJAX 局部刷新(禁 location.reload) **问题**:PopupWindow 内的表单保存成功后调用 `location.reload()` → 整页刷新,丢失页面状态用户看到闪烁。 **正确做法**:关闭弹窗 + AJAX 刷新目标面板,不触动页面其他部分。 ```javascript // ✅ 三步:fetch保存 → 关闭弹窗 → AJAX刷新面板 fetch(save_url).then(function(r){return r.json()}).then(function(d){ // 1. 关闭弹窗 var pw=null; var w=self; while(w){if(w instanceof bricks.PopupWindow||w instanceof bricks.Popup){pw=w;break};w=w.parent}; if(pw){pw.dismiss();pw.destroy()}; // 2. AJAX 获取最新面板数据并重建 var mp=document.querySelector('#media_card_panel'); if(mp&&mp.bricks_widget){ fetch('/path/to/panel.dspy?_webbricks_=1&...').then(function(r2){return r2.json()}).then(function(d2){ var mw=mp.bricks_widget; mw.clear_widgets(); bricks.widgetBuild(d2,mw).then(function(nw){if(nw)mw.add_widget(nw)}); }); } }).catch(function(){alert('网络错误')}); // ❌ 永远不要这样做 location.reload(); ``` **关键 API**:`bricks_widget.clear_widgets()` 清空面板,`bricks.widgetBuild(json, parent)` 从 JSON 构建 widget 树,`parent.add_widget(new_widget)` 插入。 **实证**:ragserver 人脸卡片标签弹窗保存后整页刷新(2026-08-07)。修复后弹窗关闭卡片面板原地更新标签。 ### 新 dspy 文件 RBAC 权限 新增 .dspy 文件后 403 = 缺 RBAC 权限行。需 INSERT 到 permission 表(path 为完整 URL 路径如 `/rag/knowledge_bases_list/xxx.dspy`)和 rolepermission 表(roleid='logined'),然后**重启服务**清 RBAC 缓存。仅 git pull 不重启不生效。 ### NULL org_id IS NULL 兼容查询 KB、文档等记录可能 org_id=NULL(表示公共/未分组)。WHERE 条件需加 `OR org_id IS NULL`: ```python # ❌ NULL org_id 的记录永远匹配不上 "WHERE id=${id}$ AND org_id=${org_id}$" # ✅ "WHERE id=${id}$ AND (org_id=${org_id}$ OR org_id IS NULL)" ``` ### sqlor LIKE 模式中的 `%%` 转义 + `"` 引号陷阱 sqlor 使用 Python `%` 格式化处理 `${...}$` 占位符,因此 SQL LIKE 的 `%` 通配符必须写成 `%%`。**DSPY Python 字符串内部的外层双引号与 SQL 内部的引号冲突时**,引号转义极易破坏 Python 语法: ```python # ❌ BROKEN — \" 在 ahserver exec(dspy) 编译时解析失败,SyntaxError "WHERE ... AND metadata LIKE '%%voiceprint_status%%\"done\"%%'" # ✅ CORRECT — 整个 SQL LIKE 模式去掉内部引号 "WHERE ... AND metadata LIKE '%%voiceprint_status%%done%%'" ``` **规则**:SQL LIKE 模式内部避免放 `"done"` 之类带引号的常量。直接用裸词 `done` 配合 `%` 前后匹配即可,如 `LIKE '%%voiceprint_status%%done%%'` → 最终 SQL `LIKE '%voiceprint_status%done%'`。 ### sqlor `IN (${ids}$)` 列表展开兼容性 `${ids}$` 在部分 sqlor 版本中无法正确展开为 `IN` 子句的逗号分隔列表,报 `OperationalError: Illegal parameter data types varchar and row for operation '='`。 ```python # ❌ 可能失败 — sqlor 把 list 当作 row 类型而非展开 "WHERE media_id IN (${ids}$)" # ✅ 兼容做法 — 手动拼接逗号分隔带引号字符串 id_list = ','.join(["'" + str(x) + "'" for x in doc_ids]) "WHERE media_id IN (" + id_list + ")" ``` ### 服务器 sqlor 与本地差异 服务器 sqlor 版本可能与本地不同(pip 版本 vs edist install)。`IN (${ids}$)` / `%%` 等模式在本地测试通过但在服务器失败 → 优先用手动 SQL 拼接 + `ns={}` 兜底。 ### 关联参考 - `references/bricks-dspy-pitfalls.md` — 本节的完整实证记录(rgserver rename/delete 开发全程) - `references/dspy-sqlor-quirks.md` — sqlor LIKE %% 转义、IN 列表展开、引号冲突完整实录 - `references/dspy-debugging-lessons.md` — DSPY 调试铁律 - `references/dspy-rag-pitfalls.md` — RAG 项目实战踩坑 - `references/dspy-file-corruption.md` — DSPY 文件污染检测与修复 - `references/sql-defensive-patterns.md` — SQL 防护规范 - `references/serverenv-dspy-pitfall.md` — ServerEnv 注册函数在 DSPY 上下文不可用 - `references/dspy-verification-checklist.md` — 提交前 DSPY 文件验证清单(AST 检查 / 同目录对比 / init.py 核对 / 命名空间 / dbname) - `references/dspy-behavioral-stub-harness.md` — 用假 DBPools/sqlor 本地执行 .dspy 源码做行为验证(stub 必须模拟 SQL 侧过滤、生成 JS 的括号配平检查、场景矩阵含"缺席断言") - `references/dspy-generator-patterns.md` — 异步生成器异常处理 + 重复 ID 重试模式 - `references/tenant-domain-routing.md` — 多租户域名路由链路(nginx→tenant 模块→tenant_domain 表)+ X-Forwarded-Host 测试法