155 lines
21 KiB
TypeScript
155 lines
21 KiB
TypeScript
import { Alert, Button, Card, Collapse, Descriptions, Drawer, Select, Space, Table, Tag, Typography } from 'antd';
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import { api } from '../api';
|
||
import type { BusinessBrief, DemandAnalysis, DemandModelConfig, DemandUsage, DiscoveryReading, PageResult } from '../types';
|
||
import { AnalysisReviewPanel, SourceReviewControl } from './AnalysisReviewPanel';
|
||
import { BriefConditions, RegionScope } from './BusinessBriefPanel';
|
||
import { ConsultationDetails } from './ConsultationDetails';
|
||
import { formatTime, NoRows } from './common';
|
||
|
||
const demandLabels: Record<DemandAnalysis['results'][number]['demand'], string> = { explicit: '明确需求', possible: '疑似需求', discussion: '普通讨论', insufficient: '信息不足' };
|
||
const missingLabels: Record<string, string> = { identity_unconfirmed: '身份未核实', region_missing: '地区未提供', budget_missing: '预算未提供', stage_missing: '装修阶段未提供', media_uninterpreted: '媒体内容未解释', reading_partial: '阅读仅部分完成' };
|
||
const errorLabels: Record<string, string> = {
|
||
MODEL_TIMEOUT: '模型请求超时', MODEL_NETWORK: '模型连接未完成', MODEL_AUTH: '模型认证未通过', MODEL_RATE_LIMIT: '模型请求受限',
|
||
MODEL_QUOTA: '模型额度不足', MODEL_UNAVAILABLE: '模型服务暂时不可用', MODEL_HTTP_ERROR: '模型服务未完成请求', MODEL_RESPONSE_INVALID: '模型响应结构未通过检查',
|
||
MODEL_OUTPUT_INVALID: '需求结果或原文依据未通过检查', MODEL_RESPONSE_TOO_LARGE: '模型响应超出本次上限', MODEL_NOT_CONFIGURED: '服务端模型尚未配置',
|
||
};
|
||
const roleLabels: Record<string, string> = { suspected_demand_side: '疑似需求方', suspected_service_provider: '疑似服务方', participant: '普通参与者', unknown: '角色未知' };
|
||
const expressionLabels: Record<string, string> = { self_need: '自身需求', question: '询问', seek_advice: '寻求建议', service_offer: '提供服务', advice: '给出建议', case_share: '案例分享', other: '其他表达', unknown: '表达不明' };
|
||
const matchLabels: Record<string, string> = { matched: '条件初步匹配', possible: '可能匹配', insufficient: '条件不足', not_matched: '不匹配' };
|
||
const nextLabels: Record<string, string> = { review_author_need: '核对作者自身需求', review_comment_need: '核对本条评论者需求', review_consultation: '核对咨询意向与实际需求', inspect_comments: '查看评论中的需求信号', need_more_context: '补充上下文后再判断', skip: '暂不列入跟进' };
|
||
const matchReasonLabels: Record<string, string> = { self_need_explicit: '有明确自身需求信号', self_need_possible: '有疑似自身需求信号', no_self_need_signal: '没有自身需求信号', service_relevant: '与服务相关', service_irrelevant: '与服务不相关', service_unclear: '服务相关性不明', region_unspecified: '商家地区未设置', region_missing: '本条原文未提供地区', region_exact: '原文地区与条件字面一致', region_unrestricted: '商家已选择不限地区', region_potential_mismatch: '地区表述可能不匹配,需核对', service_promotion_excluded: '按条件排除服务推广', media_uninterpreted: '媒体内容未解释', reading_partial: '仅有部分阅读结果' };
|
||
function Quotes({ values }: { values: string[] }) { return <>{values.length ? values.map((value, index) => <span key={index}>「{value}」{index + 1 < values.length ? ';' : ''}</span>) : '无原文依据,保留未知'}</>; }
|
||
function safeError(code?: string | null) { return code ? errorLabels[code] || '模型请求或结果未通过检查' : '未提供错误分类'; }
|
||
function sourceHref(value: string) { try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password ? url.href : undefined; } catch { return undefined; } }
|
||
function tokens(value: number | null | undefined) { return value === null || value === undefined ? '未知' : value.toLocaleString('zh-CN'); }
|
||
function Usage({ value }: { value: DemandUsage | null }) {
|
||
return <Space wrap><span>输入 Token:{tokens(value?.prompt_tokens)}</span><span>输出 Token:{tokens(value?.completion_tokens)}</span><span>总 Token:{tokens(value?.total_tokens)}</span><span>缓存命中:{tokens(value?.prompt_cache_hit_tokens)}</span><span>缓存未命中:{tokens(value?.prompt_cache_miss_tokens)}</span></Space>;
|
||
}
|
||
function ResultDetails({ analysis, reading }: { analysis: DemandAnalysis; reading: DiscoveryReading }) {
|
||
return <div className="detail-stack" style={{ gridTemplateColumns: 'minmax(0, 1fr)', minWidth: 0 }} data-testid="demand-analysis-detail" data-analysis-id={analysis.id}>
|
||
<Space wrap><Tag color={analysis.status === 'completed' ? 'success' : 'warning'}>{analysis.status === 'completed' ? '分析完成' : '分析未完成'}</Tag><Tag>{analysis.prompt_version}</Tag><Typography.Text type="secondary">{formatTime(analysis.created_at)}</Typography.Text></Space>
|
||
{analysis.status === 'failed' && <Alert data-testid="demand-analysis-failure" type="warning" showIcon title="本次分析未完成,失败记录已保留" description={analysis.error_codes.map(safeError).join(';') || '请核对模型配置和请求记录;不会自动再次分析。'} />}
|
||
<Typography.Paragraph type="secondary">本次纳入 {analysis.coverage.source_count} 个来源,仅核对已保存的文字;媒体未解释,身份待核实。</Typography.Paragraph>
|
||
{analysis.context && <section data-testid="demand-analysis-context"><h4>本次商家条件</h4><Typography.Paragraph>{analysis.context.conditions.service} · {analysis.context.conditions.target_customer} · <RegionScope conditions={analysis.context.conditions} /></Typography.Paragraph><Typography.Paragraph type="secondary">{analysis.context.kind === 'confirmed_business_brief' ? '使用已确认商家需求;分析绑定该版本,不随当前表单改变。' : '使用本次初筛条件;旧记录未额外确认商家需求。'}{analysis.context.conditions.exclude_service_promotions ? ' 匹配时排除服务推广信号。' : ''}</Typography.Paragraph></section>}
|
||
<AnalysisReviewPanel analysis={analysis}><section data-testid="demand-analysis-results"><h4>逐项需求与原文依据</h4>{analysis.status !== 'completed' ? <NoRows description="本次没有通过校验的需求结果,不能当作没有装修需求。" /> : analysis.results.map(result => {
|
||
const source = analysis.sources.find(item => item.source_ref === result.source_ref);
|
||
if (!source) return null;
|
||
return <article className="surface-card" style={{ padding: 16 }} key={source.source_ref} data-testid="demand-source-result" data-source-ref={source.source_ref} data-demand={result.demand}>
|
||
<Space wrap><Typography.Text strong>{source.kind === 'note' ? '笔记正文' : `首层评论 ${source.comment_index ?? ''}`}</Typography.Text><Tag color={result.demand === 'explicit' ? 'success' : result.demand === 'possible' ? 'processing' : 'default'}>{demandLabels[result.demand]}</Tag>{result.media_uninterpreted && <Tag>媒体内容未解释</Tag>}</Space>
|
||
<SourceReviewControl source={source} result={result} />
|
||
{source.comment_id && <Typography.Paragraph type="secondary">本条评论标识:{source.comment_id}</Typography.Paragraph>}
|
||
<Typography.Paragraph>已读原文:{source.text || '无可读文字,媒体内容未解释。'}</Typography.Paragraph>
|
||
<ConsultationDetails result={result} sources={analysis.sources} />
|
||
{result.role && <section data-testid="demand-result-axes"><Space wrap><Tag>{roleLabels[result.role]}</Tag>{result.expressions?.map((expression, index) => <Tag key={index}>{expressionLabels[expression.type]}</Tag>)}{result.match && <Tag color={result.match === 'matched' ? 'success' : 'default'}>{matchLabels[result.match]}</Tag>}</Space>
|
||
<Typography.Paragraph>角色依据:<Quotes values={result.role_evidence || []} /></Typography.Paragraph>
|
||
{result.expressions?.map((expression, index) => <Typography.Paragraph key={index}>{expressionLabels[expression.type]}依据:<Quotes values={expression.evidence} /></Typography.Paragraph>)}
|
||
{result.business_relevance && <Typography.Paragraph>服务相关性:{{ relevant: '相关', irrelevant: '不相关', unclear: '不明确' }[result.business_relevance.value]} · <Quotes values={result.business_relevance.evidence} /></Typography.Paragraph>}
|
||
{result.match_reasons?.length ? <Typography.Paragraph>匹配理由:{result.match_reasons.map(value => matchReasonLabels[value] || '条件待核对').join(';')}</Typography.Paragraph> : null}
|
||
{result.next_step && <Alert data-testid="demand-next-step" type="info" title={`下一步建议:${nextLabels[result.next_step]}`} description="仅为建议,未执行关注、私信、评论或回复;角色信号不等于账号身份已确认。" />}
|
||
</section>}
|
||
<Typography.Paragraph>需求依据:{result.evidence.length ? result.evidence.map((quote, index) => <Typography.Text key={index}>「{quote}」{index + 1 < result.evidence.length ? ';' : ''}</Typography.Text>) : '没有可核对的需求原文片段'}</Typography.Paragraph>
|
||
<Descriptions size="small" column={1} items={(['stage', 'region', 'budget'] as const).map(field => ({ key: field, label: { stage: '装修阶段', region: '地区', budget: '预算' }[field], children: result[field] ? <>{result[field].value} · 原文「{result[field].evidence}」</> : '未提供,未推断' }))} />
|
||
<Space wrap>{result.missing_information.map(item => <Tag key={item}>{missingLabels[item] || '信息待核实'}</Tag>)}</Space>
|
||
<Typography.Paragraph type="secondary">来源:本次已保存的{source.kind === 'note' ? '笔记正文' : '首层评论'}。{sourceHref(reading.note_url) && <a href={sourceHref(reading.note_url)} target="_blank" rel="noreferrer">查看笔记页面</a>}</Typography.Paragraph>
|
||
</article>;
|
||
})}</section></AnalysisReviewPanel>
|
||
<div data-testid="demand-analysis-technical"><Collapse items={[{ key: 'technical', label: '模型配置、用量与请求记录', children: <div className="detail-stack" style={{ gridTemplateColumns: 'minmax(0, 1fr)', minWidth: 0 }}>
|
||
<Descriptions size="small" column={{ xs: 1, md: 2 }} items={[
|
||
{ key: 'id', label: '分析记录', children: <Typography.Text className="mono" copyable>{analysis.id}</Typography.Text> },
|
||
{ key: 'time', label: '分析时间', children: formatTime(analysis.created_at) }, { key: 'model', label: '本次配置型号', children: analysis.config.model },
|
||
{ key: 'provider', label: '供应商', children: analysis.config.provider }, { key: 'duration', label: '分析耗时', children: `${(analysis.duration_ms / 1000).toFixed(2)} 秒` },
|
||
{ key: 'range', label: '实际分析范围', children: `${analysis.coverage.source_count} 个来源,${analysis.coverage.text_chars} 字` },
|
||
{ key: 'bounded', label: '原阅读范围', children: analysis.coverage.reading_truncated ? '已截断,仅判断已读内容' : '本次范围未截断' },
|
||
{ key: 'comments', label: '评论', children: analysis.coverage.comments_available ? '有已读评论' : '未取得,不代表没有评论' },
|
||
{ key: 'price', label: '供应商费用', children: '未知(未接价格结算)' },
|
||
{ key: 'versions', label: '规则 / 结果版本', children: `${analysis.prompt_version} / ${analysis.result_version}` },
|
||
]} />
|
||
<section data-testid="demand-analysis-usage"><h4>实际 Token 用量</h4><Usage value={analysis.usage} /><Typography.Paragraph type="secondary">仅汇总已返回用量;失败或超时未返回的用量未知,未计入汇总,费用未结算。</Typography.Paragraph></section>
|
||
<section data-testid="demand-analysis-attempts"><h4>主备请求记录</h4>{analysis.attempts.length ? <div className="detail-stack">{analysis.attempts.map((attempt, index) => <div key={index} data-testid="demand-model-attempt" data-slot={attempt.slot}>
|
||
<Space wrap><Tag>{attempt.slot === 'primary' ? '主密钥' : '备用密钥'}</Tag><Tag color={attempt.status === 'succeeded' ? 'success' : 'warning'}>{attempt.status === 'succeeded' ? '请求成功' : '请求未通过'}</Tag><span>{(attempt.duration_ms / 1000).toFixed(2)} 秒</span>{attempt.http_status !== null && <span>HTTP {attempt.http_status}</span>}</Space>
|
||
{attempt.response_model && <Typography.Paragraph>实际返回型号:{attempt.response_model}</Typography.Paragraph>}
|
||
{attempt.status === 'failed' && <Typography.Paragraph>{safeError(attempt.error_code)}</Typography.Paragraph>}
|
||
<Usage value={attempt.usage} />
|
||
</div>)}</div> : <Typography.Paragraph type="secondary">没有模型调用记录。</Typography.Paragraph>}</section>
|
||
<Typography.Paragraph className="digest">阅读内容摘要:{analysis.reading_digest}<br />模型配置摘要:{analysis.config.config_digest}</Typography.Paragraph>
|
||
</div> }]} /></div>
|
||
</div>;
|
||
}
|
||
|
||
export function ReadingDemandAnalysis({ reading }: { reading: DiscoveryReading }) {
|
||
const [config, setConfig] = useState<DemandModelConfig>();
|
||
const [briefs, setBriefs] = useState<BusinessBrief[]>([]);
|
||
const [briefId, setBriefId] = useState(reading.business_brief_id || '');
|
||
const [history, setHistory] = useState<DemandAnalysis[]>([]);
|
||
const [selectedId, setSelectedId] = useState('');
|
||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||
const [loading, setLoading] = useState(true);
|
||
const [busy, setBusy] = useState(false);
|
||
const [loadError, setLoadError] = useState(false);
|
||
const [requestError, setRequestError] = useState(false);
|
||
const [reused, setReused] = useState(false);
|
||
const active = useRef(false);
|
||
const mounted = useRef(true);
|
||
const generation = useRef(0);
|
||
const route = `/discovery/readings/${encodeURIComponent(reading.id)}/analyses`;
|
||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; generation.current += 1; }; }, []);
|
||
async function refresh() {
|
||
const current = ++generation.current;
|
||
setLoading(true); setLoadError(false);
|
||
const [status, records, business] = await Promise.allSettled([api.get<DemandModelConfig>('/analysis/config'), api.get<PageResult<DemandAnalysis>>(route), api.get<PageResult<BusinessBrief>>('/business/briefs')]);
|
||
if (!mounted.current || current !== generation.current) return;
|
||
if (status.status === 'fulfilled') setConfig(status.value); else setConfig(undefined);
|
||
if (records.status === 'fulfilled') {
|
||
const items = records.value.items.filter(item => item.reading_id === reading.id && item.discovery_run_id === reading.discovery_run_id);
|
||
setHistory(items); setSelectedId(previous => items.some(item => item.id === previous) ? previous : items[0]?.id || '');
|
||
}
|
||
if (business.status === 'fulfilled') setBriefs(business.value.items.filter(item => item.status === 'confirmed'));
|
||
setLoadError(status.status === 'rejected' || records.status === 'rejected'); setLoading(false);
|
||
}
|
||
useEffect(() => { void refresh(); }, [reading.id]);
|
||
const binding = reading.note_binding_result;
|
||
const eligible = reading.status !== 'failed' && Boolean(reading.detail?.items.length && binding?.href_checked && binding.url_checked && binding.note_id === reading.note_id);
|
||
async function analyze() {
|
||
if (active.current || !config?.configured || !eligible) return;
|
||
active.current = true; setBusy(true); setRequestError(false); setReused(false);
|
||
try {
|
||
const result = await api.post<DemandAnalysis>(route, briefId ? { business_brief_id: briefId } : {});
|
||
if (!mounted.current) return;
|
||
setReused(history.some(item => item.id === result.id));
|
||
await refresh();
|
||
if (mounted.current && result.reading_id === reading.id && result.discovery_run_id === reading.discovery_run_id) { setSelectedId(result.id); setDetailsOpen(true); }
|
||
} catch {
|
||
if (mounted.current) { setRequestError(true); await refresh(); }
|
||
} finally { active.current = false; if (mounted.current) setBusy(false); }
|
||
}
|
||
const selected = history.find(item => item.id === selectedId);
|
||
return <Card className="surface-card" style={{ minWidth: 0 }} title="装修需求判断" data-testid="reading-demand-analysis" extra={<Button disabled={busy} loading={loading} onClick={() => void refresh()}>刷新分析记录</Button>}>
|
||
<div className="detail-stack" style={{ gridTemplateColumns: 'minmax(0, 1fr)', minWidth: 0 }}>
|
||
<Alert type="info" showIcon title="仅分析本次已保存的可用文字" description="分析独立追加,不重新操作平台浏览器。结果是需求信号,身份仍需人工核实;图片和视频未解释,不据此认定没有需求。" />
|
||
{loadError && <Alert data-testid="demand-analysis-load-error" type="warning" title="分析配置或历史暂时无法读取,请刷新记录" />}
|
||
<section data-testid="demand-model-config"><h4>当前服务器模型配置</h4>{config ? <>
|
||
<Space wrap><Tag>{config.configured ? '已配置' : '尚未配置'}</Tag><Tag>{config.provider || '供应商未设置'}</Tag><Tag>{config.model || '型号未设置'}</Tag><Tag>主密钥:{config.key_slots.includes('primary') ? '已配置' : '未配置'}</Tag><Tag>独立备用:{config.key_slots.includes('fallback') ? '已配置' : '未启用'}</Tag></Space>
|
||
<Typography.Paragraph type="secondary">已配置不等于已连通。这里只显示配置状态;相同密钥会去重为一个槽位。备用仅对受支持的临时失败有限尝试,本轮不提供密钥输入或查看。</Typography.Paragraph>
|
||
</> : <Typography.Paragraph type="secondary">尚未取得安全配置状态。</Typography.Paragraph>}</section>
|
||
{!eligible && <Alert type="warning" title="本条阅读没有通过来源核对的可用正文,不能开始分析" />}
|
||
{requestError && <Alert data-testid="demand-analysis-request-error" type="warning" title="分析请求未完成,请先刷新历史核对" description="配置缺失、阅读不满足条件或已有分析运行时会拒绝请求;不会自动重试,也不会显示供应商原始错误。" />}
|
||
<section data-testid="demand-business-selection"><h4>分析所用商家条件</h4><Select aria-label="分析所用商家条件" data-testid="demand-business-select" style={{ width: '100%' }} disabled={busy || loading} value={briefId} onChange={setBriefId} options={[{ value: '', label: reading.business_brief_id ? '沿用本次阅读绑定的商家条件' : '沿用本次初筛条件' }, ...briefs.map(brief => ({ value: brief.id, label: `${brief.conditions.service} · ${brief.conditions.region || (brief.conditions.region_scope === 'unrestricted' ? '不限地区' : '地区未设置')} · ${formatTime(brief.created_at)}` }))]} />{briefs.find(brief => brief.id === briefId) && <BriefConditions brief={briefs.find(brief => brief.id === briefId)!} />}<Typography.Paragraph type="secondary">此选择只用于下一次分析。查看历史使用其保存的条件;改变条件会生成独立分析,原阅读与历史不变。</Typography.Paragraph></section>
|
||
<Button type="primary" data-testid="demand-analysis-start" loading={busy} disabled={busy || loading || loadError || !config?.configured || !eligible} onClick={() => void analyze()}>分析本次已读内容</Button>
|
||
<Typography.Paragraph type="secondary" data-testid="demand-analysis-reuse">{reused ? '相同配置复用已完成结果,本次没有新增模型调用。' : '同一阅读、配置与规则下已有完成结果时会复用,避免重复调用模型。'}</Typography.Paragraph>
|
||
<Table<DemandAnalysis> data-testid="demand-analysis-history" rowKey="id" loading={loading} size="small" dataSource={history} pagination={{ pageSize: 5, showSizeChanger: false }} scroll={{ x: 680 }} locale={{ emptyText: '本条阅读尚无分析历史;失败与再次分析会分别保留。' }} columns={[
|
||
{ title: '分析时间', dataIndex: 'created_at', key: 'time', render: value => formatTime(value) },
|
||
{ title: '状态', key: 'status', render: (_, row) => row.status === 'completed' ? '分析完成' : '分析未完成' },
|
||
{ title: '条件', key: 'context', render: (_, row) => row.context?.kind === 'confirmed_business_brief' ? '已确认商家需求' : '初筛条件 / 旧版' },
|
||
{ title: '配置型号', key: 'model', render: (_, row) => row.config.model },
|
||
{ title: '详情', key: 'view', render: (_, row) => <Button type="link" aria-haspopup="dialog" onClick={() => { setSelectedId(row.id); setDetailsOpen(true); }}>查看分析</Button> },
|
||
]} />
|
||
<Drawer data-testid="demand-analysis-drawer" title="分析详情" open={detailsOpen && Boolean(selected)} onClose={() => setDetailsOpen(false)}
|
||
size="min(880px, 100vw)" destroyOnHidden closable={{ 'aria-label': '关闭分析详情' }}
|
||
styles={{ body: { padding: 20, overflowWrap: 'anywhere' } }}>
|
||
{selected && <ResultDetails key={selected.id} analysis={selected} reading={reading} />}
|
||
</Drawer>
|
||
</div>
|
||
</Card>;
|
||
}
|