assistant/apps/admin/src/components/ReadingDemandAnalysis.tsx

155 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>;
}