29 KiB
| name | description | version | author | platforms | metadata | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| sage-dspy-development | Sage DSPY file development patterns: exec-context rules, env registration, API patterns | 1.0.0 | Hermes Agent |
|
|
Sage DSPY 开发规范
DSPY 执行机制(CRITICAL)
ahserver 将 DSPY 代码包装为 async function 并 await 执行:
# 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)
关键影响
-
必须使用
return— DSPY 代码在 async function 内部执行,变量赋值不对外可见。必须用return返回值,不能用最后一行表达式:# ✅ 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 [] 会使它不执行 -
async with/await正常工作 — 因为代码在 async function 内被 await 执行,同步调用exec()不会阻止异步操作。 -
NoneType错误 = 缺少return— 当看到return data type error, <class 'NoneType'>时,90% 的情况是 DSPY 中忘记写return。 -
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 的标准写法,那是错的,已修正。
# 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— 唯一允许的 importasync 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 模板
# 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)
# 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:
# 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 或其他模块调用的函数,必须完成三步,第三步最常被漏:
- core.py:类方法
async def my_func(self, ...) - init.py:模块级 wrapper
async def my_func(...): return await get_manager().my_func(...) - 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.<func> = <func> 在 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 模块中:
# 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/<table>.json 存在时,Sage/ahserver 框架自动生成 CRUD 路由(/table_list/)。手写 wwwroot/<table>_list/ 及其 .dspy 文件与自动路由的 handler 命名冲突 → 框架返回 403 或 500 "invalid path"。
Detection: 检查是否同时存在 json/<table>.json 和 wwwroot/<table>_list/ 目录。
Fix: 二选一,不能两者并存:
- 方案 A(推荐): 删除手写目录,用
json/<table>.json的自动 CRUD。框架自动生成列表页和 CRUD 端点。 - 方案 B: 删除
json/<table>.json,手写完整的 DSPY + UI。
Pitfall: 不要在已有 json/<table>.json 的模块中手写 <table>_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 (<string>, line N) |
dspy 源码被写入转义引号 f\"...\"(整个文件编译失败→每次请求 500)。<string>, 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, <class 'NoneType'> |
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] 截断)。
部署前验表:
SELECT TABLE_NAME, COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA='<db>' AND COLUMN_NAME='id';
不是 varchar(32) → ALTER TABLE t MODIFY id VARCHAR(32)。
代码扫描(提交前):
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"}} 有两个致命问题:
- 函数返回
None时直接爆return data type error, <class 'NoneType'>,而 Python 脚本格式的json.dumps([])永远不出此错 - 错误栈不暴露真实异常原因,导致在多轮调试中走错方向(改 Python 函数、改 SQL、改 try/except)
正确格式(只用这种):
# 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, <class 'NoneType'> 反复出现 3 次 → 一直在改 Python 代码(加 try/except、修 SQL 字段名、改数据库连接)→ 实际上就是 currency_stats.dspy 用了 JSON 格式而其他 11 个 DSPY 都是 Python 脚本格式 → 改为 Python 脚本格式一行代码解决。
sor.C() vs sor.sqlExe('INSERT') — 提交行为差异(CRITICAL)
# ❌ 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 块退出时才提交。在块内写入一行后,紧接着调用另一个函数(它开自己的连接回读同一行),读到的是旧值:
# ❌ 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:
# 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 []
# 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. 前缀:
# 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/<app>.log,格式 2026-08-03 18:35:28.860[ragserver][info][<string>:6]消息(<string>:N 是 dspy 内部行号,可定位文件内位置)。
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 正常但用户页面异常时):
- dspy 顶部加
info()记录前台实际入参:params_kw+request.method+request.url - 本地改 → commit/push → 服务器 pull → 重启(dspy 改动需重启才生效)
- 自己先 curl 触发一次,grep 日志确认日志链路通
- 让用户复现一次,grep 日志看真实入参
- 若用户操作完全没出现在日志里 → 请求根本没到这个实例:查 nginx 路由(
cat /etc/nginx/sites-enabled/<app>看 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。
# ❌ .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:
# ✅ 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 字符串时:
# ❌ 危险——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()。
# ❌ 点击删除同时触发卡片导航进 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:
"binds": [
{"wid": "self", "event": "click", "actiontype": "script", "script": "event.stopPropagation()"},
{"wid": "self", "event": "click", "actiontype": "urlwidget", ...}
]
PopupWindow 表单提交后 AJAX 局部刷新(禁 location.reload)
问题:PopupWindow 内的表单保存成功后调用 location.reload() → 整页刷新,丢失页面状态用户看到闪烁。
正确做法:关闭弹窗 + AJAX 刷新目标面板,不触动页面其他部分。
// ✅ 三步: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:
# ❌ 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 语法:
# ❌ 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 '='。
# ❌ 可能失败 — 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 测试法