#!/usr/bin/env node const fs = require('fs'); const http = require('http'); const os = require('os'); const path = require('path'); const { runTihaoSourcing } = require('../mcp/src/tools/tihao-brief-sourcing-run'); const { resolveCollectionMode, withLiveSmallRunDefaults } = require('../mcp/src/features/tihao-sourcing/sourcing-workflow'); const { updatePreferenceMemory } = require('../mcp/src/tools/tihao-preference-update'); const { parseArgs } = require('../mcp/src/tools/cli-args'); const { parseBrief } = require('../mcp/src/features/tihao-sourcing/brief-parser'); const { SOFTWARE_TABLE_HEADER } = require('../mcp/src/features/tihao-sourcing/report'); async function main() { const out = path.join(os.tmpdir(), `tihao-sourcing-smoke-${Date.now()}`); const briefText = [ '客户名称:花漾肌研', '产品:敏感肌屏障修护精华', '关键词:敏感肌、修护、护肤、成分、测评、真实体验', '平台与人数:小红书 6 人,抖音 2 人', '粉丝量范围:2万-50万', '预算范围:3000-18000', '偏好真实体验、素人感、成分拆解、敏感肌修护,不要硬广。' ].join('\n'); const parsed = parseBrief({ briefText }); assert(parsed.brand.includes('花漾肌研'), 'brief parser should parse Chinese brand'); assert(parsed.targetCount >= 8, 'brief parser should parse target count'); const skillText = fs.readFileSync(path.join(__dirname, '..', 'skills', 'tihao', 'SKILL.md'), 'utf8'); assert(skillText.includes('用 live 小规模检索出博主名单'), 'skill should route live small-run natural wording to live mode'); assert(skillText.includes('Use `collectionMode=sample` only when the user explicitly asks'), 'skill should make sample explicit-only'); assert(resolveCollectionMode({}) === 'live', 'unspecified collectionMode should default to live'); assert(resolveCollectionMode({ collectionMode: 'sample' }) === 'sample', 'explicit sample collectionMode should remain sample'); const liveDefaults = withLiveSmallRunDefaults({}); assert(liveDefaults.keywordLimit === 1 && liveDefaults.pagesPerKeyword === 1, 'default live run should be small scale'); const sample = await runTihaoSourcing({ collectionMode: 'sample', briefText, output: path.join(out, 'sample') }); assert(sample.status === 'ok', 'sample status should be ok'); assert(sample.files.every(file => fs.existsSync(file)), 'sample files should exist'); assert(sample.assistantMessage.includes('## 商务可用名单'), 'sample report should include business list'); assert(sample.assistantMessage.includes('## 软件端交付表'), 'sample report should include software table'); assert(sample.summary.clientReadyCount >= 6, 'sample should report client-ready count'); assert(sample.summary.strong >= 1, 'sample should produce at least one strong recommendation'); assert(Array.isArray(sample.data.calibrationQuestions) && sample.data.calibrationQuestions.length >= 2, 'sample should expose structured calibration questions'); assert(Array.isArray(sample.nextActions) && sample.nextActions.some(item => item.includes('校准问题')), 'sample should expose calibration next action'); assert(sample.data.criteria.homepageEvidenceStatus.status === 'not_requested', 'sample should expose homepage evidence fallback status'); assertSoftwareCsv(path.join(out, 'sample', 'tihao-sourcing-client-list.csv')); const parsedCliArgs = parseArgs([ '--message', '客户更喜欢素人感、敏感肌真实记录,不要硬广和泛美妆号', '--blocked-creators', '618母婴好物清单' ]); assert(typeof parsedCliArgs.message === 'string', 'CLI parser should keep natural-language message as text'); assert(parsedCliArgs.blockedCreators === '618母婴好物清单', 'CLI parser should keep single blocked creator value'); const memoryPath = path.join(out, 'memory.json'); const pref = updatePreferenceMemory({ message: '客户更喜欢素人感和真实体验,像“敏感肌成分研究所”这种账号,不要硬广,避开账号:618母婴好物清单', memory: memoryPath }); assert(pref.status === 'ok', 'preference update should be ok'); assert(fs.existsSync(memoryPath), 'memory file should exist'); assert(pref.summary.preferredStyles.includes('素人感'), 'preference memory should extract preferred styles'); assert(pref.summary.blockedStyles.includes('硬广'), 'preference memory should extract blocked styles'); assert(pref.summary.preferredReferenceAccounts.includes('敏感肌成分研究所'), 'preference memory should store reference account preference'); assert(pref.summary.blockedCreators.includes('618母婴好物清单'), 'preference memory should store blocked creator'); const memorySample = await runTihaoSourcing({ collectionMode: 'sample', briefText, memory: memoryPath, output: path.join(out, 'memory-sample') }); assert(memorySample.status === 'ok', 'memory sample should be ok'); assert(memorySample.summary.excluded >= 1, 'memory sample should exclude blocked creators'); assert(!memorySample.data.candidates.some(item => item.displayName === '618母婴好物清单'), 'blocked creator should not enter client-ready candidates'); const noToken = await runTihaoSourcing({ collectionMode: 'live', briefText, output: path.join(out, 'live-no-token'), allowEnvToken: false }); assert(noToken.status === 'needs_token', 'live without token should return needs_token'); assert(Array.isArray(noToken.errors) && noToken.errors.length === 0, 'friendly token state should not return errors'); assertFriendlyAuthState(noToken, 'live no-token result'); const defaultLiveNoToken = await runTihaoSourcing({ briefText, output: path.join(out, 'default-live-no-token'), allowEnvToken: false }); assert(defaultLiveNoToken.status === 'needs_token', 'omitted collectionMode should enter live and request token instead of sample'); assert(Array.isArray(defaultLiveNoToken.errors) && defaultLiveNoToken.errors.length === 0, 'default live token state should not return errors'); assertFriendlyAuthState(defaultLiveNoToken, 'default live no-token result'); const mock = await startMockHomepageProvider(); try { const homepage = await runTihaoSourcing({ collectionMode: 'sample', briefText, homepageEvidenceBaseUrl: mock.url, output: path.join(out, 'homepage-provider') }); assert(homepage.status === 'ok', 'homepage provider sample should be ok'); assert(homepage.data.criteria.homepageEvidenceStatus.status === 'ok', 'homepage provider status should be ok'); assert(homepage.data.candidates.some(item => item.homepageEvidence?.source === 'provider'), 'homepage provider should attach provider evidence'); } finally { await mock.close(); } console.log(`tihao smoke ok: ${out}`); } function assertSoftwareCsv(csvPath) { const csv = fs.readFileSync(csvPath, 'utf8').replace(/^\uFEFF/, ''); const lines = csv.trim().split(/\r?\n/); const expectedHeader = SOFTWARE_TABLE_HEADER.join(','); assert(lines[0] === expectedHeader, 'software csv should use fixed header'); const header = parseCsvLine(lines[0]); const column = name => header.indexOf(name); ['目标城市', '预算规则', '命中价格类型', '命中价格', '账号ID', '粉丝数', '图文报价', '视频报价', '最低报价', '报价状态', '地区/定位', '蒲公英判断', '数据来源'].forEach(name => { assert(column(name) >= 0, `software csv should include ${name}`); }); const quoteStatusIndex = column('报价状态'); const imagePriceIndex = column('图文报价'); const videoPriceIndex = column('视频报价'); const minPriceIndex = column('最低报价'); const seen = new Set(); const rankByBrief = new Map(); lines.slice(1).forEach((line, index) => { const row = parseCsvLine(line); const key = row[10] ? `${row[0]}|${row[7]}|${row[10]}` : `${row[0]}|${row[7]}|${row[8]}`; assert(!seen.has(key), 'software csv should have no duplicate creator keys'); seen.add(key); const expectedRank = (rankByBrief.get(row[0]) || 0) + 1; rankByBrief.set(row[0], expectedRank); assert(Number(row[2]) === expectedRank, 'software csv ranks should be continuous within each brief'); assert(row[quoteStatusIndex], 'software csv should explain quote status for every creator'); }); const body = lines.slice(1).map(parseCsvLine); assert(body.some(row => row[imagePriceIndex] || row[videoPriceIndex] || row[minPriceIndex]), 'first client list should include parsed quote fields when candidate data has prices'); } function assertFriendlyAuthState(result, label) { const text = JSON.stringify(result); assert(!/"errors"\s*:\s*\[[^\]]+\]/.test(text), `${label} should not include raw errors`); assert(!/\b(401|403)\b/.test(text), `${label} should not expose raw 401/403`); assert(!/Authorization\s*:/i.test(text), `${label} should not expose Authorization header`); assert(!/Bearer\s+[A-Za-z0-9._~+/=-]+/i.test(text), `${label} should not expose bearer token`); assert(!/(sessionToken|X-Parse-Session-Token)["':=\s]+[A-Za-z0-9._~+/=-]+/i.test(text), `${label} should not expose sessionToken`); assert(!/sk-[A-Za-z0-9_-]{20,}/.test(text), `${label} should not expose model token`); } function parseCsvLine(line) { const cells = []; let current = ''; let quoted = false; for (let i = 0; i < line.length; i += 1) { const char = line[i]; if (char === '"' && quoted && line[i + 1] === '"') { current += '"'; i += 1; } else if (char === '"') { quoted = !quoted; } else if (char === ',' && !quoted) { cells.push(current); current = ''; } else { current += char; } } cells.push(current); return cells; } function startMockHomepageProvider() { const server = http.createServer((req, res) => { if (!req.url.startsWith('/homepage-evidence')) { res.writeHead(404, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'not_found' })); return; } let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', () => { const payload = body ? JSON.parse(body) : {}; const records = (payload.creators || []).slice(0, 2).map(creator => ({ platform: creator.platform, platformUserId: creator.platformUserId, displayName: creator.displayName, profileUrl: creator.profileUrl, homepageEvidence: { source: 'provider', categoryConsistencyScore: 86, visualQualityScore: 82, toneConsistencyScore: 88, confidence: 'provider', reviewNotes: ['provider返回近10篇内容证据', '封面统一且近期内容垂类一致'] } })); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ homepageEvidence: records })); }); }); return new Promise(resolve => { server.listen(0, '127.0.0.1', () => { const port = server.address().port; resolve({ url: `http://127.0.0.1:${port}`, close: () => new Promise(done => server.close(done)) }); }); }); } function assert(condition, message) { if (!condition) throw new Error(message); } main().catch(error => { console.error(error && error.stack ? error.stack : String(error)); process.exit(1); });