diff --git a/src-tauri/src/agent/mod.rs b/src-tauri/src/agent/mod.rs index 80ea985..ff482ec 100644 --- a/src-tauri/src/agent/mod.rs +++ b/src-tauri/src/agent/mod.rs @@ -12,10 +12,15 @@ //! 通不通,走降级链会让备用服务的成功掩盖主用服务的故障。理由写在那边的代码里。 //! //! 内置提示词统一放在 [`prompts`],用户可编辑的打招呼与回复提示词仍在配置文件里。 +//! +//! [`trace`] 在这条唯一入口上旁路记录每轮的提示词、原始输出与判定,供测试模式调提示词用—— +//! 日志为了不泄露简历原文只留原因和长度,缺的正是调提示词最需要的那部分。 +//! 代价是这些内容确实敏感,所以轨迹只驻内存、随进程退出即丢,除非用户显式导出,否则不落盘。 pub mod output; pub mod prompts; pub mod run; pub mod tasks; +pub mod trace; pub use run::{run, AgentOutcome, AgentRunner, AgentStop, AgentTask}; diff --git a/src-tauri/src/agent/run.rs b/src-tauri/src/agent/run.rs index 58f3fbc..06a7e01 100644 --- a/src-tauri/src/agent/run.rs +++ b/src-tauri/src/agent/run.rs @@ -7,9 +7,12 @@ //! 这里把那条链路收成一处,调用方只需要描述任务本身:给什么上下文、 //! 期望什么结构、什么样的结果算合格。装参数、渲染、重试、净化、校验由循环负责。 +use std::time::Instant; + use serde_json::Value; use crate::agent::output; +use crate::agent::trace::{self, AgentTrace, RoundTrace, RoundVerdict}; use crate::config::AppRuntimeConfig; use crate::error::AppError; use crate::llm::service::LlmChainService; @@ -34,6 +37,17 @@ pub enum AgentStop { Recovered, } +impl AgentStop { + /// 轨迹里存字符串而不是直接序列化枚举:这个值要送到前端展示, + /// 存成稳定的小写标识后,以后给枚举加分支也不会改掉已有轨迹的字面量 + fn trace_label(self) -> &'static str { + match self { + Self::FirstTry => "first_try", + Self::Recovered => "recovered", + } + } +} + /// 一次运行的完整结果。 #[derive(Debug, Clone)] pub struct AgentOutcome { @@ -45,6 +59,8 @@ pub struct AgentOutcome { pub raw: String, /// 被否掉的轮次理由,按顺序。为空表示一次过 pub rejections: Vec, + /// 本次运行在轨迹缓冲里的 id,供测试模式关联展示 + pub trace_id: String, } /// 一个可执行的 Agent 任务。 @@ -101,6 +117,7 @@ pub trait AgentTask { pub struct AgentRunner<'a> { config: &'a AppRuntimeConfig, cancel: Option bool + Send + Sync + 'a>>, + trace_id: Option, } impl<'a> AgentRunner<'a> { @@ -108,6 +125,7 @@ impl<'a> AgentRunner<'a> { Self { config, cancel: None, + trace_id: None, } } @@ -117,6 +135,17 @@ impl<'a> AgentRunner<'a> { self } + /// 指定这次运行的轨迹 id,让调用方在运行之前就拿到它。 + /// + /// 失败时 `execute` 只能返回 [`AppError`],`AgentOutcome` 连同里面的 `trace_id` + /// 一起没有了——而失败恰恰是调提示词时最需要翻轨迹的时候。调用方靠「取最新一条」 + /// 反查在并发跑任务时会认错人,所以把 id 的分配权交给它: + /// 先 [`trace::next_id`] 拿号,再传进来,成功失败都能对上。 + pub fn with_trace_id(mut self, id: impl Into) -> Self { + self.trace_id = Some(id.into()); + self + } + fn cancelled(&self) -> bool { self.cancel.as_ref().is_some_and(|check| check()) } @@ -152,8 +181,50 @@ impl<'a> AgentRunner<'a> { T: AgentTask, F: FnMut(String) -> Result<(), AppError>, { - let service = LlmChainService::from_runtime(self.config)?; - let base_prompt = task.build_prompt()?; + // 埋点在这里而不是各个调用方:这是全部八种模型用途唯一的出口, + // 埋一处就等于全覆盖,也不会有新用途忘了接 + let trace_id = self + .trace_id + .clone() + .unwrap_or_else(trace::next_id); + let started_at = chrono::Local::now().to_rfc3339(); + let started = Instant::now(); + let mut trace_rounds: Vec = Vec::new(); + + // 准备阶段的两次失败也要留痕,哪怕一轮都还没跑起来。 + // 模板漏填变量正是这个项目踩过最深的坑(猎聘自动回复整个平台静默失效, + // 见本文件末尾的回归测试),而它的报错发生在这里、轨迹却什么都没有的话, + // 测试模式页面就会在最该看清的地方一片空白 + let service = match LlmChainService::from_runtime(self.config) { + Ok(service) => service, + Err(error) => { + record_trace( + &trace_id, + task.name(), + &started_at, + started, + trace_rounds, + None, + Some(error.message.clone()), + ); + return Err(error); + } + }; + let base_prompt = match task.build_prompt() { + Ok(prompt) => prompt, + Err(error) => { + record_trace( + &trace_id, + task.name(), + &started_at, + started, + trace_rounds, + None, + Some(error.message.clone()), + ); + return Err(error); + } + }; let streaming = on_delta.is_some(); let max_rounds = if streaming { @@ -165,7 +236,17 @@ impl<'a> AgentRunner<'a> { for round in 1..=max_rounds { if self.cancelled() { - return Err(AppError::cancelled("任务已停止,大模型调用已取消")); + let error = AppError::cancelled("任务已停止,大模型调用已取消"); + record_trace( + &trace_id, + task.name(), + &started_at, + started, + trace_rounds, + None, + Some(error.message.clone()), + ); + return Err(error); } let prompt = match rejections.last() { @@ -175,36 +256,115 @@ impl<'a> AgentRunner<'a> { // 两条路都是流式:流式避开了网关掐断静默长连接、整体超时把已生成内容 // 全部作废这两个坑。区别只在增量推不推出去——不推才敢重试和降级 - let response = match on_delta.as_mut() { - Some(callback) => service.stream_with(prompt, callback).await?, - None => service.stream_collect(prompt).await?, + let call_started = Instant::now(); + let called = match on_delta.as_mut() { + Some(callback) => service.stream_with(prompt.clone(), callback).await, + None => service.stream_collect(prompt.clone()).await, + }; + let call_ms = elapsed_ms(call_started); + + // 原先这里是直接 `?` 抛出的,失败的那一轮什么都留不下—— + // 而「调用压根没成功」恰恰是调提示词时最需要区分的一种情况, + // 所以先把这一轮连同整条轨迹落进缓冲,再原样把错误抛出去 + let response = match called { + Ok(response) => response, + Err(error) => { + // 只记 message:detail 里可能带上游返回体,含鉴权信息 + trace_rounds.push(RoundTrace { + round, + prompt, + raw: String::new(), + model: None, + usage: None, + duration_ms: call_ms, + verdict: RoundVerdict::Failed { + reason: error.message.clone(), + }, + }); + record_trace( + &trace_id, + task.name(), + &started_at, + started, + trace_rounds, + None, + Some(error.message.clone()), + ); + return Err(error); + } }; if self.cancelled() { - return Err(AppError::cancelled("任务已停止,大模型调用已取消")); + // 这一轮拿到了输出却没走到解析,三种 verdict 没有一个描述得准, + // 与其把 Rejected 的语义弄脏,不如让它缺席,由整条轨迹的 error 说明 + let error = AppError::cancelled("任务已停止,大模型调用已取消"); + record_trace( + &trace_id, + task.name(), + &started_at, + started, + trace_rounds, + None, + Some(format!("{}(末轮输出未参与解析)", error.message)), + ); + return Err(error); } let raw = output::sanitize(&response.content); + let model = response.model.clone(); + let usage = response.usage.clone(); let rejection = match task.parse(&raw) { Err(reason) => reason, Ok(parsed) => match task.validate(&parsed) { Ok(()) => { + let stop = if rejections.is_empty() { + AgentStop::FirstTry + } else { + AgentStop::Recovered + }; + trace_rounds.push(RoundTrace { + round, + prompt, + raw: raw.clone(), + model, + usage, + duration_ms: call_ms, + verdict: RoundVerdict::Passed, + }); + record_trace( + &trace_id, + task.name(), + &started_at, + started, + trace_rounds, + Some(stop.trace_label()), + None, + ); return Ok(AgentOutcome { output: parsed, - stop: if rejections.is_empty() { - AgentStop::FirstTry - } else { - AgentStop::Recovered - }, + stop, rounds: round, raw, rejections, + trace_id, }); } Err(reason) => reason, }, }; + trace_rounds.push(RoundTrace { + round, + prompt, + raw: raw.clone(), + model, + usage, + duration_ms: call_ms, + verdict: RoundVerdict::Rejected { + reason: rejection.clone(), + }, + }); + // 内容本身可能含求职者隐私,日志只留原因和长度 let _ = logger::warning(format!( "Agent「{}」第 {}/{} 轮输出未通过校验({} 字):{}", @@ -217,15 +377,54 @@ impl<'a> AgentRunner<'a> { rejections.push(rejection); } - Err(AppError::provider(format!( + let error = AppError::provider(format!( "Agent「{}」连续 {} 轮输出都不合格:{}", task.name(), max_rounds, rejections.last().map(String::as_str).unwrap_or("原因未知") - ))) + )); + record_trace( + &trace_id, + task.name(), + &started_at, + started, + trace_rounds, + None, + Some(error.message.clone()), + ); + Err(error) } } +fn elapsed_ms(since: Instant) -> u64 { + u64::try_from(since.elapsed().as_millis()).unwrap_or(u64::MAX) +} + +/// 把一次运行落进内存缓冲。 +/// +/// 每条退出路径上各调一次,而不是用 Drop 守卫自动兜底:守卫只知道「函数结束了」, +/// 拿不到「成功还是失败、失败在哪一步」这些只有退出点才清楚的信息, +/// 而这恰恰是测试模式要看的东西。代价是新增退出路径时得记得补一行。 +fn record_trace( + id: &str, + task_name: &str, + started_at: &str, + started: Instant, + rounds: Vec, + stop: Option<&str>, + error: Option, +) { + trace::record(AgentTrace { + id: id.to_string(), + task_name: task_name.to_string(), + started_at: started_at.to_string(), + duration_ms: elapsed_ms(started), + rounds, + stop: stop.map(str::to_string), + error, + }); +} + /// 不需要取消检查时的快捷入口 pub async fn run( task: &T, @@ -313,4 +512,25 @@ mod tests { assert_eq!(task.max_rounds(), 2); } + + /// 「返工一次才通过」的完整轨迹(第 1 轮 Rejected、第 2 轮 Passed)这里测不了: + /// 走完 `execute` 至少要两次真实的模型往返,而这组测试的前提就是不发网络请求, + /// 为了可测把循环拆成可注入的假 service,等于为测试重塑生产结构,代价大于收益。 + /// 因此这里只守住埋点的形状(每条退出路径都带上 trace_id), + /// 缓冲本身的行为由 `agent::trace` 的单元测试覆盖,两轮串起来的效果靠集成验证。 + #[test] + fn outcome_carries_a_trace_id_for_the_test_mode_page() { + let outcome = AgentOutcome { + output: "结果".to_string(), + stop: AgentStop::Recovered, + rounds: 2, + raw: "结果".to_string(), + rejections: vec!["输出不是合法 JSON".to_string()], + trace_id: "trace-7".to_string(), + }; + + assert_eq!(outcome.trace_id, "trace-7"); + assert_eq!(AgentStop::FirstTry.trace_label(), "first_try"); + assert_eq!(AgentStop::Recovered.trace_label(), "recovered"); + } } diff --git a/src-tauri/src/agent/trace.rs b/src-tauri/src/agent/trace.rs new file mode 100644 index 0000000..e59ac82 --- /dev/null +++ b/src-tauri/src/agent/trace.rs @@ -0,0 +1,302 @@ +//! Agent 调用轨迹的内存缓冲。 +//! +//! 调提示词以前只能靠日志倒推:日志里为了不泄露简历原文和岗位 JD,只留了原因和长度, +//! 于是「模型到底收到了什么、又吐回了什么」永远缺失,改一版提示词要重跑一遍真实投递才能看效果。 +//! 这里在 [`crate::agent::run::AgentRunner::execute`] 这个唯一入口上旁路留一份完整轨迹, +//! 测试模式页面据此展示每一轮的提示词、原始输出与判定结果。 +//! +//! **只驻内存,绝不落盘**。轨迹里装着日志刻意回避的隐私内容,一旦随应用日志一起躺在磁盘上, +//! 就等于把用户简历长期留在了一个谁都能读的文件里。只有用户显式点导出、 +//! 自己选好落点时才由 [`export`] 写文件。进程退出即丢失是有意为之的取舍。 + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, MutexGuard}; + +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; +use crate::llm::types::LlmUsage; + +/// 一轮模型调用的结局 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RoundVerdict { + /// 解析与校验都通过 + Passed, + /// 输出拿到了,但没通过解析或校验 + Rejected { reason: String }, + /// 调用本身失败(网络、鉴权、全链降级耗尽等) + Failed { reason: String }, +} + +/// 一轮模型调用的完整记录 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RoundTrace { + /// 从 1 起算 + pub round: u32, + /// 实际送出去的完整提示词,含返工追加段 + pub prompt: String, + /// 净化后的模型输出。调用失败时为空串 + pub raw: String, + pub model: Option, + pub usage: Option, + pub duration_ms: u64, + pub verdict: RoundVerdict, +} + +/// 一次 Agent 任务运行的完整轨迹 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AgentTrace { + pub id: String, + /// 任务名,取自 `AgentTask::name()` + pub task_name: String, + /// RFC3339 本地时间 + pub started_at: String, + pub duration_ms: u64, + pub rounds: Vec, + /// 成功时为 "first_try" 或 "recovered",失败时为 None + pub stop: Option, + /// 整体失败原因,成功时为 None + pub error: Option, +} + +/// 环形缓冲容量。 +/// +/// 200 条按一次运行上限两轮、每轮几 KB 提示词估算,最坏也就是几 MB 常驻内存; +/// 再大就要为「调提示词」这件辅助工作长期占用用户内存,不划算。 +pub const TRACE_CAPACITY: usize = 200; + +static TRACES: Lazy>> = + Lazy::new(|| Mutex::new(VecDeque::with_capacity(TRACE_CAPACITY))); + +static NEXT_ID: AtomicU64 = AtomicU64::new(1); + +/// 拿缓冲的锁,遇到中毒直接接管里面的数据继续用。 +/// +/// 追踪是辅助设施,绝不能反过来搞垮它监控的主链路:某个线程在持锁期间 panic 之后, +/// 如果这里跟着 `unwrap()`,后续每一次大模型调用都会连带 panic, +/// 等于「为了看提示词把投递功能弄挂了」。而本模块持锁期间只做 `VecDeque` 的 +/// 增删读,panic 不会让容器处于逻辑上半成品的状态,接管旧数据是安全的。 +fn buffer() -> MutexGuard<'static, VecDeque> { + TRACES + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// 记录一条轨迹,超出容量时挤掉最旧的一条 +pub fn record(trace: AgentTrace) { + let mut buffer = buffer(); + while buffer.len() >= TRACE_CAPACITY { + buffer.pop_front(); + } + buffer.push_back(trace); +} + +/// 取轨迹,**按时间倒序**(最新的在前)。 +/// `ids` 为空表示返回全部;否则只返回 id 命中的那些,顺序同样是时间倒序 +pub fn recent(ids: &[String]) -> Vec { + let buffer = buffer(); + buffer + .iter() + .rev() + .filter(|trace| ids.is_empty() || ids.iter().any(|id| id == &trace.id)) + .cloned() + .collect() +} + +/// 清空缓冲 +pub fn clear() { + buffer().clear(); +} + +/// 导出全部轨迹为 JSON 文件(数组,时间倒序),返回导出条数。 +/// +/// 这是本模块唯一写盘的地方,且只在用户显式点导出、自己选定落点时才会被调到。 +pub fn export(path: &std::path::Path) -> Result { + let traces = recent(&[]); + let payload = serde_json::to_string_pretty(&traces) + .map_err(|error| AppError::internal("序列化调用轨迹失败").with_detail(error.to_string()))?; + + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|error| { + AppError::storage("创建轨迹导出目录失败").with_detail(error.to_string()) + })?; + } + } + std::fs::write(path, payload).map_err(|error| { + AppError::storage("写入轨迹导出文件失败").with_detail(error.to_string()) + })?; + + Ok(traces.len()) +} + +/// 分配一个新的轨迹 id。 +/// +/// 自增计数器而不是 uuid:id 只需要在单次进程生命周期内唯一(缓冲本来就不跨进程), +/// 顺序编号还能让人一眼看出先后,排查时比一串随机十六进制有用。 +pub fn next_id() -> String { + format!("trace-{}", NEXT_ID.fetch_add(1, Ordering::Relaxed)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 缓冲是全局的,而同一个测试二进制里的测试默认并行跑。 + /// 项目没有 `serial_test` 依赖,也不值得为几个测试引一个, + /// 于是照搬 `logger.rs` 里的做法:用一把测试专用的锁把这组测试串起来。 + /// 锁中毒时接管而不是 unwrap,免得一个失败的测试把其余测试全带成 panic。 + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn isolated() -> MutexGuard<'static, ()> { + let guard = TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + clear(); + guard + } + + fn sample(id: &str, task_name: &str) -> AgentTrace { + AgentTrace { + id: id.to_string(), + task_name: task_name.to_string(), + started_at: "2026-01-01T00:00:00+08:00".to_string(), + duration_ms: 12, + rounds: vec![RoundTrace { + round: 1, + prompt: "提示词".to_string(), + raw: "输出".to_string(), + model: Some("test-model".to_string()), + usage: None, + duration_ms: 10, + verdict: RoundVerdict::Passed, + }], + stop: Some("first_try".to_string()), + error: None, + } + } + + #[test] + fn overflow_drops_the_oldest_and_never_exceeds_capacity() { + let _guard = isolated(); + + for index in 0..TRACE_CAPACITY + 5 { + record(sample(&format!("overflow-{index}"), "overflow")); + } + + let traces = recent(&[]); + assert_eq!(traces.len(), TRACE_CAPACITY); + // 最旧的 5 条被挤掉,最新的一条还在 + assert_eq!( + traces.first().unwrap().id, + format!("overflow-{}", TRACE_CAPACITY + 4) + ); + assert_eq!(traces.last().unwrap().id, "overflow-5"); + assert!(traces.iter().all(|trace| trace.id != "overflow-0")); + } + + #[test] + fn recent_returns_newest_first() { + let _guard = isolated(); + + record(sample("trace-1", "order")); + record(sample("trace-2", "order")); + record(sample("trace-3", "order")); + + let ids: Vec = recent(&[]) + .into_iter() + .filter(|trace| trace.task_name == "order") + .map(|trace| trace.id) + .collect(); + + assert_eq!(ids, vec!["trace-3", "trace-2", "trace-1"]); + } + + #[test] + fn recent_with_ids_only_returns_the_matching_ones() { + let _guard = isolated(); + + record(sample("trace-1", "filter")); + record(sample("trace-2", "filter")); + record(sample("trace-3", "filter")); + + let hit = recent(&["trace-2".to_string()]); + + assert_eq!(hit.len(), 1); + assert_eq!(hit[0].id, "trace-2"); + } + + #[test] + fn recent_with_ids_keeps_newest_first_order() { + let _guard = isolated(); + + record(sample("trace-1", "filter-order")); + record(sample("trace-2", "filter-order")); + record(sample("trace-3", "filter-order")); + + let ids: Vec = recent(&["trace-1".to_string(), "trace-3".to_string()]) + .into_iter() + .map(|trace| trace.id) + .collect(); + + assert_eq!(ids, vec!["trace-3", "trace-1"]); + } + + #[test] + fn clear_empties_the_buffer() { + let _guard = isolated(); + + record(sample("trace-1", "clear")); + clear(); + + assert!(recent(&[]).is_empty()); + } + + #[test] + fn ids_are_unique_and_prefixed() { + // 不碰缓冲,因此不需要串行锁 + let first = next_id(); + let second = next_id(); + + assert!(first.starts_with("trace-")); + assert_ne!(first, second); + } + + #[test] + fn export_writes_a_newest_first_json_array_and_reports_the_count() { + let _guard = isolated(); + let dir = tempfile::tempdir().expect("建临时目录"); + let path = dir.path().join("nested").join("traces.json"); + + record(sample("trace-1", "export")); + record(sample("trace-2", "export")); + let count = export(&path).expect("导出轨迹"); + + let restored: Vec = + serde_json::from_str(&std::fs::read_to_string(&path).expect("读回导出文件")) + .expect("导出的是合法 JSON 数组"); + + assert_eq!(count, 2); + assert_eq!(restored.len(), 2); + assert_eq!(restored[0].id, "trace-2"); + assert_eq!(restored[1].id, "trace-1"); + } + + #[test] + fn verdicts_serialize_with_a_tagged_kind() { + let rejected = serde_json::to_value(RoundVerdict::Rejected { + reason: "不是合法 JSON".to_string(), + }) + .expect("序列化判定"); + + assert_eq!(rejected["kind"], "rejected"); + assert_eq!(rejected["reason"], "不是合法 JSON"); + assert_eq!( + serde_json::to_value(RoundVerdict::Passed).expect("序列化判定")["kind"], + "passed" + ); + } +} diff --git a/src-tauri/src/command/llm.rs b/src-tauri/src/command/llm.rs index ffe7468..5a67414 100644 --- a/src-tauri/src/command/llm.rs +++ b/src-tauri/src/command/llm.rs @@ -1,44 +1,13 @@ -use crate::agent::tasks::{ - GreetTask, JobFilterRulesTask, ReplyDecisionTask, ResumeOptimizeTask, ResumeQuestionsTask, -}; +use crate::agent::tasks::{JobFilterRulesTask, ResumeOptimizeTask, ResumeQuestionsTask}; use crate::command::base::CommandResult; use crate::config::RegexRule; use crate::error::AppError; -use crate::rpa::common::{ChatMessage, RpaJob}; -use crate::rpa::conversation::{ConversationContext, GreetAction, ReplyDecision, ResumeState}; -use crate::rpa::run_flow::PlatformKind; -use serde::{Deserialize, Serialize}; +use serde::Serialize; // 这些类型定义在 agent 层,命令层只做转发:调试入口和实际运行必须是同一条链路, // 各自维护一份输入结构迟早会漂移 pub use crate::agent::tasks::{OptimizeWithAnswerRequest, PredictedQuestion}; -#[derive(Debug, Deserialize)] -pub struct DebugReplayRequest { - pub job_title: String, - pub company_name: String, - pub job_detail: String, - pub salary: String, - pub location: String, - pub messages: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DebugChatMessage { - pub text: String, - pub from_name: String, - pub received: bool, -} - -#[derive(Debug, Deserialize)] -pub struct DebugGreetRequest { - pub job_title: String, - pub company_name: String, - pub job_detail: String, - pub salary: String, - pub location: String, -} - #[derive(Debug, Serialize)] pub struct ResumeLlmResult { pub success: bool, @@ -66,127 +35,6 @@ pub async fn generate_job_filter_rules( } } -/// 调试入口:预演一次自动回复。 -/// -/// 走的是和实际运行**完全相同**的任务,包括决策、护栏与发送前体检。 -/// 此前这里自己拼了一套参数(还漏掉了 chat_history,导致调试页必然报错), -/// 于是调试看到的效果和真正跑出来的并不是一回事。 -#[tauri::command] -pub async fn debug_generate_replay( - app_handle: tauri::AppHandle, - req: DebugReplayRequest, -) -> CommandResult { - let config = match crate::config::load_app_config_inner(app_handle) { - Ok(cfg) => cfg, - Err(err) => return CommandResult::err(err), - }; - - let context = ConversationContext { - platform: PlatformKind::Boss, - conversation_id: "debug".to_string(), - job: Some(debug_job_detail(&req)), - messages: req - .messages - .iter() - .enumerate() - .map(|(index, message)| ChatMessage { - mid: index as i64, - received: message.received, - text: message.text.clone(), - time: index as i64, - from_name: message.from_name.clone(), - }) - .collect(), - // 调试没有真实页面可读,按最保守的状态给:不允许预演里出现投递动作 - resume_state: ResumeState::Unknown, - // 调试页要看的就是模型会生成什么,不该被真实会话的节流额度挡住。 - // 这里也不经过闸门,填 0 只是为了让结构完整 - auto_replies_in_window: 0, - }; - - match crate::agent::run(&ReplyDecisionTask::new(&config, &context), &config).await { - Ok(outcome) => CommandResult::ok(describe_decision(&outcome.output)), - Err(err) => CommandResult::err(err), - } -} - -/// 决策结果渲染成调试页能直接看的一段文本。 -/// -/// 不回复和转人工也要说清楚,否则用户只看到空白,会以为是生成失败 -fn describe_decision(decision: &ReplyDecision) -> String { - if decision.action.needs_text() { - return decision.reply.clone(); - } - format!( - "(AI 判断本轮不发送:{}。理由:{})", - match decision.action { - crate::rpa::conversation::ReplyAction::Skip => "无需回复", - _ => "需要转人工", - }, - decision.reason - ) -} - -fn debug_job_detail(req: &DebugReplayRequest) -> crate::dao::model::JobDetail { - crate::dao::model::JobDetail { - id: "debug".to_string(), - platform: "boss".to_string(), - source_task_id: None, - profile_id: None, - profile_name: None, - profile_snapshot_id: None, - title: req.job_title.clone(), - company_name: req.company_name.clone(), - detail: req.job_detail.clone(), - salary: req.salary.clone(), - location: Some(req.location.clone()), - is_reply: false, - is_send_resume: false, - created_at: String::new(), - resume_sent_at: None, - updated_at: String::new(), - } -} - -/// 调试入口:预演一次打招呼。与实际运行共用同一个任务 -#[tauri::command] -pub async fn debug_generate_greet( - app_handle: tauri::AppHandle, - req: DebugGreetRequest, -) -> CommandResult { - let config = match crate::config::load_app_config_inner(app_handle) { - Ok(cfg) => cfg, - Err(err) => return CommandResult::err(err), - }; - - let job = RpaJob { - platform: PlatformKind::Boss, - platform_job_id: "debug".to_string(), - title: req.job_title, - company_name: req.company_name, - detail: req.job_detail, - salary: req.salary, - location: Some(req.location), - recruiter_active_time: None, - detail_url: String::new(), - }; - - match crate::agent::run(&GreetTask::new(&config, &job), &config).await { - Ok(outcome) => { - let decision = outcome.output; - // 「不该投」也要让用户在调试页看见,否则只看到空白会以为是生成失败 - if decision.action == GreetAction::Skip { - return CommandResult::ok(format!( - "(AI 判断该岗位不适合投递,实际运行时整轮都不会发送。理由:{})", - decision.reason - )); - } - CommandResult::ok(decision.greeting) - } - Err(err) => CommandResult::err(err), - } -} - #[tauri::command] pub async fn predict_resume_questions( app_handle: tauri::AppHandle, diff --git a/src-tauri/src/command/mod.rs b/src-tauri/src/command/mod.rs index 1eb4908..b7f998b 100644 --- a/src-tauri/src/command/mod.rs +++ b/src-tauri/src/command/mod.rs @@ -8,6 +8,7 @@ pub mod llm; pub mod llm_provider; pub mod manual_review; pub mod mock_interview; +pub mod playground; pub mod resume_templates; pub mod rpa; pub mod user_resumes; diff --git a/src-tauri/src/command/playground.rs b/src-tauri/src/command/playground.rs new file mode 100644 index 0000000..fd387c5 --- /dev/null +++ b/src-tauri/src/command/playground.rs @@ -0,0 +1,1206 @@ +//! 测试模式:把整条求职链路拆成可以逐个观察的环节。 +//! +//! 这条链路上 LLM 环节和确定性环节交替出现,出问题时用户只看得到「没投出去」 +//! 这一个结果,却分不清是正则把岗位挡了、模型判断不该投、还是内容没过发送前体检。 +//! 于是每个环节在这里都单独执行、单独出一条 [`StepResult`],链路在哪一步终止一目了然。 +//! +//! **这些命令不复制任何判断逻辑**:筛选走 [`crate::verify::filter_decision`], +//! 打招呼走 [`GreetTask`] 与 [`compose_greet_resources`],回复走 +//! [`conversation`] 里的闸门、路由、校正与体检——和真实运行调的是同一批函数。 +//! 此前的调试入口自己拼了一套参数(还漏了 `chat_history`),调试看到的效果 +//! 和真跑出来的根本不是一回事,那个坑不能再踩第二次。 +//! +//! 提示词覆盖只作用在配置的内存副本上。`AgentTask::prompt_template()` 本来就从 +//! 配置里读,改副本等于换了提示词,既不需要给任何 AgentTask 加参数, +//! 也不会污染磁盘:调试期的试错不该有任何一次意外落盘。 + +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::agent::tasks::{GreetTask, JobMatchTask, ReplyDecisionTask}; +use crate::agent::trace::{self, AgentTrace}; +use crate::agent::AgentRunner; +use crate::command::base::CommandResult; +use crate::config::AppRuntimeConfig; +use crate::dao::model::JobDetail; +use crate::error::AppError; +use crate::rpa::common::{ChatMessage, RpaJob}; +use crate::rpa::conversation::{ + self, ConversationContext, GateVerdict, GreetAction, GreetDecision, OutboundKind, ReplyAction, + ReplyDecision, ReplyLimits, ReplyRoute, ResumeState, SendVerdict, +}; +use crate::rpa::greet::compose_greet_resources; +use crate::rpa::run_flow::PlatformKind; +use crate::verify::FilterDecision; + +/// 链路上的一个环节 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Stage { + /// ① 正则与关键词筛选 + RegexFilter, + /// ② 岗位语义复核 + SemanticMatch, + /// ③ 打招呼决策 + GreetDecide, + /// ④ 打招呼发送序列组装 + GreetCompose, + /// ⑤ 打招呼发送前体检 + GreetVet, + /// ⑥ 回复闸门 + Gate, + /// ⑦ 回复路由(模板 vs 模型) + Route, + /// ⑧ 自动回复决策 + ReplyDecide, + /// ⑨ 投递意图校正 + Reconcile, + /// ⑩ 回复发送前体检 + ReplyVet, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Outcome { + /// 环节通过,链路继续 + Pass, + /// 环节拦截,链路终止。reason 要写成用户看得懂的人话 + Block { reason: String }, + /// 环节不适用或被跳过,链路可能继续也可能终止 + Skip { reason: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StepResult { + pub stage: Stage, + pub outcome: Outcome, + /// 该环节的结构化产物,前端按 stage 决定怎么渲染 + pub detail: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StepReport { + pub steps: Vec, + /// 本次链路上产生的 Agent 轨迹 id,按发生顺序 + pub trace_ids: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PlaygroundJob { + pub title: String, + pub company_name: String, + pub detail: String, + pub salary: String, + pub location: String, +} + +/// 只在本次调用内生效的提示词覆盖,不落盘。 +/// 这是测试模式的核心能力:改提示词立刻重跑,满意了再由前端走正常保存流程写回方案 +#[derive(Debug, Clone, Default, Deserialize)] +pub struct PromptOverrides { + pub greet_prompt: Option, + pub reply_prompt: Option, + pub semantic_filter_intent: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PlaygroundMessage { + pub text: String, + /// true = HR 发来的,false = 我发出去的 + pub received: bool, +} + +/// 手输岗位和手造会话在库里都没有身份,统一给一个固定标识。 +/// +/// 用固定值而不是随机 id:这些数据永远不会落库,随机 id 只会让日志更难读 +const PLAYGROUND_ID: &str = "playground"; + +/// 允许模型自主投递简历所需的最低把握。 +/// +/// 与 [`conversation::reconcile`] 里的阈值一致。这里复制的只是**用于解释**的数字, +/// 降级判定本身仍然只有 `reconcile` 一处——见 [`downgrade_reason`] +const MIN_RESUME_CONFIDENCE: u8 = 70; + +impl StepReport { + fn new() -> Self { + Self { + steps: Vec::new(), + trace_ids: Vec::new(), + } + } + + fn push(&mut self, stage: Stage, outcome: Outcome, detail: Value) { + self.steps.push(StepResult { + stage, + outcome, + detail, + }); + } + +} + +// ================================ +// 公共前置 +// ================================ + +/// 取配置 → 解析方案 → 把本次调用的提示词覆盖写进内存副本。 +/// +/// 走 `resolve_job_profile` 而不是直接用顶层配置:真实运行是按方案卡跑的, +/// 调试要是读了顶层镜像,用户在方案里改的提示词根本不会生效, +/// 「调试通过了但真跑还是老样子」比没有调试页更误导人 +fn prepare_config( + app_handle: tauri::AppHandle, + profile_id: Option, + overrides: &PromptOverrides, +) -> Result { + let config = crate::config::load_app_config_inner(app_handle)?; + let resolved = crate::config::resolve_job_profile(&config, profile_id.as_deref()) + .map_err(AppError::configuration)?; + + let mut config = resolved.config; + apply_overrides(&mut config, overrides); + Ok(config) +} + +/// 覆盖只改这三处提示词,其余配置原样保留。 +/// +/// 给 `None` 表示「用方案里存着的那份」,而不是「清空」——测试模式的常见用法 +/// 是只调一个环节的提示词、其它环节保持现状对照,清空会让对照失去意义 +fn apply_overrides(config: &mut AppRuntimeConfig, overrides: &PromptOverrides) { + if let Some(prompt) = overrides.greet_prompt.clone() { + config.greet_config.reply_prompt = Some(prompt); + } + if let Some(prompt) = overrides.reply_prompt.clone() { + config.replay_config.reply_prompt = Some(prompt); + } + if let Some(intent) = overrides.semantic_filter_intent.clone() { + config.job_filter_config.semantic_filter_intent = Some(intent); + } +} + +fn to_rpa_job(job: &PlaygroundJob) -> RpaJob { + RpaJob { + platform: PlatformKind::Boss, + platform_job_id: PLAYGROUND_ID.to_string(), + title: job.title.clone(), + company_name: job.company_name.clone(), + detail: job.detail.clone(), + salary: job.salary.clone(), + location: Some(job.location.clone()), + // 测试模式里的岗位由用户手工构造,没有来源页面可提供招聘者活跃时间。 + recruiter_active_time: None, + // 手输岗位没有来源页面。留空而不是编一个 URL:任何一处误把它当真实链接 + // 点开都是错的 + detail_url: String::new(), + } +} + +fn to_job_detail(job: &PlaygroundJob) -> JobDetail { + JobDetail { + id: PLAYGROUND_ID.to_string(), + platform: "boss".to_string(), + source_task_id: None, + profile_id: None, + profile_name: None, + profile_snapshot_id: None, + title: job.title.clone(), + company_name: job.company_name.clone(), + detail: job.detail.clone(), + salary: job.salary.clone(), + location: Some(job.location.clone()), + is_reply: false, + is_send_resume: false, + created_at: String::new(), + resume_sent_at: None, + updated_at: String::new(), + } +} + +/// 用数组下标当 mid 和时间戳:手造的对话本来就只有先后顺序,没有真实时间。 +/// 下标保证了 [`conversation::merge_messages`] 那套按 (time, mid) 排序的口径依然成立 +fn to_chat_messages(messages: &[PlaygroundMessage]) -> Vec { + messages + .iter() + .enumerate() + .map(|(index, message)| ChatMessage { + mid: index as i64, + received: message.received, + text: message.text.clone(), + time: index as i64, + from_name: if message.received { "HR" } else { "我" }.to_string(), + }) + .collect() +} + +// ================================ +// ①② 岗位筛选 +// ================================ + +/// 手输岗位过一遍筛选:正则关键词 + 可选的 AI 语义复核 +#[tauri::command] +pub async fn playground_screen( + app_handle: tauri::AppHandle, + profile_id: Option, + job: PlaygroundJob, + overrides: PromptOverrides, +) -> CommandResult { + let config = match prepare_config(app_handle, profile_id, &overrides) { + Ok(config) => config, + Err(error) => return CommandResult::err(error), + }; + + let rpa_job = to_rpa_job(&job); + let mut report = StepReport::new(); + + let decision = crate::verify::filter_decision(&rpa_job, &config); + let matched = decision.matched; + report.steps.push(regex_filter_step(&decision)); + if !matched { + return CommandResult::ok(report); + } + + if !config.job_filter_config.enable_semantic_filter { + report.push( + Stage::SemanticMatch, + Outcome::Skip { + reason: "方案未启用 AI 岗位复核".to_string(), + }, + json!({}), + ); + return CommandResult::ok(report); + } + + // 先拿号再跑:失败时 `AgentOutcome` 根本不会构造出来, + // 而模型报错恰恰是最需要翻提示词原文的时候 + let trace_id = trace::next_id(); + report.trace_ids.push(trace_id.clone()); + match AgentRunner::new(&config) + .with_trace_id(trace_id) + .run(&JobMatchTask::new(&config, &rpa_job)) + .await + { + Ok(outcome) => { + let result = outcome.output; + let outcome = if result.matched { + Outcome::Pass + } else { + Outcome::Block { + reason: result.reason.clone(), + } + }; + report.push( + Stage::SemanticMatch, + outcome, + json!({ + "matched": result.matched, + "score": result.score, + "reason": result.reason, + }), + ); + } + // 复核失败按拦截处理:真实运行里判不出来就不该投, + // 把它渲染成「通过」会让用户以为提示词没问题 + Err(error) => { + report.push( + Stage::SemanticMatch, + Outcome::Block { + reason: error.message, + }, + json!({}), + ); + } + } + + CommandResult::ok(report) +} + +fn regex_filter_step(decision: &FilterDecision) -> StepResult { + StepResult { + stage: Stage::RegexFilter, + outcome: if decision.matched { + Outcome::Pass + } else { + Outcome::Block { + reason: decision.reason.clone(), + } + }, + detail: json!({ "matched": decision.matched, "reason": decision.reason }), + } +} + +// ================================ +// ③④⑤ 打招呼 +// ================================ + +/// 手输岗位预演一次打招呼:决策 → 序列组装 → 发送前体检。 +/// +/// 不直接调 [`crate::rpa::greet::build_greet_resources`],因为它把三步揉成一个结论返回, +/// 而测试模式要看的正是「模型写了什么」「拼成了什么序列」「体检拦没拦」这三件分开的事。 +/// 但内部调的是它用的同一批函数,判断逻辑没有第二份 +#[tauri::command] +pub async fn playground_greet( + app_handle: tauri::AppHandle, + profile_id: Option, + job: PlaygroundJob, + overrides: PromptOverrides, +) -> CommandResult { + let config = match prepare_config(app_handle, profile_id, &overrides) { + Ok(config) => config, + Err(error) => return CommandResult::err(error), + }; + + let rpa_job = to_rpa_job(&job); + let mut report = StepReport::new(); + + let generated = if config.greet_config.llm_resource_ready() { + let trace_id = trace::next_id(); + report.trace_ids.push(trace_id.clone()); + match AgentRunner::new(&config) + .with_trace_id(trace_id) + .run(&GreetTask::new(&config, &rpa_job)) + .await + { + Ok(outcome) => { + let decision = outcome.output; + if decision.action == GreetAction::Skip { + // 模型有正规渠道说「不该投」。真实运行里这个结论会取消整轮发送, + // 包括后面的固定文本和图片,所以调试页也必须在这里断链—— + // 让用户看到「后面还会发」是彻头彻尾的误导 + report.push( + Stage::GreetDecide, + Outcome::Block { + reason: format!( + "模型判断该岗位不适合投递(把握 {} 分):{}", + decision.confidence, decision.reason + ), + }, + greet_detail(&decision), + ); + return CommandResult::ok(report); + } + report.push(Stage::GreetDecide, Outcome::Pass, greet_detail(&decision)); + Some(decision.greeting) + } + // 生成失败属于服务不可用,不代表这个岗位不该投,固定内容照发。 + // 这个「失败不终止」的语义是 rpa::greet 里明确设计过的,两边必须一致 + Err(error) => { + report.push( + Stage::GreetDecide, + Outcome::Skip { + reason: format!( + "模型生成失败,实际运行时会跳过 AI 那条、照发固定内容:{}", + error.message + ), + }, + json!({}), + ); + None + } + } + } else { + report.push( + Stage::GreetDecide, + Outcome::Skip { + reason: "方案的打招呼未启用 AI 生成,或提示词/资源未配置".to_string(), + }, + json!({}), + ); + None + }; + + let resources = compose_greet_resources(&config.greet_config, generated); + if resources.is_empty() { + report.push( + Stage::GreetCompose, + Outcome::Block { + reason: "打招呼发送序列没有可发送内容".to_string(), + }, + json!({ "resources": [] }), + ); + return CommandResult::ok(report); + } + report.push( + Stage::GreetCompose, + Outcome::Pass, + json!({ "resources": &resources }), + ); + + report.steps.push(outbound_vet_step( + Stage::GreetVet, + conversation::vet_outbound( + resources, + config.replay_config.max_reply_chars, + OutboundKind::Greeting, + ), + )); + + CommandResult::ok(report) +} + +fn greet_detail(decision: &GreetDecision) -> Value { + json!({ + "greeting": decision.greeting, + "reason": decision.reason, + "confidence": decision.confidence, + }) +} + +/// 整轮发送的最后一道门。通过时 detail 放的是**最终真正会发出去**的内容—— +/// 体检会按句截断,它和模型原文可能并不相同,用户要核对的是前者 +fn outbound_vet_step(stage: Stage, verdict: SendVerdict) -> StepResult { + match verdict { + SendVerdict::Send(resources) => StepResult { + stage, + outcome: Outcome::Pass, + detail: json!({ "resources": resources }), + }, + SendVerdict::Hold(reason) => StepResult { + stage, + outcome: Outcome::Block { reason }, + detail: json!({}), + }, + } +} + +// ================================ +// ⑥⑦⑧⑨⑩ 自动回复 +// ================================ + +/// 手造一段和 HR 的对话,预演一次自动回复的完整链路。 +/// +/// 不看 `limits.dry_run`:测试模式本来就不发送,演练开关在这里没有意义 +#[tauri::command] +pub async fn playground_reply( + app_handle: tauri::AppHandle, + profile_id: Option, + job: PlaygroundJob, + messages: Vec, + resume_state: ResumeState, + replies_in_window: usize, + overrides: PromptOverrides, +) -> CommandResult { + let config = match prepare_config(app_handle, profile_id, &overrides) { + Ok(config) => config, + Err(error) => return CommandResult::err(error), + }; + + let context = ConversationContext { + platform: PlatformKind::Boss, + conversation_id: PLAYGROUND_ID.to_string(), + job: Some(to_job_detail(&job)), + messages: to_chat_messages(&messages), + resume_state, + auto_replies_in_window: replies_in_window, + }; + let limits = ReplyLimits::from_config(&config.replay_config); + let mut report = StepReport::new(); + + // 闸门在模型之前:这些情况根本不该消耗额度,更不该给模型自由发挥的机会 + let verdict = conversation::gate(&context, &limits); + let proceed = verdict == GateVerdict::Proceed; + report.steps.push(gate_step(verdict)); + if !proceed { + return CommandResult::ok(report); + } + + match conversation::choose_route(&config.replay_config, &context) { + ReplyRoute::Skip(reason) => { + report.push( + Stage::Route, + Outcome::Skip { reason }, + json!({ "route": "none" }), + ); + return CommandResult::ok(report); + } + ReplyRoute::Template(hit) => { + report.push( + Stage::Route, + Outcome::Pass, + json!({ + "route": "template", + "rule_name": hit.display_name(), + "resources": &hit.resources, + }), + ); + // 模板路径整条绕开模型。⑧⑨ 记成 Skip 而不是干脆不输出: + // 环节列表的长度固定下来,前端才能把「还没走到」和「走到了但跳过」 + // 画成同一条链上的两种状态,而不是让链条突然少两截 + let reason = "命中确定性模板,未经过模型".to_string(); + report.push( + Stage::ReplyDecide, + Outcome::Skip { + reason: reason.clone(), + }, + json!({}), + ); + report.push(Stage::Reconcile, Outcome::Skip { reason }, json!({})); + report.steps.push(outbound_vet_step( + Stage::ReplyVet, + conversation::vet_outbound( + hit.resources, + limits.max_reply_chars, + OutboundKind::Reply, + ), + )); + return CommandResult::ok(report); + } + ReplyRoute::Decide => { + report.push(Stage::Route, Outcome::Pass, json!({ "route": "model" })); + } + } + + let trace_id = trace::next_id(); + report.trace_ids.push(trace_id.clone()); + let decision = match AgentRunner::new(&config) + .with_trace_id(trace_id) + .run(&ReplyDecisionTask::new(&config, &context)) + .await + { + Ok(outcome) => { + let decision = outcome.output; + report.push( + Stage::ReplyDecide, + Outcome::Pass, + reply_decision_detail(&decision), + ); + decision + } + Err(error) => { + report.push( + Stage::ReplyDecide, + Outcome::Block { + reason: error.message, + }, + json!({}), + ); + return CommandResult::ok(report); + } + }; + + let effective = conversation::reconcile(&decision, resume_state, &limits); + report + .steps + .push(reconcile_step(&decision, effective, resume_state, &limits)); + + report.steps.push(reply_vet_step( + effective, + &decision.reply, + limits.max_reply_chars, + )); + + CommandResult::ok(report) +} + +fn gate_step(verdict: GateVerdict) -> StepResult { + match verdict { + GateVerdict::Proceed => StepResult { + stage: Stage::Gate, + outcome: Outcome::Pass, + detail: json!({}), + }, + GateVerdict::Skip(reason) => StepResult { + stage: Stage::Gate, + outcome: Outcome::Skip { reason }, + detail: json!({}), + }, + // kind 要带出去:前端凭它判断真实运行会把这条会话挂进哪一类待办, + // 从文案里反猜是哪种情况正是当初把 kind 加进 GateVerdict 要消灭的事 + GateVerdict::Escalate { reason, kind } => StepResult { + stage: Stage::Gate, + outcome: Outcome::Block { reason }, + detail: json!({ "kind": kind, "kind_label": kind.label() }), + }, + } +} + +fn reply_decision_detail(decision: &ReplyDecision) -> Value { + json!({ + "action": decision.action, + "reply": decision.reply, + "reason": decision.reason, + "confidence": decision.confidence, + }) +} + +/// 校正环节永远是 Pass:它不拦截链路,只把模型的意图对齐到现实能力。 +/// +/// detail 必须同时给出「模型想做什么」和「实际会做什么」。只给结论的话, +/// 用户看到的是「模型说要投简历,结果没投」,而看不到究竟是哪个开关拦住了 +fn reconcile_step( + decision: &ReplyDecision, + effective: ReplyAction, + resume_state: ResumeState, + limits: &ReplyLimits, +) -> StepResult { + let changed = effective != decision.action; + let mut detail = json!({ + "requested": decision.action, + "effective": effective, + "changed": changed, + }); + if changed { + detail["downgrade_reason"] = json!(downgrade_reason(decision, resume_state, limits)); + } + + StepResult { + stage: Stage::Reconcile, + outcome: Outcome::Pass, + detail, + } +} + +/// 找出是哪个条件把投递降级成了只回复。 +/// +/// 这里**不重新判断要不要降级**——那是 [`conversation::reconcile`] 唯一的职责, +/// 再写一份迟早会和它分叉。本函数只在降级已经发生之后,按 `reconcile` 里同样的 +/// 优先级顺序找出第一个不满足的条件,用来告诉用户「改哪里才能让它真的投出去」 +fn downgrade_reason( + decision: &ReplyDecision, + resume_state: ResumeState, + limits: &ReplyLimits, +) -> String { + if !limits.allow_auto_send_resume { + return "方案里关闭了「允许模型自动投递简历」".to_string(); + } + if decision.confidence < MIN_RESUME_CONFIDENCE { + return format!( + "模型自评把握只有 {} 分,未达到投递所需的 {MIN_RESUME_CONFIDENCE} 分", + decision.confidence + ); + } + if resume_state != ResumeState::Sendable { + return format!( + "简历入口状态是「{}」,不允许主动投递", + resume_state_label(resume_state) + ); + } + // 三个条件都满足却仍然被改写,说明 reconcile 的规则变了而这里没跟上。 + // 与其编一个像模像样的理由,不如让它显眼 + "动作被校正,但未能定位到具体原因,请检查投递校正规则".to_string() +} + +fn resume_state_label(state: ResumeState) -> &'static str { + match state { + ResumeState::Sendable => "可主动投递", + ResumeState::RequestedByPeer => "对方正在索要简历", + ResumeState::Unavailable => "已投递或平台要求先等对方回复", + ResumeState::Unknown => "页面上找不到可判定的入口", + } +} + +fn reply_vet_step(effective: ReplyAction, reply: &str, max_chars: usize) -> StepResult { + if !effective.needs_text() { + return StepResult { + stage: Stage::ReplyVet, + outcome: Outcome::Skip { + reason: "该动作不发送正文".to_string(), + }, + detail: json!({}), + }; + } + + match conversation::vet_reply(reply, max_chars) { + // 体检会按句截断,这里放的是最终真正会发出去的那段,可能与模型原文不同 + Ok(text) => StepResult { + stage: Stage::ReplyVet, + outcome: Outcome::Pass, + detail: json!({ "text": text }), + }, + Err(reason) => StepResult { + stage: Stage::ReplyVet, + outcome: Outcome::Block { reason }, + detail: json!({}), + }, + } +} + +// ================================ +// 轨迹 +// ================================ + +#[tauri::command] +pub fn playground_traces(ids: Vec) -> CommandResult> { + CommandResult::ok(trace::recent(&ids)) +} + +#[tauri::command] +pub fn playground_clear_traces() -> CommandResult<()> { + trace::clear(); + CommandResult::ok(()) +} + +#[tauri::command] +pub async fn playground_export_traces(path: String) -> CommandResult { + match trace::export(Path::new(&path)) { + Ok(count) => CommandResult::ok(count), + Err(error) => CommandResult::err(error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ + default_app_config, ReplayResourceType, ReplyRegexRule, ReplyResource, ReplyTemplate, + }; + use crate::dao::model::ManualReviewReason; + + fn job() -> PlaygroundJob { + PlaygroundJob { + title: "Rust 后端工程师".to_string(), + company_name: "示例科技".to_string(), + detail: "负责网关与限流".to_string(), + salary: "25-40K".to_string(), + location: "南京".to_string(), + } + } + + fn limits() -> ReplyLimits { + ReplyLimits { + max_auto_replies: 5, + auto_reply_window_hours: 24, + max_reply_chars: 200, + allow_auto_send_resume: true, + dry_run: false, + } + } + + fn decision(action: ReplyAction, reply: &str, confidence: u8) -> ReplyDecision { + ReplyDecision { + action, + reply: reply.to_string(), + reason: "测试".to_string(), + confidence, + } + } + + fn context(messages: Vec) -> ConversationContext { + ConversationContext { + platform: PlatformKind::Boss, + conversation_id: PLAYGROUND_ID.to_string(), + job: None, + messages, + resume_state: ResumeState::Sendable, + auto_replies_in_window: 0, + } + } + + /// 手输岗位必须变成真实链路认得的同一种形状,否则筛选和打招呼看到的 + /// 根本不是用户填进去的那个岗位 + #[test] + fn a_typed_in_job_becomes_the_same_shape_the_real_pipeline_sees() { + let rpa_job = to_rpa_job(&job()); + let detail = to_job_detail(&job()); + + assert_eq!(rpa_job.platform, PlatformKind::Boss); + assert_eq!(rpa_job.platform_job_id, PLAYGROUND_ID); + assert_eq!(rpa_job.location.as_deref(), Some("南京")); + assert!(rpa_job.detail_url.is_empty(), "手输岗位没有来源页面"); + assert_eq!(detail.id, PLAYGROUND_ID); + assert_eq!(detail.platform, "boss"); + assert_eq!(detail.title, rpa_job.title); + assert!(!detail.is_send_resume); + } + + /// 角色前缀决定了模型分不分得清谁说的话,方向标错等于让它读一段错乱的对话 + #[test] + fn handcrafted_messages_are_labelled_by_direction_and_keep_their_order() { + let messages = to_chat_messages(&[ + PlaygroundMessage { + text: "在吗".to_string(), + received: true, + }, + PlaygroundMessage { + text: "在的".to_string(), + received: false, + }, + ]); + + assert_eq!(messages[0].from_name, "HR"); + assert_eq!(messages[1].from_name, "我"); + assert!(messages[0].time < messages[1].time); + assert_eq!(context(messages).transcript(), "HR: 在吗\n我: 在的"); + } + + /// 给了值就覆盖:这是测试模式的核心能力,改一句提示词立刻重跑 + #[test] + fn a_supplied_override_replaces_the_saved_prompt() { + let mut config = default_app_config(); + config.greet_config.reply_prompt = Some("旧的打招呼".to_string()); + config.replay_config.reply_prompt = Some("旧的回复".to_string()); + config.job_filter_config.semantic_filter_intent = Some("旧的意图".to_string()); + + apply_overrides( + &mut config, + &PromptOverrides { + greet_prompt: Some("新的打招呼".to_string()), + reply_prompt: Some("新的回复".to_string()), + semantic_filter_intent: Some("新的意图".to_string()), + }, + ); + + assert_eq!( + config.greet_config.reply_prompt.as_deref(), + Some("新的打招呼") + ); + assert_eq!( + config.replay_config.reply_prompt.as_deref(), + Some("新的回复") + ); + assert_eq!( + config.job_filter_config.semantic_filter_intent.as_deref(), + Some("新的意图") + ); + } + + /// 给 None 表示「用方案里存着的那份」,而不是清空。 + /// 只调一个环节、其它环节保持现状做对照,是测试模式最常见的用法 + #[test] + fn an_absent_override_keeps_the_saved_prompt_instead_of_clearing_it() { + let mut config = default_app_config(); + config.greet_config.reply_prompt = Some("方案里的打招呼".to_string()); + config.replay_config.reply_prompt = Some("方案里的回复".to_string()); + let untouched = config.browser_config.clone(); + + apply_overrides( + &mut config, + &PromptOverrides { + greet_prompt: None, + reply_prompt: Some("只改回复".to_string()), + semantic_filter_intent: None, + }, + ); + + assert_eq!( + config.greet_config.reply_prompt.as_deref(), + Some("方案里的打招呼") + ); + assert_eq!( + config.replay_config.reply_prompt.as_deref(), + Some("只改回复") + ); + assert!(config.job_filter_config.semantic_filter_intent.is_none()); + assert_eq!(config.browser_config, untouched, "覆盖不该波及其它配置"); + } + + /// 被规则挡掉时要说清是哪条规则挡的,只说「没通过」用户无从下手 + #[test] + fn a_rejected_job_reports_the_rule_that_blocked_it() { + let mut config = default_app_config(); + config.job_filter_config.exclude_keywords = vec!["外包".to_string()]; + let mut typed = job(); + typed.title = "Java 外包开发".to_string(); + + let step = regex_filter_step(&crate::verify::filter_decision( + &to_rpa_job(&typed), + &config, + )); + + assert_eq!(step.stage, Stage::RegexFilter); + match step.outcome { + Outcome::Block { reason } => assert!(reason.contains("外包"), "实际:{reason}"), + other => panic!("命中排除关键词必须拦下,实际:{other:?}"), + } + assert_eq!(step.detail["matched"], json!(false)); + } + + /// 敏感话题是求职诈骗的常见开场。除了拦下来,还要把待办类别带给前端, + /// 让用户看到真实运行时这条会话会被挂进哪一类 + #[test] + fn a_risky_message_blocks_the_gate_and_names_the_review_kind() { + let context = context(to_chat_messages(&[PlaygroundMessage { + text: "麻烦把身份证正反面发我".to_string(), + received: true, + }])); + + let step = gate_step(conversation::gate(&context, &limits())); + + assert!(matches!(step.outcome, Outcome::Block { .. })); + assert_eq!( + step.detail["kind"], + json!(ManualReviewReason::RiskKeyword), + "待办类别必须原样带出,不能让前端从文案反猜" + ); + } + + /// 对方还没回时闸门是 Skip 不是 Block:本轮什么都不做,但这不算异常 + #[test] + fn the_gate_skips_rather_than_blocks_when_the_peer_has_not_replied_yet() { + let context = context(to_chat_messages(&[ + PlaygroundMessage { + text: "在吗".to_string(), + received: true, + }, + PlaygroundMessage { + text: "在的,我很感兴趣".to_string(), + received: false, + }, + ])); + + let step = gate_step(conversation::gate(&context, &limits())); + + assert!(matches!(step.outcome, Outcome::Skip { .. })); + } + + /// 用户关掉了自动投递时,要指向那个开关,而不是让他去怀疑提示词 + #[test] + fn a_downgrade_points_at_the_disabled_auto_send_setting() { + let mut limits = limits(); + limits.allow_auto_send_resume = false; + let decision = decision(ReplyAction::ReplyAndSendResume, "好的", 95); + let effective = conversation::reconcile(&decision, ResumeState::Sendable, &limits); + + let step = reconcile_step(&decision, effective, ResumeState::Sendable, &limits); + + assert_eq!( + step.detail["requested"], + json!(ReplyAction::ReplyAndSendResume) + ); + assert_eq!(step.detail["effective"], json!(ReplyAction::Reply)); + assert_eq!(step.detail["changed"], json!(true)); + assert!(step.detail["downgrade_reason"] + .as_str() + .unwrap() + .contains("关闭了")); + } + + /// 置信度不够时要把分数写出来:用户才知道是差一点还是差得远 + #[test] + fn a_downgrade_points_at_the_low_confidence_and_shows_the_score() { + let decision = decision(ReplyAction::ReplyAndSendResume, "好的", 40); + let effective = conversation::reconcile(&decision, ResumeState::Sendable, &limits()); + + let step = reconcile_step(&decision, effective, ResumeState::Sendable, &limits()); + + let reason = step.detail["downgrade_reason"] + .as_str() + .unwrap() + .to_string(); + assert!(reason.contains("40"), "实际:{reason}"); + assert!(reason.contains("70"), "实际:{reason}"); + } + + /// 简历入口不可用是页面状态问题,和提示词无关,说清楚才不会让人白改提示词 + #[test] + fn a_downgrade_points_at_the_unavailable_resume_entry() { + let decision = decision(ReplyAction::ReplyAndSendResume, "好的", 95); + let effective = conversation::reconcile(&decision, ResumeState::Unavailable, &limits()); + + let step = reconcile_step(&decision, effective, ResumeState::Unavailable, &limits()); + + assert!(step.detail["downgrade_reason"] + .as_str() + .unwrap() + .contains("已投递或平台要求先等对方回复")); + } + + /// 没被改写时不能凭空冒出一条降级原因,那会让用户以为发生了他没察觉的事 + #[test] + fn an_untouched_action_reports_no_downgrade_reason() { + let decision = decision(ReplyAction::ReplyAndSendResume, "好的,简历这就发您", 88); + let effective = conversation::reconcile(&decision, ResumeState::Sendable, &limits()); + + let step = reconcile_step(&decision, effective, ResumeState::Sendable, &limits()); + + assert_eq!(step.detail["changed"], json!(false)); + assert!(step.detail.get("downgrade_reason").is_none()); + assert!(matches!(step.outcome, Outcome::Pass), "校正只对齐,不拦截"); + } + + /// skip / escalate 本来就不发正文,体检环节该跳过而不是拿空串去判不合格 + #[test] + fn actions_without_a_body_skip_the_reply_vetting() { + for action in [ReplyAction::Skip, ReplyAction::Escalate] { + let step = reply_vet_step(action, "", 200); + assert!(matches!(step.outcome, Outcome::Skip { .. }), "{action:?}"); + } + } + + /// 体检会按句截断,用户要核对的是最终发出去的那段,不是模型写的原文 + #[test] + fn the_vetted_reply_shows_the_text_that_will_actually_be_sent() { + let long = format!("{}。{}", "很长的第一句".repeat(4), "第二句会被截掉"); + + let step = reply_vet_step(ReplyAction::Reply, &long, 30); + + let text = step.detail["text"].as_str().unwrap(); + assert!(matches!(step.outcome, Outcome::Pass)); + assert!(text.chars().count() <= 30, "实际:{text}"); + assert_ne!(text, long, "截断后必须和入参不同"); + } + + /// 一条不合格就整轮不发。调试页要把这个「整轮取消」如实呈现, + /// 而不是只标红那一条、让用户以为其余的还会发出去 + #[test] + fn one_declining_line_holds_the_whole_greeting_batch() { + let resources = vec![ + ReplyResource { + resource_type: ReplayResourceType::Text, + content: "您好,注意到您是猎头顾问,我暂时不考虑猎头渠道。".to_string(), + }, + ReplyResource { + resource_type: ReplayResourceType::Image, + content: "C:/resume.png".to_string(), + }, + ]; + + let step = outbound_vet_step( + Stage::GreetVet, + conversation::vet_outbound(resources, 200, OutboundKind::Greeting), + ); + + assert_eq!(step.stage, Stage::GreetVet); + assert!(matches!(step.outcome, Outcome::Block { .. })); + } + + /// 模板路径整条绕开模型。路由 detail 里必须给出命中的规则名, + /// 否则用户看到「没走模型」却不知道是哪条规则短路了它 + #[test] + fn the_template_route_names_the_rule_that_short_circuited_the_model() { + let mut config = default_app_config(); + config.replay_config.enable_template_reply = true; + config.replay_config.templates = vec![ReplyTemplate { + regex_rule: ReplyRegexRule { + name: "面试邀约".to_string(), + pattern: "面试".to_string(), + limit: 2, + }, + content: vec![ReplyResource { + resource_type: ReplayResourceType::Text, + content: "好的,时间我这边可以".to_string(), + }], + }]; + let context = context(to_chat_messages(&[PlaygroundMessage { + text: "下周二方便来面试吗".to_string(), + received: true, + }])); + + match conversation::choose_route(&config.replay_config, &context) { + ReplyRoute::Template(hit) => { + assert_eq!(hit.display_name(), "面试邀约"); + let step = outbound_vet_step( + Stage::ReplyVet, + conversation::vet_outbound(hit.resources, 200, OutboundKind::Reply), + ); + assert!(matches!(step.outcome, Outcome::Pass)); + } + other => panic!("命中模板时必须走模板路径,实际:{other:?}"), + } + } + + // ---- 前后端契约锁 ---- + // + // 下面这几条断言的是**线上格式**,不是内部实现。前端 `src/types/playground.ts` + // 里的联合类型是照着这些字面量手写的,两边没有代码生成来保证同步: + // 谁要是顺手给某个枚举加个 `rename_all`、或者把变体改个名,编译照样过、 + // 测试照样绿,只有界面会在用户点下去的那一刻悄悄渲染成空白。 + // 改这里的期望值时,必须同步改 `src/types/playground.ts`。 + + #[test] + fn every_stage_serializes_to_the_literal_the_frontend_switches_on() { + let names: Vec = [ + Stage::RegexFilter, + Stage::SemanticMatch, + Stage::GreetDecide, + Stage::GreetCompose, + Stage::GreetVet, + Stage::Gate, + Stage::Route, + Stage::ReplyDecide, + Stage::Reconcile, + Stage::ReplyVet, + ] + .iter() + .map(|stage| serde_json::to_value(stage).expect("序列化环节") + .as_str() + .expect("环节是字符串") + .to_string()) + .collect(); + + assert_eq!( + names, + vec![ + "regex_filter", + "semantic_match", + "greet_decide", + "greet_compose", + "greet_vet", + "gate", + "route", + "reply_decide", + "reconcile", + "reply_vet", + ] + ); + } + + #[test] + fn outcome_carries_its_variant_in_a_kind_tag_next_to_the_reason() { + let pass = serde_json::to_value(Outcome::Pass).expect("序列化结论"); + let block = serde_json::to_value(Outcome::Block { + reason: "内容残留未填充的占位符".to_string(), + }) + .expect("序列化结论"); + let skip = serde_json::to_value(Outcome::Skip { + reason: "方案未启用 AI 岗位复核".to_string(), + }) + .expect("序列化结论"); + + assert_eq!(pass["kind"], "pass"); + assert_eq!(block["kind"], "block"); + assert_eq!(block["reason"], "内容残留未填充的占位符"); + assert_eq!(skip["kind"], "skip"); + assert_eq!(skip["reason"], "方案未启用 AI 岗位复核"); + } + + /// 简历状态是 PascalCase——它复用的是 `conversation::ResumeState`, + /// 那个枚举没标 `rename_all`,和本模块自己定义的几个枚举**不一样**。 + /// 前端下拉框传回来的字面量必须照着这个来,写成 snake_case 会反序列化失败 + #[test] + fn resume_state_stays_pascal_case_because_it_is_shared_with_the_real_pipeline() { + let states: Vec = [ + ResumeState::Sendable, + ResumeState::RequestedByPeer, + ResumeState::Unavailable, + ResumeState::Unknown, + ] + .iter() + .map(|state| serde_json::to_value(state).expect("序列化简历状态") + .as_str() + .expect("简历状态是字符串") + .to_string()) + .collect(); + + assert_eq!( + states, + vec!["Sendable", "RequestedByPeer", "Unavailable", "Unknown"] + ); + // 反向也要通得过:这正是前端下拉传回来的那条路 + assert_eq!( + serde_json::from_str::("\"RequestedByPeer\"").expect("反序列化简历状态"), + ResumeState::RequestedByPeer + ); + } + + #[test] + fn a_report_serializes_with_the_field_names_the_frontend_reads() { + let mut report = StepReport::new(); + report.trace_ids.push("trace-1".to_string()); + report.push( + Stage::Gate, + Outcome::Skip { + reason: "对方尚未回复,不重复发送".to_string(), + }, + json!({ "note": "闸门" }), + ); + + let value = serde_json::to_value(&report).expect("序列化报告"); + + assert_eq!(value["trace_ids"][0], "trace-1"); + assert_eq!(value["steps"][0]["stage"], "gate"); + assert_eq!(value["steps"][0]["outcome"]["kind"], "skip"); + assert_eq!(value["steps"][0]["detail"]["note"], "闸门"); + } +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index bcbbb56..fa357f8 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -35,8 +35,11 @@ fn default_greet_config() -> GreetConfig { } } -pub fn default_app_config() -> AppRuntimeConfig { - let job_filter_config = JobFilterConfig { +// 下面两个和 `default_greet_config` 一样,既供 `default_app_config` 组装, +// 也作为 serde 的缺省来源:配置文件里整段缺失时得有个兜底, +// 否则少一个键就会让整份配置反序列化失败、用户直接进不去应用 +fn default_job_filter_config() -> JobFilterConfig { + JobFilterConfig { query: Some("Rust 工程师".to_string()), city: None, job_type: 0, @@ -53,10 +56,11 @@ pub fn default_app_config() -> AppRuntimeConfig { enable_semantic_filter: false, semantic_filter_intent: None, regex_rules: Vec::new(), - }; - let platform_filter_config = PlatformFilterConfig::default(); - let greet_config = default_greet_config(); - let replay_config = ReplayConfig { + } +} + +fn default_replay_config() -> ReplayConfig { + ReplayConfig { enable_template_reply: false, templates: Vec::new(), enable_llm: false, @@ -67,12 +71,33 @@ pub fn default_app_config() -> AppRuntimeConfig { auto_reply_window_hours: default_auto_reply_window_hours(), max_reply_chars: default_max_reply_chars(), dry_run: false, - }; - let resume_config = ResumeConfig { + } +} + +fn default_resume_config() -> ResumeConfig { + ResumeConfig { inject_llm_context: false, resume_path: None, resume_content: None, - }; + } +} + +/// 用户数据目录留空由 `ensure_browser_user_data_dir` 在加载时按平台补全, +/// 这里不碰文件系统:解析配置是纯函数,测试才不必先搭一套目录 +fn default_browser_config() -> BrowserConfig { + BrowserConfig { + user_data_dir: String::new(), + chrome_exe_path: None, + max_parallel_tasks: default_max_parallel_tasks(), + } +} + +pub fn default_app_config() -> AppRuntimeConfig { + let job_filter_config = default_job_filter_config(); + let platform_filter_config = PlatformFilterConfig::default(); + let greet_config = default_greet_config(); + let replay_config = default_replay_config(); + let resume_config = default_resume_config(); let default_profile = JobProfile { id: DEFAULT_JOB_PROFILE_ID.to_string(), name: DEFAULT_JOB_PROFILE_NAME.to_string(), @@ -104,11 +129,7 @@ pub fn default_app_config() -> AppRuntimeConfig { reply_polling_config: ReplyPollingConfig::default(), periodic_delivery_config: PeriodicDeliveryConfig::default(), humanize_config: HumanizeConfig::default(), - browser_config: BrowserConfig { - user_data_dir: "".to_string(), - chrome_exe_path: None, - max_parallel_tasks: default_max_parallel_tasks(), - }, + browser_config: default_browser_config(), resume_config, } } @@ -234,52 +255,52 @@ fn read_config_file(path: &Path) -> Result { }) } +/// 把配置文件的内容解析成运行配置。 +/// +/// **整份反序列化,不要退回逐字段搬运。** 这里原本是一长串 +/// `if let Some(x) = value.get("x") { config.x = ... }`,于是每新增一个配置段 +/// 都必须记得回来补一行。漏了不会报错、编译通过、测试也全绿, +/// 只有用户改完设置重启后发现又回到了默认值——分析口径、回复轮询节奏、 +/// 周期投递参数、拟人化四段就是这么整段丢掉的,而且丢了很久没人发现。 +/// +/// 现在新增字段默认就能存活,只有下面三处真正的版本迁移需要额外照顾。 +/// `config_roundtrip_survives_every_section` 那条测试守着这个性质: +/// 以后再加配置段,忘了处理会当场变红,而不是等用户来报。 pub(crate) fn parse_config_content(content: &str) -> Result { let value: serde_yaml::Value = serde_yaml::from_str(content).map_err(|error| error.to_string())?; - let mut config = default_app_config(); - config.schema_version = value + + let schema_version: u32 = value .get("schema_version") .map(|version| serde_yaml::from_value(version.clone())) .transpose() .map_err(|error| error.to_string())? .unwrap_or(0); - config.onboarding_completed = value - .get("onboarding_completed") - .map(|completed| serde_yaml::from_value(completed.clone())) - .transpose() - .map_err(|error| error.to_string())? - .unwrap_or(false); - if let Some(llm_config) = value.get("llm_config") { - config.llm_config = parse_llm_config(llm_config, config.schema_version == 0)?; - } - if let Some(llm_enabled) = value.get("llm_enabled") { - config.llm_enabled = - serde_yaml::from_value(llm_enabled.clone()).map_err(|error| error.to_string())?; - } - if let Some(llm_fallbacks) = value.get("llm_fallbacks") { - config.llm_fallbacks = - serde_yaml::from_value(llm_fallbacks.clone()).map_err(|error| error.to_string())?; - } - if let Some(llm_retry_config) = value.get("llm_retry_config") { - config.llm_retry_config = - serde_yaml::from_value(llm_retry_config.clone()).map_err(|error| error.to_string())?; - } - if let Some(job_filter_config) = value.get("job_filter_config") { - config.job_filter_config = - serde_yaml::from_value(job_filter_config.clone()).map_err(|error| error.to_string())?; + // 迁移点一:v0 的 llm_config 是另一套结构(密钥还是明文), + // 交给标准反序列化会连累整份配置一起失败,所以先摘出去单独走迁移 + let raw_llm_config = value.get("llm_config").cloned(); + // 后面两处迁移要判断「键在不在」,而不是「值是什么」,得趁反序列化前问清楚 + let raw_greet_config = value.get("greet_config").cloned(); + let has_job_profiles = value.get("job_profiles").is_some(); + + let mut without_llm_config = value; + if let Some(mapping) = without_llm_config.as_mapping_mut() { + mapping.remove(serde_yaml::Value::String("llm_config".to_string())); } - if let Some(platform_filter_config) = value.get("platform_filter_config") { - config.platform_filter_config = serde_yaml::from_value(platform_filter_config.clone()) - .map_err(|error| error.to_string())?; + + let mut config: AppRuntimeConfig = + serde_yaml::from_value(without_llm_config).map_err(|error| error.to_string())?; + config.schema_version = schema_version; + + if let Some(raw_llm_config) = raw_llm_config { + config.llm_config = parse_llm_config(&raw_llm_config, schema_version == 0)?; } - if let Some(greet_config) = value.get("greet_config") { - config.greet_config = - serde_yaml::from_value(greet_config.clone()).map_err(|error| error.to_string())?; - // 兼容旧配置:早期版本没有 enable_llm 键,只要配过提示词就视为已启用 LLM 打招呼, + + if let Some(raw_greet_config) = raw_greet_config { + // 迁移点二:早期版本没有 enable_llm 键,只要配过提示词就视为已启用 LLM 打招呼, // 避免升级后功能被静默关闭。用户显式写了 enable_llm 时以用户设置为准。 - if greet_config.get("enable_llm").is_none() + if raw_greet_config.get("enable_llm").is_none() && config .greet_config .reply_prompt @@ -289,40 +310,20 @@ pub(crate) fn parse_config_content(content: &str) -> Result Result<(), Strin normalize_llm_retry_config(&mut config.llm_retry_config); normalize_llm_fallbacks(&mut config.llm_fallbacks)?; - normalize_analysis_config(&mut config.analysis_config); + // 顶层 analysis_config 不在这里规整:它是默认方案的镜像, + // 值由下面的 normalize_job_profiles 从方案卡回写(方案卡各自已 clamp 过)。 + // 在这里再 clamp 一次的结果总会被覆盖,留着只会让人以为顶层是独立数据 config.periodic_delivery_config.migrate_legacy_window(); config.humanize_config.ensure_seed(); normalize_job_profiles(config)?; @@ -612,6 +615,7 @@ pub struct AppRuntimeConfig { pub active_job_profile: Option, /// 岗位筛选配置 + #[serde(default = "default_job_filter_config")] pub job_filter_config: JobFilterConfig, /// 平台专属搜索筛选配置 @@ -619,9 +623,11 @@ pub struct AppRuntimeConfig { pub platform_filter_config: PlatformFilterConfig, /// 主动打招呼配置 + #[serde(default = "default_greet_config")] pub greet_config: GreetConfig, /// 自动回复配置 + #[serde(default = "default_replay_config")] pub replay_config: ReplayConfig, /// 岗位自动分析配置 @@ -641,9 +647,11 @@ pub struct AppRuntimeConfig { pub humanize_config: HumanizeConfig, /// 浏览器运行配置 + #[serde(default = "default_browser_config")] pub browser_config: BrowserConfig, /// 简历配置 + #[serde(default = "default_resume_config")] pub resume_config: ResumeConfig, } @@ -1843,11 +1851,17 @@ fn normalize_job_profiles(config: &mut AppRuntimeConfig) -> Result<(), String> { return Err("默认求职方案不能是已归档方案".to_string()); } + // 顶层是默认方案的执行镜像,**每一块都要同步**。这里原先漏了 analysis_config: + // 加方案卡字段时改了 from_runtime_mirror 和 resolve_job_profile,唯独忘了这一处, + // 于是顶层镜像里躺着一份永远不会更新的分析策略。 + // `top_level_mirror_matches_the_default_profile` 用整体比对守着这件事, + // 以后再加配置块漏了同步会当场变红。 config.job_filter_config = default_profile.job_filter_config.clone(); config.platform_filter_config = default_profile.platform_filter_config.clone(); config.resume_config = default_profile.resume_config.clone(); config.greet_config = default_profile.greet_config.clone(); config.replay_config = default_profile.replay_config.clone(); + config.analysis_config = default_profile.analysis_config.clone(); Ok(()) } @@ -1875,6 +1889,122 @@ pub struct ResumeConfig { mod tests { use super::*; + /// 存进去再读出来必须原样还在——**这条测试是防「设置重启后回到默认值」的总闸**。 + /// + /// 它不点名任何具体字段,而是拿整份配置比对:以后新增配置段,只要解析路径 + /// 漏掉了它,这里就会当场变红。此前 `parse_config_content` 是逐字段手工搬运的, + /// 新增的分析口径、回复轮询、周期投递、拟人化四段谁都没记得去补, + /// 于是用户改完设置一重启就全没了,而所有测试当时都是绿的。 + /// + /// 断言整体相等而不是逐项列举,正是为了不让这条测试也需要「记得维护」。 + #[test] + fn config_roundtrip_survives_every_section() { + let mut config = default_app_config(); + + // 每一段都调成非默认值,默认值相等会让漏读字段看起来也「通过」 + config.onboarding_completed = true; + config.analysis_config.high_match_score = 88; + config.analysis_config.max_per_task = 42; + config.analysis_config.skip_analyzed = false; + config.reply_polling_config.interval_minutes = 7; + config.reply_polling_config.jitter_seconds = 123; + config.reply_polling_config.active_hours_enabled = false; + config.periodic_delivery_config.interval_minutes = 45; + config.periodic_delivery_config.window_enabled = true; + config.humanize_config.enabled = true; + config.humanize_config.persona_seed = 4_644_236_598_824_193; + config.humanize_config.intensity = HumanizeIntensity::Cautious; + config.job_filter_config.query = Some("Rust 后端".to_string()); + config.greet_config.enable_llm = true; + config.greet_config.reply_prompt = Some("打招呼提示词".to_string()); + config.replay_config.enable_llm = true; + config.replay_config.max_reply_chars = 321; + config.resume_config.inject_llm_context = true; + config.browser_config.max_parallel_tasks = 2; + + // 走真实的落盘路径:先规整,再序列化,再解析回来 + validate_and_normalize(&mut config).expect("基准配置本身必须是合法的"); + let yaml = serde_yaml::to_string(&config).expect("序列化配置"); + let parsed = parse_config_content(&yaml).expect("解析刚写出去的配置"); + + assert_eq!( + parsed, config, + "存进去又读出来的配置和原来不一样,说明解析路径漏掉了某个配置段" + ); + } + + /// 顶层那几块是默认方案卡的执行镜像,规整之后两边必须逐块一致。 + /// + /// 同一份配置在代码里有三处反向搬运:`from_runtime_mirror`(顶层→方案卡)、 + /// `resolve_job_profile`(方案卡→顶层)、`normalize_job_profiles`(默认方案→顶层)。 + /// 加一块新配置就要记得改三处,而 analysis_config 当初只改了前两处—— + /// 顶层因此长期躺着一份永不更新的分析策略。 + /// + /// 这条测试**不列举任何配置块**:它把规整后的顶层重新投影成一张方案卡, + /// 与默认方案整体比对。以后新增配置块只要漏了同步,这里立刻失败。 + #[test] + fn top_level_mirror_matches_the_default_profile() { + let mut config = default_app_config(); + let default_id = config.default_job_profile_id.clone(); + { + let profile = config + .job_profiles + .iter_mut() + .find(|profile| profile.id == default_id) + .expect("出厂配置必须有默认方案"); + // 每一块都调成非默认值,否则「漏同步」和「本来就相等」区分不开 + profile.job_filter_config.query = Some("Rust 后端".to_string()); + profile.platform_filter_config.liepin.dq = Some("020".to_string()); + profile.resume_config.inject_llm_context = true; + profile.greet_config.enable_llm = true; + profile.greet_config.reply_prompt = Some("打招呼提示词".to_string()); + profile.replay_config.max_reply_chars = 321; + profile.analysis_config.high_match_score = 88; + } + + validate_and_normalize(&mut config).expect("规整必须通过"); + + let default_profile = config + .job_profiles + .iter() + .find(|profile| profile.id == default_id) + .expect("默认方案还在") + .clone(); + let mirrored = + JobProfile::from_runtime_mirror(&default_profile.id, &default_profile.name, &config); + let mut expected = default_profile; + // from_runtime_mirror 只投影配置块,这两个字段本来就不参与镜像 + expected.description = None; + expected.archived = false; + + assert_eq!( + mirrored, expected, + "顶层镜像和默认方案卡不一致,说明 normalize_job_profiles 漏同步了某一块配置" + ); + } + + /// 整段缺失时不能让整份配置反序列化失败——那等于用户直接进不去应用。 + /// 逐字段搬运的老写法天生容错,换成整体反序列化后这个性质要靠 + /// `#[serde(default = ...)]` 显式保住,所以专门测一条 + #[test] + fn a_config_file_missing_whole_sections_still_loads_with_defaults() { + let parsed = parse_config_content( + "schema_version: 3\nonboarding_completed: true\n", + ) + .expect("缺整段的配置文件必须仍能加载"); + + assert!(parsed.onboarding_completed); + assert_eq!( + parsed.job_filter_config.query, + default_job_filter_config().query + ); + assert_eq!( + parsed.replay_config.max_reply_chars, + default_replay_config().max_reply_chars + ); + assert!(!parsed.job_profiles.is_empty(), "必须补出默认方案卡"); + } + fn configured_llm() -> LlmConfig { LlmConfig { provider: LlmProviderPreset::Ollama, @@ -2285,22 +2415,28 @@ llm_config: ); } - /// 分数线和限额都必须落在可用区间内,避免手改配置文件把统计口径改坏 + /// 分数线和限额都必须落在可用区间内,避免手改配置文件把统计口径改坏。 + /// + /// 权威值在方案卡上,顶层只是默认方案的镜像,所以越界值在方案卡上被夹住之后, + /// 顶层拿到的是夹住之后的结果——而不是顶层自己那份越界值各夹各的 #[test] fn analysis_config_is_clamped_to_a_usable_range() { let mut config = default_app_config(); + // 顶层这两个越界值都该被默认方案的镜像盖掉,不该幸存下来 config.analysis_config.high_match_score = 5; config.analysis_config.max_per_task = 9_999; config.job_profiles[0].analysis_config.high_match_score = 200; + config.job_profiles[0].analysis_config.max_per_task = 9_999; validate_and_normalize(&mut config).unwrap(); + assert_eq!(config.job_profiles[0].analysis_config.high_match_score, 100); assert_eq!( - config.analysis_config.high_match_score, - MIN_HIGH_MATCH_SCORE + config.job_profiles[0].analysis_config.max_per_task, + MAX_ANALYSIS_PER_TASK ); + assert_eq!(config.analysis_config.high_match_score, 100); assert_eq!(config.analysis_config.max_per_task, MAX_ANALYSIS_PER_TASK); - assert_eq!(config.job_profiles[0].analysis_config.high_match_score, 100); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e58bc41..184fc9c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -76,8 +76,6 @@ pub fn run() { command::analysis::analysis_get_by_job_id, command::analysis::analysis_create, command::analysis::analysis_delete, - command::llm::debug_generate_replay, - command::llm::debug_generate_greet, command::llm::generate_job_filter_rules, command::llm::predict_resume_questions, command::llm::optimize_resume_with_answer, @@ -93,6 +91,12 @@ pub fn run() { command::llm_provider::swap_llm_credentials, command::llm_provider::test_llm_entry_connection, command::llm_provider::list_llm_models_for, + command::playground::playground_screen, + command::playground::playground_greet, + command::playground::playground_reply, + command::playground::playground_traces, + command::playground::playground_clear_traces, + command::playground::playground_export_traces, command::mock_interview::stream_mock_interview_question, command::mock_interview::stream_mock_interview_summary, command::data_management::export_data_bundle, diff --git a/src-tauri/src/rpa/greet.rs b/src-tauri/src/rpa/greet.rs index d73b8be..633002d 100644 --- a/src-tauri/src/rpa/greet.rs +++ b/src-tauri/src/rpa/greet.rs @@ -12,7 +12,12 @@ use crate::{ /// 禁用项和空内容不会发送。LLM 那一条**生成失败**时只跳过它、不影响后续固定内容—— /// 那属于服务不可用,固定的自我介绍照发是合理的。 /// 但模型判断「不该投」是另一回事,那要整轮取消,由调用方在这之前拦掉。 -fn compose_greet_resources(greet: &GreetConfig, generated: Option) -> Vec { +// 可见性放开到 crate:测试模式要把打招呼链路拆成「决策 / 组装 / 体检」三步单独展示, +// 只有复用这一个函数,调试页看到的序列才和真实运行完全一致 +pub(crate) fn compose_greet_resources( + greet: &GreetConfig, + generated: Option, +) -> Vec { let generated = generated.filter(|text| !text.trim().is_empty()); greet diff --git a/src/App.tsx b/src/App.tsx index 147bff2..4b77228 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,14 +7,16 @@ import { copyJobProfile, DEFAULT_REGEX_RULE_LIMIT, getAnalysisConfig, getDefault import type { JobDetail } from "@/types/job-detail"; import { Onboarding } from "@/view/onboarding"; import { ConfigPage } from "@/view/config"; -import ConversationDebugPage from "@/view/conversation-debug"; import JobDataPage from "@/view/job-data"; import JobOverviewPage from "@/view/job-overview"; +import PlaygroundPage from "@/view/playground"; import ResumeOptimizerPage from "@/view/resume-optimizer"; import WorkspacePage from "@/view/workspace"; import { AutoUpdaterModal, UpdaterProvider } from "@/lib/updater"; -type AppTabKey = "workspace" | "job-overview" | "job-data" | "conversation-debug" | "resume-optimizer" | "config"; +type AppTabKey = "workspace" | "job-overview" | "job-data" | "resume-optimizer" | "config"; +// 测试模式不在这里:它是调配置的工具,入口收在「配置中心 · 系统能力」下, +// 与大模型、浏览器环境并列——顶层这几个页签留给日常求职的主流程 const tabs: Array<{ key: AppTabKey; label: string }> = [ { key: "workspace", label: "工作台" }, { key: "job-overview", label: "求职数据" }, @@ -39,7 +41,7 @@ function MainShell({ config, update, save, status, message, dirty, importConfig, const [activeTab, setActiveTab] = useState("workspace"); const [focusJobId, setFocusJobId] = useState(); const [interviewJob, setInterviewJob] = useState(); - const [configGroup, setConfigGroup] = useState<"resume" | "llm" | "job" | "greet" | "reply" | "analysis" | "browser">("job"); + const [configGroup, setConfigGroup] = useState<"resume" | "llm" | "job" | "greet" | "reply" | "analysis" | "browser" | "playground">("job"); const [activeProfileId, setActiveProfileId] = useState(() => getDefaultJobProfile(config).id); const profiles = getJobProfiles(config); const llmConfigured = isLlmConfigured(config); @@ -107,6 +109,19 @@ function MainShell({ config, update, save, status, message, dirty, importConfig, // 拟人化同样是顶层配置,旧配置里整块缺失,不能直接走 merge const updateHumanize = (next: Partial) => update((c) => ({ ...c, humanize_config: { ...getHumanizeConfig(c), ...next } })); + // 测试模式可以挑任意一个方案来跑,选中的不一定是配置页当前编辑的那个, + // 所以写回提示词时只能按 id 定位,不能复用 updateActiveProfile + const savePlaygroundPrompts = (id: string, prompts: { greet_prompt: string | null; reply_prompt: string | null; semantic_filter_intent: string | null }) => + update((c) => ({ + ...c, + job_profiles: getJobProfiles(c).map((profile) => profile.id === id ? { + ...profile, + greet_config: { ...profile.greet_config, reply_prompt: prompts.greet_prompt }, + replay_config: { ...profile.replay_config, reply_prompt: prompts.reply_prompt }, + job_filter_config: { ...profile.job_filter_config, semantic_filter_intent: prompts.semantic_filter_intent }, + } : profile), + default_job_profile_id: c.default_job_profile_id || getDefaultJobProfile(c).id, + })); const updateProfiles = (nextProfiles: JobProfile[], defaultId = config.default_job_profile_id) => update((c) => ({ ...c, job_profiles: nextProfiles, @@ -119,7 +134,10 @@ function MainShell({ config, update, save, status, message, dirty, importConfig, updateProfiles([...profiles, next]); setActiveProfileId(id); }; + const playgroundPage = ; const configPage = createProfile(getDefaultJobProfile(config), "新方案", meta)} @@ -167,7 +185,7 @@ function MainShell({ config, update, save, status, message, dirty, importConfig, removeRule={(i: number) => updateActiveProfile((p) => ({ ...p, job_filter_config: { ...p.job_filter_config, regex_rules: p.job_filter_config.regex_rules.filter((_, x) => x !== i) } }))} importConfig={importConfig} exportConfig={exportConfig} />; - const content = activeTab === "workspace" ? void navigate(tab)} onOpenConfig={openConfig} onOpenConversation={openConversation} /> : activeTab === "job-overview" ? target === "job-data" ? navigate("job-data") : openConfig(target)} onOpenConversation={openConversation} /> : activeTab === "job-data" ? : activeTab === "conversation-debug" ? : activeTab === "resume-optimizer" ? updateProfileSection("resume_config", { resume_content })} pendingInterviewJob={interviewJob} onPendingInterviewHandled={clearInterviewJob} /> : configPage; + const content = activeTab === "workspace" ? void navigate(tab)} onOpenConfig={openConfig} onOpenConversation={openConversation} /> : activeTab === "job-overview" ? target === "job-data" ? navigate("job-data") : openConfig(target)} onOpenConversation={openConversation} /> : activeTab === "job-data" ? : activeTab === "resume-optimizer" ? updateProfileSection("resume_config", { resume_content })} pendingInterviewJob={interviewJob} onPendingInterviewHandled={clearInterviewJob} /> : configPage; return (
diff --git a/src/test/setup.ts b/src/test/setup.ts index 9e34f28..a21270a 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -15,3 +15,19 @@ if (!window.matchMedia) { dispatchEvent: () => false, }) as unknown as MediaQueryList; } + +// 同理,jsdom 没有 ResizeObserver。antd 的 Tabs / Segmented 这类会测量自身尺寸的 +// 组件挂载时就要用它,缺了它整棵树都渲染不出来。 +if (!window.ResizeObserver) { + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; +} + +// jsdom 的元素没有 scrollIntoView。聊天类界面挂载后就会滚到底部, +// 让它成为空操作,比在组件里到处写「测试环境跳过」干净得多。 +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} diff --git a/src/types/app-config.ts b/src/types/app-config.ts index 38f85e5..0c08fc4 100644 --- a/src/types/app-config.ts +++ b/src/types/app-config.ts @@ -13,6 +13,7 @@ export type ConfigGroup = | "reply" | "analysis" | "browser" + | "playground" | "resume" | "rules" | "data" diff --git a/src/types/playground.ts b/src/types/playground.ts new file mode 100644 index 0000000..59a7d77 --- /dev/null +++ b/src/types/playground.ts @@ -0,0 +1,252 @@ +/** 链路上的一个环节 */ +export type PlaygroundStage = + | "regex_filter" // ① 正则与关键词筛选 + | "semantic_match" // ② 岗位语义复核 + | "greet_decide" // ③ 打招呼决策 + | "greet_compose" // ④ 打招呼序列组装 + | "greet_vet" // ⑤ 打招呼发送前体检 + | "gate" // ⑥ 回复闸门 + | "route" // ⑦ 回复路由 + | "reply_decide" // ⑧ 自动回复决策 + | "reconcile" // ⑨ 投递意图校正 + | "reply_vet"; // ⑩ 回复发送前体检 + +export type PlaygroundOutcome = + | { kind: "pass" } + | { kind: "block"; reason: string } + | { kind: "skip"; reason: string }; + +export interface StepResult { + stage: PlaygroundStage; + outcome: PlaygroundOutcome; + /** 该环节的结构化产物,按 stage 决定怎么渲染 */ + detail: unknown; +} + +export interface StepReport { + steps: StepResult[]; + trace_ids: string[]; +} + +export type RoundVerdict = + | { kind: "passed" } + | { kind: "rejected"; reason: string } + | { kind: "failed"; reason: string }; + +export interface LlmUsage { + prompt_tokens: number | null; + completion_tokens: number | null; + total_tokens: number | null; +} + +export interface RoundTrace { + round: number; + /** 实际送出去的完整提示词,含返工追加段 */ + prompt: string; + /** 净化后的模型输出,调用失败时为空串 */ + raw: string; + model: string | null; + usage: LlmUsage | null; + duration_ms: number; + verdict: RoundVerdict; +} + +export interface AgentTrace { + id: string; + task_name: string; + /** RFC3339 本地时间 */ + started_at: string; + duration_ms: number; + rounds: RoundTrace[]; + /** "first_try" | "recovered",失败时为 null */ + stop: string | null; + error: string | null; +} + +/** 简历投递入口状态。注意后端是 PascalCase 序列化 */ +export type ResumeState = "Sendable" | "RequestedByPeer" | "Unavailable" | "Unknown"; + +export interface PlaygroundJob { + title: string; + company_name: string; + detail: string; + salary: string; + location: string; +} + +/** 只在本次调用内生效的提示词覆盖,不落盘 */ +export interface PromptOverrides { + greet_prompt?: string | null; + reply_prompt?: string | null; + semantic_filter_intent?: string | null; +} + +export interface PlaygroundMessage { + text: string; + /** true = HR 发来的,false = 我发出去的 */ + received: boolean; +} + +/* -------------------------------------------------------------------------- */ +/* 展示元数据 */ +/* -------------------------------------------------------------------------- */ + +/** + * 三条链路各对应一条后端命令。环节归属固定不变,所以直接写死在元数据里: + * 跑「筛选」只会点亮 screen 链上的环节,其余的保持未执行的灰态, + * 用户一眼能看出「没跑到」和「跑了但被拦下」的区别。 + */ +export type PlaygroundChain = "screen" | "greet" | "reply"; + +export interface StageMeta { + stage: PlaygroundStage; + /** 序号从 1 开始,与产品口径的 ①②③ 对齐 */ + order: number; + label: string; + chain: PlaygroundChain; + /** 一句话说明这个环节在判什么,展开前就能看懂 */ + hint: string; +} + +export const PLAYGROUND_STAGES: readonly StageMeta[] = [ + { stage: "regex_filter", order: 1, label: "正则与关键词筛选", chain: "screen", hint: "按方案里的关键词与正则规则先过一遍,纯本地判断" }, + { stage: "semantic_match", order: 2, label: "岗位语义复核", chain: "screen", hint: "让模型读 JD,判断岗位方向是否真的对得上" }, + { stage: "greet_decide", order: 3, label: "打招呼决策", chain: "greet", hint: "决定这个岗位要不要打招呼、用什么口径开场" }, + { stage: "greet_compose", order: 4, label: "打招呼序列组装", chain: "greet", hint: "把模型话术与固定模板拼成实际要发的几条消息" }, + { stage: "greet_vet", order: 5, label: "打招呼发送前体检", chain: "greet", hint: "发送前的最后一道闸:占位符、超长、明显机器腔都会被拦下" }, + { stage: "gate", order: 6, label: "回复闸门", chain: "reply", hint: "判断此刻该不该回:对方是否在等回复、额度是否用完" }, + { stage: "route", order: 7, label: "回复路由", chain: "reply", hint: "决定走正则模板还是交给模型生成" }, + { stage: "reply_decide", order: 8, label: "自动回复决策", chain: "reply", hint: "模型给出回复正文与是否该投简历的判断" }, + { stage: "reconcile", order: 9, label: "投递意图校正", chain: "reply", hint: "把模型的投递意图与真实的简历入口状态对齐" }, + { stage: "reply_vet", order: 10, label: "回复发送前体检", chain: "reply", hint: "发送前的最后一道闸,与打招呼体检同一套规则" }, +] as const; + +const STAGE_META_BY_KEY = new Map( + PLAYGROUND_STAGES.map((meta) => [meta.stage, meta]), +); + +export function stageMeta(stage: PlaygroundStage): StageMeta | undefined { + return STAGE_META_BY_KEY.get(stage); +} + +/** 三段可覆盖的提示词,与后端 PromptOverrides 一一对应 */ +export type PromptKey = "greet_prompt" | "reply_prompt" | "semantic_filter_intent"; + +export interface ChainMeta { + chain: PlaygroundChain; + label: string; + /** 选链路时卡片上的那一句话 */ + summary: string; + /** 底部主按钮的动作名 */ + action: string; + /** 这条链路吃哪条提示词;null = 纯本地判断,没提示词可调 */ + promptKey: PromptKey | null; + /** 要先在沙盘里造出对话才跑得起来 */ + needsChat: boolean; + /** 要交代简历入口与已回复条数这类会话现场 */ + needsReplyContext: boolean; +} + +/** + * 一次只跑一条链路——后端三条命令各自只填自己的那几个环节。 + * + * 旧版把十个环节和三条链路的输入一起摊在一屏上,于是「我现在到底在测什么」 + * 全靠用户自己记;按链路先做一次选择,后面每一步要填什么、结果该看哪几行, + * 都能由这份元数据推出来 + */ +export const PLAYGROUND_CHAINS: readonly ChainMeta[] = [ + { + chain: "screen", + label: "岗位筛选", + summary: "拿一个岗位过筛选规则与 AI 语义复核,看它会不会被挡在门外", + action: "跑筛选", + promptKey: "semantic_filter_intent", + needsChat: false, + needsReplyContext: false, + }, + { + chain: "greet", + label: "打招呼", + summary: "预演开场白:模型写什么、拼成几条消息、体检拦不拦", + action: "跑打招呼", + promptKey: "greet_prompt", + needsChat: false, + needsReplyContext: false, + }, + { + chain: "reply", + label: "自动回复", + summary: "自己扮 HR 造一段对话,看这一刻该不该回、会回什么", + action: "让 AI 回复", + promptKey: "reply_prompt", + needsChat: true, + needsReplyContext: true, + }, +] as const; + +const CHAIN_META_BY_KEY = new Map( + PLAYGROUND_CHAINS.map((meta) => [meta.chain, meta]), +); + +export function chainMeta(chain: PlaygroundChain): ChainMeta { + // 三条链路写死在上面,取不到只可能是类型被绕过了 + return CHAIN_META_BY_KEY.get(chain) ?? PLAYGROUND_CHAINS[0]; +} + +export function stagesOfChain(chain: PlaygroundChain): StageMeta[] { + return PLAYGROUND_STAGES.filter((meta) => meta.chain === chain); +} + +export type ReportVerdict = + | { kind: "pass"; stage: StageMeta } + | { kind: "block"; stage: StageMeta; reason: string } + | { kind: "empty" }; + +/** + * 一趟跑完的结论:走通了,还是断在哪一步。 + * + * 断点是用户跑这一趟最想知道的事,不该逼他自己在十行状态里找那一行红的。 + * 「跳过」不算断点——语义复核没开也照样往下走,把它报成失败会让人去改一个没坏的地方 + */ +export function reportVerdict(steps: StepResult[]): ReportVerdict { + const blocked = steps.find((step) => step.outcome.kind === "block"); + if (blocked) { + const meta = stageMeta(blocked.stage); + if (meta) { + return { + kind: "block", + stage: meta, + reason: blocked.outcome.kind === "block" ? blocked.outcome.reason : "", + }; + } + } + const executed = steps + .map((step) => stageMeta(step.stage)) + .filter((meta): meta is StageMeta => meta !== undefined); + const last = executed[executed.length - 1]; + return last ? { kind: "pass", stage: last } : { kind: "empty" }; +} + +const ORDER_SYMBOLS = ["①", "②", "③", "④", "⑤", "⑥", "⑦", "⑧", "⑨", "⑩"]; + +/** 圈号只到 ⑩,超出后退回普通数字而不是渲染出空白 */ +export function stageSymbol(order: number): string { + return ORDER_SYMBOLS[order - 1] ?? String(order); +} + +export const CHAIN_LABELS: Record = { + screen: "筛选", + greet: "打招呼", + reply: "回复", +}; + +export const RESUME_STATE_OPTIONS: ReadonlyArray<{ + value: ResumeState; + label: string; + hint: string; +}> = [ + { value: "Sendable", label: "可主动投递", hint: "简历入口开着,模型说要投就能投" }, + { value: "RequestedByPeer", label: "对方正在索要", hint: "HR 点了要简历,等我方确认" }, + { value: "Unavailable", label: "已投递或不可用", hint: "投过了或平台不给投,投递意图会被校正掉" }, + { value: "Unknown", label: "无法确认", hint: "页面没读到入口状态,按保守口径处理" }, +]; diff --git a/src/view/config/index.tsx b/src/view/config/index.tsx index b1676d4..31671b6 100644 --- a/src/view/config/index.tsx +++ b/src/view/config/index.tsx @@ -112,8 +112,9 @@ import { SolutionOutlined, ClockCircleOutlined, FontSizeOutlined, + ExperimentOutlined, } from "@ant-design/icons"; -import { useEffect, useState } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { invoke } from "@tauri-apps/api/core"; import { open, save } from "@tauri-apps/plugin-dialog"; import { commandErrorMessage, type CommandResult } from "@/types/command"; @@ -378,6 +379,8 @@ export interface ConfigPageProps { message: string; dirty?: boolean; initialGroup?: ConfigGroup; + /** 「系统能力 · 测试模式」那一页的内容,由 App 注入 */ + playgroundSlot?: ReactNode; onOpenLlmConfig: () => void; updateLlm: (next: AppRuntimeConfig["llm_config"]) => void; persistLlm: (next: AppRuntimeConfig["llm_config"]) => Promise; @@ -470,6 +473,7 @@ const ANALYSIS_TRIGGER_OPTIONS: Array<{ const configGroupKeys = [ "browser", "llm", + "playground", "job", "resume", "greet", @@ -499,6 +503,7 @@ const menuItems = [ children: [ { key: "llm", icon: , label: "大模型" }, { key: "browser", icon: , label: "浏览器环境" }, + { key: "playground", icon: , label: "测试模式" }, ], }, { @@ -918,6 +923,9 @@ export function ConfigPage(props: ConfigPageProps) { dirty={props.dirty} /> ); + // 测试模式不走这里:它常驻挂载在内容区,见下方的 playgroundSlot + case "playground": + return null; case "data": return ; case "job": @@ -2613,7 +2621,17 @@ export function ConfigPage(props: ConfigPageProps) { )}
- {renderContent()} + {/* + 测试模式整块由 App 注入,且切到别的分组时只是藏起来、不卸载: + 那里攒着手输的 JD、造了一半的对话和刚跑出来的结果, + 去大模型页改个模型名回来就得从头填一遍,谁都不会愿意用第二次 + */} + {props.playgroundSlot && ( +
+ {props.playgroundSlot} +
+ )} + {activeGroup !== "playground" && renderContent()}
diff --git a/src/view/conversation-debug/index.tsx b/src/view/conversation-debug/index.tsx deleted file mode 100644 index 610ec4a..0000000 --- a/src/view/conversation-debug/index.tsx +++ /dev/null @@ -1,395 +0,0 @@ -import { useCallback, useRef, useState } from "react"; -import { - Button, - Divider, - Input, - message, - Space, - Spin, - Typography, -} from "antd"; -import { SendOutlined, UserOutlined } from "@ant-design/icons"; -import { invoke } from "@tauri-apps/api/core"; -import type { CommandResult } from "../../types/command"; -import { commandErrorMessage } from "../../types/command"; -import { AiFeatureGate } from "@/components/AiFeatureGate"; - -const { TextArea } = Input; - -interface JobInput { - job_title: string; - company_name: string; - job_detail: string; - salary: string; - location: string; -} - -interface DebugChatMessage { - text: string; - from_name: string; - received: boolean; -} - -interface ChatBubble { - role: "user" | "hr" | "assistant"; - content: string; -} - -const emptyJob: JobInput = { - job_title: "", - company_name: "", - job_detail: "", - salary: "", - location: "", -}; - -const ConversationDebugPage = ({ aiConfigured, llmConfigured, onConfigureAi }: { aiConfigured: boolean; llmConfigured: boolean; onConfigureAi: () => void }) => { - const [job, setJob] = useState(emptyJob); - const [bubbles, setBubbles] = useState([]); - const [inputText, setInputText] = useState(""); - const [generating, setGenerating] = useState(false); - const [messageApi, contextHolder] = message.useMessage(); - const chatEndRef = useRef(null); - - const scrollToBottom = useCallback(() => { - setTimeout(() => { - chatEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, 100); - }, []); - - const updateJob = useCallback( - (field: keyof JobInput, value: string) => { - setJob((prev) => ({ ...prev, [field]: value })); - }, - [], - ); - - const sendUserMessage = useCallback(async () => { - if (!aiConfigured) return; - const text = inputText.trim(); - if (!text || generating) return; - const newBubbles: ChatBubble[] = [ - ...bubbles, - { role: "hr", content: text }, - ]; - setBubbles(newBubbles); - setInputText(""); - scrollToBottom(); - - setGenerating(true); - try { - const messages: DebugChatMessage[] = newBubbles - .filter((b) => b.role === "hr" || b.role === "assistant") - .map((b) => ({ - text: b.content, - from_name: b.role === "hr" ? "HR" : "我", - received: b.role === "hr", - })); - - const result = await invoke>( - "debug_generate_replay", - { req: { ...job, messages } }, - ); - - if (!result.success || result.data === null) { - messageApi.error(commandErrorMessage(result.error, "生成失败")); - return; - } - - setBubbles((prev) => [ - ...prev, - { role: "assistant", content: result.data! }, - ]); - scrollToBottom(); - } catch (error: unknown) { - messageApi.error( - error instanceof Error ? error.message : "生成回复失败", - ); - } finally { - setGenerating(false); - } - }, [aiConfigured, inputText, bubbles, job, generating, messageApi, scrollToBottom]); - - const generateGreet = useCallback(async () => { - if (!aiConfigured) return; - if (!job.job_title.trim()) { - messageApi.warning("请填写岗位名称"); - return; - } - setGenerating(true); - try { - const result = await invoke>( - "debug_generate_greet", - { req: job }, - ); - if (!result.success || result.data === null) { - messageApi.error(commandErrorMessage(result.error, "生成失败")); - return; - } - setBubbles((prev) => [ - ...prev, - { role: "assistant", content: result.data! }, - ]); - scrollToBottom(); - } catch (error: unknown) { - messageApi.error( - error instanceof Error ? error.message : "生成打招呼内容失败", - ); - } finally { - setGenerating(false); - } - }, [aiConfigured, job, messageApi, scrollToBottom]); - - const clearChat = useCallback(() => { - setBubbles([]); - }, []); - - return ( -
- {contextHolder} - <> - - {/* 左侧:岗位信息输入 */} -
- - 岗位信息 - - -
- - 岗位名称 - - updateJob("job_title", e.target.value)} - /> -
- -
- - 公司名称 - - updateJob("company_name", e.target.value)} - /> -
- -
- - 薪资范围 - - updateJob("salary", e.target.value)} - /> -
- -
- - 工作地点 - - updateJob("location", e.target.value)} - /> -
- -
- - 岗位描述(JD) - -