16 KiB
| name | description | version |
|---|---|---|
| ahserver-pitfalls | POST 405 fix, auth crash, multipart hang. Voiceprint howto. | 1.0.0 |
ahserver Pitfalls & Voiceprint Integration
POST 405 for startswiths Routes
aiohttp StaticResource tracks allowed methods in _allowed_methods set. ProcessorResource.__init__ adds POST to _routes but not _allowed_methods, so POST returns 405 with Allow: GET,HEAD.
Fix: In processorResource.py __init__, after _routes.update lines:
self._allowed_methods = set(self._routes.keys())
Auth Middleware Crash on Multipart Uploads
get_session_userinfo calls auth.get_auth(request) which raises RuntimeError('auth_middleware not installed') when auth is disabled via self.user = None. This crashes getPostData during multipart processing.
Fix in auth_api.py:
async def get_session_userinfo(request):
try:
d = await auth.get_auth(request)
except:
d = None
if d is None:
return DictObject()
client_max_size Too Small → Silent Hang
conf/config.json client_max_size: 10000 (10KB) causes large multipart uploads to hang. Small files work, large files (>client_max_size) never return a response.
Fix: Set to >= expected max file size. For audio/video: 104857600 (100MB).
Voiceprint Service (media.opencomputing.net:10443)
- Location:
ymq@opencomputing.net:/share/ymq/run/voiceprint - Start:
PYTHONPATH='.:sqlor:ahserver:appPublic:longtasks' python3 ah.py -p 9087 - GPU: cuda:1, ECAPA-TDNN model via speechbrain
- Endpoint:
POST /extract/submitwith multipartfilefield - Response:
{"status":"SUCCEEDED","embedding":[...],"embedding_dim":192}— nospeakersfield
speechbrain load_audio Signature
SpeakerRecognition.load_audio(self, path, savedir=None) — 2nd arg is savedir, NOT sample rate.
- ❌
load_audio(path, 16000)→ TypeError (int as Path) - ✅
load_audio(path)
sqlor IN (${ids}$) List Expansion Failure
On some sqlor versions, passing a Python list to ${ids}$ for IN clauses raises:
Illegal parameter data types varchar and row for operation '='
Workaround — build quoted comma-separated string manually:
id_list = ','.join(["'" + str(x) + "'" for x in doc_ids])
# Then use in raw SQL concatenation:
"... WHERE id IN (" + id_list + ")"
Always wrap in try/except as fallback.
DSPY %% LIKE Patterns — Avoid Escaped Quotes
Using \" inside a %%...%% LIKE pattern in a double-quoted Python string causes SyntaxError:
# ❌ BROKEN — \" closes the Python string
"... AND metadata LIKE '%%voiceprint_status%%\"done\"%%' ..."
# ✅ Use simple patterns without quoted substrings:
"... AND metadata LIKE '%%voiceprint_status%%done%%' ..."
SSH Background Process on This Server
nohup ... & hangs SSH. Preferred order:
ssh -f user@host "cmd"— forks background, returns immediately- Use
terminal(background=true)— Hermes's own background mode
Multipart Handler Pattern
ahserver auto-handles multipart: file saved to FileStorage, params_kw has web_path:
web_path = params_kw.get('file')
fs = FileStorage()
abs_path = fs.realPath(web_path)
Frontend: bricks 上传文件必须用 FormData,JSON.stringify 会静默丢 File 对象
bricks UiFile 只把浏览器 File 对象存进 this.value(内存),不会自动上传。AgentIO/TextFiles 经 HttpResponseStream.post → bricks_fetch 发送,当 params 不是 FormData 时走 JSON.stringify(data) —— File 对象被序列化成 {},文件内容静默丢失(只有 f.name 字符串能传出去)。这就是"用户上传了文件但后端 agent 收不到内容"的根因。
修复(bricks/agent.js 的 user_inputed):有 add_files 时构造 FormData 上传文件二进制:
var files = params.add_files || [];
var send_params = params;
if (files.length > 0) {
send_params = new FormData();
Object.keys(params).forEach(function(k){
if (k !== 'add_files' && k !== 'file_names') send_params.append(k, params[k]);
});
files.forEach(function(f){ send_params.append('file', f); });
}
var resp = await hr.post(this.opts.url, {params:send_params});
bricks_fetch 已处理 data instanceof FormData(自动 append session、body=FormData)。改完需重新 build dist/bricks.js:bash build.sh(把 bricks/*.js 按 SOURCES 列表 cat 合并到 dist,前端加载的是 dist 打包版,不是源码)。
dspy 后端取文件:params_kw.get('file')(单个 web_path 或 list),FileStorage().realPath() 拿绝对路径。docx 文本提取:zipfile 读 word/document.xml + re.findall(r'<w:t[^>]*>(.*?)</w:t>', xml)(cat 读 docx 是乱码,必须解 zip 提取 <w:t>)。
DSPY Silent Error Swallowing
DSPY files often wrap logic in except Exception: return "加载失败". This silently hides the real error. When debugging, always replace with:
except Exception as _e:
import traceback
return {"widgettype":"Text","options":{"text":str(_e)+"\n"+traceback.format_exc()[-200:]}}}
Common hidden errors: sqlor placeholder mismatch, Python SyntaxError in string concatenation, missing imports.
Tag Storage: Dual Sources (media_tags table + metadata.tags)
Tags can live in TWO places:
media_tags+tagstables — from face processing, tag assignmentdocuments.metadata.tagsJSON array — fromadd_tag.dspyUI
When displaying tags on cards, read from BOTH sources:
# Source 1: media_tags table
doc_tags = {}
try:
id_list = ','.join(["'" + str(x) + "'" for x in doc_ids])
mt_recs = await sor.sqlExe(
"SELECT mt.media_id, t.name, t.color FROM media_tags mt " +
"JOIN tags t ON mt.tag_id=t.id " +
"WHERE mt.media_type='document' AND mt.media_id IN (" + id_list + ")",
ns={})
for mt in mt_recs:
doc_tags.setdefault(mt.media_id, []).append({"name": mt.name, "color": mt.color})
except: pass
# Source 2: metadata.tags from add_tag.dspy
for r in rows:
try:
meta = json.loads(r.get("metadata", "{}"))
for t in meta.get("tags", []):
# deduplicate
existing = doc_tags.get(r["id"], [])
if not any(e.get("name")==t for e in existing):
existing.append({"name": t, "color": "#3b82f6"})
doc_tags[r["id"]] = existing
except: pass
Media URLs in DSPY: Use entire_url()
Widgets like VideoPlayer/Image/Audio need absolute URLs. safe_url() only prepends /idfile:
# ❌ Relative path — breaks in some contexts
media_url = safe_url(h.get("file_path", ""))
# ✅ Absolute URL
media_url = entire_url(safe_url(h.get("file_path", "")))
# Result: https://rag.opencomputing.cn/idfile/117/169/.../file.mp4
Media Cards: Voice Query Including Videos
Voice cards should show BOTH audio files AND videos with extracted voiceprints:
# Query includes videos that have voiceprint_status=done
"WHERE kb_id=${kb_id}$ AND (metadata LIKE '%%voiceprint_status%%done%%' " +
"OR LOWER(file_name) LIKE '%%.mp3' OR LOWER(file_name) LIKE '%%.wav' ...)"
Video Playback: Native <video> Over bricks VideoPlayer
bricks VideoPlayer widget may not render/play in search result cards due to height collapse (height:100% vs parent with no height). Use native HTML5 <video> tag via Html widget instead (same pattern as <audio>):
{"widgettype": "Html", "options": {
"html": "<video controls autoplay muted playsinline style=\"width:100%;max-height:400px\" src=\"" + media_url + "\"></video>",
"padding": "4px 0"}}
controls— visible playback controls (seek bar, volume)autoplay muted— autoplay works because muted (browser policy)playsinline— iOS inline playbackmax-height:400px— prevents distortion without fixed height
DSPY Boolean: Python True not JS true
DSPY files are Python code. JavaScript-style true/false causes NameError at runtime:
# ❌ NameError: name 'true' is not defined
{"autoplay": true}
# ✅ Correct
{"autoplay": True}
JSON serialization converts Python True → JS true automatically.
Chunk Metadata for Position Info
Search results can show position info (face bbox, video timestamps) if stored in document_chunks.metadata during ingest:
- Face images:
{"bbox": {"x1":100,"y1":200,"x2":300,"y2":400}}or{"bboxes":[{...}]}for multiple faces - Video frames:
{"start_time": 3.5, "bboxes":[...]} - Audio segments:
{"start_time": 0.0, "end_time": 5.2}
In upload_file.dspy, save face detection results to chunk metadata:
faces = results[0].get("faces", [])
frame_bboxes = [f.get("bbox", {}) for f in faces[:10]]
chunk_meta = {"start_time": 0}
if frame_bboxes:
chunk_meta["bboxes"] = frame_bboxes
# Pass to INSERT as json.dumps(chunk_meta)
In search_result.dspy, display position info:
meta_info = []
bbox = h.get("bbox")
if bbox:
meta_info.append(f"📍 ({x1:.0f},{y1:.0f})-({x2:.0f},{y2:.0f})")
start_t, end_t = h.get("start_time"), h.get("end_time")
if start_t is not None:
meta_info.append(f"⏱ {start_t:.1f}s → {end_t:.1f}s")
DSPY exec() SyntaxError — Orphaned Lines
When replacing code in a DSPY file, orphaned lines after return cause IndentationError because exec() processes the entire file as one block. After return, all following lines must be syntactically valid or removed. Use skip-mode replacement (remove all lines between if kind == 'voice': and else: when inserting a debug return).
DSPY Filename Reservation — Silent 404
ahserver silently returns 404 for dspy files whose names contain any of these reserved words:
login, auth, signin, identify, usercheck
All tested failing names (returns 404 with ANY content, including return {"status":"ok"}):
login, do_login, pccs_login, auth_login, signin, auth, identify, pccsauth, usercheck, member
Working names: test, test2, hello, testnow, gateway, world, welcome, verify, member (before cache), open, sesame, check, user, enter, abcdefgh, random strings
Fix: use single dictionary words or short random strings for dspy filenames.
gateway.dspy, member.dspy, check.dspy, hello.dspy all work.
DSPY Failure Cache — PERMANENT and UNAVOIDABLE
Once a dspy file fails (code error, import error, etc.), ahserver caches the filename as "failing" and returns 404 for ALL future requests to that filename. Even restarting the server does NOT clear this cache. Changing the file content also does NOT fix it.
Only workaround: create a file with a COMPLETELY NEW filename that has never been used.
Correct workflow for creating a login dspy:
- Copy a known-working dspy to a never-used name:
cp working.dspy newname.dspy - Overwrite with your code: write full login logic to newname.dspy
- Test once — if it works, NEVER change the file content
Wrong workflow (will cause permanent 404):
- Create new file with full code → fails (e.g. missing import) → cached as "failing"
- Fix the code → file still returns 404 (cache persists)
- Delete file, recreate → still 404
aiohttp 3.10 + Empty Prefix Fix
website.paths MUST use empty string prefix, NOT "/":
"paths": [["/d/pccs/wwwroot", ""]]
With "/" prefix, ProcessorResource._handle is never called; aiohttp's StaticResource.resolve()
fails to match, falling through to SystemRoute._handle → 404. Empty prefix "" works correctly.
DSPY Login: Use password_encode() not SHA256
Sage framework stores passwords via password_encode() which AES-encrypts. In login dspy:
pw_encoded = password_encode(password)
# Compare with user.password from DB (AES-encrypted)
if pw_encoded != (user.password or ''):
return {'status': 'error', 'message': '密码错误'}
password_encode is injected into dspy namespace (from globalEnv.py).
SHA256 will never match the AES ciphertext.
getConfig() 传目录,不是 config.json 文件路径
appPublic.jsonConfig.getConfig(path) 内部执行 os.path.join(path, 'conf', 'config.json'),所以第一个参数是目录(ROOT_DIR),不是 config.json 文件本身:
# ❌ NotADirectoryError: '.../conf/config.json/conf/config.json'
config = getConfig(os.path.join(ROOT_DIR, 'conf', 'config.json'))
# ✅ 传目录
config = getConfig(ROOT_DIR, NS={'workdir': ROOT_DIR, 'ProgramPath': ProgramPath()})
独立应用(pipeline-app 等)根目录的 set_role_perm.py 常从 Sage 直接复制,自带这个 bug:一跑就 NotADirectoryError。第二个移植 bug:它硬编码 Sage 的 role_path 表 + sqlorContext('sage'),而 pipeline 独立应用用 permission(id/path 唯一)+ rolepermission(id/roleid/permid)表,库名是 pipeline(SAGE_RBAC_DB: pipeline)。正确做法是 sor.R('permission', {'path': path}) 取 permid,再 sor.C('rolepermission', {'id': getID(), 'roleid': role, 'permid': permid})。
诊断相关:sqlor 的 LIKE '/x%' 中 % 会被 aiomysql query % args 当成占位符 → TypeError: not enough arguments for format string,用 %% 转义(LIKE '/pipeline-sdlc%%')。
Wterm 终端 + .xterm 文件(SSH 终端 / vi 编辑)
Bricks Wterm 控件通过 WebSocket 连到 .xterm 后端:Wterm → XtermProcessor → SSHServer(appPublic/sshx.py)→ asyncssh。完整链路:
.xterm文件放 modulewwwroot/,是 Python 脚本(同 .dspy 语法),返回DictObject{host, username, cmdargs}(SSH 连接信息 + 要执行的命令)。params_kw.id取查询串参数。- 前端触发:
ws_url = entire_url('/wss/<module>/xxx.xterm') + '?id=' + quote(file_id)。.xterm里读params_kw.id。 - nginx
location /wss/消费 WebSocket 升级(proxy_pass http://localhost:PORT/+Upgrade $http_upgrade+Connection $connection_upgrade+X-Forwarded-Path 'wss')。 conf/config.jsonprocessors需加[".xterm","xterm"](pipeline 部署默认只有 dspy/bui/tmpl,缺则.xterm被当静态文件 404)。.ws同理加[".ws","ws"]。- SSH 免密登录:
.xterm返回{host:'localhost', username:'pipeline'},靠~/.ssh/authorized_keys(服务端ssh localhost免密)。sshx.SSHServer无 password/client_keys 时走默认 key auth。
关键坑 — asyncssh create_process 只接受单个 command
xtermProcessor.run_xterm() 用 conn.create_process(*login_info.cmdargs, term_type='xterm-256color', term_size=(80,24))。新版 asyncssh 的 create_process(*args, **kw) → create_session(factory, command, *, ...) 只接受单个 command 位置参数(command: Optional[str])。
cmdargs = ['vi', '/path'](2 元素)展开为 create_session(factory, 'vi', '/path', ...) → 报:
TypeError: SSHClientConnection.create_session() takes from 2 to 3 positional arguments but 4 positional arguments (and 2 keyword-only arguments) were given
(单元素 ['cmd'] 正常,因为只传 1 个 command。)
Fix:cmdargs 必须是单元素列表,命令 join 成字符串:
import shlex
r.cmdargs = ['vi ' + shlex.quote(full_path)] # ✅ 单元素
# r.cmdargs = ['vi', full_path] # ❌ 2 元素 → TypeError
entire_url 返回 https:// 但 WebSocket 自动转 wss
entire_url('/wss/...xterm') 返回 https://...(urlWebsocketify 只对 .ws/.wss 结尾做 ws 转换,.xterm 不转)。但浏览器 new WebSocket('https://...') 按 WebSocket spec 自动把 https→wss,所以用 entire_url 正确,无需 websocket_url(websocket_url 也会转 wss,两者都能连,但 entire_url 是约定)。
验证 vi/终端启动成功
浏览器控制台出现 VIM 初始化序列即证明 SSH 连接成功 + 进程运行:
ws msg= {type: 1, data: Object} ← WebSocket 数据流
key= \u001b[2;2R \u001b[3;1R ← 光标定位查询
key= \u001b[>0;276;0c ← 设备属性响应
key= \u001b]10;rgb:ffff/ffff/ffff ← 前景色查询
websocket closed: 1000 + 服务器日志 create_process ... TypeError 则是 cmdargs 展开问题(见上)。