|
|
@@ -1,21 +1,33 @@
|
|
|
/**
|
|
|
- * 验证「把附件地址拼进 question 文本」这条链路(方案②)。
|
|
|
+ * 验证「多模态附件」这条链路(`/api/chat` 的 `pictures` / `files` 两个字段)。
|
|
|
*
|
|
|
- * 背景(2026-09-18):新接口 /api/chat 只收 {thread_id, question},多字段 422,
|
|
|
- * 旧协议的 transmission.files 发不出去;改为把 OSS 地址拼进 question。
|
|
|
+ * 历史:2026-09-18 时新接口只收 `{thread_id, question}`,多一个字段 422,
|
|
|
+ * 附件只能**把 OSS 地址拼进 question 文本**(方案②)。
|
|
|
+ * 2026-09-23 后端支持了 `pictures` / `files`(方案①),改为走请求体字段。
|
|
|
+ *
|
|
|
+ * **实测到的契约**(2026-09-23,打真实后端):
|
|
|
+ * - 两者都是 **URL 字符串数组**;传对象数组(`[{url,name}]`)会被 `422 invalid_request` 拒掉
|
|
|
+ * - `pictures` 里的图**由后端自己去取**(必须公网可达)——
|
|
|
+ * 实测带上 `pictures:[图URL]` 问「这张图片里是什么」,模型确实描述了图的内容
|
|
|
+ * - 空数组与不传都接受
|
|
|
*
|
|
|
* 不变量:
|
|
|
- * ① 没有附件时**必须原样返回**问题(不能凭空多个换行 —— 那会改变所有普通提问)
|
|
|
- * ② 有附件时:原文在前、每条 URL 各占一行、**不加**任何自定义措辞
|
|
|
- * ③ 空/无效 URL 被过滤(上传失败的文件不该混进问题里)
|
|
|
+ * ① **没有附件时请求体逐字节与从前一致**(只有 thread_id + question)——
|
|
|
+ * 既不多传无意义字段,也让「后端若回滚」只影响带附件的轮次
|
|
|
+ * ② 有附件时:`question` 仍是**用户原文**(地址不拼进去),附件在 `pictures` / `files` 里
|
|
|
+ * ③ 图片按 `isImage` 进 `pictures`,其余进 `files`;空 URL 被过滤掉
|
|
|
*
|
|
|
* 怎么跑(在项目根目录):
|
|
|
* npx esbuild harness/tools/_entry-attachments.ts --bundle --format=esm \
|
|
|
- * --outfile=harness/tools/_attachments.mjs
|
|
|
+ * --outfile=harness/tools/_attachments.mjs --alias:@=./src \
|
|
|
+ * --define:import.meta.env='{}' --loader:.png=dataurl
|
|
|
+ * # ⚠️ 必须带 --loader:.png=dataurl:协调器 import 了企业微信二维码
|
|
|
* node harness/tools/verify-attachment-question.mjs
|
|
|
*/
|
|
|
|
|
|
-import { buildQuestionWithAttachments } from './_attachments.mjs';
|
|
|
+import { readFileSync, readdirSync } from 'node:fs';
|
|
|
+
|
|
|
+const { toChatAttachmentUrls, ApiChatCoordinator } = await import('./_attachments.mjs');
|
|
|
|
|
|
let pass = 0;
|
|
|
let fail = 0;
|
|
|
@@ -29,25 +41,126 @@ const check = (name, cond, extra = '') => {
|
|
|
}
|
|
|
};
|
|
|
|
|
|
-const Q = '帮我看看这份材料符合条件吗';
|
|
|
-const U1 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/a.png';
|
|
|
-const U2 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/b.pdf';
|
|
|
-
|
|
|
-console.log('【1】没有附件时:必须原样返回');
|
|
|
-check('undefined → 原文', buildQuestionWithAttachments(Q, undefined) === Q);
|
|
|
-check('null → 原文', buildQuestionWithAttachments(Q, null) === Q);
|
|
|
-check('空数组 → 原文', buildQuestionWithAttachments(Q, []) === Q);
|
|
|
-check('全是空串 → 原文(不要多个换行)', buildQuestionWithAttachments(Q, ['', ' ', null]) === Q, JSON.stringify(buildQuestionWithAttachments(Q, ['', ' ', null])));
|
|
|
-
|
|
|
-console.log('\n【2】有附件时:原文在前、URL 各一行');
|
|
|
-check('单个附件', buildQuestionWithAttachments(Q, [U1]) === `${Q}\n${U1}`, JSON.stringify(buildQuestionWithAttachments(Q, [U1])));
|
|
|
-check('多个附件按顺序', buildQuestionWithAttachments(Q, [U1, U2]) === `${Q}\n${U1}\n${U2}`);
|
|
|
-check('**原文完整保留在前面**', buildQuestionWithAttachments(Q, [U1]).startsWith(Q + '\n'));
|
|
|
-check('**不添加自定义措辞**(只有原文与 URL)', !/附件|文件|attachment/i.test(buildQuestionWithAttachments(Q, [U1]).slice(Q.length)));
|
|
|
-
|
|
|
-console.log('\n【3】脏数据过滤');
|
|
|
-check('混入空值只保留有效 URL', buildQuestionWithAttachments(Q, [U1, '', null, U2]) === `${Q}\n${U1}\n${U2}`);
|
|
|
-check('URL 前后空格被去掉', buildQuestionWithAttachments(Q, [` ${U1} `]) === `${Q}\n${U1}`);
|
|
|
+const IMG1 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/a.png';
|
|
|
+const IMG2 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/b.jpg';
|
|
|
+const PDF = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/c.pdf';
|
|
|
+const DOC = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/d.docx';
|
|
|
+
|
|
|
+const att = (url, isImage) => ({ id: url, name: url.split('/').pop(), size: 1, isImage, url });
|
|
|
+const tx = (attachments, files) => ({
|
|
|
+ files: files ?? attachments.map((a) => a.url),
|
|
|
+ file_pos: attachments.map(() => 0),
|
|
|
+ attachments,
|
|
|
+});
|
|
|
+
|
|
|
+console.log('\n【1】按类型拆分(纯逻辑)');
|
|
|
+{
|
|
|
+ const mixed = toChatAttachmentUrls(tx([att(IMG1, true), att(PDF, false), att(IMG2, true)]));
|
|
|
+ check('图片进 pictures', JSON.stringify(mixed.pictures) === JSON.stringify([IMG1, IMG2]), JSON.stringify(mixed.pictures));
|
|
|
+ check('非图片进 files', JSON.stringify(mixed.files) === JSON.stringify([PDF]), JSON.stringify(mixed.files));
|
|
|
+
|
|
|
+ const onlyImages = toChatAttachmentUrls(tx([att(IMG1, true)]));
|
|
|
+ check('只有图片时 files 为空数组', onlyImages.files.length === 0 && onlyImages.pictures.length === 1);
|
|
|
+
|
|
|
+ const none = toChatAttachmentUrls(undefined);
|
|
|
+ check('没有附件 → 两个空数组', none.pictures.length === 0 && none.files.length === 0);
|
|
|
+ check('null 也安全', JSON.stringify(toChatAttachmentUrls(null)) === JSON.stringify({ pictures: [], files: [] }));
|
|
|
+ check('空 attachments 数组 → 两个空数组', JSON.stringify(toChatAttachmentUrls(tx([]))) === JSON.stringify({ pictures: [], files: [] }));
|
|
|
+
|
|
|
+ const dirty = toChatAttachmentUrls(tx([att(' ' + IMG1 + ' ', true), att('', false)]));
|
|
|
+ check('URL 前后空格去掉、空 URL 过滤掉', JSON.stringify(dirty.pictures) === JSON.stringify([IMG1]) && dirty.files.length === 0, JSON.stringify(dirty));
|
|
|
+
|
|
|
+ const noAttachments = toChatAttachmentUrls({ files: [PDF, DOC], file_pos: [0, 0] });
|
|
|
+ check('只有 files(没 attachments)→ 全落 files,地址不丢', JSON.stringify(noAttachments.files) === JSON.stringify([PDF, DOC]), JSON.stringify(noAttachments));
|
|
|
+}
|
|
|
+
|
|
|
+/* ------------------------------------------------------------------ *
|
|
|
+ * 协调器:真正发出的请求体
|
|
|
+ * ------------------------------------------------------------------ */
|
|
|
+const sse = (events) =>
|
|
|
+ events
|
|
|
+ .map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify({ thread_id: 't1', request_id: 'r1', data })}\n\n`)
|
|
|
+ .join('');
|
|
|
+
|
|
|
+let captured = [];
|
|
|
+const stubFetch = () => {
|
|
|
+ globalThis.fetch = async (url, init = {}) => {
|
|
|
+ captured.push(init.body ? JSON.parse(String(init.body)) : null);
|
|
|
+ return new Response(
|
|
|
+ new ReadableStream({
|
|
|
+ start(controller) {
|
|
|
+ controller.enqueue(new TextEncoder().encode(sse([['done', { status: 'completed' }]])));
|
|
|
+ controller.close();
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } }
|
|
|
+ );
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+const runTurn = async (question, attachments) => {
|
|
|
+ captured = [];
|
|
|
+ stubFetch();
|
|
|
+ const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
|
|
|
+ await coordinator.generateAnswer(question, attachments);
|
|
|
+ await new Promise((r) => setTimeout(r, 60));
|
|
|
+ return captured[0];
|
|
|
+};
|
|
|
+
|
|
|
+console.log('\n【2】请求体:没有附件时与从前逐字节一致');
|
|
|
+{
|
|
|
+ const body = await runTurn('帮我看看这份材料符合条件吗');
|
|
|
+ check(
|
|
|
+ '只有 thread_id + question',
|
|
|
+ JSON.stringify(Object.keys(body).sort()) === JSON.stringify(['question', 'thread_id']),
|
|
|
+ JSON.stringify(body)
|
|
|
+ );
|
|
|
+ check('不该出现空数组字段', !('pictures' in body) && !('files' in body), JSON.stringify(body));
|
|
|
+
|
|
|
+ const empty = await runTurn('没有附件', { pictures: [], files: [] });
|
|
|
+ check('显式传空数组也不发字段', !('pictures' in empty) && !('files' in empty), JSON.stringify(empty));
|
|
|
+}
|
|
|
+
|
|
|
+console.log('\n【3】请求体:带附件时');
|
|
|
+{
|
|
|
+ const Q = '帮我看看这份材料符合条件吗';
|
|
|
+ const body = await runTurn(Q, { pictures: [IMG1], files: [PDF] });
|
|
|
+ check('**question 是用户原文**(地址不拼进去)', body.question === Q, JSON.stringify(body.question));
|
|
|
+ check('pictures 是 URL 字符串数组', JSON.stringify(body.pictures) === JSON.stringify([IMG1]), JSON.stringify(body.pictures));
|
|
|
+ check('files 是 URL 字符串数组', JSON.stringify(body.files) === JSON.stringify([PDF]), JSON.stringify(body.files));
|
|
|
+ check('没有多余字段', JSON.stringify(Object.keys(body).sort()) === JSON.stringify(['files', 'pictures', 'question', 'thread_id']), JSON.stringify(Object.keys(body)));
|
|
|
+ check(
|
|
|
+ 'pictures 里**不带**对象结构(后端只认字符串数组,传对象 422)',
|
|
|
+ body.pictures.every((item) => typeof item === 'string'),
|
|
|
+ JSON.stringify(body.pictures)
|
|
|
+ );
|
|
|
+
|
|
|
+ const multi = await runTurn(Q, { pictures: [IMG1, IMG2], files: [PDF, DOC] });
|
|
|
+ check('支持多个图片与文件', multi.pictures.length === 2 && multi.files.length === 2, JSON.stringify(multi));
|
|
|
+
|
|
|
+ const dirty = await runTurn(Q, { pictures: ['', ' '], files: [PDF] });
|
|
|
+ check('空 URL 被清掉(不会发出 [""])', !('pictures' in dirty) && JSON.stringify(dirty.files) === JSON.stringify([PDF]), JSON.stringify(dirty));
|
|
|
+}
|
|
|
+
|
|
|
+console.log('\n【4】旧方案已停用(源码级)');
|
|
|
+{
|
|
|
+ const files = [];
|
|
|
+ const walk = (dir) => {
|
|
|
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
|
+ const full = `${dir}/${entry.name}`;
|
|
|
+ if (entry.isDirectory()) walk(full);
|
|
|
+ else if (/\.(ts|vue)$/.test(entry.name)) files.push(full);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ walk('src');
|
|
|
+
|
|
|
+ const callers = files.filter((file) => {
|
|
|
+ const text = readFileSync(file, 'utf8');
|
|
|
+ // 跳过它自己的定义(已在 shared.ts 里注释掉)
|
|
|
+ return text.split('\n').some((line) => !line.trim().startsWith('//') && line.includes('buildQuestionWithAttachments'));
|
|
|
+ });
|
|
|
+ check('没有活跃调用点(地址不再拼进 question)', callers.length === 0, callers.join(', '));
|
|
|
+}
|
|
|
|
|
|
console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
|
|
|
process.exit(fail ? 1 : 0);
|