pipeline-llm/scripts/test_llm_call_trace_io.py
yumoqing 69fe362dde feat(trace): 调用原文追踪「查看IO」前端闭环+按项目费用页(2026-09-10定夺落地)
- json/llm_call_trace_list.json: noedit只读列表;exclouded隐藏id/req_path/resp_path/org_id;
  url/note加宽;kind/method内联枚举(含gen同步生成,此前草稿引用的get_llm_kind_options.dspy不存在);
  删color_mapping(bricks全库0消费的死配置);toolbar查看IO+binds urlwidget→PopupWindow(85%)
  url不带?id=${id}$——生成期ArgsConvert会eval成<built-in function id>垃圾串,
  行数据id经_add_event_data自动并入params(dapi/llmage生产同款)
- api/llm_call_trace_io.dspy: 薄代理(get_user门禁+get_userorgid机构隔离)→env.llm_call_trace_io;
  修草稿4处实锤bug:get_user未await/user.get当dict/sor块外使用/返回PopupWindow套娃
- init.py: llm_call_trace_io读表+同批次+llm_usage上下文(块内读全拷dict)→读文件
  (trace.trace_root realpath前缀校验防穿越)→TabPanel三tab(摘要Html+pre escape防注入/
  请求/响应CodeEditor readonly mode=null);单侧150K截断提示盘上路径;env注册
- api/llm_project_cost_query.dspy: 改{total,rows}契约接Tabular data_url(PageDataLoader);
  wwwroot/llm_project_cost/index.ui: InlineForm(date_from/date_to/status)+Tabular;
  script内getWidgetById起点bricks.app(弹窗DOM挂body下,app.root搜不到)
- card_popup.dspy: titles加llm_call_trace;新增page_targets白名单挂按项目费用页;
  index.ui挂两张卡片(调用原文追踪/按项目费用)
- accounting.py: _maybe_cleanup_traces每日一次接cleanup_expired(trace.py铁律3
  文档承诺worker每日调但循环没接,补齐);放抢锁前(清理幂等多进程同日无害)
- scripts/load_path.py: 注释说明/**通配已覆盖新页面(rbac check_roles_path前缀匹配)
- models/mysql.ddl/README/design-spec: 9张表同步(kind补gen枚举;3.9节;F19/F20)
- scripts/test_llm_call_trace_io.py: stub harness 22断言全过(隔离/穿越/转义/截断/每日门控)
- .gitignore: wwwroot/llm_call_trace/生成目录不入库
2026-09-11 14:58:21 +08:00

180 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""llm_call_trace_io 行为验证stub harness不依赖真实 DB/宿主)。
覆盖场景:
1. 无 id / 超长 id → Message 提示
2. 查无记录 → Message 提示
3. 机构隔离:普通机构看别家行 → 拒绝;平台 org '0' → 放行;本机构 → 放行
4. 正常行 → TabPanel 三 tabsummary/req/respCodeEditor readonly摘要含
usage 上下文 + 同批次行
5. 路径穿越req_path='../../etc/passwd' → [非法路径,拒绝访问]
6. 文件缺失 → 提示无原文文件
"""
import asyncio
import json
import os
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ---- stub: 假 filesroot + 真实 trace 文件 ----
tmp = tempfile.mkdtemp()
FILESROOT = tmp
trace_rel = 'llm_trace/20260911/callA_000'
os.makedirs(os.path.join(FILESROOT, 'llm_trace/20260911'), exist_ok=True)
with open(os.path.join(FILESROOT, trace_rel + '.req.json'), 'w') as f:
json.dump({'url': 'https://up/v1/chat', 'method': 'POST', 'kind': 'chat',
'headers': {'Authorization': '[REDACTED]'},
'body': {'model': 'm1', 'messages': [{'role': 'user', 'content': 'hi <b>'}]}}, f)
with open(os.path.join(FILESROOT, trace_rel + '.resp.json'), 'w') as f:
json.dump({'status_code': 200, 'elapsed_ms': 123, 'error': '',
'body': {'choices': [{'message': {'content': 'hello'}}]}}, f)
# ---- stub: appPublic.jsonConfig.getConfig 返回假 filesroot ----
import types
fake_appPublic = types.ModuleType('appPublic')
fake_jc = types.ModuleType('appPublic.jsonConfig')
class _Cfg:
filesroot = FILESROOT
fake_jc.getConfig = lambda *a, **k: _Cfg()
sys.modules['appPublic.jsonConfig'] = fake_jc
# ---- stub: DBPools / sqlor ----
TRACE_ROW = {
'id': 'tid001', 'call_id': 'callA', 'seq': 0, 'kind': 'chat',
'url': 'https://up/v1/chat/completions', 'method': 'POST',
'status_code': 200, 'elapsed_ms': 123,
'req_path': trace_rel + '.req.json', 'resp_path': trace_rel + '.resp.json',
'org_id': 'org9', 'note': '', 'created_at': '2026-09-11 10:00:00',
}
BATCH_ROWS = [TRACE_ROW, {**TRACE_ROW, 'id': 'tid002', 'seq': 1, 'kind': 'query',
'status_code': 200, 'note': ''}]
USAGE_ROWS = [{'status': 'SUCCEEDED', 'accounting_status': 'accounted', 'cost': '0.01',
'charge': '0.02', 'task_ref': 'sess1', 'project_id': '0', 'model_name': 'qwen-x'}]
class FakeSor:
def __init__(self, db): self.db = db
async def sqlExe(self, sql, ns):
self.db.calls.append((sql, ns))
if 'FROM llm_call_trace WHERE id=' in sql:
return [dict(TRACE_ROW)] if not self.db.no_row else []
if 'FROM llm_call_trace WHERE call_id=' in sql:
return [dict(r) for r in BATCH_ROWS]
if 'FROM llm_usage u LEFT JOIN llm_model' in sql:
return [dict(r) for r in USAGE_ROWS]
return []
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
class FakeDB:
def __init__(self): self.databases = {}; self.no_row = False; self.calls = []
def sqlorContext(self, dbname): return FakeSor(self)
_FAKE_DB = FakeDB()
import sqlor.dbpools as dbpools
dbpools.DBPools = lambda *a, **k: _FAKE_DB
# ---- stub: ahserver.serverenv.ServerEnv ----
class FakeEnv(dict):
_inst = None
def __new__(cls):
if FakeEnv._inst is None: FakeEnv._inst = super().__new__(cls)
return FakeEnv._inst
def __getattr__(self, k):
try: return self[k]
except KeyError: raise AttributeError(k)
import ahserver.serverenv as serverenv
serverenv.ServerEnv = FakeEnv
# ---- stub: gateway._get_dbtrace.py/accounting 用)----
import pipeline_llm.gateway as gw
gw._get_db = lambda: (_FAKE_DB, 'pipeline')
import pipeline_llm.init as m
FAILS = []
def check(name, cond, detail=''):
print(('PASS' if cond else 'FAIL'), name, detail if not cond else '')
if not cond: FAILS.append(name)
def find_widget(w, wtype):
if isinstance(w, dict):
if w.get('widgettype') == wtype: return w
for v in w.values():
r = find_widget(v, wtype)
if r: return r
elif isinstance(w, list):
for v in w:
r = find_widget(v, wtype)
if r: return r
return None
async def main():
# 1. 缺 id / 超长 id
r = await m.llm_call_trace_io({}, '0')
check('缺id → Message', r['widgettype'] == 'Message')
r = await m.llm_call_trace_io({'id': 'x' * 40}, '0')
check('超长id → Message', r['widgettype'] == 'Message')
# 2. 查无记录
_FAKE_DB.no_row = True
r = await m.llm_call_trace_io({'id': 'tid001'}, '0')
check('查无 → Message', r['widgettype'] == 'Message')
_FAKE_DB.no_row = False
# 3. 机构隔离
r = await m.llm_call_trace_io({'id': 'tid001'}, 'other_org')
check('他机构 → 拒绝', r['widgettype'] == 'Message' and '无权' in r['options']['message'])
r = await m.llm_call_trace_io({'id': 'tid001'}, '0')
check("平台org'0' → 放行", r['widgettype'] == 'TabPanel')
r = await m.llm_call_trace_io({'id': 'tid001'}, 'org9')
check('本机构 → 放行', r['widgettype'] == 'TabPanel')
# 4. TabPanel 结构
items = r['options']['items']
check('3个tab', len(items) == 3)
check('tab名', [t['name'] for t in items] == ['summary', 'req', 'resp'])
ce_req = items[1]['content']
check('req是CodeEditor', ce_req['widgettype'] == 'CodeEditor')
check('readonly', ce_req['options']['readonly'] is True)
check('mode null', ce_req['options']['mode'] == 'null')
val = json.loads(ce_req['options']['value'])
check('req原文含REDACTED头', val['headers']['Authorization'] == '[REDACTED]')
check('req原文含body', val['body']['messages'][0]['content'] == 'hi <b>')
resp_val = json.loads(items[2]['content']['options']['value'])
check('resp原文body', resp_val['body']['choices'][0]['message']['content'] == 'hello')
summary_html = find_widget(items[0]['content'], 'Html')['options']['html']
check('摘要含usage模型名', 'qwen-x' in summary_html)
check('摘要含批次往返数', '同批次往返2 次)' in summary_html)
# HTML 转义:摘要走 Html widget正文里任何 < > 必须被 escape防注入
TRACE_ROW['note'] = '<script>alert(1)</script>'
r2 = await m.llm_call_trace_io({'id': 'tid001'}, '0')
s2 = find_widget(r2['options']['items'][0]['content'], 'Html')['options']['html']
check('note<script>被转义', '<script>' not in s2 and '&lt;script&gt;' in s2)
TRACE_ROW['note'] = ''
# CodeEditor value 是 JSON 字符串(非 HTMLreq body 里的 <b> 原样保留不转义
check('req原文<b>原样', 'hi <b>' in items[1]['content']['options']['value'])
# 5. 路径穿越
bad = dict(TRACE_ROW)
bad['req_path'] = '../../etc/passwd'
txt, err = m._read_trace_file('../../etc/passwd')
check('穿越拒绝', err == '[非法路径,拒绝访问]' and txt == '')
txt, err = m._read_trace_file('llm_trace/20260911/../../etc/passwd')
check('穿越拒绝2(中间..)', err == '[非法路径,拒绝访问]')
# 6. 文件缺失
txt, err = m._read_trace_file('llm_trace/20260911/nonexist.req.json')
check('缺文件报错', '文件读取失败' in err)
txt, err = m._read_trace_file('')
check('空路径提示', '无原文文件' in err)
print()
if FAILS:
print('FAILED:', FAILS); sys.exit(1)
print('ALL PASS')
asyncio.run(main())