| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229 |
- #!/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);
- });
|