16 KiB
Raw Blame History

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/submit with multipart file field
  • Response: {"status":"SUCCEEDED","embedding":[...],"embedding_dim":192} — no speakers field

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:

  1. ssh -f user@host "cmd" — forks background, returns immediately
  2. 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 上传文件必须用 FormDataJSON.stringify 会静默丢 File 对象

bricks UiFile 只把浏览器 File 对象存进 this.value(内存),不会自动上传AgentIO/TextFilesHttpResponseStream.post → bricks_fetch 发送,当 params 不是 FormData 时走 JSON.stringify(data) —— File 对象被序列化成 {},文件内容静默丢失(只有 f.name 字符串能传出去)。这就是"用户上传了文件但后端 agent 收不到内容"的根因。

修复(bricks/agent.jsuser_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.jsbash build.sh(把 bricks/*.js 按 SOURCES 列表 cat 合并到 dist前端加载的是 dist 打包版,不是源码)。

dspy 后端取文件:params_kw.get('file')(单个 web_path 或 listFileStorage().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:

  1. media_tags + tags tables — from face processing, tag assignment
  2. documents.metadata.tags JSON array — from add_tag.dspy UI

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 playback
  • max-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:

  1. Copy a known-working dspy to a never-used name: cp working.dspy newname.dspy
  2. Overwrite with your code: write full login logic to newname.dspy
  3. Test once — if it works, NEVER change the file content

Wrong workflow (will cause permanent 404):

  1. Create new file with full code → fails (e.g. missing import) → cached as "failing"
  2. Fix the code → file still returns 404 (cache persists)
  3. 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 独立应用用 permissionid/path 唯一)+ rolepermissionid/roleid/permid库名是 pipelineSAGE_RBAC_DB: pipeline)。正确做法是 sor.R('permission', {'path': path}) 取 permidsor.C('rolepermission', {'id': getID(), 'roleid': role, 'permid': permid})

诊断相关:sqlorLIKE '/x%'% 会被 aiomysql query % args 当成占位符 → TypeError: not enough arguments for format string,用 %% 转义(LIKE '/pipeline-sdlc%%')。

Wterm 终端 + .xterm 文件SSH 终端 / vi 编辑)

Bricks Wterm 控件通过 WebSocket 连到 .xterm 后端:WtermXtermProcessorSSHServerappPublic/sshx.pyasyncssh。完整链路:

  1. .xterm 文件放 module wwwroot/,是 Python 脚本(同 .dspy 语法),返回 DictObject{host, username, cmdargs}SSH 连接信息 + 要执行的命令)。params_kw.id 取查询串参数。
  2. 前端触发ws_url = entire_url('/wss/<module>/xxx.xterm') + '?id=' + quote(file_id).xterm 里读 params_kw.id
  3. nginx location /wss/ 消费 WebSocket 升级(proxy_pass http://localhost:PORT/ + Upgrade $http_upgrade + Connection $connection_upgrade + X-Forwarded-Path 'wss')。
  4. conf/config.json processors 需加 [".xterm","xterm"]pipeline 部署默认只有 dspy/bui/tmpl缺则 .xterm 被当静态文件 404.ws 同理加 [".ws","ws"]
  5. 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。

Fixcmdargs 必须是单元素列表,命令 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_urlwebsocket_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 展开问题(见上)。