148 lines
5.8 KiB
TypeScript
148 lines
5.8 KiB
TypeScript
import { useState } from 'react';
|
||
import { Check, Play, Wand2, X } from 'lucide-react';
|
||
import { api, errorText } from '../api';
|
||
import { CATEGORY_LABEL, SCOPE_LABEL, type Scope } from '../types';
|
||
|
||
interface Plan {
|
||
id: number;
|
||
key: string;
|
||
value: string;
|
||
category: string;
|
||
school_count: number;
|
||
observations: number;
|
||
action: 'verify' | 'reject' | 'hold';
|
||
scope: Scope | null;
|
||
reason: string;
|
||
}
|
||
|
||
interface Result {
|
||
dry_run: boolean;
|
||
examined: number;
|
||
verified: number;
|
||
rejected: number;
|
||
held: number;
|
||
policy: { min_schools: number; min_observations: number; require_llm: boolean; auto_reject: boolean; max_actions: number };
|
||
plans: { verify: Plan[]; reject: Plan[]; hold: Plan[] };
|
||
}
|
||
|
||
function PlanList({ title, plans, tone }: { title: string; plans: Plan[]; tone: string }) {
|
||
if (plans.length === 0) return null;
|
||
return (
|
||
<div className="plan-group">
|
||
<div className={`plan-title ${tone}`}>{title} · {plans.length}</div>
|
||
<ul className="plan-list">
|
||
{plans.slice(0, 40).map(plan => (
|
||
<li key={plan.id}>
|
||
<span className="plan-key">{plan.key}</span>
|
||
<span className={`chip cat-${plan.category}`}>{CATEGORY_LABEL[plan.category] ?? plan.category}</span>
|
||
{plan.scope && <span className="chip">{SCOPE_LABEL[plan.scope]}</span>}
|
||
<span className="muted small">{plan.school_count} 校 · 观察 {plan.observations}</span>
|
||
<div className="plan-reason muted small">{plan.reason}</div>
|
||
</li>
|
||
))}
|
||
{plans.length > 40 && <li className="muted small">还有 {plans.length - 40} 条未列出</li>}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AutoReview({ llmReady, onApplied, onMessage }: {
|
||
llmReady: boolean;
|
||
onApplied: () => void;
|
||
onMessage: (text: string) => void;
|
||
}) {
|
||
const [minSchools, setMinSchools] = useState(2);
|
||
const [minObservations, setMinObservations] = useState(3);
|
||
const [requireLlm, setRequireLlm] = useState(true);
|
||
const [autoReject, setAutoReject] = useState(true);
|
||
const [result, setResult] = useState<Result | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
async function run(dryRun: boolean): Promise<void> {
|
||
setBusy(true);
|
||
try {
|
||
const body = {
|
||
dry_run: dryRun,
|
||
min_schools: minSchools,
|
||
min_observations: minObservations,
|
||
require_llm: requireLlm,
|
||
auto_reject: autoReject,
|
||
};
|
||
const data = await api.post<Result>('/api/entries/auto-review', body);
|
||
setResult(data);
|
||
if (!dryRun) {
|
||
onMessage(`已自动采纳 ${data.verified} 条、驳回 ${data.rejected} 条,留给人工 ${data.held} 条`);
|
||
onApplied();
|
||
}
|
||
} catch (error) {
|
||
onMessage(errorText(error));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="panel auto-panel">
|
||
<h2>智能采纳</h2>
|
||
<p className="muted">
|
||
只对证据够硬的条目动手。<strong>系统规则和其他类永远留给人</strong>,因为一条错的规则会让 Agent 以后一直绕开正确操作。
|
||
自动驳回只是暂缓,那条知识再被观察到会退回队列;人工驳回才是终局。
|
||
{llmReady ? '模型已配置,会参与判断。' : '未配模型 Key,只用规则层,能自动处理的会少很多。'}
|
||
</p>
|
||
|
||
<div className="policy-row">
|
||
<label>
|
||
跨校门槛
|
||
<input type="number" min={1} max={20} value={minSchools}
|
||
onChange={event => setMinSchools(Number(event.target.value))} />
|
||
<span className="muted small">几所学校各自观察到就算成立</span>
|
||
</label>
|
||
<label>
|
||
单校观察
|
||
<input type="number" min={1} max={50} value={minObservations}
|
||
onChange={event => setMinObservations(Number(event.target.value))} />
|
||
<span className="muted small">只有一所学校时的观察次数门槛</span>
|
||
</label>
|
||
<label className="check">
|
||
<input type="checkbox" checked={requireLlm} disabled={!llmReady}
|
||
onChange={event => setRequireLlm(event.target.checked)} />
|
||
证据够也要模型点头
|
||
</label>
|
||
<label className="check">
|
||
<input type="checkbox" checked={autoReject} onChange={event => setAutoReject(event.target.checked)} />
|
||
自动清掉明显的垃圾
|
||
</label>
|
||
</div>
|
||
|
||
<div className="policy-actions">
|
||
<button type="button" className="secondary-button small" disabled={busy} onClick={() => { void run(true); }}>
|
||
<Wand2 size={14} />先看会动哪些
|
||
</button>
|
||
<button type="button" className="primary-button small" disabled={busy || !result || result.dry_run === false}
|
||
onClick={() => { void run(false); }}>
|
||
<Play size={14} />按这个计划执行
|
||
</button>
|
||
{busy && <span className="muted small">模型逐条判断中,几十条要半分钟左右…</span>}
|
||
</div>
|
||
|
||
{result && (
|
||
<div className="plan-result">
|
||
<p className="muted small">
|
||
{result.dry_run ? '干跑结果(还没改任何状态)' : '已执行'}:检查 {result.examined} 条 →
|
||
采纳 {result.verified}、驳回 {result.rejected}、留给人工 {result.held}
|
||
</p>
|
||
<PlanList title="自动采纳" plans={result.plans.verify} tone="ok" />
|
||
<PlanList title="自动驳回(暂缓)" plans={result.plans.reject} tone="danger" />
|
||
<PlanList title="留给人工" plans={result.plans.hold} tone="warn" />
|
||
</div>
|
||
)}
|
||
|
||
<p className="muted small">
|
||
<Check size={12} /> 自动处理过的条目会标记来源为 auto,在队列里能筛出来复查,审错了点「退回待审」即可,
|
||
退回会分配新版本号推回各校。
|
||
<X size={12} /> 单次最多处理 {result?.policy.max_actions ?? 50} 条。
|
||
</p>
|
||
</section>
|
||
);
|
||
}
|