fix(upload): 上传截断显式告知+generic补文件处理

This commit is contained in:
ymq 2026-08-31 21:22:41 +08:00
parent c4f37f61db
commit e598665b8e
3 changed files with 177 additions and 36 deletions

View File

@ -0,0 +1,122 @@
# upload_tools.py - 会话上传文件处理统一入口agent_chat / agent_chat_generic 共用)
#
# 根治两个真实 bug
# 1. 静默截断_extract_text 硬截 15000 字符LLM 把截断内容当完整文件回答
# 实测30031 字符文件LLM 只看到前 ~15000答"最后一行"答错且不自知)。
# 修复:截断必须显式告知——注入预览 + 总字符数 + 全文获取手段read_file 路径)。
# 2. generic 入口完全忽略 file 字段:上传静默丢弃。修复:两个入口走同一处理。
import os
import re
import zipfile
PREVIEW_LIMIT = 15000 # 注入 prompt 的预览上限read_file 单次可读 30000全文可取
TEXT_EXTS = ('.txt', '.md', '.json', '.csv', '.py', '.log', '.yaml', '.yml', '.xml', '.html', '.ini')
def extract_text(path, name):
"""提取文件文本。返回 (preview_text, total_chars, truncated)。
二进制/无法解析 ('', 0, False)绝不静默截断调用方拿得到总长与截断标志
"""
ext = os.path.splitext(name)[1].lower()
try:
if ext in TEXT_EXTS or ext == '':
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
full = f.read()
return full[:PREVIEW_LIMIT], len(full), len(full) > PREVIEW_LIMIT
if ext == '.docx':
with zipfile.ZipFile(path) as z:
xml = z.read('word/document.xml').decode('utf-8', errors='ignore')
full = '\n'.join(re.findall(r'<w:t[^>]*>(.*?)</w:t>', xml))
return full[:PREVIEW_LIMIT], len(full), len(full) > PREVIEW_LIMIT
except Exception:
pass
return '', 0, False
async def resolve_upload_dir(sor, uid, session_id=''):
"""上传文件落盘目录:有当前项目 → 项目根目录;无项目 → workspace 会话目录。
两个位置都在会话 agent read_file 根内项目根 / WORKSPACE_BASE
保证上传放的位置 = agent 读的位置
返回 (dir, rel_prefix)rel_prefix 是相对 read_file 根的路径前缀项目根为 ''
"""
try:
from pipeline_service.workspace import (
get_session_project_id, get_project_dir_by_id, WORKSPACE_BASE)
except Exception:
return '', ''
try:
pid = await get_session_project_id(sor, uid, session_id or '')
if pid:
pdir, _ = await get_project_dir_by_id(sor, pid)
if pdir:
return pdir, ''
except Exception:
pass
sess = (session_id or uid or 'default').replace('/', '_').replace('..', '_')
return os.path.join(WORKSPACE_BASE, '_uploads', sess), '_uploads/' + sess + '/'
def save_uploads(target_dir, uploads):
"""复制上传文件到目标目录,同名自动加后缀防覆盖。返回 [(保存名, 绝对路径)]。"""
import shutil
saved = []
if not target_dir:
return saved
try:
os.makedirs(target_dir, exist_ok=True)
except Exception:
return saved
for src, name in uploads:
name = os.path.basename((name or '').strip())
if not name or not os.path.isfile(src):
continue
target = os.path.join(target_dir, name)
if os.path.exists(target):
stem, ext = os.path.splitext(name)
i = 1
while os.path.exists(os.path.join(target_dir, f"{stem}_{i}{ext}")):
i += 1
target = os.path.join(target_dir, f"{stem}_{i}{ext}")
try:
shutil.copyfile(src, target)
saved.append((os.path.basename(target), target))
except Exception:
pass
return saved
def build_file_context(items):
"""items: [(filename, relpath, preview, total, truncated, is_binary)]
生成注入 prompt 的上下文截断时显式告知预览性质与全文获取手段
LLM 绝不能把预览当完整文件
"""
if not items:
return ''
parts = ["用户本次上传了以下文件:"]
for fname, rel, preview, total, truncated, is_binary in items:
parts.append(f" - {fname}" + (f"read_file 路径:{rel}" if rel else ""))
parts.append("")
for fname, rel, preview, total, truncated, is_binary in items:
if is_binary:
parts.append(f"【文件 {fname}】二进制文件,无法直接读取文本。")
if rel:
parts.append(f"如需处理,用 run_command 或 read_file 提示用户传文本版本。")
parts.append("")
continue
if truncated:
parts.append(
f"【文件 {fname} 内容预览:前 {len(preview)} 字符 / 全文共 {total} 字符,"
f"其余未展示——这不是完整文件】")
parts.append(preview)
if rel:
parts.append(
f"⚠️ 需要完整内容或后文时,用 read_file 读取 \"{rel}\""
f"(单次最多 30000 字符,可分段读);不要基于预览臆断被截断部分。")
else:
parts.append(f"【文件 {fname} 完整内容({total} 字符)】")
parts.append(preview)
parts.append("")
return "\n".join(parts)

View File

@ -10,20 +10,10 @@ from ahserver.filestorage import FileStorage
def _extract_text(path, name):
"""提取文件文本内容docx/txt/md等返回文本或空字符串。二进制/无法解析返回空。"""
ext = os.path.splitext(name)[1].lower()
try:
if ext in ('.txt', '.md', '.json', '.csv', '.py', '.log', '.yaml', '.yml', '.xml', '.html', '.ini'):
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
return f.read()[:15000]
if ext == '.docx':
with zipfile.ZipFile(path) as z:
xml = z.read('word/document.xml').decode('utf-8', errors='ignore')
texts = re.findall(r'<w:t[^>]*>(.*?)</w:t>', xml)
return '\n'.join(texts)[:15000]
except Exception:
pass
return ''
"""[已废弃] 保留签名兼容;真实逻辑在 pipeline_core.upload_tools.extract_text。"""
from pipeline_core.upload_tools import extract_text
preview, _total, _trunc = extract_text(path, name)
return preview
action = (params_kw or {}).get('action', 'send_message')
@ -46,9 +36,9 @@ if action == 'send_message':
prompt = prompt.strip()
# 处理用户上传的文件multipart file 字段 → web_path
# 1. 抽取文本作为中性上下文注入 prompt
# 2. 收集 (绝对路径, 文件名),稍后复制进项目根目录,让会话 agent 的 read_file 能找到
file_ctx = ''
# 统一走 pipeline_core.upload_tools——截断显式告知、落盘位置=agent 可读位置。
from pipeline_core.upload_tools import extract_text, resolve_upload_dir, save_uploads, build_file_context
_items = [] # build_file_context 入参
_uploads = [] # [(src_abs, filename)]
_fval = (params_kw or {}).get('file')
_fpaths = _fval if isinstance(_fval, list) else ([_fval] if _fval else [])
@ -57,11 +47,6 @@ if action == 'send_message':
_abs = FileStorage().realPath(_fp)
_name = os.path.basename(_abs)
_uploads.append((_abs, _name))
_txt = _extract_text(_abs, _name)
if _txt:
file_ctx += f"【文件 {_name} 内容】\n{_txt}\n\n"
else:
file_ctx += f"【文件 {_name}】二进制文件,无法直接读取文本。\n\n"
except Exception:
pass
@ -81,24 +66,24 @@ if action == 'send_message':
# ── 产线默认能力(入口指定,如 bidding_general无当前项目时装载该产线能力 ──
pipeline_id = (params_kw or {}).get('pipeline_id', '') or ''
# ── 上传文件落盘到项目根目录(会话 agent 的 read_file 以项目目录为根,
# 不复制进去则 agent 永远找不到;无当前项目时仅注入文本)──
_saved_files = []
# ── 上传文件落盘有项目→项目根无项目→workspace 会话目录)+ 生成显式截断上下文 ──
file_ctx = ''
if _uploads:
try:
async with DBPools().sqlorContext(dbname) as sor:
_pid = await get_session_project_id(sor, uid, session_id)
_pdir, _ = await get_project_dir_by_id(sor, _pid)
_saved_files = copy_uploads_to_project(_pdir, _uploads)
_udir, _rel = await resolve_upload_dir(sor, uid, session_id)
_saved = save_uploads(_udir, _uploads)
_byname = {os.path.basename(src): n for (src, _n), (n, _p) in zip(_uploads, _saved)}
for _src, _name in _uploads:
_preview, _total, _trunc = extract_text(_src, _name)
_isbin = (not _preview and _total == 0)
_relp = (_rel + _byname.get(_name, _name)) if _name in _byname else ''
_items.append((_name, _relp, _preview, _total, _trunc, _isbin))
file_ctx = build_file_context(_items)
except Exception:
_saved_files = []
if file_ctx or _saved_files:
_loc = ''
if _saved_files:
_loc = "文件已保存到项目根目录(" + _pdir + "),可用 read_file 直接读取:\n"
_loc += "\n".join(" - " + n for n, _ in _saved_files) + "\n\n"
prompt = "用户本次上传了以下文件:\n" + _loc + file_ctx + "\n用户指令" + prompt
file_ctx = ''
if file_ctx:
prompt = file_ctx + "\n用户指令" + prompt
# ── 走 gateway 统一入口Web AgentIO 通道)──
# model_id前端模型下拉选中的模型 → gateway 校验后持久化到项目(项目模型一经设置

View File

@ -30,6 +30,40 @@ if action == 'send_message':
# 默认模型名与 llm 表不匹配时直接"无可用模型"卡死。
model_id = (params_kw or {}).get('model_id', '') or ''
# 上传文件:之前完全忽略 file 字段(上传静默丢弃)。与 agent_chat 走同一套处理:
# 落盘到 workspace 会话目录generic 无项目read_file 根=WORKSPACE_BASE相对路径可读
import os
from ahserver.filestorage import FileStorage
from sqlor.dbpools import DBPools
from pipeline_core.upload_tools import extract_text, resolve_upload_dir, save_uploads, build_file_context
_uploads = []
_fval = (params_kw or {}).get('file')
_fpaths = _fval if isinstance(_fval, list) else ([_fval] if _fval else [])
for _fp in _fpaths:
try:
_abs = FileStorage().realPath(_fp)
_uploads.append((_abs, os.path.basename(_abs)))
except Exception:
pass
if _uploads:
try:
_dbname = get_module_dbname('pipeline_core')
async with DBPools().sqlorContext(_dbname) as sor:
_udir, _rel = await resolve_upload_dir(sor, uid, '')
_saved = save_uploads(_udir, _uploads)
_byname = {os.path.basename(src): n for (src, _n), (n, _p) in zip(_uploads, _saved)}
_items = []
for _src, _name in _uploads:
_preview, _total, _trunc = extract_text(_src, _name)
_isbin = (not _preview and _total == 0)
_relp = (_rel + _byname.get(_name, _name)) if _name in _byname else ''
_items.append((_name, _relp, _preview, _total, _trunc, _isbin))
_fctx = build_file_context(_items)
if _fctx:
prompt = _fctx + "\n用户指令" + prompt
except Exception:
pass
async def agent_stream():
async for chunk in gateway.run_message("web", uid, prompt, generic=True, model_id=model_id):
data = json.loads(chunk)