provider-contract-audit.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. #!/usr/bin/env node
  2. const http = require('http');
  3. const fs = require('fs');
  4. const os = require('os');
  5. const path = require('path');
  6. const { runTihaoSourcing } = require('../mcp/src/tools/tihao-brief-sourcing-run');
  7. async function main() {
  8. const args = new Set(process.argv.slice(2));
  9. const useMock = args.has('--mock');
  10. const mock = useMock ? await startMockProvider() : null;
  11. try {
  12. const referenceBaseUrl = mock?.url || process.env.SOCIAL_ANALYSIS_BASE_URL || process.env.TIHAO_REFERENCE_ENRICHMENT_BASE_URL || '';
  13. const evidenceBaseUrl = mock?.url || process.env.TIHAO_EVIDENCE_BASE_URL || process.env.MULTIMODAL_EVIDENCE_BASE_URL || '';
  14. if (!referenceBaseUrl && !evidenceBaseUrl) {
  15. console.log('provider contract audit skipped: set SOCIAL_ANALYSIS_BASE_URL/TIHAO_EVIDENCE_BASE_URL or run with --mock');
  16. return;
  17. }
  18. const out = path.join(os.tmpdir(), `tihao-provider-contract-${Date.now()}`);
  19. const input = {
  20. collectionMode: 'sample',
  21. briefText: [
  22. '平台:小红书',
  23. '产品:DHA',
  24. '干货科普参考账号:',
  25. '图文 http://xhslink.com/o/7KZdAn1bNgj',
  26. '视频 http://xhslink.com/o/2fg3MzG2Iau'
  27. ].join('\n'),
  28. referenceEnrichmentBaseUrl: referenceBaseUrl || undefined,
  29. multimodalEvidenceBaseUrl: evidenceBaseUrl || undefined,
  30. socialAnalysisToken: process.env.VOC_SOCIAL_TOKEN || process.env.TIHAO_SESSION_TOKEN || undefined,
  31. evidenceProviderToken: mock ? 'mock-evidence-token' : (process.env.TIHAO_EVIDENCE_TOKEN || process.env.MULTIMODAL_EVIDENCE_TOKEN || undefined),
  32. output: out
  33. };
  34. const result = await runTihaoSourcing(input);
  35. assert(result.status === 'ok', 'provider contract sample run should remain ok');
  36. assert(result.files && result.files.length === 3, 'provider contract should write report files');
  37. if (referenceBaseUrl) {
  38. assert(result.data.criteria.referenceBaselines.length >= 1, 'reference provider should return at least one baseline');
  39. assert(result.data.criteria.referenceResources.length >= 1, 'reference provider should return reference resources');
  40. assert(result.data.criteria.referenceStyleFingerprints.some(item => String(item.coverOrVideoResource || '').includes('reference-video.mp4')), 'reference video resource should become a style fingerprint');
  41. assert(result.data.candidates.some(item => item.referenceSimilarity > 0), 'reference provider should affect candidate reference similarity');
  42. }
  43. if (evidenceBaseUrl) {
  44. assert(result.data.criteria.evidenceCards.length >= 1, 'evidence provider should return at least one evidence card');
  45. assert(result.data.candidates.some(item => Array.isArray(item.evidenceSignals) && item.evidenceSignals.length), 'evidence provider should attach evidence signals');
  46. }
  47. if (mock) {
  48. assertMockProviderContract(mock);
  49. await assertMockDoubaoVisionContract(mock);
  50. await assertMockDoubaoOpenAIChatContract(mock);
  51. await assertMockProviderFailureStates();
  52. }
  53. const serialized = JSON.stringify(result);
  54. assert(!/Authorization:\s*Bearer/i.test(serialized), 'provider contract result should not leak Authorization headers');
  55. assert(!/(VOC_SOCIAL_TOKEN|TIHAO_SESSION_TOKEN|VOC_ECOMMERCE_TOKEN)\s*=/.test(serialized), 'provider contract result should not leak env tokens');
  56. console.log(`provider contract audit ok: ${out}`);
  57. } finally {
  58. if (mock) await mock.close();
  59. }
  60. }
  61. function startMockProvider() {
  62. const state = {
  63. referenceRequests: [],
  64. evidenceRequests: []
  65. };
  66. const server = http.createServer(async (req, res) => {
  67. if (req.url.startsWith('/reference-baselines')) {
  68. state.referenceRequests.push(await readRequest(req));
  69. res.writeHead(200, { 'content-type': 'application/json' });
  70. res.end(JSON.stringify({
  71. referenceBaselines: [
  72. {
  73. bucket: 'contract-reference',
  74. name: 'Contract Nutrition Mom',
  75. redId: 'contract-ref-001',
  76. userId: 'contract-user-001',
  77. fans: 52000,
  78. topSignals: ['DHA', '母婴', '营养师', '育婴师']
  79. }
  80. ],
  81. referenceResources: [
  82. {
  83. referenceId: 'contract-video-note-001',
  84. url: 'http://xhslink.com/o/2fg3MzG2Iau',
  85. platform: 'xiaohongshu',
  86. contentType: 'video',
  87. accountName: 'Contract Nutrition Mom',
  88. title: 'DHA 618 囤货怎么选',
  89. bodySummary: '营养师妈妈讲 DHA 选择和宝宝食用场景',
  90. authorInfo: '营养师妈妈',
  91. videoUrl: 'https://cdn.example.test/reference-video.mp4',
  92. coverUrl: 'https://cdn.example.test/reference-cover.jpg',
  93. asrText: '这条视频讲宝宝 DHA 怎么选',
  94. frameUrls: ['https://cdn.example.test/frame-001.jpg']
  95. }
  96. ]
  97. }));
  98. return;
  99. }
  100. if (req.url.startsWith('/evidence-cards')) {
  101. state.evidenceRequests.push(await readRequest(req));
  102. res.writeHead(200, { 'content-type': 'application/json' });
  103. res.end(JSON.stringify({
  104. evidenceCards: [
  105. {
  106. creatorId: 'xhs-dha-001',
  107. platform: 'xiaohongshu',
  108. displayName: '营养师妈妈DHA笔记',
  109. sourceLevel: 'contract_multimodal_verified',
  110. contentEvidence: {
  111. textSignals: ['DHA', '母婴'],
  112. asrSignals: ['宝宝营养'],
  113. visualSignals: ['真人出镜', '产品实拍']
  114. },
  115. referenceFit: {
  116. score: 86,
  117. level: 'high',
  118. hitPoints: ['DHA', '母婴']
  119. },
  120. riskEvidence: {
  121. needsManualReview: ['宝宝营养功效表达需人工复核']
  122. }
  123. }
  124. ]
  125. }));
  126. return;
  127. }
  128. if (req.url.startsWith('/doubao-vision')) {
  129. state.evidenceRequests.push(await readRequest(req));
  130. res.writeHead(200, { 'content-type': 'application/json' });
  131. res.end(JSON.stringify({
  132. choices: [
  133. {
  134. message: {
  135. content: JSON.stringify({
  136. evidenceCards: [
  137. {
  138. creatorId: 'xhs-dha-001',
  139. platform: 'xiaohongshu',
  140. displayName: '营养师妈妈DHA笔记',
  141. sourceLevel: 'doubao_vision_verified',
  142. contentEvidence: {
  143. textSignals: ['DHA'],
  144. asrSignals: ['宝宝营养'],
  145. visualSignals: ['真人出镜', '产品实拍'],
  146. frameEvidence: [
  147. { framePath: 'frames/doubao-001.jpg', observation: '产品实拍' }
  148. ]
  149. },
  150. referenceFit: {
  151. score: 87,
  152. level: 'high',
  153. hitPoints: ['DHA']
  154. },
  155. riskEvidence: {
  156. needsManualReview: ['功效表达需人工复核']
  157. }
  158. }
  159. ]
  160. })
  161. }
  162. }
  163. ]
  164. }));
  165. return;
  166. }
  167. if (req.url.startsWith('/v1/chat/completions')) {
  168. state.evidenceRequests.push(await readRequest(req));
  169. res.writeHead(200, { 'content-type': 'application/json' });
  170. res.end(JSON.stringify({
  171. choices: [
  172. {
  173. message: {
  174. content: JSON.stringify({
  175. evidenceCards: [
  176. {
  177. creatorId: 'xhs-dha-001',
  178. platform: 'xiaohongshu',
  179. displayName: '营养师妈妈DHA笔记',
  180. sourceLevel: 'doubao_vision_verified',
  181. contentEvidence: {
  182. textSignals: ['DHA', '营养师'],
  183. asrSignals: [],
  184. visualSignals: [],
  185. frameEvidence: []
  186. },
  187. referenceFit: {
  188. score: 92,
  189. level: 'high',
  190. hitPoints: ['DHA', '营养师']
  191. },
  192. riskEvidence: {
  193. needsManualReview: ['No actual video frame/transcript provided']
  194. }
  195. }
  196. ]
  197. })
  198. }
  199. }
  200. ]
  201. }));
  202. return;
  203. }
  204. res.writeHead(404, { 'content-type': 'application/json' });
  205. res.end(JSON.stringify({ error: 'not found' }));
  206. });
  207. return new Promise(resolve => {
  208. server.listen(0, '127.0.0.1', () => {
  209. const { port } = server.address();
  210. resolve({
  211. url: `http://127.0.0.1:${port}`,
  212. state,
  213. close: () => new Promise(done => server.close(done))
  214. });
  215. });
  216. });
  217. }
  218. async function assertMockDoubaoVisionContract(mock) {
  219. const beforeCount = mock.state.evidenceRequests.length;
  220. const out = path.join(os.tmpdir(), `tihao-doubao-contract-${Date.now()}`);
  221. const result = await runTihaoSourcing({
  222. collectionMode: 'sample',
  223. briefText: [
  224. '平台:小红书',
  225. '产品:DHA',
  226. '参考账号:http://xhslink.com/o/7KZdAn1bNgj'
  227. ].join('\n'),
  228. doubaoVisionBaseUrl: `${mock.url}/doubao-vision`,
  229. doubaoVisionModel: 'doubao-seed-2-0-pro-260215',
  230. doubaoVisionToken: 'mock-doubao-token',
  231. output: out
  232. });
  233. assert(result.status === 'ok', 'mock doubao vision run should remain ok');
  234. assert(result.data.criteria.evidenceCards.length >= 1, 'mock doubao vision should return evidence cards');
  235. assert(result.data.candidates.some(item => item.sourceLevel === 'doubao_vision_verified'), 'mock doubao vision should attach parsed evidence card');
  236. assert(result.assistantMessage.includes('## 多模态证据卡'), 'mock doubao vision report should include evidence card section');
  237. assert(result.assistantMessage.includes('doubao_vision_verified'), 'mock doubao vision report should include evidence source level');
  238. const request = mock.state.evidenceRequests[beforeCount];
  239. assert(request.headers.authorization === 'Bearer mock-doubao-token', 'doubao vision request should receive Authorization header');
  240. assert(request.body.providerType === 'doubao_vision_video_analysis', 'doubao vision request should include providerType');
  241. assert(request.body.model === 'doubao-seed-2-0-pro-260215', 'doubao vision request should include requested model id');
  242. assert(Array.isArray(request.body.requestedCapabilities) && request.body.requestedCapabilities.includes('visual_signals'), 'doubao vision request should include visual capability');
  243. }
  244. async function assertMockDoubaoOpenAIChatContract(mock) {
  245. const beforeCount = mock.state.evidenceRequests.length;
  246. const out = path.join(os.tmpdir(), `tihao-doubao-openai-contract-${Date.now()}`);
  247. const result = await runTihaoSourcing({
  248. collectionMode: 'sample',
  249. briefText: [
  250. '平台:小红书',
  251. '产品:DHA',
  252. '参考账号:http://xhslink.com/o/7KZdAn1bNgj'
  253. ].join('\n'),
  254. doubaoVisionBaseUrl: mock.url,
  255. doubaoVisionPath: 'v1/chat/completions',
  256. doubaoVisionMode: 'openai_chat',
  257. doubaoVisionModel: 'doubao-seed-2-0-pro-260215',
  258. doubaoVisionToken: 'mock-doubao-token',
  259. output: out
  260. });
  261. assert(result.status === 'ok', 'mock doubao openai chat run should remain ok');
  262. assert(result.data.criteria.evidenceCards.length >= 1, 'mock doubao openai chat should return evidence cards');
  263. assert(result.data.criteria.evidenceStatus.providerStatus === 'ok', 'mock doubao openai chat provider status should be ok');
  264. assert(result.data.candidates.some(item => item.sourceLevel === 'doubao_vision_verified'), 'mock doubao openai chat should attach parsed evidence card');
  265. const request = mock.state.evidenceRequests[beforeCount];
  266. assert(request.headers.authorization === 'Bearer mock-doubao-token', 'doubao openai chat request should receive Authorization header');
  267. assert(request.body.model === 'doubao-seed-2-0-pro-260215', 'doubao openai chat request should include requested model id');
  268. assert(Array.isArray(request.body.messages), 'doubao openai chat request should include messages');
  269. const userMessage = request.body.messages.find(item => item.role === 'user');
  270. assert(userMessage && typeof userMessage.content === 'string', 'doubao openai chat request should include a user JSON payload');
  271. const contract = JSON.parse(userMessage.content);
  272. assert(contract.contractVersion === 'tihao-provider-v1', 'doubao openai chat user payload should include contractVersion');
  273. assert(contract.providerType === 'doubao_vision_video_analysis', 'doubao openai chat user payload should include providerType');
  274. assert(Array.isArray(contract.requestedCapabilities) && contract.requestedCapabilities.includes('visual_signals'), 'doubao openai chat user payload should include visual capability');
  275. }
  276. async function assertMockProviderFailureStates() {
  277. const mock = await startFailureProvider();
  278. try {
  279. const out = path.join(os.tmpdir(), `tihao-provider-failure-${Date.now()}`);
  280. const result = await runTihaoSourcing({
  281. collectionMode: 'sample',
  282. briefText: [
  283. '平台:小红书',
  284. '产品:DHA',
  285. '参考账号:http://xhslink.com/o/7KZdAn1bNgj'
  286. ].join('\n'),
  287. referenceEnrichmentBaseUrl: mock.url,
  288. multimodalEvidenceBaseUrl: mock.url,
  289. output: out
  290. });
  291. assert(result.status === 'ok', 'provider failure sample run should remain ok');
  292. assert(result.data.criteria.referenceEvidenceStatus.providerRequested === true, 'reference failure should mark provider requested');
  293. assert(result.data.criteria.referenceEvidenceStatus.providerStatus === 'unauthorized_or_quota', 'reference failure should keep provider status');
  294. assert(result.data.criteria.referenceEvidenceStatus.providerHttpStatus === 403, 'reference failure should keep http status');
  295. assert(result.data.criteria.evidenceStatus.providerRequested === true, 'evidence empty result should mark provider requested');
  296. assert(result.data.criteria.evidenceStatus.providerStatus === 'empty_result', 'evidence empty result should keep provider status');
  297. assert(result.warnings.some(item => String(item).includes('社媒分析服务')), 'reference failure warning should be surfaced');
  298. assert(result.warnings.some(item => String(item).includes('未返回可用 evidenceCards')), 'evidence empty warning should be surfaced');
  299. assertProviderFailurePublicOutputSafe({ result, out });
  300. } finally {
  301. await mock.close();
  302. }
  303. }
  304. function assertProviderFailurePublicOutputSafe({ result, out }) {
  305. const markdownPath = path.join(out, 'tihao-sourcing-report.md');
  306. const csvPath = path.join(out, 'tihao-sourcing-client-list.csv');
  307. const publicText = [
  308. result.assistantMessage || '',
  309. fs.existsSync(markdownPath) ? fs.readFileSync(markdownPath, 'utf8') : '',
  310. fs.existsSync(csvPath) ? fs.readFileSync(csvPath, 'utf8') : ''
  311. ].join('\n');
  312. assert(!/\b(401|403)\b/.test(publicText), 'provider failure public output should not expose raw 401/403');
  313. assert(!/hidden by client|not found|unauthorized|permission|quota/i.test(publicText), 'provider failure public output should not expose raw upstream error text');
  314. assert(!/Authorization\s*:/i.test(publicText), 'provider failure public output should not expose Authorization header');
  315. assert(!/Bearer\s+[A-Za-z0-9._~+/=-]+/i.test(publicText), 'provider failure public output should not expose bearer token');
  316. assert(!/sk-[A-Za-z0-9_-]{20,}/.test(publicText), 'provider failure public output should not expose model token');
  317. }
  318. function startFailureProvider() {
  319. const server = http.createServer((req, res) => {
  320. if (req.url.startsWith('/reference-baselines')) {
  321. res.writeHead(403, { 'content-type': 'application/json' });
  322. res.end(JSON.stringify({ error: 'hidden by client' }));
  323. return;
  324. }
  325. if (req.url.startsWith('/evidence-cards')) {
  326. res.writeHead(200, { 'content-type': 'application/json' });
  327. res.end(JSON.stringify({ evidenceCards: [] }));
  328. return;
  329. }
  330. res.writeHead(404, { 'content-type': 'application/json' });
  331. res.end(JSON.stringify({ error: 'not found' }));
  332. });
  333. return new Promise(resolve => {
  334. server.listen(0, '127.0.0.1', () => {
  335. const { port } = server.address();
  336. resolve({
  337. url: `http://127.0.0.1:${port}`,
  338. close: () => new Promise(done => server.close(done))
  339. });
  340. });
  341. });
  342. }
  343. function readRequest(req) {
  344. return new Promise(resolve => {
  345. const chunks = [];
  346. req.on('data', chunk => chunks.push(chunk));
  347. req.on('end', () => {
  348. const raw = Buffer.concat(chunks).toString('utf8');
  349. let body = {};
  350. try {
  351. body = raw ? JSON.parse(raw) : {};
  352. } catch {
  353. body = { raw };
  354. }
  355. resolve({
  356. method: req.method,
  357. headers: req.headers,
  358. body
  359. });
  360. });
  361. });
  362. }
  363. function assertMockProviderContract(mock) {
  364. assert(mock.state.referenceRequests.length >= 1, 'mock reference provider should receive a request');
  365. assert(mock.state.evidenceRequests.length >= 1, 'mock evidence provider should receive a request');
  366. const reference = mock.state.referenceRequests[0];
  367. assert(reference.method === 'POST', 'reference provider should receive POST');
  368. assert(reference.body.contractVersion === 'tihao-provider-v1', 'reference request should include contractVersion');
  369. assert(Array.isArray(reference.body.referenceLinks) && reference.body.referenceLinks.length >= 2, 'reference request should include referenceLinks');
  370. assert(Array.isArray(reference.body.referenceStyleAnchors) && reference.body.referenceStyleAnchors.length >= 1, 'reference request should include referenceStyleAnchors');
  371. assert(reference.body.criteria && Array.isArray(reference.body.criteria.keywords), 'reference request should include criteria keywords');
  372. const evidence = mock.state.evidenceRequests[0];
  373. assert(evidence.method === 'POST', 'evidence provider should receive POST');
  374. assert(evidence.headers.authorization === 'Bearer mock-evidence-token', 'evidence provider should receive Authorization header when token is supplied');
  375. assert(evidence.body.contractVersion === 'tihao-provider-v1', 'evidence request should include contractVersion');
  376. assert(Array.isArray(evidence.body.requestedCapabilities) && evidence.body.requestedCapabilities.includes('asr_signals'), 'evidence request should include requested capabilities');
  377. assert(evidence.body.criteria && Array.isArray(evidence.body.criteria.referenceBaselines), 'evidence request should include reference baselines');
  378. assert(evidence.body.criteria && Array.isArray(evidence.body.criteria.referenceResources), 'evidence request should include reference resources');
  379. assert(JSON.stringify(evidence.body.criteria.referenceResources).includes('reference-video.mp4'), 'evidence request should include reference video URL resource');
  380. assert(Array.isArray(evidence.body.creators) && evidence.body.creators.length >= 1, 'evidence request should include creators');
  381. assert(evidence.body.creators[0].platformUserId || evidence.body.creators[0].displayName, 'evidence creator payload should include identity');
  382. }
  383. function assert(condition, message) {
  384. if (!condition) throw new Error(message);
  385. }
  386. main().catch(error => {
  387. console.error(error && error.stack ? error.stack : String(error));
  388. process.exit(1);
  389. });