29 KiB
Raw Blame History

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
linux
hermes
tags related_skills
dspy
sage
development
patterns
bricks
pre-commit-crud-check
rbac-permission-initialization-pattern

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)

关键影响

  1. 必须使用 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 [] 会使它不执行
    
  2. async with / await 正常工作 — 因为代码在 async function 内被 await 执行,同步调用 exec() 不会阻止异步操作。

  3. NoneType 错误 = 缺少 return — 当看到 return data type error, <class 'NoneType'> 时,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 的标准写法,那是错的,已修正。

# 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 模板

# 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 或其他模块调用的函数,必须完成三步,第三步最常被漏:

  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.<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"}} 有两个致命问题:

  1. 函数返回 None 时直接爆 return data type error, <class 'NoneType'>,而 Python 脚本格式的 json.dumps([]) 永远不出此错
  2. 错误栈不暴露真实异常原因,导致在多轮调试中走错方向(改 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 正常但用户页面异常时):

  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/<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 测试法