151 lines
19 KiB
TypeScript
151 lines
19 KiB
TypeScript
import { useRef, useState } from 'react';
|
||
import { ArrowRight, BookOpen, ChevronDown, LoaderCircle, Pencil } from 'lucide-react';
|
||
import { api } from '../api';
|
||
import type { KnowledgeMethod, KnowledgeMethods, KnowledgeMethodStatus, ValidationProof } from '../types';
|
||
import { displayTime } from '../taskView';
|
||
|
||
type DisplayStatus = KnowledgeMethodStatus | 'inapplicable';
|
||
const STATUS: Record<DisplayStatus, string> = { preferred: '优先复用', trial: '可试用', exploration: '探索候选', paused: '当前场景暂停', inapplicable: '本阶段不适用', legacy: '历史记录' };
|
||
function displayStatus(method: KnowledgeMethod): DisplayStatus {
|
||
if (method.status === 'paused' && !method.applicability && !method.match_reasons.includes('unresolved_contradiction')) return 'inapplicable';
|
||
return STATUS[method.status] ? method.status : 'legacy';
|
||
}
|
||
const ORIGIN: Record<string, string> = { program_template: '预置程序模板', preset: '预置阶段模板', observed: '真实执行归纳', model: '模型建议', manual: '人工补充', legacy: '历史记录', historical: '历史记录' };
|
||
const VERIFIER: Record<string, string> = { program: '程序验收', system: '系统验收', program_assertion: '程序验收', program_evidence: '程序证据核验', model: '模型报告', human: '人工确认', manual: '人工确认', source_container_quote_and_persisted: '来源归属、原话与保存结果核对', reply_delta_and_parent: '新增回复与所属讨论核对', profile_source_header: '来源作者与主页账号核对', file_manifest_and_current_result: '文件存在及当前版本核对', same_note_saved_sources: '同篇正文与讨论的保存结果核对' };
|
||
const OUTCOME: Record<string, string> = { success: '有证据的成功', contradiction: '相关反证', blocked: '受阻', program_error: '程序异常', unknown: '结果未知', unused: '未使用', not_used: '未使用' };
|
||
const MATCH_REASON: Record<string, string> = {
|
||
scope_matches: '已通过当前已提供条件的匹配检查', platform_mismatch: '平台不匹配', stage_mismatch: '任务阶段不匹配',
|
||
capability_mismatch: '所需操作能力不匹配', source_kind_mismatch: '来源类型不匹配', page_variant_mismatch: '页面结构版本不匹配',
|
||
method_version_mismatch: '方法版本不匹配', dependency_versions_unconfirmed: '依赖能力的版本尚未确认', dependency_version_mismatch: '依赖版本不匹配',
|
||
action_not_authorized: '本次尚未授权所需动作', unresolved_contradiction: '存在未解决的相关反证', historical_validation_incomplete: '历史验证依据不完整,不参与本次优先推荐',
|
||
};
|
||
const SCOPE: Record<string, string> = { any: '不限', unknown: '未记录', xiaohongshu: '小红书', note: '笔记', comment: '评论', reply: '回复', source_capture: '来源取证', search_read: '搜索阅读', export: '名单导出', save_source: '保存来源', inspect_profile: '核对主页', expand_replies: '展开回复', follow: '关注', message: '私信' };
|
||
const FIELD: Record<string, string> = {
|
||
type: '验收类型', checks: '验收条件', description: '说明', text: '说明', title: '说明', required: '必要条件', action: '操作', outcome: '结果', capability: '能力', source_kind: '来源类型', version: '版本',
|
||
source_id: '来源标识', source_type: '来源类型', content: '来源原文', quote: '来源原话', observed_at: '观察时间', persisted: '已持久保存', verified: '核验状态',
|
||
count: '数量', reply_count: '回复数量', parent_source_id: '父来源标识', root_source_id: '根来源标识', profile_url: '主页入口', xiaohongshu_id: '页面可见小红书号',
|
||
result_version: '结果版本', goal_version: '目标版本', filename: '文件名', available: '文件可用', exists: '文件存在', path: '文件路径', matched: '归属匹配',
|
||
note_id: '笔记标识', container_selector: '来源容器定位', observed_excerpt: '页面实际摘录', quote_matched: '原话与来源相符', relations_checked: '讨论归属已核对', owner_selector: '所属讨论定位', fingerprint: '页面状态指纹', parent_scope_checked: '所属父讨论一致', owner_reply_delta: '本讨论新增回复数', new_visible_replies: '新出现的可见回复数', new_reply_fingerprints: '新增回复指纹', expected_profile_digest: '来源主页关联标识', matched_source_author: '当前主页与来源作者匹配', visible_account_label_checked: '可见账号标签已核对', persisted_profile: '主页结果已保存', export_id: '文件记录标识', current_version: '对应当前结果', row_count: '文件记录数', format: '文件格式',
|
||
};
|
||
function count(value: number | null | undefined): string { return typeof value === 'number' && Number.isFinite(value) ? String(value) : '未记录'; }
|
||
function lines(value: unknown, depth = 0): string[] {
|
||
if (depth > 4 || value === null || value === undefined) return [];
|
||
if (typeof value === 'string') return value ? [value] : [];
|
||
if (typeof value === 'number') return [String(value)];
|
||
if (typeof value === 'boolean') return [value ? '是' : '否'];
|
||
if (Array.isArray(value)) return value.flatMap(item => lines(item, depth + 1));
|
||
if (typeof value === 'object') return Object.entries(value).flatMap(([key, item]) => lines(item, depth + 1).map(line => `${FIELD[key] || key}:${line}`));
|
||
return [];
|
||
}
|
||
|
||
function ValidationProofView({ evidenceRef }: { evidenceRef: KnowledgeMethod['evidence_refs'][number] }) {
|
||
const reference = Array.isArray(evidenceRef.pre) ? evidenceRef.pre[0] : evidenceRef.pre;
|
||
const valid = Boolean(evidenceRef.task_id && typeof reference === 'string' && /^[\w.:/\-]{1,512}$/.test(reference) && !reference.includes('://') && !/^(?:https?|file|data|javascript):/i.test(reference));
|
||
const [proof, setProof] = useState<ValidationProof | null>(null);
|
||
const [open, setOpen] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
const [failed, setFailed] = useState(false);
|
||
const requesting = useRef(false);
|
||
async function show(): Promise<void> {
|
||
if (!valid || requesting.current) return;
|
||
if (proof) { setOpen(value => !value); return; }
|
||
requesting.current = true; setLoading(true); setFailed(false); setOpen(true);
|
||
try {
|
||
const result = await api.get<ValidationProof>(`/tasks/${encodeURIComponent(evidenceRef.task_id!)}/validation-proof?reference=${encodeURIComponent(reference!)}${evidenceRef.event_id ? `&event_id=${encodeURIComponent(evidenceRef.event_id)}` : ''}`);
|
||
setProof(result);
|
||
} catch { setFailed(true); }
|
||
finally { requesting.current = false; setLoading(false); }
|
||
}
|
||
const before = proof ? lines(proof.pre) : [];
|
||
const after = proof ? lines(proof.post) : [];
|
||
return <div className="validation-proof" data-testid="validation-proof">
|
||
<button type="button" className="text-action" disabled={!valid || loading} aria-expanded={open} onClick={() => { void show(); }} title={valid ? undefined : '历史记录缺少可读取的任务内动作引用'}>{loading ? <><LoaderCircle size={12} className="spin" />正在读取验收记录…</> : proof && open ? '收起动作验收' : failed ? '重新读取动作验收' : '查看动作验收'}<ChevronDown size={12} /></button>
|
||
{!valid && <p className="scene-footnote">历史记录未保存可读取的动作引用。</p>}
|
||
{open && failed && <p className="validation-proof-error" role="status">这条验收快照暂时无法读取,已有成果仍可查看。可以重试或打开任务证据。</p>}
|
||
{open && proof && <section className="validation-proof-content" aria-label="已持久保存的动作验收快照">
|
||
<header><strong>{SCOPE[proof.capability] || proof.capability}</strong><span>{OUTCOME[proof.outcome] || proof.outcome}</span></header>
|
||
<p className="validation-proof-meta">{VERIFIER[proof.verifier] || proof.verifier || '验证方式未记录'}{proof.observed_at && <> · {displayTime(proof.observed_at)}</>}</p>
|
||
<div className="validation-proof-states"><section><h4>动作前状态</h4>{before.length ? <ul>{before.map((line, index) => <li key={index}>{line}</li>)}</ul> : <p>这条记录未保存动作前快照。</p>}</section><section><h4>动作后状态</h4>{after.length ? <ul>{after.map((line, index) => <li key={index}>{line}</li>)}</ul> : <p>这条记录未保存动作后快照。</p>}</section></div>
|
||
<p className="scene-footnote">这里读取原任务已保存的程序快照,不会重新执行动作。原话和账号资料仅在对应任务证据中展示。</p>
|
||
</section>}
|
||
</div>;
|
||
}
|
||
|
||
function MethodFeedbackForm({ methodId, taskId }: { methodId: string; taskId?: string }) {
|
||
const [open, setOpen] = useState(false);
|
||
const [text, setText] = useState('');
|
||
const [sending, setSending] = useState(false);
|
||
const [failed, setFailed] = useState(false);
|
||
const [saved, setSaved] = useState(false);
|
||
const composing = useRef(false);
|
||
const submitting = useRef(false);
|
||
async function submit(): Promise<void> {
|
||
if (!text.trim() || submitting.current || composing.current) return;
|
||
submitting.current = true; setSending(true); setFailed(false); setSaved(false);
|
||
try {
|
||
const result = await api.post<{ ok: boolean }>(`/knowledge/methods/${encodeURIComponent(methodId)}/feedback`, { text: text.trim(), ...(taskId ? { task_id: taskId } : {}) });
|
||
if (result.ok !== true) throw new Error('feedback not confirmed');
|
||
setSaved(true); setOpen(false); setText('');
|
||
} catch { setFailed(true); }
|
||
finally { submitting.current = false; setSending(false); }
|
||
}
|
||
return <div className="method-feedback" data-testid="method-feedback">
|
||
<button type="button" className="text-action" disabled={sending} aria-expanded={open} onClick={() => { setOpen(value => !value); setSaved(false); }}><Pencil size={12} />纠正或说明不适用</button>
|
||
{saved && <p className="method-feedback-saved" role="status">已记录,系统结合后续证据判断。</p>}
|
||
{open && <form className="method-feedback-form" onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }} onKeyDown={event => { if (event.key === 'Enter' && (composing.current || event.nativeEvent.isComposing)) event.preventDefault(); }} onSubmit={event => { event.preventDefault(); void submit(); }}>
|
||
<label htmlFor={`method-feedback-${methodId}`}>补充方法的适用条件或问题<textarea id={`method-feedback-${methodId}`} rows={3} maxLength={1000} required disabled={sending} value={text} placeholder="例如:当前页面没有这个入口,或这个方法适用的条件需要补充" onChange={event => { setText(event.target.value); setFailed(false); }} /></label>
|
||
<p className="scene-footnote">这条用户反馈先保留为待判断信息,不自动记为方法反证或成功。提交不会改写业务目标,也不会重新执行浏览器。</p>
|
||
{failed && <p className="field-error" role="alert">尚未确认保存成功,填写内容已保留。请稍后重试。</p>}
|
||
<div className="method-feedback-actions"><button type="button" className="text-action" disabled={sending} onClick={() => setOpen(false)}>收起</button><button type="submit" className="secondary-button small" disabled={sending || !text.trim()}>{sending ? '正在保存…' : '保存方法反馈'}</button></div>
|
||
</form>}
|
||
</div>;
|
||
}
|
||
|
||
export function KnowledgePanel({ data, error, loading, compact = false, taskSelected, taskId, onOpenEvidence, onRetry }: {
|
||
data: KnowledgeMethods | null; error: string; loading: boolean; compact?: boolean; taskSelected: boolean; taskId?: string;
|
||
onOpenEvidence: (taskId: string) => void; onRetry: () => void;
|
||
}) {
|
||
const [filter, setFilter] = useState<'all' | DisplayStatus>('all');
|
||
const [showAll, setShowAll] = useState(false);
|
||
const methods = data?.methods || [];
|
||
const filtered = methods.filter(method => filter === 'all' || displayStatus(method) === filter);
|
||
const visible = compact && !showAll ? filtered.slice(0, 3) : filtered;
|
||
return <section className={`knowledge-panel${compact ? ' compact' : ''}`} data-testid="knowledge-panel">
|
||
<div className="knowledge-heading"><span className="scene-kind"><BookOpen size={13} />可复用的方法</span>{loading && <small role="status">正在读取…</small>}</div>
|
||
<p className="result-note">先匹配适用条件,再根据独立执行证据选择。结果满意度、名单质量与方法可靠性分别记录;无需逐条人工审核。</p>
|
||
{error && <div className="knowledge-error" role="status"><p>知识记录暂时无法读取,已有任务结果仍可查看。</p><button type="button" className="text-action" disabled={loading} onClick={onRetry}>重新读取<ArrowRight size={12} /></button></div>}
|
||
<div className="knowledge-filters" aria-label="知识状态筛选"><button type="button" aria-pressed={filter === 'all'} onClick={() => setFilter('all')}>全部<span>{methods.length}</span></button>{(Object.keys(STATUS) as DisplayStatus[]).map(status => <button key={status} type="button" aria-pressed={filter === status} onClick={() => setFilter(status)}>{STATUS[status]}<span>{methods.filter(method => displayStatus(method) === status).length}</span></button>)}</div>
|
||
{visible.length ? <div className="knowledge-methods">{visible.map(method => {
|
||
const last = method.last_validation;
|
||
const selected = Boolean(data?.selected_ids.includes(method.id));
|
||
const status = displayStatus(method);
|
||
return <article className={`knowledge-method ${status}`} key={method.id} data-testid="knowledge-method" data-method-status={status}>
|
||
<header><strong>{method.title || method.key}</strong><span className={`knowledge-status ${status}`}>{STATUS[status]}</span>{selected && <span className="knowledge-selected">本次召回</span>}</header>
|
||
<p className="knowledge-scope">{[method.platform, method.stage, method.capability, method.source_kind, method.page_variant].filter(Boolean).map(value => SCOPE[value!] || value).join(' · ')}<span>版本 {method.version}</span></p>
|
||
{method.match_reasons.length > 0 ? <ul className="knowledge-match-reasons">{method.match_reasons.map((reason, index) => <li key={`${index}-${reason}`}>{MATCH_REASON[reason] || reason}</li>)}</ul> : <p className="scene-footnote">{taskSelected ? '尚无当前任务的适用性依据。' : '选择任务后,按平台、阶段、来源和授权范围说明适用性。'}</p>}
|
||
{taskSelected && method.applicability === false && status !== 'legacy' && <p className="knowledge-scope-warning">{status === 'paused' ? '当前场景存在未解决的反证,已暂停复用。' : '本阶段不使用此方法;其他阶段的验证记录仍保留。'}</p>}
|
||
{(method.historical_unverified || status === 'legacy') && <p className="knowledge-scope-warning">历史记录不完整,仅保留审计;缺失统计不按零失败处理。</p>}
|
||
{status !== 'inapplicable' && <><dl className="knowledge-stats"><div><dt>独立成功</dt><dd>{count(method.stats?.success)}</dd></div><div><dt>相关反证</dt><dd>{count(method.stats?.contradiction)}</dd></div><div><dt>结果未知</dt><dd>{count(method.stats?.unknown)}</dd></div></dl>
|
||
<p className="knowledge-coverage">有效覆盖:{count(method.stats?.independent_tasks)} 个独立任务 · {count(method.stats?.independent_objects)} 个不同对象</p>
|
||
<p className="knowledge-last">最近验证:{last?.at ? displayTime(last.at) : '尚无可追溯验证'}{last?.outcome && <span> · {OUTCOME[last.outcome] || last.outcome}</span>}</p></>}
|
||
<details className="knowledge-details"><summary>方法、验证来源与证据<ChevronDown size={12} /></summary>
|
||
<p>方法来源:{ORIGIN[method.provenance] || method.provenance || '未记录'}{last?.verifier && <> · 最近验证方式:{VERIFIER[last.verifier] || last.verifier}</>}</p>
|
||
{last?.reason && <p>最近结果依据:{last.reason}</p>}
|
||
<p className="knowledge-reference">标识:{method.key || method.id}</p>
|
||
{lines(method.preconditions).length > 0 && <div><strong>前置条件</strong>{lines(method.preconditions).map((line, index) => <p key={index}>{line}</p>)}</div>}
|
||
{lines(method.parameters).length > 0 && <div><strong>本次需传入的参数</strong>{lines(method.parameters).map((line, index) => <p key={index}>{line}</p>)}</div>}
|
||
{lines(method.steps).length > 0 && <div><strong>操作步骤</strong><ol>{lines(method.steps).map((step, index) => <li key={`${index}-${step}`}>{step}</li>)}</ol></div>}
|
||
{lines(method.expected_result).length > 0 && <div><strong>成功标准</strong>{lines(method.expected_result).map((line, index) => <p key={index}>{line}</p>)}</div>}
|
||
{lines(method.recovery).length > 0 && <div><strong>失败恢复</strong>{lines(method.recovery).map((line, index) => <p key={index}>{line}</p>)}</div>}
|
||
{method.dependencies && method.dependencies.length > 0 && <p>依赖版本:{method.dependencies.map(dep => typeof dep === 'string' ? dep : dep.id || `${dep.key}(版本 ${dep.version})`).join('、')}</p>}
|
||
{status !== 'inapplicable' && <p className="knowledge-other-outcomes">其他记录:受阻 {count(method.stats?.blocked)} · 程序异常 {count(method.stats?.program_error)} · 未使用 {count(method.stats?.unused)}。这些结果不冒充成功或方法反证。</p>}
|
||
{method.evidence_refs.length > 0 ? <ul className="knowledge-evidence-refs">{method.evidence_refs.map((ref, index) => <li key={`${ref.event_id || ref.task_id || method.id}-${index}`}><strong>证据引用 {index + 1}</strong>{ref.event_id && <code>事件 {ref.event_id}</code>}{ref.action_id && <code>动作 {ref.action_id}</code>}{lines(ref.pre).map((line, at) => <code key={`pre-${at}`}>动作前 {line}</code>)}{lines(ref.post).map((line, at) => <code key={`post-${at}`}>动作后 {line}</code>)}<ValidationProofView key={`${ref.task_id}-${ref.event_id || ''}-${Array.isArray(ref.pre) ? ref.pre[0] : ref.pre}`} evidenceRef={ref} />{ref.task_id && <button type="button" className="text-action" onClick={() => onOpenEvidence(ref.task_id!)}>查看任务证据<ArrowRight size={12} /></button>}</li>)}</ul> : <p className="scene-footnote">{status === 'inapplicable' ? '本阶段没有此方法的验收记录;其他适用阶段的记录仍保留。' : '尚未保存可追溯的任务证据引用。'}</p>}
|
||
</details>
|
||
<footer><span>{status === 'legacy' ? '历史验证依据缺失,不参与自动排名' : status === 'inapplicable' ? '当前阶段不参与选择;不表示方法失效或历史成功归零' : status === 'exploration' ? '独立样本不足,继续受控试用' : status === 'paused' ? '保留历史依据,当前场景暂停使用' : '计数来自有效独立事件,重复重试不累加'}</span><MethodFeedbackForm methodId={method.id} taskId={taskId} /></footer>
|
||
</article>;
|
||
})}</div> : !loading && !error && <div className="empty-state compact"><BookOpen size={24} /><h3>{methods.length ? '这个分类暂无方法' : '尚无可复用的方法记录'}</h3><p>有执行依据的复盘会形成候选,后续真实任务逐次验证;缺少证据时保持为空。</p></div>}
|
||
{compact && filtered.length > 3 && <button type="button" className="text-action knowledge-show-all" onClick={() => setShowAll(value => !value)}>{showAll ? '收起方法' : `展开全部 ${filtered.length} 条方法`}<ChevronDown size={12} /></button>}
|
||
{data?.policy.version && <p className="scene-footnote">选择规则版本:{data.policy.version}。状态与样本用于复用选择,不表示未来成功或业务成交概率。</p>}
|
||
</section>;
|
||
}
|