feat(v2): 补齐模型连接与会议反馈闭环并完成真实模拟联调

This commit is contained in:
2026-10-01 14:13:58 +08:00
parent fa4163b2d0
commit 8c5999c786
21 changed files with 392 additions and 15 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ export default function ImprovementPanel({project, identity, send}: any) {
const path = `/projects/${encodeURIComponent(project.id)}`;
const canReview = Boolean(identity?.preview || ["admin", "reviewer"].includes(identity?.role));
const canPublish = Boolean(identity?.preview || identity?.role === "reviewer");
const canPublish = Boolean(identity?.preview || identity?.role === "admin");
// 不自批:提出者看不到自己的"通过审核"(后端 must_not_self_approve 另有强制)
const mayApprove = (item: any) => canReview && item.proposed_by !== identity?.id;
@@ -4,6 +4,7 @@ import {Database,Search,SlidersHorizontal,Sparkles,Check,ChevronLeft,ChevronRigh
import {LeaseCase,industries,outcomes} from '@/lib/lease-library';
import KnowledgePools from './KnowledgePools';
import LeaseCaseDialog from './LeaseCaseDialog';
import ModelConnectionPanel from './ModelConnectionPanel';
export default function LeaseLibraryWorkbench({project,identity,send}:any) {
const [items,setItems]=useState<LeaseCase[]>([]),[total,setTotal]=useState(0),[source,setSource]=useState(''),[modelAvailable,setModelAvailable]=useState(false);
@@ -21,6 +22,7 @@ export default function LeaseLibraryWorkbench({project,identity,send}:any) {
const visible=items.slice(page*20,page*20+20),pages=Math.max(1,Math.ceil(items.length/20));
return <div className="v2-library">
<div className="v2-metrics"><article><span><Database size={16}/>历史对公租赁案例</span><b>{total}</b><small>SQLite 持久化 · 全部模拟</small></article><article><span>本次筛选匹配</span><b>{items.length}</b><small>{preview?'实际只读查询结果':'全部案例,尚未确认加工'}</small></article><article><span>已确认加工范围</span><b>{selection?.count??'—'}</b><small>{selection?'选择快照已冻结':'先预览,再确认'}</small></article><article><span>经验加工模型</span><b className="v2-metric-text">{modelAvailable?'已配置':'待连接'}</b><small>沿用受信模型配置,无 Jev 依赖</small></article></div>
{canEdit&&<ModelConnectionPanel send={send} onSaved={()=>send("GET","/library/cases").then((r:any)=>setModelAvailable(r.model_available))}/>}
<div className="v2-library-layout"><section className="v2-surface"><div className="v2-section-heading"><div><span className="v2-kicker">LEASE CASE DATABASE</span><h2>先看案例,再提炼经验</h2></div><span className="v2-tag">对公金融租赁</span></div>
<div className="v2-tabs" role="tablist" aria-label="案例筛选方式">{[['filters','条件筛选'],['natural','自然语言'],['sql','只读 SQL']].map(([key,label])=><button role="tab" aria-selected={mode===key} key={key} disabled={submitting||!canEdit} onClick={()=>{setMode(key);setText(key==='sql'?'SELECT * FROM lease_cases ORDER BY id':'');invalidate();}}>{label}</button>)}</div>
{mode==='filters'&&<div className="v2-filter-grid">{[['industry','行业',industries],['mode','租赁方式',['直接租赁','售后回租']],['outcome','会议结果',outcomes],['year','评审年份',['2023','2024','2025']]].map(([key,label,options])=><label className="ra-field" key={key as string}><span>{label as string}</span><select disabled={submitting||!canEdit} value={filters[key as string]||''} onChange={e=>update(key as string,e.target.value)}><option value="">全部</option>{(options as string[]).map(v=><option key={v}>{v}</option>)}</select></label>)}</div>}
@@ -0,0 +1,9 @@
'use client';
import {useEffect,useState} from 'react';
const LABEL:Record<string,string>={advance:'建议推进',supplement:'建议补件',stop:'暂不推进',insufficient_information:'信息不足'};
export default function MeetingFeedbackPanel({project,send}:any){
const [report,setReport]=useState<any>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false);
async function load(){setBusy(true);setError('');try{setReport(await send('GET',`/library/projects/${encodeURIComponent(project.id)}/feedback`));}catch(e:any){setError(e.message);}finally{setBusy(false);}}
useEffect(()=>{setReport(null);load();},[project.id]);
return <section className="v2-surface"><div className="v2-section-heading"><div><span className="v2-kicker">MEETING FEEDBACK</span><h2>预审建议,后来是否真的通过?</h2></div><button className="ra-button" disabled={busy} onClick={load}>刷新反馈</button></div><p>只比较会议之前已完成的冻结预审。正式结果需关联材料版本并确认通过/否决口径;更正、暂缓和未送审单列。</p>{busy&&<p role="status">读取冻结预审与会议结果…</p>}{error&&<p role="alert" className="ra-alert error">{error}</p>}{report&&<><div className="v2-metrics"><article><span>有效对照样本</span><b>{report.observed}</b></article><article><span>建议推进组</span><b>{report.advance_group.count}</b></article><article><span>推进组实际通过占比</span><b>{report.advance_group.rate===null?'待积累':`${(report.advance_group.rate*100).toFixed(1)}%`}</b></article><article><span>排除记录</span><b>{report.excluded.length}</b></article></div><p className="v2-notice">{report.note}</p>{!report.rows.length&&<p>尚无符合口径的反馈样本。完成预审后,在下方登记会议结果、会议时间与实际审议的材料版本,再刷新这里。</p>}<ul>{report.rows.map((r:any)=><li key={r.outcome_id}><b>{LABEL[r.recommendation]||r.recommendation} → {r.approved?'会议通过':'会议否决'}</b>{r.divergence&&' · 优先复盘分歧'}<p>运行 {r.run_id} · 会议 {r.meeting_id} · 方案 {r.plan_ref?.id}@{r.plan_ref?.version} · {r.data_domain==='demo'?'模拟域':'真实域'}</p></li>)}</ul><details><summary>查看排除原因({report.excluded.length})</summary><ul>{report.excluded.map((r:any)=><li key={r.outcome_id}>{r.reason} · {r.outcome_id}</li>)}</ul></details></>}</section>;
}
@@ -0,0 +1,10 @@
'use client';
import {useEffect,useState} from 'react';
export default function ModelConnectionPanel({send,onSaved}:any){
const [config,setConfig]=useState<any>(null),[endpoint,setEndpoint]=useState(''),[name,setName]=useState(''),[key,setKey]=useState('');
const [busy,setBusy]=useState(false),[message,setMessage]=useState(''),[error,setError]=useState('');
useEffect(()=>{let live=true;send('GET','/library/model-settings').then((data:any)=>{if(live){setConfig(data);setEndpoint(data.endpoint);setName(data.name);}}).catch((e:any)=>{if(live)setError(e.message);});return()=>{live=false;};},[]);
async function save(){setBusy(true);setError('');setMessage('');try{const data=await send('PUT','/library/model-settings',{endpoint,name,api_key:key||null});setConfig(data);setKey('');setMessage('设置已保存,预审与历史提炼共用此模型。');onSaved?.();}catch(e:any){setError(e.message);}finally{setBusy(false);}}
async function probe(){setBusy(true);setError('');setMessage('');try{const data=await send('POST','/library/model-settings/probe');if(data.ok)setMessage(data.message);else setError(data.message);}catch(e:any){setError(e.message);}finally{setBusy(false);}}
return <details className="v2-advanced"><summary>模型连接与配置 · {config?.available?'已配置':'待连接'}</summary><p>选择本地兼容服务或 DeepSeek;Jev 不影响主流程。连接检查只发送测试问候,不发送案例材料。</p>{error&&<p role="alert" className="ra-alert error">{error}</p>}{message&&<p role="status" className="ra-alert">{message}</p>}{config&&<><p>{config.managed_by==='environment'?'由部署环境管理,页面只读':'本机私有设置'} · 密钥{config.key_configured?'已设置':'未设置(本地服务可无需密钥)'}</p><div className="v2-form-grid"><label className="ra-field"><span>模型接口地址</span><input value={endpoint} disabled={busy||config.managed_by==='environment'} placeholder="http://127.0.0.1:11434/v1" onChange={e=>setEndpoint(e.target.value)}/></label><label className="ra-field"><span>模型名称</span><input value={name} disabled={busy||config.managed_by==='environment'} placeholder="本地服务已安装的模型名称" onChange={e=>setName(e.target.value)}/></label><label className="ra-field"><span>新密钥(留空保留当前密钥)</span><input type="password" autoComplete="new-password" value={key} disabled={busy||config.managed_by==='environment'} onChange={e=>setKey(e.target.value)}/></label></div><div className="xb-actions"><button className="ra-button primary" disabled={busy||!endpoint||!name||config.managed_by==='environment'} onClick={save}>保存模型设置</button><button className="ra-button" disabled={busy||!config.available} onClick={probe}>{busy?'正在处理…':'检查已保存的连接'}</button></div></>}</details>;
}
+2 -1
View File
@@ -33,7 +33,7 @@ const NORMALIZED = [
const EMPTY_FORM = {
raw_code: "", meeting_id: "", normalized: "", reviewed_snapshot_id: "",
occurred_at: "", supersedes_id: "", conditions: "",
occurred_at: "", supersedes_id: "", conditions: "", mapping_confirmed: false,
};
export default function TrajectoryPanel({project, identity, send, view="all"}: any) {
@@ -87,6 +87,7 @@ export default function TrajectoryPanel({project, identity, send, view="all"}: a
conditions: form.conditions.split(",").map((item: string) => item.trim()).filter(Boolean),
};
if (form.normalized) body.normalized = form.normalized;
if (form.mapping_confirmed) body.mapping_ref = {id:"meeting-explicit-label", version:"1"};
if (form.reviewed_snapshot_id) body.reviewed_snapshot_id = form.reviewed_snapshot_id;
if (form.supersedes_id) body.supersedes_id = form.supersedes_id;
if (form.occurred_at) body.occurred_at = new Date(form.occurred_at).toISOString();