{"repo": "cheng20050714/litscout-rs", "files": {"config.example.toml": {"df730e4a09eb4f1c5830a9f3deacc3ae94c1aa19": {"t": 1785745357.613245, "n": 827, "h": "deb67d9f8ebe0499", "c": "# Reference configuration for LitScout-RS.\n# The current CLI reads these values from command-line flags and environment\n# variables; this file documents a stable project configuration profile.\n\n[run]\ngithub_limit = 10\narxiv_limit = 10\nacademic_extra = false\nacademic_limit = 10\noutput = \"reports\"\ncache_dir = \".litscout-cache\"\ncache_ttl_hours = 24\nsession_dir = \"sessions\"\ntags_file = \"tags.example.toml\"\ntimeout_secs = 30\nenrich = false\n\n[github]\ntoken_env = \"GITHUB_TOKEN\"\n\n[semantic_scholar]\napi_key_env = \"SEMANTIC_SCHOLAR_API_KEY\"\n\n[openalex]\napi_key_env = \"OPENALEX_API_KEY\"\n\n[crossref]\nmailto_env = \"CROSSREF_MAILTO\"\n\n[llm.deepseek]\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\"\nbase_url = \"https://api.deepseek.com\"\nmain_model = \"deepseek-v4-pro\"\nside_model = \"deepseek-v4-flash\"\nmax_tokens = 4096\ntimeout_secs = 30\n"}, "b8f76fdd9fdfaaf0957a9293477eb193d392c98f": {"t": 1785750660.5172215, "n": 827, "h": "deb67d9f8ebe0499", "c": "# Reference configuration for LitScout-RS.\n# The current CLI reads these values from command-line flags and environment\n# variables; this file documents a stable project configuration profile.\n\n[run]\ngithub_limit = 10\narxiv_limit = 10\nacademic_extra = false\nacademic_limit = 10\noutput = \"reports\"\ncache_dir = \".litscout-cache\"\ncache_ttl_hours = 24\nsession_dir = \"sessions\"\ntags_file = \"tags.example.toml\"\ntimeout_secs = 30\nenrich = false\n\n[github]\ntoken_env = \"GITHUB_TOKEN\"\n\n[semantic_scholar]\napi_key_env = \"SEMANTIC_SCHOLAR_API_KEY\"\n\n[openalex]\napi_key_env = \"OPENALEX_API_KEY\"\n\n[crossref]\nmailto_env = \"CROSSREF_MAILTO\"\n\n[llm.deepseek]\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\"\nbase_url = \"https://api.deepseek.com\"\nmain_model = \"deepseek-v4-pro\"\nside_model = \"deepseek-v4-flash\"\nmax_tokens = 4096\ntimeout_secs = 30\n"}, "89599bc6feef2b153f24871f481493e359e6ff87": {"t": 1785750666.2890484, "n": 739, "h": "d325bc293add82bc", "c": "# Reference configuration for LitScout-RS.\n# The current CLI reads these values from command-line flags and environment\n# variables; this file documents a stable project configuration profile.\n\n[run]\ngithub_limit = 10\narxiv_limit = 10\nacademic_extra = false\nacademic_limit = 10\noutput = \"reports\"\ncache_dir = \".litscout-cache\"\ncache_ttl_hours = 24\nsession_dir = \"sessions\"\ntags_file = \"tags.example.toml\"\ntimeout_secs = 30\nenrich = false\n\n[github]\ntoken_env = \"GITHUB_TOKEN\"\n\n[semantic_scholar]\napi_key_env = \"SEMANTIC_SCHOLAR_API_KEY\"\n\n[llm.deepseek]\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\"\nbase_url = \"https://api.deepseek.com\"\nmain_model = \"deepseek-v4-pro\"\nside_model = \"deepseek-v4-flash\"\nmax_tokens = 4096\ntimeout_secs = 30\n"}, "75250f5469aba3f88ce699f257a03aa1287fee25": {"t": 1785750667.3604565, "n": 635, "h": "ec987d5297f27b2e", "c": "# Reference configuration for LitScout-RS.\n# The current CLI reads these values from command-line flags and environment\n# variables; this file documents a stable project configuration profile.\n\n[run]\ngithub_limit = 10\narxiv_limit = 10\noutput = \"reports\"\ncache_dir = \".litscout-cache\"\ncache_ttl_hours = 24\nsession_dir = \"sessions\"\ntags_file = \"tags.example.toml\"\ntimeout_secs = 30\nenrich = false\n\n[github]\ntoken_env = \"GITHUB_TOKEN\"\n\n[llm.deepseek]\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\"\nbase_url = \"https://api.deepseek.com\"\nmain_model = \"deepseek-v4-pro\"\nside_model = \"deepseek-v4-flash\"\nmax_tokens = 4096\ntimeout_secs = 30\n"}}, "src/server/routes.rs": {"df730e4a09eb4f1c5830a9f3deacc3ae94c1aa19": {"t": 1785745842.0671983, "n": 23034, "h": "d939f3203833caea", "c": "use axum::{\n extract::{Path, State},\n response::sse::{Event, KeepAlive, Sse},\n routing::{get, post},\n Json, Router,\n};\nuse serde::Serialize;\nuse std::convert::Infallible;\nuse std::pin::Pin;\nuse std::time::Duration;\nuse tokio::sync::mpsc;\nuse tokio_stream::{wrappers::ReceiverStream, Stream, StreamExt};\nuse tower_http::cors::{Any, CorsLayer};\nuse tower_http::services::ServeDir;\n\nuse crate::agent::orchestrator;\nuse crate::checkpoint;\nuse crate::config::{AppConfig, LlmConfig};\nuse crate::error::{AppError, Result};\nuse crate::llm::deepseek::{DeepSeekClient, DeepSeekConfig};\nuse crate::reading::models::{NoteQuality, PaperChatMessage, ReadingStatus, TextCoverage};\nuse crate::reading::{\n fetcher as full_text_fetcher, library as reading_library, note_writer, paper_chat,\n};\n\nuse super::dto::{\n AddReadingLibraryItemRequest, BranchRunRequest, ChatStreamEvent, CheckpointListResponse,\n CitationAuditResponse, ContinueStatefulRunRequest, CoverageResponse, CreateStatefulRunRequest,\n EvidenceResponse, FrontendConfig, GenerateReadingNoteRequest, PaperChatRequest,\n ReadingLibraryItemResponse, ReadingLibraryResponse, ReportTranslateRequest,\n ReportTranslateResponse, ReviseStatefulPlanRequest, StatefulRunResponse,\n StatefulRunStreamEvent,\n};\nuse super::state::AppState;\n\npub fn build_router(state: AppState) -> Router {\n let cors = CorsLayer::new()\n .allow_origin(Any)\n .allow_methods(Any)\n .allow_headers(Any);\n\n Router::new()\n .route(\"/api/health\", get(health))\n .route(\"/api/runs\", post(create_stateful_run))\n .route(\"/api/runs/{run_id}\", get(get_stateful_run))\n .route(\"/api/runs/{run_id}/events\", get(stream_stateful_run_events))\n .route(\n \"/api/runs/{run_id}/approve-plan\",\n post(continue_stateful_run),\n )\n .route(\"/api/runs/{run_id}/continue\", post(continue_stateful_run))\n .route(\"/api/runs/{run_id}/revise-plan\", post(revise_stateful_plan))\n .route(\"/api/runs/{run_id}/evidence\", get(get_stateful_evidence))\n .route(\"/api/runs/{run_id}/coverage\", get(get_stateful_coverage))\n .route(\n \"/api/runs/{run_id}/citation-audit\",\n get(get_stateful_citation_audit),\n )\n .route(\n \"/api/runs/{run_id}/checkpoints\",\n get(list_stateful_checkpoints),\n )\n .route(\n \"/api/runs/{run_id}/branch-from-checkpoint\",\n post(branch_stateful_run),\n )\n .route(\"/api/report/translate\", post(translate_report))\n .route(\"/api/library\", get(list_reading_library))\n .route(\"/api/library/items\", post(add_reading_library_item))\n .route(\n \"/api/library/items/{paper_key}\",\n get(get_reading_library_item).delete(delete_reading_library_item),\n )\n .route(\n \"/api/library/items/{paper_key}/generate-note\",\n post(generate_reading_note),\n )\n .route(\n \"/api/library/items/{paper_key}/chat/stream\",\n post(chat_with_paper_stream),\n )\n .fallback_service(ServeDir::new(\"web/dist\"))\n .layer(cors)\n .with_state(state)\n}\n\nasync fn health(State(state): State) -> Json {\n Json(HealthResponse {\n status: \"ok\",\n service: \"litscout-rs\",\n llm_enabled: state.llm_config.enabled,\n github_token_configured: state.app_config.github_token.is_some(),\n })\n}\n\nasync fn create_stateful_run(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let app_config = effective_app_config(&state, &req.config);\n let llm_config = effective_llm_config(&state, &req.config);\n let run = orchestrator::create_run(req.topic, app_config, llm_config, req.policy).await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn get_stateful_run(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let run = orchestrator::load_run(&state.app_config, &run_id).await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn stream_stateful_run_events(\n State(state): State,\n Path(run_id): Path,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(16);\n tokio::spawn(async move {\n match orchestrator::load_run(&state.app_config, &run_id).await {\n Ok(run) => {\n let response = StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n };\n let _ = tx\n .send(StatefulRunStreamEvent::RunReady(Box::new(response)))\n .await;\n }\n Err(err) => {\n let _ = tx\n .send(StatefulRunStreamEvent::RunFailed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match &event {\n StatefulRunStreamEvent::Agent(_) => \"agent\",\n StatefulRunStreamEvent::RunReady(_) => \"run_ready\",\n StatefulRunStreamEvent::RunFailed { .. } => \"run_failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"run_failed\").data(err.to_string())))\n });\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn continue_stateful_run(\n State(state): State,\n Path(run_id): Path,\n req: Option>,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(64);\n tokio::spawn(async move {\n let (event_tx, mut event_rx) = mpsc::channel(64);\n let forward_tx = tx.clone();\n tokio::spawn(async move {\n while let Some(event) = event_rx.recv().await {\n if forward_tx\n .send(StatefulRunStreamEvent::Agent(event))\n .await\n .is_err()\n {\n break;\n }\n }\n });\n match orchestrator::continue_run(\n &run_id,\n effective_app_config(\n &state,\n req.as_ref()\n .map(|Json(req)| &req.config)\n .unwrap_or(&FrontendConfig::default()),\n ),\n effective_llm_config(\n &state,\n req.as_ref()\n .map(|Json(req)| &req.config)\n .unwrap_or(&FrontendConfig::default()),\n ),\n Some(event_tx),\n )\n .await\n {\n Ok(run) => {\n let response = StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n };\n let _ = tx\n .send(StatefulRunStreamEvent::RunReady(Box::new(response)))\n .await;\n }\n Err(err) => {\n let _ = tx\n .send(StatefulRunStreamEvent::RunFailed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match &event {\n StatefulRunStreamEvent::Agent(_) => \"agent\",\n StatefulRunStreamEvent::RunReady(_) => \"run_ready\",\n StatefulRunStreamEvent::RunFailed { .. } => \"run_failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"run_failed\").data(err.to_string())))\n });\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn revise_stateful_plan(\n State(state): State,\n Path(run_id): Path,\n Json(req): Json,\n) -> Result> {\n let run = orchestrator::revise_plan(\n &state.app_config,\n &run_id,\n req.chapters,\n req.query_portfolio,\n req.user_feedback,\n )\n .await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn get_stateful_evidence(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let evidence_memory = orchestrator::evidence_for_run(&state.app_config, &run_id).await?;\n Ok(Json(EvidenceResponse {\n run_id,\n evidence_memory,\n }))\n}\n\nasync fn get_stateful_coverage(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let coverage_report = orchestrator::coverage_for_run(&state.app_config, &run_id).await?;\n Ok(Json(CoverageResponse {\n run_id,\n coverage_report,\n }))\n}\n\nasync fn get_stateful_citation_audit(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let run = orchestrator::load_run(&state.app_config, &run_id).await?;\n let citation_audit = run\n .citation_audit\n .ok_or_else(|| AppError::Workflow(\"run has no CitationAuditReport yet\".to_string()))?;\n Ok(Json(CitationAuditResponse {\n run_id,\n citation_audit,\n }))\n}\n\nasync fn list_stateful_checkpoints(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let mut checkpoints = Vec::new();\n let checkpoint_dir = state\n .app_config\n .session_dir\n .join(&run_id)\n .join(\"checkpoints\");\n if let Ok(mut entries) = tokio::fs::read_dir(checkpoint_dir).await {\n while let Some(entry) = entries.next_entry().await? {\n let body = tokio::fs::read_to_string(entry.path()).await?;\n let snapshot: checkpoint::CheckpointSnapshot = serde_json::from_str(&body)?;\n checkpoints.push(snapshot.checkpoint);\n }\n }\n checkpoints.sort_by_key(|checkpoint| checkpoint.created_at);\n Ok(Json(CheckpointListResponse {\n run_id,\n checkpoints,\n }))\n}\n\nasync fn branch_stateful_run(\n State(state): State,\n Path(run_id): Path,\n Json(req): Json,\n) -> Result> {\n let run = orchestrator::branch_from_plan_ready(\n (*state.app_config).clone(),\n &run_id,\n &req.checkpoint_id,\n )\n .await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn list_reading_library(\n State(state): State,\n) -> Result> {\n let items = reading_library::list_items(&state.app_config).await?;\n Ok(Json(ReadingLibraryResponse { items }))\n}\n\nasync fn add_reading_library_item(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let item = reading_library::add_item(&state.app_config, req.run_id, req.evidence).await?;\n Ok(Json(ReadingLibraryItemResponse { item }))\n}\n\nasync fn get_reading_library_item(\n State(state): State,\n Path(paper_key): Path,\n) -> Result> {\n let item = reading_library::get_item(&state.app_config, &paper_key).await?;\n Ok(Json(ReadingLibraryItemResponse { item }))\n}\n\nasync fn delete_reading_library_item(\n State(state): State,\n Path(paper_key): Path,\n) -> Result> {\n reading_library::delete_item(&state.app_config, &paper_key).await?;\n let items = reading_library::list_items(&state.app_config).await?;\n Ok(Json(ReadingLibraryResponse { items }))\n}\n\nasync fn generate_reading_note(\n State(state): State,\n Path(paper_key): Path,\n Json(req): Json,\n) -> Result> {\n let mut item = reading_library::get_item(&state.app_config, &paper_key).await?;\n item.error = None;\n item.note_quality = None;\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n\n let fetch_item = item.clone();\n let callback_config = state.app_config.clone();\n let callback_paper_key = paper_key.clone();\n let mut status_callback = move |status: ReadingStatus| -> Pin<\n Box> + Send>,\n > {\n let app_config = callback_config.clone();\n let paper_key = callback_paper_key.clone();\n Box::pin(async move {\n let mut item = reading_library::get_item(&app_config, &paper_key).await?;\n item.status = status;\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&app_config, &item).await\n })\n };\n\n let text = match full_text_fetcher::fetch_full_text(\n &state.app_config,\n &fetch_item,\n Some(&mut status_callback),\n )\n .await\n {\n Ok(text) => text,\n Err(err) => {\n item.status = ReadingStatus::TextFailed;\n item.error = Some(err.to_string());\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n return Ok(Json(ReadingLibraryItemResponse { item }));\n }\n };\n item = reading_library::get_item(&state.app_config, &paper_key).await?;\n item.text = Some(text.text);\n item.text_source_url = Some(text.source_url);\n item.text_coverage = Some(text.coverage);\n item.text_meta = Some(text.meta);\n if matches!(\n item.text_coverage,\n Some(TextCoverage::Failed | TextCoverage::AbstractOnly)\n ) {\n item.status = ReadingStatus::TextFailed;\n item.error = Some(\"全文获取失败,未生成详细笔记。\".to_string());\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n return Ok(Json(ReadingLibraryItemResponse { item }));\n }\n\n item.status = ReadingStatus::TextReady;\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n\n item.status = ReadingStatus::GeneratingNote;\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n\n let llm_config = effective_llm_config(&state, &req.config);\n match note_writer::generate_note(&item, &llm_config).await {\n Ok(note) => {\n item.note = Some(note);\n item.note_quality = Some(NoteQuality::FullText);\n item.status = ReadingStatus::Ready;\n item.error = None;\n }\n Err(err) => {\n item.status = ReadingStatus::NoteFailed;\n item.error = Some(err.to_string());\n }\n }\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n Ok(Json(ReadingLibraryItemResponse { item }))\n}\n\nasync fn chat_with_paper_stream(\n State(state): State,\n Path(paper_key): Path,\n Json(req): Json,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(32);\n\n tokio::spawn(async move {\n let mut item = match reading_library::get_item(&state.app_config, &paper_key).await {\n Ok(item) => item,\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n return;\n }\n };\n let llm_config = effective_llm_config(&state, &req.config);\n let request = match paper_chat::build_paper_chat_request(&item, &req.question, &llm_config)\n {\n Ok(request) => request,\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n return;\n }\n };\n let config = match DeepSeekConfig::from_llm_config(&llm_config) {\n Some(config) => config,\n None => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: \"论文追问需要 DeepSeek API Key。\".to_string(),\n })\n .await;\n return;\n }\n };\n let client = match DeepSeekClient::new(config) {\n Ok(client) => client,\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n return;\n }\n };\n let (delta_tx, mut delta_rx) = mpsc::channel::(32);\n let forward_tx = tx.clone();\n tokio::spawn(async move {\n while let Some(text) = delta_rx.recv().await {\n if forward_tx\n .send(ChatStreamEvent::Delta { text })\n .await\n .is_err()\n {\n break;\n }\n }\n });\n match client\n .chat_completions_stream_text(request, Some(delta_tx))\n .await\n {\n Ok(answer) => {\n let now = chrono::Utc::now();\n item.chat_history.push(PaperChatMessage {\n role: \"user\".to_string(),\n content: req.question,\n created_at: now,\n });\n item.chat_history.push(PaperChatMessage {\n role: \"assistant\".to_string(),\n content: answer,\n created_at: chrono::Utc::now(),\n });\n item.updated_at = chrono::Utc::now();\n let _ = reading_library::save_item(&state.app_config, &item).await;\n let _ = tx.send(ChatStreamEvent::Done).await;\n }\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match event {\n ChatStreamEvent::Delta { .. } => \"delta\",\n ChatStreamEvent::Done => \"done\",\n ChatStreamEvent::Failed { .. } => \"failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"failed\").data(err.to_string())))\n });\n\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn translate_report(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let llm_config = effective_llm_config(&state, &req.config);\n let config = DeepSeekConfig::from_llm_config(&llm_config).ok_or_else(|| {\n AppError::InvalidConfig(\n \"翻译报告需要 DeepSeek API Key,请在阶段 1 配置或设置 DEEPSEEK_API_KEY。\".to_string(),\n )\n })?;\n let client = DeepSeekClient::new(config)?;\n let translated = client\n .translate_report_to_chinese(&req.report_markdown)\n .await?;\n Ok(Json(ReportTranslateResponse {\n translated_markdown: translated,\n }))\n}\n\nfn effective_app_config(state: &AppState, frontend: &FrontendConfig) -> AppConfig {\n let mut app_config = (*state.app_config).clone();\n if let Some(token) = non_empty(frontend.github_token.as_deref()) {\n app_config.github_token = Some(token.to_string());\n }\n if let Some(key) = non_empty(frontend.semantic_scholar_api_key.as_deref()) {\n app_config.semantic_scholar_api_key = Some(key.to_string());\n }\n if let Some(key) = non_empty(frontend.openalex_api_key.as_deref()) {\n app_config.openalex_api_key = Some(key.to_string());\n }\n if let Some(mailto) = non_empty(frontend.crossref_mailto.as_deref()) {\n app_config.crossref_mailto = Some(mailto.to_string());\n }\n app_config\n}\n\nfn effective_llm_config(state: &AppState, frontend: &FrontendConfig) -> LlmConfig {\n let mut llm_config = (*state.llm_config).clone();\n if let Some(api_key) = non_empty(frontend.deepseek_api_key.as_deref()) {\n llm_config.enabled = true;\n llm_config.api_key = Some(api_key.to_string());\n }\n if let Some(base_url) = non_empty(frontend.deepseek_base_url.as_deref()) {\n llm_config.base_url = Some(base_url.to_string());\n }\n if let Some(model) = non_empty(frontend.deepseek_model.as_deref()) {\n llm_config.main_model = model.to_string();\n }\n if let Some(side_model) = non_empty(frontend.deepseek_side_model.as_deref()) {\n llm_config.side_model = Some(side_model.to_string());\n }\n llm_config\n}\n\nfn non_empty(value: Option<&str>) -> Option<&str> {\n value.map(str::trim).filter(|value| !value.is_empty())\n}\n\n#[derive(Debug, Serialize)]\nstruct HealthResponse {\n status: &'static str,\n service: &'static str,\n llm_enabled: bool,\n github_token_configured: bool,\n}\n\n#[cfg(test)]\nmod tests {\n use std::path::PathBuf;\n\n use super::build_router;\n use crate::config::{AppConfig, LlmConfig};\n use crate::server::state::AppState;\n\n #[test]\n fn builds_router_for_health_endpoint() {\n let state = AppState::new(app_config(), LlmConfig::from_env(false, 30));\n\n let _router = build_router(state);\n }\n\n fn app_config() -> AppConfig {\n AppConfig {\n github_token: None,\n semantic_scholar_api_key: None,\n openalex_api_key: None,\n crossref_mailto: None,\n output: PathBuf::from(\"reports\"),\n cache_dir: PathBuf::from(\".litscout-cache\"),\n session_dir: PathBuf::from(\"sessions\"),\n tags_file: None,\n use_cache: true,\n cache_ttl_hours: 24,\n timeout_secs: 30,\n enrich: false,\n }\n }\n}\n"}, "f0e381df64f2448f45cfc7566949703029fd6216": {"t": 1785750659.9460387, "n": 23034, "h": "d939f3203833caea", "c": "use axum::{\n extract::{Path, State},\n response::sse::{Event, KeepAlive, Sse},\n routing::{get, post},\n Json, Router,\n};\nuse serde::Serialize;\nuse std::convert::Infallible;\nuse std::pin::Pin;\nuse std::time::Duration;\nuse tokio::sync::mpsc;\nuse tokio_stream::{wrappers::ReceiverStream, Stream, StreamExt};\nuse tower_http::cors::{Any, CorsLayer};\nuse tower_http::services::ServeDir;\n\nuse crate::agent::orchestrator;\nuse crate::checkpoint;\nuse crate::config::{AppConfig, LlmConfig};\nuse crate::error::{AppError, Result};\nuse crate::llm::deepseek::{DeepSeekClient, DeepSeekConfig};\nuse crate::reading::models::{NoteQuality, PaperChatMessage, ReadingStatus, TextCoverage};\nuse crate::reading::{\n fetcher as full_text_fetcher, library as reading_library, note_writer, paper_chat,\n};\n\nuse super::dto::{\n AddReadingLibraryItemRequest, BranchRunRequest, ChatStreamEvent, CheckpointListResponse,\n CitationAuditResponse, ContinueStatefulRunRequest, CoverageResponse, CreateStatefulRunRequest,\n EvidenceResponse, FrontendConfig, GenerateReadingNoteRequest, PaperChatRequest,\n ReadingLibraryItemResponse, ReadingLibraryResponse, ReportTranslateRequest,\n ReportTranslateResponse, ReviseStatefulPlanRequest, StatefulRunResponse,\n StatefulRunStreamEvent,\n};\nuse super::state::AppState;\n\npub fn build_router(state: AppState) -> Router {\n let cors = CorsLayer::new()\n .allow_origin(Any)\n .allow_methods(Any)\n .allow_headers(Any);\n\n Router::new()\n .route(\"/api/health\", get(health))\n .route(\"/api/runs\", post(create_stateful_run))\n .route(\"/api/runs/{run_id}\", get(get_stateful_run))\n .route(\"/api/runs/{run_id}/events\", get(stream_stateful_run_events))\n .route(\n \"/api/runs/{run_id}/approve-plan\",\n post(continue_stateful_run),\n )\n .route(\"/api/runs/{run_id}/continue\", post(continue_stateful_run))\n .route(\"/api/runs/{run_id}/revise-plan\", post(revise_stateful_plan))\n .route(\"/api/runs/{run_id}/evidence\", get(get_stateful_evidence))\n .route(\"/api/runs/{run_id}/coverage\", get(get_stateful_coverage))\n .route(\n \"/api/runs/{run_id}/citation-audit\",\n get(get_stateful_citation_audit),\n )\n .route(\n \"/api/runs/{run_id}/checkpoints\",\n get(list_stateful_checkpoints),\n )\n .route(\n \"/api/runs/{run_id}/branch-from-checkpoint\",\n post(branch_stateful_run),\n )\n .route(\"/api/report/translate\", post(translate_report))\n .route(\"/api/library\", get(list_reading_library))\n .route(\"/api/library/items\", post(add_reading_library_item))\n .route(\n \"/api/library/items/{paper_key}\",\n get(get_reading_library_item).delete(delete_reading_library_item),\n )\n .route(\n \"/api/library/items/{paper_key}/generate-note\",\n post(generate_reading_note),\n )\n .route(\n \"/api/library/items/{paper_key}/chat/stream\",\n post(chat_with_paper_stream),\n )\n .fallback_service(ServeDir::new(\"web/dist\"))\n .layer(cors)\n .with_state(state)\n}\n\nasync fn health(State(state): State) -> Json {\n Json(HealthResponse {\n status: \"ok\",\n service: \"litscout-rs\",\n llm_enabled: state.llm_config.enabled,\n github_token_configured: state.app_config.github_token.is_some(),\n })\n}\n\nasync fn create_stateful_run(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let app_config = effective_app_config(&state, &req.config);\n let llm_config = effective_llm_config(&state, &req.config);\n let run = orchestrator::create_run(req.topic, app_config, llm_config, req.policy).await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn get_stateful_run(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let run = orchestrator::load_run(&state.app_config, &run_id).await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn stream_stateful_run_events(\n State(state): State,\n Path(run_id): Path,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(16);\n tokio::spawn(async move {\n match orchestrator::load_run(&state.app_config, &run_id).await {\n Ok(run) => {\n let response = StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n };\n let _ = tx\n .send(StatefulRunStreamEvent::RunReady(Box::new(response)))\n .await;\n }\n Err(err) => {\n let _ = tx\n .send(StatefulRunStreamEvent::RunFailed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match &event {\n StatefulRunStreamEvent::Agent(_) => \"agent\",\n StatefulRunStreamEvent::RunReady(_) => \"run_ready\",\n StatefulRunStreamEvent::RunFailed { .. } => \"run_failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"run_failed\").data(err.to_string())))\n });\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn continue_stateful_run(\n State(state): State,\n Path(run_id): Path,\n req: Option>,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(64);\n tokio::spawn(async move {\n let (event_tx, mut event_rx) = mpsc::channel(64);\n let forward_tx = tx.clone();\n tokio::spawn(async move {\n while let Some(event) = event_rx.recv().await {\n if forward_tx\n .send(StatefulRunStreamEvent::Agent(event))\n .await\n .is_err()\n {\n break;\n }\n }\n });\n match orchestrator::continue_run(\n &run_id,\n effective_app_config(\n &state,\n req.as_ref()\n .map(|Json(req)| &req.config)\n .unwrap_or(&FrontendConfig::default()),\n ),\n effective_llm_config(\n &state,\n req.as_ref()\n .map(|Json(req)| &req.config)\n .unwrap_or(&FrontendConfig::default()),\n ),\n Some(event_tx),\n )\n .await\n {\n Ok(run) => {\n let response = StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n };\n let _ = tx\n .send(StatefulRunStreamEvent::RunReady(Box::new(response)))\n .await;\n }\n Err(err) => {\n let _ = tx\n .send(StatefulRunStreamEvent::RunFailed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match &event {\n StatefulRunStreamEvent::Agent(_) => \"agent\",\n StatefulRunStreamEvent::RunReady(_) => \"run_ready\",\n StatefulRunStreamEvent::RunFailed { .. } => \"run_failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"run_failed\").data(err.to_string())))\n });\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn revise_stateful_plan(\n State(state): State,\n Path(run_id): Path,\n Json(req): Json,\n) -> Result> {\n let run = orchestrator::revise_plan(\n &state.app_config,\n &run_id,\n req.chapters,\n req.query_portfolio,\n req.user_feedback,\n )\n .await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn get_stateful_evidence(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let evidence_memory = orchestrator::evidence_for_run(&state.app_config, &run_id).await?;\n Ok(Json(EvidenceResponse {\n run_id,\n evidence_memory,\n }))\n}\n\nasync fn get_stateful_coverage(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let coverage_report = orchestrator::coverage_for_run(&state.app_config, &run_id).await?;\n Ok(Json(CoverageResponse {\n run_id,\n coverage_report,\n }))\n}\n\nasync fn get_stateful_citation_audit(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let run = orchestrator::load_run(&state.app_config, &run_id).await?;\n let citation_audit = run\n .citation_audit\n .ok_or_else(|| AppError::Workflow(\"run has no CitationAuditReport yet\".to_string()))?;\n Ok(Json(CitationAuditResponse {\n run_id,\n citation_audit,\n }))\n}\n\nasync fn list_stateful_checkpoints(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let mut checkpoints = Vec::new();\n let checkpoint_dir = state\n .app_config\n .session_dir\n .join(&run_id)\n .join(\"checkpoints\");\n if let Ok(mut entries) = tokio::fs::read_dir(checkpoint_dir).await {\n while let Some(entry) = entries.next_entry().await? {\n let body = tokio::fs::read_to_string(entry.path()).await?;\n let snapshot: checkpoint::CheckpointSnapshot = serde_json::from_str(&body)?;\n checkpoints.push(snapshot.checkpoint);\n }\n }\n checkpoints.sort_by_key(|checkpoint| checkpoint.created_at);\n Ok(Json(CheckpointListResponse {\n run_id,\n checkpoints,\n }))\n}\n\nasync fn branch_stateful_run(\n State(state): State,\n Path(run_id): Path,\n Json(req): Json,\n) -> Result> {\n let run = orchestrator::branch_from_plan_ready(\n (*state.app_config).clone(),\n &run_id,\n &req.checkpoint_id,\n )\n .await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn list_reading_library(\n State(state): State,\n) -> Result> {\n let items = reading_library::list_items(&state.app_config).await?;\n Ok(Json(ReadingLibraryResponse { items }))\n}\n\nasync fn add_reading_library_item(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let item = reading_library::add_item(&state.app_config, req.run_id, req.evidence).await?;\n Ok(Json(ReadingLibraryItemResponse { item }))\n}\n\nasync fn get_reading_library_item(\n State(state): State,\n Path(paper_key): Path,\n) -> Result> {\n let item = reading_library::get_item(&state.app_config, &paper_key).await?;\n Ok(Json(ReadingLibraryItemResponse { item }))\n}\n\nasync fn delete_reading_library_item(\n State(state): State,\n Path(paper_key): Path,\n) -> Result> {\n reading_library::delete_item(&state.app_config, &paper_key).await?;\n let items = reading_library::list_items(&state.app_config).await?;\n Ok(Json(ReadingLibraryResponse { items }))\n}\n\nasync fn generate_reading_note(\n State(state): State,\n Path(paper_key): Path,\n Json(req): Json,\n) -> Result> {\n let mut item = reading_library::get_item(&state.app_config, &paper_key).await?;\n item.error = None;\n item.note_quality = None;\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n\n let fetch_item = item.clone();\n let callback_config = state.app_config.clone();\n let callback_paper_key = paper_key.clone();\n let mut status_callback = move |status: ReadingStatus| -> Pin<\n Box> + Send>,\n > {\n let app_config = callback_config.clone();\n let paper_key = callback_paper_key.clone();\n Box::pin(async move {\n let mut item = reading_library::get_item(&app_config, &paper_key).await?;\n item.status = status;\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&app_config, &item).await\n })\n };\n\n let text = match full_text_fetcher::fetch_full_text(\n &state.app_config,\n &fetch_item,\n Some(&mut status_callback),\n )\n .await\n {\n Ok(text) => text,\n Err(err) => {\n item.status = ReadingStatus::TextFailed;\n item.error = Some(err.to_string());\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n return Ok(Json(ReadingLibraryItemResponse { item }));\n }\n };\n item = reading_library::get_item(&state.app_config, &paper_key).await?;\n item.text = Some(text.text);\n item.text_source_url = Some(text.source_url);\n item.text_coverage = Some(text.coverage);\n item.text_meta = Some(text.meta);\n if matches!(\n item.text_coverage,\n Some(TextCoverage::Failed | TextCoverage::AbstractOnly)\n ) {\n item.status = ReadingStatus::TextFailed;\n item.error = Some(\"全文获取失败,未生成详细笔记。\".to_string());\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n return Ok(Json(ReadingLibraryItemResponse { item }));\n }\n\n item.status = ReadingStatus::TextReady;\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n\n item.status = ReadingStatus::GeneratingNote;\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n\n let llm_config = effective_llm_config(&state, &req.config);\n match note_writer::generate_note(&item, &llm_config).await {\n Ok(note) => {\n item.note = Some(note);\n item.note_quality = Some(NoteQuality::FullText);\n item.status = ReadingStatus::Ready;\n item.error = None;\n }\n Err(err) => {\n item.status = ReadingStatus::NoteFailed;\n item.error = Some(err.to_string());\n }\n }\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n Ok(Json(ReadingLibraryItemResponse { item }))\n}\n\nasync fn chat_with_paper_stream(\n State(state): State,\n Path(paper_key): Path,\n Json(req): Json,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(32);\n\n tokio::spawn(async move {\n let mut item = match reading_library::get_item(&state.app_config, &paper_key).await {\n Ok(item) => item,\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n return;\n }\n };\n let llm_config = effective_llm_config(&state, &req.config);\n let request = match paper_chat::build_paper_chat_request(&item, &req.question, &llm_config)\n {\n Ok(request) => request,\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n return;\n }\n };\n let config = match DeepSeekConfig::from_llm_config(&llm_config) {\n Some(config) => config,\n None => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: \"论文追问需要 DeepSeek API Key。\".to_string(),\n })\n .await;\n return;\n }\n };\n let client = match DeepSeekClient::new(config) {\n Ok(client) => client,\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n return;\n }\n };\n let (delta_tx, mut delta_rx) = mpsc::channel::(32);\n let forward_tx = tx.clone();\n tokio::spawn(async move {\n while let Some(text) = delta_rx.recv().await {\n if forward_tx\n .send(ChatStreamEvent::Delta { text })\n .await\n .is_err()\n {\n break;\n }\n }\n });\n match client\n .chat_completions_stream_text(request, Some(delta_tx))\n .await\n {\n Ok(answer) => {\n let now = chrono::Utc::now();\n item.chat_history.push(PaperChatMessage {\n role: \"user\".to_string(),\n content: req.question,\n created_at: now,\n });\n item.chat_history.push(PaperChatMessage {\n role: \"assistant\".to_string(),\n content: answer,\n created_at: chrono::Utc::now(),\n });\n item.updated_at = chrono::Utc::now();\n let _ = reading_library::save_item(&state.app_config, &item).await;\n let _ = tx.send(ChatStreamEvent::Done).await;\n }\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match event {\n ChatStreamEvent::Delta { .. } => \"delta\",\n ChatStreamEvent::Done => \"done\",\n ChatStreamEvent::Failed { .. } => \"failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"failed\").data(err.to_string())))\n });\n\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn translate_report(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let llm_config = effective_llm_config(&state, &req.config);\n let config = DeepSeekConfig::from_llm_config(&llm_config).ok_or_else(|| {\n AppError::InvalidConfig(\n \"翻译报告需要 DeepSeek API Key,请在阶段 1 配置或设置 DEEPSEEK_API_KEY。\".to_string(),\n )\n })?;\n let client = DeepSeekClient::new(config)?;\n let translated = client\n .translate_report_to_chinese(&req.report_markdown)\n .await?;\n Ok(Json(ReportTranslateResponse {\n translated_markdown: translated,\n }))\n}\n\nfn effective_app_config(state: &AppState, frontend: &FrontendConfig) -> AppConfig {\n let mut app_config = (*state.app_config).clone();\n if let Some(token) = non_empty(frontend.github_token.as_deref()) {\n app_config.github_token = Some(token.to_string());\n }\n if let Some(key) = non_empty(frontend.semantic_scholar_api_key.as_deref()) {\n app_config.semantic_scholar_api_key = Some(key.to_string());\n }\n if let Some(key) = non_empty(frontend.openalex_api_key.as_deref()) {\n app_config.openalex_api_key = Some(key.to_string());\n }\n if let Some(mailto) = non_empty(frontend.crossref_mailto.as_deref()) {\n app_config.crossref_mailto = Some(mailto.to_string());\n }\n app_config\n}\n\nfn effective_llm_config(state: &AppState, frontend: &FrontendConfig) -> LlmConfig {\n let mut llm_config = (*state.llm_config).clone();\n if let Some(api_key) = non_empty(frontend.deepseek_api_key.as_deref()) {\n llm_config.enabled = true;\n llm_config.api_key = Some(api_key.to_string());\n }\n if let Some(base_url) = non_empty(frontend.deepseek_base_url.as_deref()) {\n llm_config.base_url = Some(base_url.to_string());\n }\n if let Some(model) = non_empty(frontend.deepseek_model.as_deref()) {\n llm_config.main_model = model.to_string();\n }\n if let Some(side_model) = non_empty(frontend.deepseek_side_model.as_deref()) {\n llm_config.side_model = Some(side_model.to_string());\n }\n llm_config\n}\n\nfn non_empty(value: Option<&str>) -> Option<&str> {\n value.map(str::trim).filter(|value| !value.is_empty())\n}\n\n#[derive(Debug, Serialize)]\nstruct HealthResponse {\n status: &'static str,\n service: &'static str,\n llm_enabled: bool,\n github_token_configured: bool,\n}\n\n#[cfg(test)]\nmod tests {\n use std::path::PathBuf;\n\n use super::build_router;\n use crate::config::{AppConfig, LlmConfig};\n use crate::server::state::AppState;\n\n #[test]\n fn builds_router_for_health_endpoint() {\n let state = AppState::new(app_config(), LlmConfig::from_env(false, 30));\n\n let _router = build_router(state);\n }\n\n fn app_config() -> AppConfig {\n AppConfig {\n github_token: None,\n semantic_scholar_api_key: None,\n openalex_api_key: None,\n crossref_mailto: None,\n output: PathBuf::from(\"reports\"),\n cache_dir: PathBuf::from(\".litscout-cache\"),\n session_dir: PathBuf::from(\"sessions\"),\n tags_file: None,\n use_cache: true,\n cache_ttl_hours: 24,\n timeout_secs: 30,\n enrich: false,\n }\n }\n}\n"}, "89599bc6feef2b153f24871f481493e359e6ff87": {"t": 1785750661.1822298, "n": 25061, "h": "3bc7f741c37daf5c", "c": "use axum::{\n extract::{Path, State},\n response::sse::{Event, KeepAlive, Sse},\n routing::{get, post},\n Json, Router,\n};\nuse serde::Serialize;\nuse std::convert::Infallible;\nuse std::pin::Pin;\nuse std::time::Duration;\nuse tokio::sync::mpsc;\nuse tokio_stream::{wrappers::ReceiverStream, Stream, StreamExt};\nuse tower_http::cors::{Any, CorsLayer};\nuse tower_http::services::ServeDir;\n\nuse crate::agent::{orchestrator, report_chat};\nuse crate::checkpoint;\nuse crate::config::{AppConfig, LlmConfig};\nuse crate::error::{AppError, Result};\nuse crate::llm::deepseek::{DeepSeekClient, DeepSeekConfig};\nuse crate::reading::models::{NoteQuality, PaperChatMessage, ReadingStatus, TextCoverage};\nuse crate::reading::{\n fetcher as full_text_fetcher, library as reading_library, note_writer, paper_chat,\n};\n\nuse super::dto::{\n AddReadingLibraryItemRequest, BranchRunRequest, ChatStreamEvent, CheckpointListResponse,\n CitationAuditResponse, ContinueStatefulRunRequest, CoverageResponse, CreateStatefulRunRequest,\n EvidenceResponse, FrontendConfig, GenerateReadingNoteRequest, PaperChatRequest,\n ReadingLibraryItemResponse, ReadingLibraryResponse, ReportChatRequest, ReportChatResponse,\n ReportTranslateRequest, ReportTranslateResponse, ReviseStatefulPlanRequest,\n StatefulRunResponse, StatefulRunStreamEvent,\n};\nuse super::state::AppState;\n\npub fn build_router(state: AppState) -> Router {\n let cors = CorsLayer::new()\n .allow_origin(Any)\n .allow_methods(Any)\n .allow_headers(Any);\n\n Router::new()\n .route(\"/api/health\", get(health))\n .route(\"/api/runs\", post(create_stateful_run))\n .route(\"/api/runs/{run_id}\", get(get_stateful_run))\n .route(\"/api/runs/{run_id}/events\", get(stream_stateful_run_events))\n .route(\n \"/api/runs/{run_id}/approve-plan\",\n post(continue_stateful_run),\n )\n .route(\"/api/runs/{run_id}/continue\", post(continue_stateful_run))\n .route(\"/api/runs/{run_id}/revise-plan\", post(revise_stateful_plan))\n .route(\"/api/runs/{run_id}/evidence\", get(get_stateful_evidence))\n .route(\"/api/runs/{run_id}/coverage\", get(get_stateful_coverage))\n .route(\n \"/api/runs/{run_id}/citation-audit\",\n get(get_stateful_citation_audit),\n )\n .route(\n \"/api/runs/{run_id}/checkpoints\",\n get(list_stateful_checkpoints),\n )\n .route(\n \"/api/runs/{run_id}/branch-from-checkpoint\",\n post(branch_stateful_run),\n )\n .route(\"/api/report/chat\", post(chat_with_report))\n .route(\"/api/report/chat/stream\", post(chat_with_report_stream))\n .route(\"/api/report/translate\", post(translate_report))\n .route(\"/api/library\", get(list_reading_library))\n .route(\"/api/library/items\", post(add_reading_library_item))\n .route(\n \"/api/library/items/{paper_key}\",\n get(get_reading_library_item).delete(delete_reading_library_item),\n )\n .route(\n \"/api/library/items/{paper_key}/generate-note\",\n post(generate_reading_note),\n )\n .route(\n \"/api/library/items/{paper_key}/chat/stream\",\n post(chat_with_paper_stream),\n )\n .fallback_service(ServeDir::new(\"web/dist\"))\n .layer(cors)\n .with_state(state)\n}\n\nasync fn health(State(state): State) -> Json {\n Json(HealthResponse {\n status: \"ok\",\n service: \"litscout-rs\",\n llm_enabled: state.llm_config.enabled,\n github_token_configured: state.app_config.github_token.is_some(),\n })\n}\n\nasync fn create_stateful_run(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let app_config = effective_app_config(&state, &req.config);\n let llm_config = effective_llm_config(&state, &req.config);\n let run = orchestrator::create_run(req.topic, app_config, llm_config, req.policy).await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn get_stateful_run(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let run = orchestrator::load_run(&state.app_config, &run_id).await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn stream_stateful_run_events(\n State(state): State,\n Path(run_id): Path,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(16);\n tokio::spawn(async move {\n match orchestrator::load_run(&state.app_config, &run_id).await {\n Ok(run) => {\n let response = StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n };\n let _ = tx\n .send(StatefulRunStreamEvent::RunReady(Box::new(response)))\n .await;\n }\n Err(err) => {\n let _ = tx\n .send(StatefulRunStreamEvent::RunFailed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match &event {\n StatefulRunStreamEvent::Agent(_) => \"agent\",\n StatefulRunStreamEvent::RunReady(_) => \"run_ready\",\n StatefulRunStreamEvent::RunFailed { .. } => \"run_failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"run_failed\").data(err.to_string())))\n });\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn continue_stateful_run(\n State(state): State,\n Path(run_id): Path,\n req: Option>,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(64);\n tokio::spawn(async move {\n let (event_tx, mut event_rx) = mpsc::channel(64);\n let forward_tx = tx.clone();\n tokio::spawn(async move {\n while let Some(event) = event_rx.recv().await {\n if forward_tx\n .send(StatefulRunStreamEvent::Agent(event))\n .await\n .is_err()\n {\n break;\n }\n }\n });\n match orchestrator::continue_run(\n &run_id,\n effective_app_config(\n &state,\n req.as_ref()\n .map(|Json(req)| &req.config)\n .unwrap_or(&FrontendConfig::default()),\n ),\n effective_llm_config(\n &state,\n req.as_ref()\n .map(|Json(req)| &req.config)\n .unwrap_or(&FrontendConfig::default()),\n ),\n Some(event_tx),\n )\n .await\n {\n Ok(run) => {\n let response = StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n };\n let _ = tx\n .send(StatefulRunStreamEvent::RunReady(Box::new(response)))\n .await;\n }\n Err(err) => {\n let _ = tx\n .send(StatefulRunStreamEvent::RunFailed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match &event {\n StatefulRunStreamEvent::Agent(_) => \"agent\",\n StatefulRunStreamEvent::RunReady(_) => \"run_ready\",\n StatefulRunStreamEvent::RunFailed { .. } => \"run_failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"run_failed\").data(err.to_string())))\n });\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn revise_stateful_plan(\n State(state): State,\n Path(run_id): Path,\n Json(req): Json,\n) -> Result> {\n let run = orchestrator::revise_plan(\n &state.app_config,\n &run_id,\n req.chapters,\n req.query_portfolio,\n req.user_feedback,\n )\n .await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn get_stateful_evidence(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let evidence_memory = orchestrator::evidence_for_run(&state.app_config, &run_id).await?;\n Ok(Json(EvidenceResponse {\n run_id,\n evidence_memory,\n }))\n}\n\nasync fn get_stateful_coverage(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let coverage_report = orchestrator::coverage_for_run(&state.app_config, &run_id).await?;\n Ok(Json(CoverageResponse {\n run_id,\n coverage_report,\n }))\n}\n\nasync fn get_stateful_citation_audit(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let run = orchestrator::load_run(&state.app_config, &run_id).await?;\n let citation_audit = run\n .citation_audit\n .ok_or_else(|| AppError::Workflow(\"run has no CitationAuditReport yet\".to_string()))?;\n Ok(Json(CitationAuditResponse {\n run_id,\n citation_audit,\n }))\n}\n\nasync fn list_stateful_checkpoints(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let mut checkpoints = Vec::new();\n let checkpoint_dir = state\n .app_config\n .session_dir\n .join(&run_id)\n .join(\"checkpoints\");\n if let Ok(mut entries) = tokio::fs::read_dir(checkpoint_dir).await {\n while let Some(entry) = entries.next_entry().await? {\n let body = tokio::fs::read_to_string(entry.path()).await?;\n let snapshot: checkpoint::CheckpointSnapshot = serde_json::from_str(&body)?;\n checkpoints.push(snapshot.checkpoint);\n }\n }\n checkpoints.sort_by_key(|checkpoint| checkpoint.created_at);\n Ok(Json(CheckpointListResponse {\n run_id,\n checkpoints,\n }))\n}\n\nasync fn branch_stateful_run(\n State(state): State,\n Path(run_id): Path,\n Json(req): Json,\n) -> Result> {\n let run = orchestrator::branch_from_plan_ready(\n (*state.app_config).clone(),\n &run_id,\n &req.checkpoint_id,\n )\n .await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn chat_with_report(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let answer = report_chat::answer_report_question(\n &req.report_markdown,\n &req.question,\n &effective_llm_config(&state, &req.config),\n )\n .await?;\n Ok(Json(ReportChatResponse { answer }))\n}\n\nasync fn chat_with_report_stream(\n State(state): State,\n Json(req): Json,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(32);\n\n tokio::spawn(async move {\n let (delta_tx, mut delta_rx) = mpsc::channel::(32);\n let forward_tx = tx.clone();\n tokio::spawn(async move {\n while let Some(text) = delta_rx.recv().await {\n if forward_tx\n .send(ChatStreamEvent::Delta { text })\n .await\n .is_err()\n {\n break;\n }\n }\n });\n let result = report_chat::answer_report_question_streaming(\n &req.report_markdown,\n &req.question,\n &effective_llm_config(&state, &req.config),\n delta_tx,\n )\n .await;\n match result {\n Ok(_) => {\n let _ = tx.send(ChatStreamEvent::Done).await;\n }\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match event {\n ChatStreamEvent::Delta { .. } => \"delta\",\n ChatStreamEvent::Done => \"done\",\n ChatStreamEvent::Failed { .. } => \"failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"failed\").data(err.to_string())))\n });\n\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn list_reading_library(\n State(state): State,\n) -> Result> {\n let items = reading_library::list_items(&state.app_config).await?;\n Ok(Json(ReadingLibraryResponse { items }))\n}\n\nasync fn add_reading_library_item(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let item = reading_library::add_item(&state.app_config, req.run_id, req.evidence).await?;\n Ok(Json(ReadingLibraryItemResponse { item }))\n}\n\nasync fn get_reading_library_item(\n State(state): State,\n Path(paper_key): Path,\n) -> Result> {\n let item = reading_library::get_item(&state.app_config, &paper_key).await?;\n Ok(Json(ReadingLibraryItemResponse { item }))\n}\n\nasync fn delete_reading_library_item(\n State(state): State,\n Path(paper_key): Path,\n) -> Result> {\n reading_library::delete_item(&state.app_config, &paper_key).await?;\n let items = reading_library::list_items(&state.app_config).await?;\n Ok(Json(ReadingLibraryResponse { items }))\n}\n\nasync fn generate_reading_note(\n State(state): State,\n Path(paper_key): Path,\n Json(req): Json,\n) -> Result> {\n let mut item = reading_library::get_item(&state.app_config, &paper_key).await?;\n item.error = None;\n item.note_quality = None;\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n\n let fetch_item = item.clone();\n let callback_config = state.app_config.clone();\n let callback_paper_key = paper_key.clone();\n let mut status_callback = move |status: ReadingStatus| -> Pin<\n Box> + Send>,\n > {\n let app_config = callback_config.clone();\n let paper_key = callback_paper_key.clone();\n Box::pin(async move {\n let mut item = reading_library::get_item(&app_config, &paper_key).await?;\n item.status = status;\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&app_config, &item).await\n })\n };\n\n let text = match full_text_fetcher::fetch_full_text(\n &state.app_config,\n &fetch_item,\n Some(&mut status_callback),\n )\n .await\n {\n Ok(text) => text,\n Err(err) => {\n item.status = ReadingStatus::TextFailed;\n item.error = Some(err.to_string());\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n return Ok(Json(ReadingLibraryItemResponse { item }));\n }\n };\n item = reading_library::get_item(&state.app_config, &paper_key).await?;\n item.text = Some(text.text);\n item.text_source_url = Some(text.source_url);\n item.text_coverage = Some(text.coverage);\n item.text_meta = Some(text.meta);\n if matches!(\n item.text_coverage,\n Some(TextCoverage::Failed | TextCoverage::AbstractOnly)\n ) {\n item.status = ReadingStatus::TextFailed;\n item.error = Some(\"全文获取失败,未生成详细笔记。\".to_string());\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n return Ok(Json(ReadingLibraryItemResponse { item }));\n }\n\n item.status = ReadingStatus::TextReady;\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n\n item.status = ReadingStatus::GeneratingNote;\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n\n let llm_config = effective_llm_config(&state, &req.config);\n match note_writer::generate_note(&item, &llm_config).await {\n Ok(note) => {\n item.note = Some(note);\n item.note_quality = Some(NoteQuality::FullText);\n item.status = ReadingStatus::Ready;\n item.error = None;\n }\n Err(err) => {\n item.status = ReadingStatus::NoteFailed;\n item.error = Some(err.to_string());\n }\n }\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n Ok(Json(ReadingLibraryItemResponse { item }))\n}\n\nasync fn chat_with_paper_stream(\n State(state): State,\n Path(paper_key): Path,\n Json(req): Json,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(32);\n\n tokio::spawn(async move {\n let mut item = match reading_library::get_item(&state.app_config, &paper_key).await {\n Ok(item) => item,\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n return;\n }\n };\n let llm_config = effective_llm_config(&state, &req.config);\n let request = match paper_chat::build_paper_chat_request(&item, &req.question, &llm_config)\n {\n Ok(request) => request,\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n return;\n }\n };\n let config = match DeepSeekConfig::from_llm_config(&llm_config) {\n Some(config) => config,\n None => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: \"论文追问需要 DeepSeek API Key。\".to_string(),\n })\n .await;\n return;\n }\n };\n let client = match DeepSeekClient::new(config) {\n Ok(client) => client,\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n return;\n }\n };\n let (delta_tx, mut delta_rx) = mpsc::channel::(32);\n let forward_tx = tx.clone();\n tokio::spawn(async move {\n while let Some(text) = delta_rx.recv().await {\n if forward_tx\n .send(ChatStreamEvent::Delta { text })\n .await\n .is_err()\n {\n break;\n }\n }\n });\n match client\n .chat_completions_stream_text(request, Some(delta_tx))\n .await\n {\n Ok(answer) => {\n let now = chrono::Utc::now();\n item.chat_history.push(PaperChatMessage {\n role: \"user\".to_string(),\n content: req.question,\n created_at: now,\n });\n item.chat_history.push(PaperChatMessage {\n role: \"assistant\".to_string(),\n content: answer,\n created_at: chrono::Utc::now(),\n });\n item.updated_at = chrono::Utc::now();\n let _ = reading_library::save_item(&state.app_config, &item).await;\n let _ = tx.send(ChatStreamEvent::Done).await;\n }\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match event {\n ChatStreamEvent::Delta { .. } => \"delta\",\n ChatStreamEvent::Done => \"done\",\n ChatStreamEvent::Failed { .. } => \"failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"failed\").data(err.to_string())))\n });\n\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn translate_report(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let llm_config = effective_llm_config(&state, &req.config);\n let config = DeepSeekConfig::from_llm_config(&llm_config).ok_or_else(|| {\n AppError::InvalidConfig(\n \"翻译报告需要 DeepSeek API Key,请在阶段 1 配置或设置 DEEPSEEK_API_KEY。\".to_string(),\n )\n })?;\n let client = DeepSeekClient::new(config)?;\n let translated = client\n .translate_report_to_chinese(&req.report_markdown)\n .await?;\n Ok(Json(ReportTranslateResponse {\n translated_markdown: translated,\n }))\n}\n\nfn effective_app_config(state: &AppState, frontend: &FrontendConfig) -> AppConfig {\n let mut app_config = (*state.app_config).clone();\n if let Some(token) = non_empty(frontend.github_token.as_deref()) {\n app_config.github_token = Some(token.to_string());\n }\n if let Some(key) = non_empty(frontend.semantic_scholar_api_key.as_deref()) {\n app_config.semantic_scholar_api_key = Some(key.to_string());\n }\n app_config\n}\n\nfn effective_llm_config(state: &AppState, frontend: &FrontendConfig) -> LlmConfig {\n let mut llm_config = (*state.llm_config).clone();\n if let Some(api_key) = non_empty(frontend.deepseek_api_key.as_deref()) {\n llm_config.enabled = true;\n llm_config.api_key = Some(api_key.to_string());\n }\n if let Some(base_url) = non_empty(frontend.deepseek_base_url.as_deref()) {\n llm_config.base_url = Some(base_url.to_string());\n }\n if let Some(model) = non_empty(frontend.deepseek_model.as_deref()) {\n llm_config.main_model = model.to_string();\n }\n if let Some(side_model) = non_empty(frontend.deepseek_side_model.as_deref()) {\n llm_config.side_model = Some(side_model.to_string());\n }\n llm_config\n}\n\nfn non_empty(value: Option<&str>) -> Option<&str> {\n value.map(str::trim).filter(|value| !value.is_empty())\n}\n\n#[derive(Debug, Serialize)]\nstruct HealthResponse {\n status: &'static str,\n service: &'static str,\n llm_enabled: bool,\n github_token_configured: bool,\n}\n\n#[cfg(test)]\nmod tests {\n use std::path::PathBuf;\n\n use super::build_router;\n use crate::config::{AppConfig, LlmConfig};\n use crate::server::state::AppState;\n\n #[test]\n fn builds_router_for_health_endpoint() {\n let state = AppState::new(app_config(), LlmConfig::from_env(false, 30));\n\n let _router = build_router(state);\n }\n\n fn app_config() -> AppConfig {\n AppConfig {\n github_token: None,\n semantic_scholar_api_key: None,\n output: PathBuf::from(\"reports\"),\n cache_dir: PathBuf::from(\".litscout-cache\"),\n session_dir: PathBuf::from(\"sessions\"),\n tags_file: None,\n use_cache: true,\n cache_ttl_hours: 24,\n timeout_secs: 30,\n enrich: false,\n }\n }\n}\n"}, "638e49c5b42c5e902379c23b653519ba0ed59bda": {"t": 1785750666.4291215, "n": 24861, "h": "da2ddf37c6b0e9d7", "c": "use axum::{\n extract::{Path, State},\n response::sse::{Event, KeepAlive, Sse},\n routing::{get, post},\n Json, Router,\n};\nuse serde::Serialize;\nuse std::convert::Infallible;\nuse std::pin::Pin;\nuse std::time::Duration;\nuse tokio::sync::mpsc;\nuse tokio_stream::{wrappers::ReceiverStream, Stream, StreamExt};\nuse tower_http::cors::{Any, CorsLayer};\nuse tower_http::services::ServeDir;\n\nuse crate::agent::{orchestrator, report_chat};\nuse crate::checkpoint;\nuse crate::config::{AppConfig, LlmConfig};\nuse crate::error::{AppError, Result};\nuse crate::llm::deepseek::{DeepSeekClient, DeepSeekConfig};\nuse crate::reading::models::{NoteQuality, PaperChatMessage, ReadingStatus, TextCoverage};\nuse crate::reading::{\n fetcher as full_text_fetcher, library as reading_library, note_writer, paper_chat,\n};\n\nuse super::dto::{\n AddReadingLibraryItemRequest, BranchRunRequest, ChatStreamEvent, CheckpointListResponse,\n CitationAuditResponse, ContinueStatefulRunRequest, CoverageResponse, CreateStatefulRunRequest,\n EvidenceResponse, FrontendConfig, GenerateReadingNoteRequest, PaperChatRequest,\n ReadingLibraryItemResponse, ReadingLibraryResponse, ReportChatRequest, ReportChatResponse,\n ReportTranslateRequest, ReportTranslateResponse, ReviseStatefulPlanRequest,\n StatefulRunResponse, StatefulRunStreamEvent,\n};\nuse super::state::AppState;\n\npub fn build_router(state: AppState) -> Router {\n let cors = CorsLayer::new()\n .allow_origin(Any)\n .allow_methods(Any)\n .allow_headers(Any);\n\n Router::new()\n .route(\"/api/health\", get(health))\n .route(\"/api/runs\", post(create_stateful_run))\n .route(\"/api/runs/{run_id}\", get(get_stateful_run))\n .route(\"/api/runs/{run_id}/events\", get(stream_stateful_run_events))\n .route(\n \"/api/runs/{run_id}/approve-plan\",\n post(continue_stateful_run),\n )\n .route(\"/api/runs/{run_id}/continue\", post(continue_stateful_run))\n .route(\"/api/runs/{run_id}/revise-plan\", post(revise_stateful_plan))\n .route(\"/api/runs/{run_id}/evidence\", get(get_stateful_evidence))\n .route(\"/api/runs/{run_id}/coverage\", get(get_stateful_coverage))\n .route(\n \"/api/runs/{run_id}/citation-audit\",\n get(get_stateful_citation_audit),\n )\n .route(\n \"/api/runs/{run_id}/checkpoints\",\n get(list_stateful_checkpoints),\n )\n .route(\n \"/api/runs/{run_id}/branch-from-checkpoint\",\n post(branch_stateful_run),\n )\n .route(\"/api/report/chat\", post(chat_with_report))\n .route(\"/api/report/chat/stream\", post(chat_with_report_stream))\n .route(\"/api/report/translate\", post(translate_report))\n .route(\"/api/library\", get(list_reading_library))\n .route(\"/api/library/items\", post(add_reading_library_item))\n .route(\n \"/api/library/items/{paper_key}\",\n get(get_reading_library_item).delete(delete_reading_library_item),\n )\n .route(\n \"/api/library/items/{paper_key}/generate-note\",\n post(generate_reading_note),\n )\n .route(\n \"/api/library/items/{paper_key}/chat/stream\",\n post(chat_with_paper_stream),\n )\n .fallback_service(ServeDir::new(\"web/dist\"))\n .layer(cors)\n .with_state(state)\n}\n\nasync fn health(State(state): State) -> Json {\n Json(HealthResponse {\n status: \"ok\",\n service: \"litscout-rs\",\n llm_enabled: state.llm_config.enabled,\n github_token_configured: state.app_config.github_token.is_some(),\n })\n}\n\nasync fn create_stateful_run(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let app_config = effective_app_config(&state, &req.config);\n let llm_config = effective_llm_config(&state, &req.config);\n let run = orchestrator::create_run(req.topic, app_config, llm_config, req.policy).await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn get_stateful_run(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let run = orchestrator::load_run(&state.app_config, &run_id).await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn stream_stateful_run_events(\n State(state): State,\n Path(run_id): Path,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(16);\n tokio::spawn(async move {\n match orchestrator::load_run(&state.app_config, &run_id).await {\n Ok(run) => {\n let response = StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n };\n let _ = tx\n .send(StatefulRunStreamEvent::RunReady(Box::new(response)))\n .await;\n }\n Err(err) => {\n let _ = tx\n .send(StatefulRunStreamEvent::RunFailed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match &event {\n StatefulRunStreamEvent::Agent(_) => \"agent\",\n StatefulRunStreamEvent::RunReady(_) => \"run_ready\",\n StatefulRunStreamEvent::RunFailed { .. } => \"run_failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"run_failed\").data(err.to_string())))\n });\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn continue_stateful_run(\n State(state): State,\n Path(run_id): Path,\n req: Option>,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(64);\n tokio::spawn(async move {\n let (event_tx, mut event_rx) = mpsc::channel(64);\n let forward_tx = tx.clone();\n tokio::spawn(async move {\n while let Some(event) = event_rx.recv().await {\n if forward_tx\n .send(StatefulRunStreamEvent::Agent(event))\n .await\n .is_err()\n {\n break;\n }\n }\n });\n match orchestrator::continue_run(\n &run_id,\n effective_app_config(\n &state,\n req.as_ref()\n .map(|Json(req)| &req.config)\n .unwrap_or(&FrontendConfig::default()),\n ),\n effective_llm_config(\n &state,\n req.as_ref()\n .map(|Json(req)| &req.config)\n .unwrap_or(&FrontendConfig::default()),\n ),\n Some(event_tx),\n )\n .await\n {\n Ok(run) => {\n let response = StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n };\n let _ = tx\n .send(StatefulRunStreamEvent::RunReady(Box::new(response)))\n .await;\n }\n Err(err) => {\n let _ = tx\n .send(StatefulRunStreamEvent::RunFailed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match &event {\n StatefulRunStreamEvent::Agent(_) => \"agent\",\n StatefulRunStreamEvent::RunReady(_) => \"run_ready\",\n StatefulRunStreamEvent::RunFailed { .. } => \"run_failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"run_failed\").data(err.to_string())))\n });\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn revise_stateful_plan(\n State(state): State,\n Path(run_id): Path,\n Json(req): Json,\n) -> Result> {\n let run = orchestrator::revise_plan(\n &state.app_config,\n &run_id,\n req.chapters,\n req.query_portfolio,\n req.user_feedback,\n )\n .await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn get_stateful_evidence(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let evidence_memory = orchestrator::evidence_for_run(&state.app_config, &run_id).await?;\n Ok(Json(EvidenceResponse {\n run_id,\n evidence_memory,\n }))\n}\n\nasync fn get_stateful_coverage(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let coverage_report = orchestrator::coverage_for_run(&state.app_config, &run_id).await?;\n Ok(Json(CoverageResponse {\n run_id,\n coverage_report,\n }))\n}\n\nasync fn get_stateful_citation_audit(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let run = orchestrator::load_run(&state.app_config, &run_id).await?;\n let citation_audit = run\n .citation_audit\n .ok_or_else(|| AppError::Workflow(\"run has no CitationAuditReport yet\".to_string()))?;\n Ok(Json(CitationAuditResponse {\n run_id,\n citation_audit,\n }))\n}\n\nasync fn list_stateful_checkpoints(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let mut checkpoints = Vec::new();\n let checkpoint_dir = state\n .app_config\n .session_dir\n .join(&run_id)\n .join(\"checkpoints\");\n if let Ok(mut entries) = tokio::fs::read_dir(checkpoint_dir).await {\n while let Some(entry) = entries.next_entry().await? {\n let body = tokio::fs::read_to_string(entry.path()).await?;\n let snapshot: checkpoint::CheckpointSnapshot = serde_json::from_str(&body)?;\n checkpoints.push(snapshot.checkpoint);\n }\n }\n checkpoints.sort_by_key(|checkpoint| checkpoint.created_at);\n Ok(Json(CheckpointListResponse {\n run_id,\n checkpoints,\n }))\n}\n\nasync fn branch_stateful_run(\n State(state): State,\n Path(run_id): Path,\n Json(req): Json,\n) -> Result> {\n let run = orchestrator::branch_from_plan_ready(\n (*state.app_config).clone(),\n &run_id,\n &req.checkpoint_id,\n )\n .await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn chat_with_report(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let answer = report_chat::answer_report_question(\n &req.report_markdown,\n &req.question,\n &effective_llm_config(&state, &req.config),\n )\n .await?;\n Ok(Json(ReportChatResponse { answer }))\n}\n\nasync fn chat_with_report_stream(\n State(state): State,\n Json(req): Json,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(32);\n\n tokio::spawn(async move {\n let (delta_tx, mut delta_rx) = mpsc::channel::(32);\n let forward_tx = tx.clone();\n tokio::spawn(async move {\n while let Some(text) = delta_rx.recv().await {\n if forward_tx\n .send(ChatStreamEvent::Delta { text })\n .await\n .is_err()\n {\n break;\n }\n }\n });\n let result = report_chat::answer_report_question_streaming(\n &req.report_markdown,\n &req.question,\n &effective_llm_config(&state, &req.config),\n delta_tx,\n )\n .await;\n match result {\n Ok(_) => {\n let _ = tx.send(ChatStreamEvent::Done).await;\n }\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match event {\n ChatStreamEvent::Delta { .. } => \"delta\",\n ChatStreamEvent::Done => \"done\",\n ChatStreamEvent::Failed { .. } => \"failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"failed\").data(err.to_string())))\n });\n\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn list_reading_library(\n State(state): State,\n) -> Result> {\n let items = reading_library::list_items(&state.app_config).await?;\n Ok(Json(ReadingLibraryResponse { items }))\n}\n\nasync fn add_reading_library_item(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let item = reading_library::add_item(&state.app_config, req.run_id, req.evidence).await?;\n Ok(Json(ReadingLibraryItemResponse { item }))\n}\n\nasync fn get_reading_library_item(\n State(state): State,\n Path(paper_key): Path,\n) -> Result> {\n let item = reading_library::get_item(&state.app_config, &paper_key).await?;\n Ok(Json(ReadingLibraryItemResponse { item }))\n}\n\nasync fn delete_reading_library_item(\n State(state): State,\n Path(paper_key): Path,\n) -> Result> {\n reading_library::delete_item(&state.app_config, &paper_key).await?;\n let items = reading_library::list_items(&state.app_config).await?;\n Ok(Json(ReadingLibraryResponse { items }))\n}\n\nasync fn generate_reading_note(\n State(state): State,\n Path(paper_key): Path,\n Json(req): Json,\n) -> Result> {\n let mut item = reading_library::get_item(&state.app_config, &paper_key).await?;\n item.error = None;\n item.note_quality = None;\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n\n let fetch_item = item.clone();\n let callback_config = state.app_config.clone();\n let callback_paper_key = paper_key.clone();\n let mut status_callback = move |status: ReadingStatus| -> Pin<\n Box> + Send>,\n > {\n let app_config = callback_config.clone();\n let paper_key = callback_paper_key.clone();\n Box::pin(async move {\n let mut item = reading_library::get_item(&app_config, &paper_key).await?;\n item.status = status;\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&app_config, &item).await\n })\n };\n\n let text = match full_text_fetcher::fetch_full_text(\n &state.app_config,\n &fetch_item,\n Some(&mut status_callback),\n )\n .await\n {\n Ok(text) => text,\n Err(err) => {\n item.status = ReadingStatus::TextFailed;\n item.error = Some(err.to_string());\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n return Ok(Json(ReadingLibraryItemResponse { item }));\n }\n };\n item = reading_library::get_item(&state.app_config, &paper_key).await?;\n item.text = Some(text.text);\n item.text_source_url = Some(text.source_url);\n item.text_coverage = Some(text.coverage);\n item.text_meta = Some(text.meta);\n if matches!(\n item.text_coverage,\n Some(TextCoverage::Failed | TextCoverage::AbstractOnly)\n ) {\n item.status = ReadingStatus::TextFailed;\n item.error = Some(\"全文获取失败,未生成详细笔记。\".to_string());\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n return Ok(Json(ReadingLibraryItemResponse { item }));\n }\n\n item.status = ReadingStatus::TextReady;\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n\n item.status = ReadingStatus::GeneratingNote;\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n\n let llm_config = effective_llm_config(&state, &req.config);\n match note_writer::generate_note(&item, &llm_config).await {\n Ok(note) => {\n item.note = Some(note);\n item.note_quality = Some(NoteQuality::FullText);\n item.status = ReadingStatus::Ready;\n item.error = None;\n }\n Err(err) => {\n item.status = ReadingStatus::NoteFailed;\n item.error = Some(err.to_string());\n }\n }\n item.updated_at = chrono::Utc::now();\n reading_library::save_item(&state.app_config, &item).await?;\n Ok(Json(ReadingLibraryItemResponse { item }))\n}\n\nasync fn chat_with_paper_stream(\n State(state): State,\n Path(paper_key): Path,\n Json(req): Json,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(32);\n\n tokio::spawn(async move {\n let mut item = match reading_library::get_item(&state.app_config, &paper_key).await {\n Ok(item) => item,\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n return;\n }\n };\n let llm_config = effective_llm_config(&state, &req.config);\n let request = match paper_chat::build_paper_chat_request(&item, &req.question, &llm_config)\n {\n Ok(request) => request,\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n return;\n }\n };\n let config = match DeepSeekConfig::from_llm_config(&llm_config) {\n Some(config) => config,\n None => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: \"论文追问需要 DeepSeek API Key。\".to_string(),\n })\n .await;\n return;\n }\n };\n let client = match DeepSeekClient::new(config) {\n Ok(client) => client,\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n return;\n }\n };\n let (delta_tx, mut delta_rx) = mpsc::channel::(32);\n let forward_tx = tx.clone();\n tokio::spawn(async move {\n while let Some(text) = delta_rx.recv().await {\n if forward_tx\n .send(ChatStreamEvent::Delta { text })\n .await\n .is_err()\n {\n break;\n }\n }\n });\n match client\n .chat_completions_stream_text(request, Some(delta_tx))\n .await\n {\n Ok(answer) => {\n let now = chrono::Utc::now();\n item.chat_history.push(PaperChatMessage {\n role: \"user\".to_string(),\n content: req.question,\n created_at: now,\n });\n item.chat_history.push(PaperChatMessage {\n role: \"assistant\".to_string(),\n content: answer,\n created_at: chrono::Utc::now(),\n });\n item.updated_at = chrono::Utc::now();\n let _ = reading_library::save_item(&state.app_config, &item).await;\n let _ = tx.send(ChatStreamEvent::Done).await;\n }\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match event {\n ChatStreamEvent::Delta { .. } => \"delta\",\n ChatStreamEvent::Done => \"done\",\n ChatStreamEvent::Failed { .. } => \"failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"failed\").data(err.to_string())))\n });\n\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn translate_report(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let llm_config = effective_llm_config(&state, &req.config);\n let config = DeepSeekConfig::from_llm_config(&llm_config).ok_or_else(|| {\n AppError::InvalidConfig(\n \"翻译报告需要 DeepSeek API Key,请在阶段 1 配置或设置 DEEPSEEK_API_KEY。\".to_string(),\n )\n })?;\n let client = DeepSeekClient::new(config)?;\n let translated = client\n .translate_report_to_chinese(&req.report_markdown)\n .await?;\n Ok(Json(ReportTranslateResponse {\n translated_markdown: translated,\n }))\n}\n\nfn effective_app_config(state: &AppState, frontend: &FrontendConfig) -> AppConfig {\n let mut app_config = (*state.app_config).clone();\n if let Some(token) = non_empty(frontend.github_token.as_deref()) {\n app_config.github_token = Some(token.to_string());\n }\n app_config\n}\n\nfn effective_llm_config(state: &AppState, frontend: &FrontendConfig) -> LlmConfig {\n let mut llm_config = (*state.llm_config).clone();\n if let Some(api_key) = non_empty(frontend.deepseek_api_key.as_deref()) {\n llm_config.enabled = true;\n llm_config.api_key = Some(api_key.to_string());\n }\n if let Some(base_url) = non_empty(frontend.deepseek_base_url.as_deref()) {\n llm_config.base_url = Some(base_url.to_string());\n }\n if let Some(model) = non_empty(frontend.deepseek_model.as_deref()) {\n llm_config.main_model = model.to_string();\n }\n if let Some(side_model) = non_empty(frontend.deepseek_side_model.as_deref()) {\n llm_config.side_model = Some(side_model.to_string());\n }\n llm_config\n}\n\nfn non_empty(value: Option<&str>) -> Option<&str> {\n value.map(str::trim).filter(|value| !value.is_empty())\n}\n\n#[derive(Debug, Serialize)]\nstruct HealthResponse {\n status: &'static str,\n service: &'static str,\n llm_enabled: bool,\n github_token_configured: bool,\n}\n\n#[cfg(test)]\nmod tests {\n use std::path::PathBuf;\n\n use super::build_router;\n use crate::config::{AppConfig, LlmConfig};\n use crate::server::state::AppState;\n\n #[test]\n fn builds_router_for_health_endpoint() {\n let state = AppState::new(app_config(), LlmConfig::from_env(false, 30));\n\n let _router = build_router(state);\n }\n\n fn app_config() -> AppConfig {\n AppConfig {\n github_token: None,\n output: PathBuf::from(\"reports\"),\n cache_dir: PathBuf::from(\".litscout-cache\"),\n session_dir: PathBuf::from(\"sessions\"),\n tags_file: None,\n use_cache: true,\n cache_ttl_hours: 24,\n timeout_secs: 30,\n enrich: false,\n }\n }\n}\n"}, "bccd8c422afb8962e69d703d2ddcbdac277a8101": {"t": 1785750667.539814, "n": 16543, "h": "04895b929b6d17dd", "c": "use axum::{\n extract::{Path, State},\n response::sse::{Event, KeepAlive, Sse},\n routing::{get, post},\n Json, Router,\n};\nuse serde::Serialize;\nuse std::convert::Infallible;\nuse std::time::Duration;\nuse tokio::sync::mpsc;\nuse tokio_stream::{wrappers::ReceiverStream, Stream, StreamExt};\nuse tower_http::cors::{Any, CorsLayer};\nuse tower_http::services::ServeDir;\n\nuse crate::agent::{orchestrator, report_chat};\nuse crate::checkpoint;\nuse crate::config::{AppConfig, LlmConfig};\nuse crate::error::{AppError, Result};\nuse crate::llm::deepseek::{DeepSeekClient, DeepSeekConfig};\n\nuse super::dto::{\n BranchRunRequest, ChatStreamEvent, CheckpointListResponse, CitationAuditResponse,\n ContinueStatefulRunRequest, CoverageResponse, CreateStatefulRunRequest, EvidenceResponse,\n FrontendConfig, ReportChatRequest, ReportChatResponse, ReportTranslateRequest,\n ReportTranslateResponse, ReviseStatefulPlanRequest, StatefulFollowupRequest,\n StatefulFollowupResponse, StatefulRunResponse, StatefulRunStreamEvent,\n};\nuse super::state::AppState;\n\npub fn build_router(state: AppState) -> Router {\n let cors = CorsLayer::new()\n .allow_origin(Any)\n .allow_methods(Any)\n .allow_headers(Any);\n\n Router::new()\n .route(\"/api/health\", get(health))\n .route(\"/api/runs\", post(create_stateful_run))\n .route(\"/api/runs/{run_id}\", get(get_stateful_run))\n .route(\"/api/runs/{run_id}/events\", get(stream_stateful_run_events))\n .route(\n \"/api/runs/{run_id}/approve-plan\",\n post(continue_stateful_run),\n )\n .route(\"/api/runs/{run_id}/continue\", post(continue_stateful_run))\n .route(\"/api/runs/{run_id}/revise-plan\", post(revise_stateful_plan))\n .route(\"/api/runs/{run_id}/evidence\", get(get_stateful_evidence))\n .route(\"/api/runs/{run_id}/coverage\", get(get_stateful_coverage))\n .route(\n \"/api/runs/{run_id}/citation-audit\",\n get(get_stateful_citation_audit),\n )\n .route(\n \"/api/runs/{run_id}/checkpoints\",\n get(list_stateful_checkpoints),\n )\n .route(\n \"/api/runs/{run_id}/branch-from-checkpoint\",\n post(branch_stateful_run),\n )\n .route(\"/api/runs/{run_id}/follow-up\", post(followup_stateful_run))\n .route(\"/api/report/chat\", post(chat_with_report))\n .route(\"/api/report/chat/stream\", post(chat_with_report_stream))\n .route(\"/api/report/translate\", post(translate_report))\n .fallback_service(ServeDir::new(\"web/dist\"))\n .layer(cors)\n .with_state(state)\n}\n\nasync fn health(State(state): State) -> Json {\n Json(HealthResponse {\n status: \"ok\",\n service: \"litscout-rs\",\n llm_enabled: state.llm_config.enabled,\n github_token_configured: state.app_config.github_token.is_some(),\n })\n}\n\nasync fn create_stateful_run(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let app_config = effective_app_config(&state, &req.config);\n let llm_config = effective_llm_config(&state, &req.config);\n let run = orchestrator::create_run(req.topic, app_config, llm_config, req.policy).await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn get_stateful_run(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let run = orchestrator::load_run(&state.app_config, &run_id).await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn stream_stateful_run_events(\n State(state): State,\n Path(run_id): Path,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(16);\n tokio::spawn(async move {\n match orchestrator::load_run(&state.app_config, &run_id).await {\n Ok(run) => {\n let response = StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n };\n let _ = tx\n .send(StatefulRunStreamEvent::RunReady(Box::new(response)))\n .await;\n }\n Err(err) => {\n let _ = tx\n .send(StatefulRunStreamEvent::RunFailed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match &event {\n StatefulRunStreamEvent::Agent(_) => \"agent\",\n StatefulRunStreamEvent::RunReady(_) => \"run_ready\",\n StatefulRunStreamEvent::RunFailed { .. } => \"run_failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"run_failed\").data(err.to_string())))\n });\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn continue_stateful_run(\n State(state): State,\n Path(run_id): Path,\n req: Option>,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(64);\n tokio::spawn(async move {\n let (event_tx, mut event_rx) = mpsc::channel(64);\n let forward_tx = tx.clone();\n tokio::spawn(async move {\n while let Some(event) = event_rx.recv().await {\n if forward_tx\n .send(StatefulRunStreamEvent::Agent(event))\n .await\n .is_err()\n {\n break;\n }\n }\n });\n match orchestrator::continue_run(\n &run_id,\n effective_app_config(\n &state,\n req.as_ref()\n .map(|Json(req)| &req.config)\n .unwrap_or(&FrontendConfig::default()),\n ),\n effective_llm_config(\n &state,\n req.as_ref()\n .map(|Json(req)| &req.config)\n .unwrap_or(&FrontendConfig::default()),\n ),\n Some(event_tx),\n )\n .await\n {\n Ok(run) => {\n let response = StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n };\n let _ = tx\n .send(StatefulRunStreamEvent::RunReady(Box::new(response)))\n .await;\n }\n Err(err) => {\n let _ = tx\n .send(StatefulRunStreamEvent::RunFailed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match &event {\n StatefulRunStreamEvent::Agent(_) => \"agent\",\n StatefulRunStreamEvent::RunReady(_) => \"run_ready\",\n StatefulRunStreamEvent::RunFailed { .. } => \"run_failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"run_failed\").data(err.to_string())))\n });\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn revise_stateful_plan(\n State(state): State,\n Path(run_id): Path,\n Json(req): Json,\n) -> Result> {\n let run = orchestrator::revise_plan(\n &state.app_config,\n &run_id,\n req.chapters,\n req.query_portfolio,\n req.user_feedback,\n )\n .await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn get_stateful_evidence(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let evidence_memory = orchestrator::evidence_for_run(&state.app_config, &run_id).await?;\n Ok(Json(EvidenceResponse {\n run_id,\n evidence_memory,\n }))\n}\n\nasync fn get_stateful_coverage(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let coverage_report = orchestrator::coverage_for_run(&state.app_config, &run_id).await?;\n Ok(Json(CoverageResponse {\n run_id,\n coverage_report,\n }))\n}\n\nasync fn get_stateful_citation_audit(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let run = orchestrator::load_run(&state.app_config, &run_id).await?;\n let citation_audit = run\n .citation_audit\n .ok_or_else(|| AppError::Workflow(\"run has no CitationAuditReport yet\".to_string()))?;\n Ok(Json(CitationAuditResponse {\n run_id,\n citation_audit,\n }))\n}\n\nasync fn list_stateful_checkpoints(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let mut checkpoints = Vec::new();\n let checkpoint_dir = state\n .app_config\n .session_dir\n .join(&run_id)\n .join(\"checkpoints\");\n if let Ok(mut entries) = tokio::fs::read_dir(checkpoint_dir).await {\n while let Some(entry) = entries.next_entry().await? {\n let body = tokio::fs::read_to_string(entry.path()).await?;\n let snapshot: checkpoint::CheckpointSnapshot = serde_json::from_str(&body)?;\n checkpoints.push(snapshot.checkpoint);\n }\n }\n checkpoints.sort_by_key(|checkpoint| checkpoint.created_at);\n Ok(Json(CheckpointListResponse {\n run_id,\n checkpoints,\n }))\n}\n\nasync fn branch_stateful_run(\n State(state): State,\n Path(run_id): Path,\n Json(req): Json,\n) -> Result> {\n let run = orchestrator::branch_from_plan_ready(\n (*state.app_config).clone(),\n &run_id,\n &req.checkpoint_id,\n )\n .await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn followup_stateful_run(\n State(state): State,\n Path(run_id): Path,\n Json(req): Json,\n) -> Result> {\n let run = orchestrator::load_run(&state.app_config, &run_id).await?;\n let route = orchestrator::route_followup_for_run(\n &req.question,\n run.evidence_memory.as_ref(),\n run.report_markdown.as_deref(),\n );\n Ok(Json(StatefulFollowupResponse { run_id, route }))\n}\n\nasync fn chat_with_report(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let answer = report_chat::answer_report_question(\n &req.report_markdown,\n &req.question,\n &effective_llm_config(&state, &req.config),\n )\n .await?;\n Ok(Json(ReportChatResponse { answer }))\n}\n\nasync fn chat_with_report_stream(\n State(state): State,\n Json(req): Json,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(32);\n\n tokio::spawn(async move {\n let result = report_chat::answer_report_question_streaming(\n &req.report_markdown,\n &req.question,\n &effective_llm_config(&state, &req.config),\n )\n .await;\n\n match result {\n Ok(answer) => {\n for chunk in markdown_chunks(&answer) {\n if tx\n .send(ChatStreamEvent::Delta { text: chunk })\n .await\n .is_err()\n {\n return;\n }\n tokio::time::sleep(Duration::from_millis(45)).await;\n }\n let _ = tx.send(ChatStreamEvent::Done).await;\n }\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match event {\n ChatStreamEvent::Delta { .. } => \"delta\",\n ChatStreamEvent::Done => \"done\",\n ChatStreamEvent::Failed { .. } => \"failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"failed\").data(err.to_string())))\n });\n\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn translate_report(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let llm_config = effective_llm_config(&state, &req.config);\n let config = DeepSeekConfig::from_llm_config(&llm_config).ok_or_else(|| {\n AppError::InvalidConfig(\n \"翻译报告需要 DeepSeek API Key,请在阶段 1 配置或设置 DEEPSEEK_API_KEY。\".to_string(),\n )\n })?;\n let client = DeepSeekClient::new(config)?;\n let translated = client\n .translate_report_to_chinese(&req.report_markdown)\n .await?;\n Ok(Json(ReportTranslateResponse {\n translated_markdown: translated,\n }))\n}\n\nfn markdown_chunks(answer: &str) -> Vec {\n let mut chunks = answer\n .split_inclusive('\\n')\n .map(str::to_string)\n .filter(|chunk| !chunk.is_empty())\n .collect::>();\n if chunks.is_empty() && !answer.is_empty() {\n chunks.push(answer.to_string());\n }\n chunks\n}\n\nfn effective_app_config(state: &AppState, frontend: &FrontendConfig) -> AppConfig {\n let mut app_config = (*state.app_config).clone();\n if let Some(token) = non_empty(frontend.github_token.as_deref()) {\n app_config.github_token = Some(token.to_string());\n }\n app_config\n}\n\nfn effective_llm_config(state: &AppState, frontend: &FrontendConfig) -> LlmConfig {\n let mut llm_config = (*state.llm_config).clone();\n if let Some(api_key) = non_empty(frontend.deepseek_api_key.as_deref()) {\n llm_config.enabled = true;\n llm_config.api_key = Some(api_key.to_string());\n }\n if let Some(base_url) = non_empty(frontend.deepseek_base_url.as_deref()) {\n llm_config.base_url = Some(base_url.to_string());\n }\n if let Some(model) = non_empty(frontend.deepseek_model.as_deref()) {\n llm_config.main_model = model.to_string();\n }\n if let Some(side_model) = non_empty(frontend.deepseek_side_model.as_deref()) {\n llm_config.side_model = Some(side_model.to_string());\n }\n llm_config\n}\n\nfn non_empty(value: Option<&str>) -> Option<&str> {\n value.map(str::trim).filter(|value| !value.is_empty())\n}\n\n#[derive(Debug, Serialize)]\nstruct HealthResponse {\n status: &'static str,\n service: &'static str,\n llm_enabled: bool,\n github_token_configured: bool,\n}\n\n#[cfg(test)]\nmod tests {\n use std::path::PathBuf;\n\n use super::build_router;\n use crate::config::{AppConfig, LlmConfig};\n use crate::server::state::AppState;\n\n #[test]\n fn builds_router_for_health_endpoint() {\n let state = AppState::new(app_config(), LlmConfig::from_env(false, 30));\n\n let _router = build_router(state);\n }\n\n fn app_config() -> AppConfig {\n AppConfig {\n github_token: None,\n output: PathBuf::from(\"reports\"),\n cache_dir: PathBuf::from(\".litscout-cache\"),\n session_dir: PathBuf::from(\"sessions\"),\n tags_file: None,\n use_cache: true,\n cache_ttl_hours: 24,\n timeout_secs: 30,\n enrich: false,\n }\n }\n}\n"}, "1d352f50bc41fcab4e021216bca591d876481974": {"t": 1785750668.7556002, "n": 23315, "h": "508035d99f858294", "c": "use axum::{\n extract::{Path, State},\n response::sse::{Event, KeepAlive, Sse},\n routing::{get, post},\n Json, Router,\n};\nuse serde::Serialize;\nuse std::convert::Infallible;\nuse std::time::Duration;\nuse tokio::sync::mpsc;\nuse tokio_stream::{wrappers::ReceiverStream, Stream, StreamExt};\nuse tower_http::cors::{Any, CorsLayer};\nuse tower_http::services::ServeDir;\nuse uuid::Uuid;\n\nuse crate::agent::{orchestrator, planner, report_chat};\nuse crate::checkpoint;\nuse crate::config::{AppConfig, LlmConfig};\nuse crate::error::{AppError, Result};\nuse crate::llm::deepseek::{DeepSeekClient, DeepSeekConfig};\nuse crate::model::{SearchAspect, SearchPlan, SearchQuery};\nuse crate::workflow;\n\nuse super::dto::{\n BranchRunRequest, ChatStreamEvent, CheckpointListResponse, CitationAuditResponse,\n CoverageResponse, CreateStatefulRunRequest, EvidenceResponse, FrontendConfig, PlanRequest,\n PlanResponse, PlanReviseRequest, ReportChatRequest, ReportChatResponse, ReportTranslateRequest,\n ReportTranslateResponse, ReviseStatefulPlanRequest, RunRequest, RunResponse, RunStreamEvent,\n StatefulFollowupRequest, StatefulFollowupResponse, StatefulRunResponse, StatefulRunStreamEvent,\n};\nuse super::state::AppState;\n\npub fn build_router(state: AppState) -> Router {\n let cors = CorsLayer::new()\n .allow_origin(Any)\n .allow_methods(Any)\n .allow_headers(Any);\n\n Router::new()\n .route(\"/api/health\", get(health))\n .route(\"/api/plan\", post(create_plan))\n .route(\"/api/plan/revise\", post(revise_plan))\n .route(\"/api/run\", post(run_research))\n .route(\"/api/run/stream\", post(run_research_stream))\n .route(\"/api/runs\", post(create_stateful_run))\n .route(\"/api/runs/{run_id}\", get(get_stateful_run))\n .route(\"/api/runs/{run_id}/events\", get(stream_stateful_run_events))\n .route(\n \"/api/runs/{run_id}/approve-plan\",\n post(continue_stateful_run),\n )\n .route(\"/api/runs/{run_id}/continue\", post(continue_stateful_run))\n .route(\"/api/runs/{run_id}/revise-plan\", post(revise_stateful_plan))\n .route(\"/api/runs/{run_id}/evidence\", get(get_stateful_evidence))\n .route(\"/api/runs/{run_id}/coverage\", get(get_stateful_coverage))\n .route(\n \"/api/runs/{run_id}/citation-audit\",\n get(get_stateful_citation_audit),\n )\n .route(\n \"/api/runs/{run_id}/checkpoints\",\n get(list_stateful_checkpoints),\n )\n .route(\n \"/api/runs/{run_id}/branch-from-checkpoint\",\n post(branch_stateful_run),\n )\n .route(\"/api/runs/{run_id}/follow-up\", post(followup_stateful_run))\n .route(\"/api/report/chat\", post(chat_with_report))\n .route(\"/api/report/chat/stream\", post(chat_with_report_stream))\n .route(\"/api/report/translate\", post(translate_report))\n .fallback_service(ServeDir::new(\"web/dist\"))\n .layer(cors)\n .with_state(state)\n}\n\nasync fn health(State(state): State) -> Json {\n Json(HealthResponse {\n status: \"ok\",\n service: \"litscout-rs\",\n llm_enabled: state.llm_config.enabled,\n github_token_configured: state.app_config.github_token.is_some(),\n })\n}\n\nasync fn create_plan(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let plan = planner::generate_chinese_plan(\n &req.topic,\n req.github_limit,\n req.arxiv_limit,\n &effective_llm_config(&state, &req.config),\n )\n .await?;\n let mut response = PlanResponse::from(plan);\n response.language = req.language;\n Ok(Json(response))\n}\n\nasync fn revise_plan(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let mut current_plan = planner::PlanOutput::from(req.current_plan);\n current_plan.plan_id = req.plan_id;\n let revised = planner::revise_chinese_plan(\n ¤t_plan,\n &req.user_feedback,\n &effective_llm_config(&state, &req.config),\n )\n .await?;\n Ok(Json(PlanResponse::from(revised)))\n}\n\nasync fn run_research(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let plan_id = req.plan_id.clone();\n let language = req.language.clone();\n let plan_output = planner::PlanOutput::from(req.current_plan);\n let query = SearchQuery {\n topic: plan_output.original_topic.clone(),\n github_limit: total_github_limit(&plan_output),\n arxiv_limit: total_arxiv_limit(&plan_output),\n };\n let search_plan = to_search_plan(plan_output);\n let app_config = effective_app_config(&state, &req.config);\n let llm_config = effective_llm_config(&state, &req.config);\n let result =\n workflow::run_with_plan_for_report(query, search_plan, app_config, llm_config).await?;\n let report_markdown = tokio::fs::read_to_string(&result.output_path).await?;\n\n Ok(Json(RunResponse {\n session_id: format!(\"{language}-{plan_id}-{}\", Uuid::new_v4()),\n output_report: result.output_path.display().to_string(),\n session_path: result\n .session_path\n .as_ref()\n .map(|path| path.display().to_string()),\n warnings: result.report.quality.warnings.clone(),\n citations: result.report.citations.citations.clone(),\n ranked_items: result.report.ranked_items.clone(),\n quality: result.report.quality.clone(),\n report_markdown,\n }))\n}\n\nasync fn run_research_stream(\n State(state): State,\n Json(req): Json,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(64);\n\n tokio::spawn(async move {\n let stream_result = run_research_for_stream(state, req, tx.clone()).await;\n if let Err(err) = stream_result {\n let _ = tx\n .send(RunStreamEvent::RunFailed {\n error: err.to_string(),\n })\n .await;\n }\n });\n\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match &event {\n RunStreamEvent::Workflow(workflow_event) => match workflow_event {\n workflow::WorkflowEvent::FetchStarted { .. } => \"fetch_started\",\n workflow::WorkflowEvent::SourceFinished { .. } => \"source_finished\",\n workflow::WorkflowEvent::RankingFinished { .. } => \"ranking_finished\",\n workflow::WorkflowEvent::ClassificationFinished { .. } => \"classification_finished\",\n workflow::WorkflowEvent::SynthesisStarted => \"synthesis_started\",\n workflow::WorkflowEvent::QualityWarning { .. } => \"quality_warning\",\n workflow::WorkflowEvent::ReportReady { .. } => \"workflow_report_ready\",\n },\n RunStreamEvent::ReportReady(_) => \"report_ready\",\n RunStreamEvent::RunFailed { .. } => \"run_failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"run_failed\").data(err.to_string())))\n });\n\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn run_research_for_stream(\n state: AppState,\n req: RunRequest,\n tx: mpsc::Sender,\n) -> Result<()> {\n let plan_id = req.plan_id.clone();\n let language = req.language.clone();\n let plan_output = planner::PlanOutput::from(req.current_plan);\n let query = SearchQuery {\n topic: plan_output.original_topic.clone(),\n github_limit: total_github_limit(&plan_output),\n arxiv_limit: total_arxiv_limit(&plan_output),\n };\n let search_plan = to_search_plan(plan_output);\n let app_config = effective_app_config(&state, &req.config);\n let llm_config = effective_llm_config(&state, &req.config);\n let result =\n workflow::run_with_plan_events(query, search_plan, app_config, llm_config, |event| {\n let tx = tx.clone();\n tokio::spawn(async move {\n let _ = tx.send(RunStreamEvent::Workflow(event)).await;\n });\n })\n .await?;\n let report_markdown = tokio::fs::read_to_string(&result.output_path).await?;\n let response = RunResponse {\n session_id: format!(\"{language}-{plan_id}-{}\", Uuid::new_v4()),\n output_report: result.output_path.display().to_string(),\n session_path: result\n .session_path\n .as_ref()\n .map(|path| path.display().to_string()),\n report_markdown,\n warnings: result.report.quality.warnings.clone(),\n citations: result.report.citations.citations.clone(),\n ranked_items: result.report.ranked_items.clone(),\n quality: result.report.quality.clone(),\n };\n tx.send(RunStreamEvent::ReportReady(response))\n .await\n .map_err(|_| AppError::Workflow(\"failed to send report_ready event\".to_string()))?;\n Ok(())\n}\n\nasync fn create_stateful_run(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let app_config = effective_app_config(&state, &req.config);\n let llm_config = effective_llm_config(&state, &req.config);\n let run = orchestrator::create_run(req.topic, app_config, llm_config, req.policy).await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn get_stateful_run(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let run = orchestrator::load_run(&state.app_config, &run_id).await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn stream_stateful_run_events(\n State(state): State,\n Path(run_id): Path,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(16);\n tokio::spawn(async move {\n match orchestrator::load_run(&state.app_config, &run_id).await {\n Ok(run) => {\n let response = StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n };\n let _ = tx\n .send(StatefulRunStreamEvent::RunReady(Box::new(response)))\n .await;\n }\n Err(err) => {\n let _ = tx\n .send(StatefulRunStreamEvent::RunFailed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match &event {\n StatefulRunStreamEvent::Agent(_) => \"agent\",\n StatefulRunStreamEvent::RunReady(_) => \"run_ready\",\n StatefulRunStreamEvent::RunFailed { .. } => \"run_failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"run_failed\").data(err.to_string())))\n });\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn continue_stateful_run(\n State(state): State,\n Path(run_id): Path,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(64);\n tokio::spawn(async move {\n let (event_tx, mut event_rx) = mpsc::channel(64);\n let forward_tx = tx.clone();\n tokio::spawn(async move {\n while let Some(event) = event_rx.recv().await {\n if forward_tx\n .send(StatefulRunStreamEvent::Agent(event))\n .await\n .is_err()\n {\n break;\n }\n }\n });\n match orchestrator::continue_run(\n &run_id,\n (*state.app_config).clone(),\n (*state.llm_config).clone(),\n Some(event_tx),\n )\n .await\n {\n Ok(run) => {\n let response = StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n };\n let _ = tx\n .send(StatefulRunStreamEvent::RunReady(Box::new(response)))\n .await;\n }\n Err(err) => {\n let _ = tx\n .send(StatefulRunStreamEvent::RunFailed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match &event {\n StatefulRunStreamEvent::Agent(_) => \"agent\",\n StatefulRunStreamEvent::RunReady(_) => \"run_ready\",\n StatefulRunStreamEvent::RunFailed { .. } => \"run_failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"run_failed\").data(err.to_string())))\n });\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn revise_stateful_plan(\n State(state): State,\n Path(run_id): Path,\n Json(req): Json,\n) -> Result> {\n let run = orchestrator::revise_plan(\n &state.app_config,\n &run_id,\n req.chapters,\n req.query_portfolio,\n req.user_feedback,\n )\n .await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn get_stateful_evidence(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let evidence_memory = orchestrator::evidence_for_run(&state.app_config, &run_id).await?;\n Ok(Json(EvidenceResponse {\n run_id,\n evidence_memory,\n }))\n}\n\nasync fn get_stateful_coverage(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let coverage_report = orchestrator::coverage_for_run(&state.app_config, &run_id).await?;\n Ok(Json(CoverageResponse {\n run_id,\n coverage_report,\n }))\n}\n\nasync fn get_stateful_citation_audit(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let run = orchestrator::load_run(&state.app_config, &run_id).await?;\n let citation_audit = run\n .citation_audit\n .ok_or_else(|| AppError::Workflow(\"run has no CitationAuditReport yet\".to_string()))?;\n Ok(Json(CitationAuditResponse {\n run_id,\n citation_audit,\n }))\n}\n\nasync fn list_stateful_checkpoints(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let mut checkpoints = Vec::new();\n let checkpoint_dir = state\n .app_config\n .session_dir\n .join(&run_id)\n .join(\"checkpoints\");\n if let Ok(mut entries) = tokio::fs::read_dir(checkpoint_dir).await {\n while let Some(entry) = entries.next_entry().await? {\n let body = tokio::fs::read_to_string(entry.path()).await?;\n let snapshot: checkpoint::CheckpointSnapshot = serde_json::from_str(&body)?;\n checkpoints.push(snapshot.checkpoint);\n }\n }\n checkpoints.sort_by_key(|checkpoint| checkpoint.created_at);\n Ok(Json(CheckpointListResponse {\n run_id,\n checkpoints,\n }))\n}\n\nasync fn branch_stateful_run(\n State(state): State,\n Path(run_id): Path,\n Json(req): Json,\n) -> Result> {\n let run = orchestrator::branch_from_plan_ready(\n (*state.app_config).clone(),\n &run_id,\n &req.checkpoint_id,\n )\n .await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn followup_stateful_run(\n State(state): State,\n Path(run_id): Path,\n Json(req): Json,\n) -> Result> {\n let run = orchestrator::load_run(&state.app_config, &run_id).await?;\n let route = orchestrator::route_followup_for_run(\n &req.question,\n run.evidence_memory.as_ref(),\n run.report_markdown.as_deref(),\n );\n Ok(Json(StatefulFollowupResponse { run_id, route }))\n}\n\nasync fn chat_with_report(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let answer = report_chat::answer_report_question(\n &req.report_markdown,\n &req.question,\n &effective_llm_config(&state, &req.config),\n )\n .await?;\n Ok(Json(ReportChatResponse { answer }))\n}\n\nasync fn chat_with_report_stream(\n State(state): State,\n Json(req): Json,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(32);\n\n tokio::spawn(async move {\n let result = report_chat::answer_report_question_streaming(\n &req.report_markdown,\n &req.question,\n &effective_llm_config(&state, &req.config),\n )\n .await;\n\n match result {\n Ok(answer) => {\n for chunk in markdown_chunks(&answer) {\n if tx\n .send(ChatStreamEvent::Delta { text: chunk })\n .await\n .is_err()\n {\n return;\n }\n tokio::time::sleep(Duration::from_millis(45)).await;\n }\n let _ = tx.send(ChatStreamEvent::Done).await;\n }\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match event {\n ChatStreamEvent::Delta { .. } => \"delta\",\n ChatStreamEvent::Done => \"done\",\n ChatStreamEvent::Failed { .. } => \"failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"failed\").data(err.to_string())))\n });\n\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn translate_report(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let llm_config = effective_llm_config(&state, &req.config);\n let config = DeepSeekConfig::from_llm_config(&llm_config).ok_or_else(|| {\n AppError::InvalidConfig(\n \"翻译报告需要 DeepSeek API Key,请在阶段 1 配置或设置 DEEPSEEK_API_KEY。\".to_string(),\n )\n })?;\n let client = DeepSeekClient::new(config)?;\n let translated = client\n .translate_report_to_chinese(&req.report_markdown)\n .await?;\n Ok(Json(ReportTranslateResponse {\n translated_markdown: translated,\n }))\n}\n\nfn markdown_chunks(answer: &str) -> Vec {\n let mut chunks = answer\n .split_inclusive('\\n')\n .map(str::to_string)\n .filter(|chunk| !chunk.is_empty())\n .collect::>();\n if chunks.is_empty() && !answer.is_empty() {\n chunks.push(answer.to_string());\n }\n chunks\n}\n\nfn effective_app_config(state: &AppState, frontend: &FrontendConfig) -> AppConfig {\n let mut app_config = (*state.app_config).clone();\n if let Some(token) = non_empty(frontend.github_token.as_deref()) {\n app_config.github_token = Some(token.to_string());\n }\n app_config\n}\n\nfn effective_llm_config(state: &AppState, frontend: &FrontendConfig) -> LlmConfig {\n let mut llm_config = (*state.llm_config).clone();\n if let Some(api_key) = non_empty(frontend.deepseek_api_key.as_deref()) {\n llm_config.enabled = true;\n llm_config.api_key = Some(api_key.to_string());\n }\n if let Some(base_url) = non_empty(frontend.deepseek_base_url.as_deref()) {\n llm_config.base_url = Some(base_url.to_string());\n }\n if let Some(model) = non_empty(frontend.deepseek_model.as_deref()) {\n llm_config.main_model = model.to_string();\n }\n if let Some(side_model) = non_empty(frontend.deepseek_side_model.as_deref()) {\n llm_config.side_model = Some(side_model.to_string());\n }\n llm_config\n}\n\nfn non_empty(value: Option<&str>) -> Option<&str> {\n value.map(str::trim).filter(|value| !value.is_empty())\n}\n\nfn to_search_plan(plan: planner::PlanOutput) -> SearchPlan {\n SearchPlan {\n original_topic: plan.original_topic,\n llm_generated: plan.llm_generated,\n aspects: plan\n .aspects\n .into_iter()\n .map(|aspect| SearchAspect {\n name: aspect.name_zh,\n github_query: aspect.github_query,\n arxiv_query: aspect.arxiv_query,\n github_limit: aspect.github_limit,\n arxiv_limit: aspect.arxiv_limit,\n rationale: Some(aspect.rationale_zh),\n })\n .collect(),\n }\n}\n\nfn total_github_limit(plan: &planner::PlanOutput) -> usize {\n plan.aspects\n .iter()\n .map(|aspect| aspect.github_limit)\n .sum::()\n .max(1)\n}\n\nfn total_arxiv_limit(plan: &planner::PlanOutput) -> usize {\n plan.aspects\n .iter()\n .map(|aspect| aspect.arxiv_limit)\n .sum::()\n .max(1)\n}\n\n#[derive(Debug, Serialize)]\nstruct HealthResponse {\n status: &'static str,\n service: &'static str,\n llm_enabled: bool,\n github_token_configured: bool,\n}\n\n#[cfg(test)]\nmod tests {\n use std::path::PathBuf;\n\n use super::build_router;\n use crate::config::{AppConfig, LlmConfig};\n use crate::server::state::AppState;\n\n #[test]\n fn builds_router_for_health_endpoint() {\n let state = AppState::new(app_config(), LlmConfig::from_env(false, 30));\n\n let _router = build_router(state);\n }\n\n fn app_config() -> AppConfig {\n AppConfig {\n github_token: None,\n output: PathBuf::from(\"reports\"),\n cache_dir: PathBuf::from(\".litscout-cache\"),\n session_dir: PathBuf::from(\"sessions\"),\n tags_file: None,\n use_cache: true,\n cache_ttl_hours: 24,\n timeout_secs: 30,\n enrich: false,\n }\n }\n}\n"}, "7c0c9f55db1e84d4039b524e6a5c6712e65c973e": {"t": 1785750670.0229423, "n": 23211, "h": "a57ee88fad2392c9", "c": "use axum::{\n extract::{Path, State},\n response::sse::{Event, KeepAlive, Sse},\n routing::{get, post},\n Json, Router,\n};\nuse serde::Serialize;\nuse std::convert::Infallible;\nuse std::time::Duration;\nuse tokio::sync::mpsc;\nuse tokio_stream::{wrappers::ReceiverStream, Stream, StreamExt};\nuse tower_http::cors::{Any, CorsLayer};\nuse tower_http::services::ServeDir;\nuse uuid::Uuid;\n\nuse crate::agent::{orchestrator, planner, report_chat};\nuse crate::checkpoint;\nuse crate::config::{AppConfig, LlmConfig};\nuse crate::error::{AppError, Result};\nuse crate::llm::deepseek::{DeepSeekClient, DeepSeekConfig};\nuse crate::model::{SearchAspect, SearchPlan, SearchQuery};\nuse crate::workflow;\n\nuse super::dto::{\n BranchRunRequest, ChatStreamEvent, CheckpointListResponse, CitationAuditResponse,\n CoverageResponse, CreateStatefulRunRequest, EvidenceResponse, FrontendConfig, PlanRequest,\n PlanResponse, PlanReviseRequest, ReportChatRequest, ReportChatResponse, ReportTranslateRequest,\n ReportTranslateResponse, ReviseStatefulPlanRequest, RunRequest, RunResponse, RunStreamEvent,\n StatefulFollowupRequest, StatefulFollowupResponse, StatefulRunResponse, StatefulRunStreamEvent,\n};\nuse super::state::AppState;\n\npub fn build_router(state: AppState) -> Router {\n let cors = CorsLayer::new()\n .allow_origin(Any)\n .allow_methods(Any)\n .allow_headers(Any);\n\n Router::new()\n .route(\"/api/health\", get(health))\n .route(\"/api/plan\", post(create_plan))\n .route(\"/api/plan/revise\", post(revise_plan))\n .route(\"/api/run\", post(run_research))\n .route(\"/api/run/stream\", post(run_research_stream))\n .route(\"/api/runs\", post(create_stateful_run))\n .route(\"/api/runs/{run_id}\", get(get_stateful_run))\n .route(\"/api/runs/{run_id}/events\", get(stream_stateful_run_events))\n .route(\n \"/api/runs/{run_id}/approve-plan\",\n post(continue_stateful_run),\n )\n .route(\"/api/runs/{run_id}/continue\", post(continue_stateful_run))\n .route(\"/api/runs/{run_id}/revise-plan\", post(revise_stateful_plan))\n .route(\"/api/runs/{run_id}/evidence\", get(get_stateful_evidence))\n .route(\"/api/runs/{run_id}/coverage\", get(get_stateful_coverage))\n .route(\n \"/api/runs/{run_id}/citation-audit\",\n get(get_stateful_citation_audit),\n )\n .route(\n \"/api/runs/{run_id}/checkpoints\",\n get(list_stateful_checkpoints),\n )\n .route(\n \"/api/runs/{run_id}/branch-from-checkpoint\",\n post(branch_stateful_run),\n )\n .route(\"/api/runs/{run_id}/follow-up\", post(followup_stateful_run))\n .route(\"/api/report/chat\", post(chat_with_report))\n .route(\"/api/report/chat/stream\", post(chat_with_report_stream))\n .route(\"/api/report/translate\", post(translate_report))\n .fallback_service(ServeDir::new(\"web/dist\"))\n .layer(cors)\n .with_state(state)\n}\n\nasync fn health(State(state): State) -> Json {\n Json(HealthResponse {\n status: \"ok\",\n service: \"litscout-rs\",\n llm_enabled: state.llm_config.enabled,\n github_token_configured: state.app_config.github_token.is_some(),\n })\n}\n\nasync fn create_plan(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let plan = planner::generate_chinese_plan(\n &req.topic,\n req.github_limit,\n req.arxiv_limit,\n &effective_llm_config(&state, &req.config),\n )\n .await?;\n let mut response = PlanResponse::from(plan);\n response.language = req.language;\n Ok(Json(response))\n}\n\nasync fn revise_plan(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let mut current_plan = planner::PlanOutput::from(req.current_plan);\n current_plan.plan_id = req.plan_id;\n let revised = planner::revise_chinese_plan(\n ¤t_plan,\n &req.user_feedback,\n &effective_llm_config(&state, &req.config),\n )\n .await?;\n Ok(Json(PlanResponse::from(revised)))\n}\n\nasync fn run_research(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let plan_id = req.plan_id.clone();\n let language = req.language.clone();\n let plan_output = planner::PlanOutput::from(req.current_plan);\n let query = SearchQuery {\n topic: plan_output.original_topic.clone(),\n github_limit: total_github_limit(&plan_output),\n arxiv_limit: total_arxiv_limit(&plan_output),\n };\n let search_plan = to_search_plan(plan_output);\n let app_config = effective_app_config(&state, &req.config);\n let llm_config = effective_llm_config(&state, &req.config);\n let result =\n workflow::run_with_plan_for_report(query, search_plan, app_config, llm_config).await?;\n let report_markdown = tokio::fs::read_to_string(&result.output_path).await?;\n\n Ok(Json(RunResponse {\n session_id: format!(\"{language}-{plan_id}-{}\", Uuid::new_v4()),\n output_report: result.output_path.display().to_string(),\n session_path: result\n .session_path\n .as_ref()\n .map(|path| path.display().to_string()),\n warnings: result.report.quality.warnings.clone(),\n citations: result.report.citations.citations.clone(),\n ranked_items: result.report.ranked_items.clone(),\n quality: result.report.quality.clone(),\n report_markdown,\n }))\n}\n\nasync fn run_research_stream(\n State(state): State,\n Json(req): Json,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(64);\n\n tokio::spawn(async move {\n let stream_result = run_research_for_stream(state, req, tx.clone()).await;\n if let Err(err) = stream_result {\n let _ = tx\n .send(RunStreamEvent::RunFailed {\n error: err.to_string(),\n })\n .await;\n }\n });\n\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match &event {\n RunStreamEvent::Workflow(workflow_event) => match workflow_event {\n workflow::WorkflowEvent::FetchStarted { .. } => \"fetch_started\",\n workflow::WorkflowEvent::SourceFinished { .. } => \"source_finished\",\n workflow::WorkflowEvent::RankingFinished { .. } => \"ranking_finished\",\n workflow::WorkflowEvent::ClassificationFinished { .. } => \"classification_finished\",\n workflow::WorkflowEvent::SynthesisStarted => \"synthesis_started\",\n workflow::WorkflowEvent::QualityWarning { .. } => \"quality_warning\",\n workflow::WorkflowEvent::ReportReady { .. } => \"workflow_report_ready\",\n },\n RunStreamEvent::ReportReady(_) => \"report_ready\",\n RunStreamEvent::RunFailed { .. } => \"run_failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"run_failed\").data(err.to_string())))\n });\n\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn run_research_for_stream(\n state: AppState,\n req: RunRequest,\n tx: mpsc::Sender,\n) -> Result<()> {\n let plan_id = req.plan_id.clone();\n let language = req.language.clone();\n let plan_output = planner::PlanOutput::from(req.current_plan);\n let query = SearchQuery {\n topic: plan_output.original_topic.clone(),\n github_limit: total_github_limit(&plan_output),\n arxiv_limit: total_arxiv_limit(&plan_output),\n };\n let search_plan = to_search_plan(plan_output);\n let app_config = effective_app_config(&state, &req.config);\n let llm_config = effective_llm_config(&state, &req.config);\n let result =\n workflow::run_with_plan_events(query, search_plan, app_config, llm_config, |event| {\n let tx = tx.clone();\n tokio::spawn(async move {\n let _ = tx.send(RunStreamEvent::Workflow(event)).await;\n });\n })\n .await?;\n let report_markdown = tokio::fs::read_to_string(&result.output_path).await?;\n let response = RunResponse {\n session_id: format!(\"{language}-{plan_id}-{}\", Uuid::new_v4()),\n output_report: result.output_path.display().to_string(),\n session_path: result\n .session_path\n .as_ref()\n .map(|path| path.display().to_string()),\n report_markdown,\n warnings: result.report.quality.warnings.clone(),\n citations: result.report.citations.citations.clone(),\n ranked_items: result.report.ranked_items.clone(),\n quality: result.report.quality.clone(),\n };\n tx.send(RunStreamEvent::ReportReady(response))\n .await\n .map_err(|_| AppError::Workflow(\"failed to send report_ready event\".to_string()))?;\n Ok(())\n}\n\nasync fn create_stateful_run(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let app_config = effective_app_config(&state, &req.config);\n let llm_config = effective_llm_config(&state, &req.config);\n let run = orchestrator::create_run(req.topic, app_config, llm_config, req.policy).await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn get_stateful_run(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let run = orchestrator::load_run(&state.app_config, &run_id).await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn stream_stateful_run_events(\n State(state): State,\n Path(run_id): Path,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(16);\n tokio::spawn(async move {\n match orchestrator::load_run(&state.app_config, &run_id).await {\n Ok(run) => {\n let response = StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n };\n let _ = tx.send(StatefulRunStreamEvent::RunReady(response)).await;\n }\n Err(err) => {\n let _ = tx\n .send(StatefulRunStreamEvent::RunFailed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match &event {\n StatefulRunStreamEvent::Agent(_) => \"agent\",\n StatefulRunStreamEvent::RunReady(_) => \"run_ready\",\n StatefulRunStreamEvent::RunFailed { .. } => \"run_failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"run_failed\").data(err.to_string())))\n });\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn continue_stateful_run(\n State(state): State,\n Path(run_id): Path,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(64);\n tokio::spawn(async move {\n let (event_tx, mut event_rx) = mpsc::channel(64);\n let forward_tx = tx.clone();\n tokio::spawn(async move {\n while let Some(event) = event_rx.recv().await {\n if forward_tx\n .send(StatefulRunStreamEvent::Agent(event))\n .await\n .is_err()\n {\n break;\n }\n }\n });\n match orchestrator::continue_run(\n &run_id,\n (*state.app_config).clone(),\n (*state.llm_config).clone(),\n Some(event_tx),\n )\n .await\n {\n Ok(run) => {\n let response = StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n };\n let _ = tx.send(StatefulRunStreamEvent::RunReady(response)).await;\n }\n Err(err) => {\n let _ = tx\n .send(StatefulRunStreamEvent::RunFailed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match &event {\n StatefulRunStreamEvent::Agent(_) => \"agent\",\n StatefulRunStreamEvent::RunReady(_) => \"run_ready\",\n StatefulRunStreamEvent::RunFailed { .. } => \"run_failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"run_failed\").data(err.to_string())))\n });\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn revise_stateful_plan(\n State(state): State,\n Path(run_id): Path,\n Json(req): Json,\n) -> Result> {\n let run = orchestrator::revise_plan(\n &state.app_config,\n &run_id,\n req.chapters,\n req.query_portfolio,\n req.user_feedback,\n )\n .await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn get_stateful_evidence(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let evidence_memory = orchestrator::evidence_for_run(&state.app_config, &run_id).await?;\n Ok(Json(EvidenceResponse {\n run_id,\n evidence_memory,\n }))\n}\n\nasync fn get_stateful_coverage(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let coverage_report = orchestrator::coverage_for_run(&state.app_config, &run_id).await?;\n Ok(Json(CoverageResponse {\n run_id,\n coverage_report,\n }))\n}\n\nasync fn get_stateful_citation_audit(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let run = orchestrator::load_run(&state.app_config, &run_id).await?;\n let citation_audit = run\n .citation_audit\n .ok_or_else(|| AppError::Workflow(\"run has no CitationAuditReport yet\".to_string()))?;\n Ok(Json(CitationAuditResponse {\n run_id,\n citation_audit,\n }))\n}\n\nasync fn list_stateful_checkpoints(\n State(state): State,\n Path(run_id): Path,\n) -> Result> {\n let mut checkpoints = Vec::new();\n let checkpoint_dir = state\n .app_config\n .session_dir\n .join(&run_id)\n .join(\"checkpoints\");\n if let Ok(mut entries) = tokio::fs::read_dir(checkpoint_dir).await {\n while let Some(entry) = entries.next_entry().await? {\n let body = tokio::fs::read_to_string(entry.path()).await?;\n let snapshot: checkpoint::CheckpointSnapshot = serde_json::from_str(&body)?;\n checkpoints.push(snapshot.checkpoint);\n }\n }\n checkpoints.sort_by(|a, b| a.created_at.cmp(&b.created_at));\n Ok(Json(CheckpointListResponse {\n run_id,\n checkpoints,\n }))\n}\n\nasync fn branch_stateful_run(\n State(state): State,\n Path(run_id): Path,\n Json(req): Json,\n) -> Result> {\n let run = orchestrator::branch_from_plan_ready(\n (*state.app_config).clone(),\n &run_id,\n &req.checkpoint_id,\n )\n .await?;\n Ok(Json(StatefulRunResponse {\n run_id: run.run_id.clone(),\n topic: run.topic.clone(),\n state: run.state.clone(),\n run,\n }))\n}\n\nasync fn followup_stateful_run(\n State(state): State,\n Path(run_id): Path,\n Json(req): Json,\n) -> Result> {\n let run = orchestrator::load_run(&state.app_config, &run_id).await?;\n let route = orchestrator::route_followup_for_run(\n &req.question,\n run.evidence_memory.as_ref(),\n run.report_markdown.as_deref(),\n );\n Ok(Json(StatefulFollowupResponse { run_id, route }))\n}\n\nasync fn chat_with_report(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let answer = report_chat::answer_report_question(\n &req.report_markdown,\n &req.question,\n &effective_llm_config(&state, &req.config),\n )\n .await?;\n Ok(Json(ReportChatResponse { answer }))\n}\n\nasync fn chat_with_report_stream(\n State(state): State,\n Json(req): Json,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(32);\n\n tokio::spawn(async move {\n let result = report_chat::answer_report_question_streaming(\n &req.report_markdown,\n &req.question,\n &effective_llm_config(&state, &req.config),\n )\n .await;\n\n match result {\n Ok(answer) => {\n for chunk in markdown_chunks(&answer) {\n if tx\n .send(ChatStreamEvent::Delta { text: chunk })\n .await\n .is_err()\n {\n return;\n }\n tokio::time::sleep(Duration::from_millis(45)).await;\n }\n let _ = tx.send(ChatStreamEvent::Done).await;\n }\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match event {\n ChatStreamEvent::Delta { .. } => \"delta\",\n ChatStreamEvent::Done => \"done\",\n ChatStreamEvent::Failed { .. } => \"failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"failed\").data(err.to_string())))\n });\n\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn translate_report(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let llm_config = effective_llm_config(&state, &req.config);\n let config = DeepSeekConfig::from_llm_config(&llm_config).ok_or_else(|| {\n AppError::InvalidConfig(\n \"翻译报告需要 DeepSeek API Key,请在阶段 1 配置或设置 DEEPSEEK_API_KEY。\".to_string(),\n )\n })?;\n let client = DeepSeekClient::new(config)?;\n let translated = client\n .translate_report_to_chinese(&req.report_markdown)\n .await?;\n Ok(Json(ReportTranslateResponse {\n translated_markdown: translated,\n }))\n}\n\nfn markdown_chunks(answer: &str) -> Vec {\n let mut chunks = answer\n .split_inclusive('\\n')\n .map(str::to_string)\n .filter(|chunk| !chunk.is_empty())\n .collect::>();\n if chunks.is_empty() && !answer.is_empty() {\n chunks.push(answer.to_string());\n }\n chunks\n}\n\nfn effective_app_config(state: &AppState, frontend: &FrontendConfig) -> AppConfig {\n let mut app_config = (*state.app_config).clone();\n if let Some(token) = non_empty(frontend.github_token.as_deref()) {\n app_config.github_token = Some(token.to_string());\n }\n app_config\n}\n\nfn effective_llm_config(state: &AppState, frontend: &FrontendConfig) -> LlmConfig {\n let mut llm_config = (*state.llm_config).clone();\n if let Some(api_key) = non_empty(frontend.deepseek_api_key.as_deref()) {\n llm_config.enabled = true;\n llm_config.api_key = Some(api_key.to_string());\n }\n if let Some(base_url) = non_empty(frontend.deepseek_base_url.as_deref()) {\n llm_config.base_url = Some(base_url.to_string());\n }\n if let Some(model) = non_empty(frontend.deepseek_model.as_deref()) {\n llm_config.main_model = model.to_string();\n }\n if let Some(side_model) = non_empty(frontend.deepseek_side_model.as_deref()) {\n llm_config.side_model = Some(side_model.to_string());\n }\n llm_config\n}\n\nfn non_empty(value: Option<&str>) -> Option<&str> {\n value.map(str::trim).filter(|value| !value.is_empty())\n}\n\nfn to_search_plan(plan: planner::PlanOutput) -> SearchPlan {\n SearchPlan {\n original_topic: plan.original_topic,\n llm_generated: plan.llm_generated,\n aspects: plan\n .aspects\n .into_iter()\n .map(|aspect| SearchAspect {\n name: aspect.name_zh,\n github_query: aspect.github_query,\n arxiv_query: aspect.arxiv_query,\n github_limit: aspect.github_limit,\n arxiv_limit: aspect.arxiv_limit,\n rationale: Some(aspect.rationale_zh),\n })\n .collect(),\n }\n}\n\nfn total_github_limit(plan: &planner::PlanOutput) -> usize {\n plan.aspects\n .iter()\n .map(|aspect| aspect.github_limit)\n .sum::()\n .max(1)\n}\n\nfn total_arxiv_limit(plan: &planner::PlanOutput) -> usize {\n plan.aspects\n .iter()\n .map(|aspect| aspect.arxiv_limit)\n .sum::()\n .max(1)\n}\n\n#[derive(Debug, Serialize)]\nstruct HealthResponse {\n status: &'static str,\n service: &'static str,\n llm_enabled: bool,\n github_token_configured: bool,\n}\n\n#[cfg(test)]\nmod tests {\n use std::path::PathBuf;\n\n use super::build_router;\n use crate::config::{AppConfig, LlmConfig};\n use crate::server::state::AppState;\n\n #[test]\n fn builds_router_for_health_endpoint() {\n let state = AppState::new(app_config(), LlmConfig::from_env(false, 30));\n\n let _router = build_router(state);\n }\n\n fn app_config() -> AppConfig {\n AppConfig {\n github_token: None,\n output: PathBuf::from(\"reports\"),\n cache_dir: PathBuf::from(\".litscout-cache\"),\n session_dir: PathBuf::from(\"sessions\"),\n tags_file: None,\n use_cache: true,\n cache_ttl_hours: 24,\n timeout_secs: 30,\n enrich: false,\n }\n }\n}\n"}, "bc832b32501006b47bc177689b760973d026efe2": {"t": 1785750672.1664362, "n": 13942, "h": "293a00c4017b7a8b", "c": "use axum::{\n extract::State,\n response::sse::{Event, KeepAlive, Sse},\n routing::{get, post},\n Json, Router,\n};\nuse serde::Serialize;\nuse std::convert::Infallible;\nuse std::time::Duration;\nuse tokio::sync::mpsc;\nuse tokio_stream::{wrappers::ReceiverStream, Stream, StreamExt};\nuse tower_http::cors::{Any, CorsLayer};\nuse tower_http::services::ServeDir;\nuse uuid::Uuid;\n\nuse crate::agent::{planner, report_chat};\nuse crate::config::{AppConfig, LlmConfig};\nuse crate::error::{AppError, Result};\nuse crate::llm::deepseek::{DeepSeekClient, DeepSeekConfig};\nuse crate::model::{SearchAspect, SearchPlan, SearchQuery};\nuse crate::workflow;\n\nuse super::dto::{\n ChatStreamEvent, FrontendConfig, PlanRequest, PlanResponse, PlanReviseRequest,\n ReportChatRequest, ReportChatResponse, ReportTranslateRequest, ReportTranslateResponse,\n RunRequest, RunResponse, RunStreamEvent,\n};\nuse super::state::AppState;\n\npub fn build_router(state: AppState) -> Router {\n let cors = CorsLayer::new()\n .allow_origin(Any)\n .allow_methods(Any)\n .allow_headers(Any);\n\n Router::new()\n .route(\"/api/health\", get(health))\n .route(\"/api/plan\", post(create_plan))\n .route(\"/api/plan/revise\", post(revise_plan))\n .route(\"/api/run\", post(run_research))\n .route(\"/api/run/stream\", post(run_research_stream))\n .route(\"/api/report/chat\", post(chat_with_report))\n .route(\"/api/report/chat/stream\", post(chat_with_report_stream))\n .route(\"/api/report/translate\", post(translate_report))\n .fallback_service(ServeDir::new(\"web/dist\"))\n .layer(cors)\n .with_state(state)\n}\n\nasync fn health(State(state): State) -> Json {\n Json(HealthResponse {\n status: \"ok\",\n service: \"litscout-rs\",\n llm_enabled: state.llm_config.enabled,\n github_token_configured: state.app_config.github_token.is_some(),\n })\n}\n\nasync fn create_plan(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let plan = planner::generate_chinese_plan(\n &req.topic,\n req.github_limit,\n req.arxiv_limit,\n &effective_llm_config(&state, &req.config),\n )\n .await?;\n let mut response = PlanResponse::from(plan);\n response.language = req.language;\n Ok(Json(response))\n}\n\nasync fn revise_plan(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let mut current_plan = planner::PlanOutput::from(req.current_plan);\n current_plan.plan_id = req.plan_id;\n let revised = planner::revise_chinese_plan(\n ¤t_plan,\n &req.user_feedback,\n &effective_llm_config(&state, &req.config),\n )\n .await?;\n Ok(Json(PlanResponse::from(revised)))\n}\n\nasync fn run_research(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let plan_id = req.plan_id.clone();\n let language = req.language.clone();\n let plan_output = planner::PlanOutput::from(req.current_plan);\n let query = SearchQuery {\n topic: plan_output.original_topic.clone(),\n github_limit: total_github_limit(&plan_output),\n arxiv_limit: total_arxiv_limit(&plan_output),\n };\n let search_plan = to_search_plan(plan_output);\n let app_config = effective_app_config(&state, &req.config);\n let llm_config = effective_llm_config(&state, &req.config);\n let result =\n workflow::run_with_plan_for_report(query, search_plan, app_config, llm_config).await?;\n let report_markdown = tokio::fs::read_to_string(&result.output_path).await?;\n\n Ok(Json(RunResponse {\n session_id: format!(\"{language}-{plan_id}-{}\", Uuid::new_v4()),\n output_report: result.output_path.display().to_string(),\n session_path: result\n .session_path\n .as_ref()\n .map(|path| path.display().to_string()),\n warnings: result.report.quality.warnings.clone(),\n citations: result.report.citations.citations.clone(),\n ranked_items: result.report.ranked_items.clone(),\n quality: result.report.quality.clone(),\n report_markdown,\n }))\n}\n\nasync fn run_research_stream(\n State(state): State,\n Json(req): Json,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(64);\n\n tokio::spawn(async move {\n let stream_result = run_research_for_stream(state, req, tx.clone()).await;\n if let Err(err) = stream_result {\n let _ = tx\n .send(RunStreamEvent::RunFailed {\n error: err.to_string(),\n })\n .await;\n }\n });\n\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match &event {\n RunStreamEvent::Workflow(workflow_event) => match workflow_event {\n workflow::WorkflowEvent::FetchStarted { .. } => \"fetch_started\",\n workflow::WorkflowEvent::SourceFinished { .. } => \"source_finished\",\n workflow::WorkflowEvent::RankingFinished { .. } => \"ranking_finished\",\n workflow::WorkflowEvent::ClassificationFinished { .. } => \"classification_finished\",\n workflow::WorkflowEvent::SynthesisStarted => \"synthesis_started\",\n workflow::WorkflowEvent::QualityWarning { .. } => \"quality_warning\",\n workflow::WorkflowEvent::ReportReady { .. } => \"workflow_report_ready\",\n },\n RunStreamEvent::ReportReady(_) => \"report_ready\",\n RunStreamEvent::RunFailed { .. } => \"run_failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"run_failed\").data(err.to_string())))\n });\n\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn run_research_for_stream(\n state: AppState,\n req: RunRequest,\n tx: mpsc::Sender,\n) -> Result<()> {\n let plan_id = req.plan_id.clone();\n let language = req.language.clone();\n let plan_output = planner::PlanOutput::from(req.current_plan);\n let query = SearchQuery {\n topic: plan_output.original_topic.clone(),\n github_limit: total_github_limit(&plan_output),\n arxiv_limit: total_arxiv_limit(&plan_output),\n };\n let search_plan = to_search_plan(plan_output);\n let app_config = effective_app_config(&state, &req.config);\n let llm_config = effective_llm_config(&state, &req.config);\n let result =\n workflow::run_with_plan_events(query, search_plan, app_config, llm_config, |event| {\n let tx = tx.clone();\n tokio::spawn(async move {\n let _ = tx.send(RunStreamEvent::Workflow(event)).await;\n });\n })\n .await?;\n let report_markdown = tokio::fs::read_to_string(&result.output_path).await?;\n let response = RunResponse {\n session_id: format!(\"{language}-{plan_id}-{}\", Uuid::new_v4()),\n output_report: result.output_path.display().to_string(),\n session_path: result\n .session_path\n .as_ref()\n .map(|path| path.display().to_string()),\n report_markdown,\n warnings: result.report.quality.warnings.clone(),\n citations: result.report.citations.citations.clone(),\n ranked_items: result.report.ranked_items.clone(),\n quality: result.report.quality.clone(),\n };\n tx.send(RunStreamEvent::ReportReady(response))\n .await\n .map_err(|_| AppError::Workflow(\"failed to send report_ready event\".to_string()))?;\n Ok(())\n}\n\nasync fn chat_with_report(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let answer = report_chat::answer_report_question(\n &req.report_markdown,\n &req.question,\n &effective_llm_config(&state, &req.config),\n )\n .await?;\n Ok(Json(ReportChatResponse { answer }))\n}\n\nasync fn chat_with_report_stream(\n State(state): State,\n Json(req): Json,\n) -> Sse>> {\n let (tx, rx) = mpsc::channel::(32);\n\n tokio::spawn(async move {\n let result = report_chat::answer_report_question_streaming(\n &req.report_markdown,\n &req.question,\n &effective_llm_config(&state, &req.config),\n )\n .await;\n\n match result {\n Ok(answer) => {\n for chunk in markdown_chunks(&answer) {\n if tx\n .send(ChatStreamEvent::Delta { text: chunk })\n .await\n .is_err()\n {\n return;\n }\n tokio::time::sleep(Duration::from_millis(45)).await;\n }\n let _ = tx.send(ChatStreamEvent::Done).await;\n }\n Err(err) => {\n let _ = tx\n .send(ChatStreamEvent::Failed {\n error: err.to_string(),\n })\n .await;\n }\n }\n });\n\n let stream = ReceiverStream::new(rx).map(|event| {\n let event_name = match event {\n ChatStreamEvent::Delta { .. } => \"delta\",\n ChatStreamEvent::Done => \"done\",\n ChatStreamEvent::Failed { .. } => \"failed\",\n };\n Ok(Event::default()\n .event(event_name)\n .json_data(event)\n .unwrap_or_else(|err| Event::default().event(\"failed\").data(err.to_string())))\n });\n\n Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))\n}\n\nasync fn translate_report(\n State(state): State,\n Json(req): Json,\n) -> Result> {\n let llm_config = effective_llm_config(&state, &req.config);\n let config = DeepSeekConfig::from_llm_config(&llm_config).ok_or_else(|| {\n AppError::InvalidConfig(\n \"翻译报告需要 DeepSeek API Key,请在阶段 1 配置或设置 DEEPSEEK_API_KEY。\".to_string(),\n )\n })?;\n let client = DeepSeekClient::new(config)?;\n let translated = client\n .translate_report_to_chinese(&req.report_markdown)\n .await?;\n Ok(Json(ReportTranslateResponse {\n translated_markdown: translated,\n }))\n}\n\nfn markdown_chunks(answer: &str) -> Vec {\n let mut chunks = answer\n .split_inclusive('\\n')\n .map(str::to_string)\n .filter(|chunk| !chunk.is_empty())\n .collect::>();\n if chunks.is_empty() && !answer.is_empty() {\n chunks.push(answer.to_string());\n }\n chunks\n}\n\nfn effective_app_config(state: &AppState, frontend: &FrontendConfig) -> AppConfig {\n let mut app_config = (*state.app_config).clone();\n if let Some(token) = non_empty(frontend.github_token.as_deref()) {\n app_config.github_token = Some(token.to_string());\n }\n app_config\n}\n\nfn effective_llm_config(state: &AppState, frontend: &FrontendConfig) -> LlmConfig {\n let mut llm_config = (*state.llm_config).clone();\n if let Some(api_key) = non_empty(frontend.deepseek_api_key.as_deref()) {\n llm_config.enabled = true;\n llm_config.api_key = Some(api_key.to_string());\n }\n if let Some(base_url) = non_empty(frontend.deepseek_base_url.as_deref()) {\n llm_config.base_url = Some(base_url.to_string());\n }\n if let Some(model) = non_empty(frontend.deepseek_model.as_deref()) {\n llm_config.main_model = model.to_string();\n }\n if let Some(side_model) = non_empty(frontend.deepseek_side_model.as_deref()) {\n llm_config.side_model = Some(side_model.to_string());\n }\n llm_config\n}\n\nfn non_empty(value: Option<&str>) -> Option<&str> {\n value.map(str::trim).filter(|value| !value.is_empty())\n}\n\nfn to_search_plan(plan: planner::PlanOutput) -> SearchPlan {\n SearchPlan {\n original_topic: plan.original_topic,\n llm_generated: plan.llm_generated,\n aspects: plan\n .aspects\n .into_iter()\n .map(|aspect| SearchAspect {\n name: aspect.name_zh,\n github_query: aspect.github_query,\n arxiv_query: aspect.arxiv_query,\n github_limit: aspect.github_limit,\n arxiv_limit: aspect.arxiv_limit,\n rationale: Some(aspect.rationale_zh),\n })\n .collect(),\n }\n}\n\nfn total_github_limit(plan: &planner::PlanOutput) -> usize {\n plan.aspects\n .iter()\n .map(|aspect| aspect.github_limit)\n .sum::()\n .max(1)\n}\n\nfn total_arxiv_limit(plan: &planner::PlanOutput) -> usize {\n plan.aspects\n .iter()\n .map(|aspect| aspect.arxiv_limit)\n .sum::()\n .max(1)\n}\n\n#[derive(Debug, Serialize)]\nstruct HealthResponse {\n status: &'static str,\n service: &'static str,\n llm_enabled: bool,\n github_token_configured: bool,\n}\n\n#[cfg(test)]\nmod tests {\n use std::path::PathBuf;\n\n use super::build_router;\n use crate::config::{AppConfig, LlmConfig};\n use crate::server::state::AppState;\n\n #[test]\n fn builds_router_for_health_endpoint() {\n let state = AppState::new(app_config(), LlmConfig::from_env(false, 30));\n\n let _router = build_router(state);\n }\n\n fn app_config() -> AppConfig {\n AppConfig {\n github_token: None,\n output: PathBuf::from(\"reports\"),\n cache_dir: PathBuf::from(\".litscout-cache\"),\n session_dir: PathBuf::from(\"sessions\"),\n tags_file: None,\n use_cache: true,\n cache_ttl_hours: 24,\n timeout_secs: 30,\n enrich: false,\n }\n }\n}\n"}}, "src/agent/writer.rs": {"df730e4a09eb4f1c5830a9f3deacc3ae94c1aa19": {"t": 1785745857.1112325, "n": 50022, "h": "1c25fd4fdd794826", "c": "use crate::config::LlmConfig;\nuse crate::error::{AppError, Result};\nuse crate::llm::deepseek::{\n strip_json_fence, ChatCompletionsRequest, ChatMessage, DeepSeekClient, DeepSeekConfig,\n ResponseFormat,\n};\nuse crate::model::{\n ChapterDraft, ChapterNode, CitationLedger, EvidenceItem, EvidenceMemory,\n ParagraphWithCitations, ReportDraft, SourceKind,\n};\nuse chrono::Utc;\nuse serde::{Deserialize, Serialize};\nuse std::collections::BTreeSet;\nuse tracing::{debug, warn};\n\nconst MAX_EVIDENCE_PER_BATCH: usize = 3;\nconst MAX_BATCH_CONTEXT_CHARS: usize = 3_500;\nconst MAX_BATCH_PARAGRAPHS: usize = 1;\nconst MAX_CHAPTER_PARAGRAPHS: usize = 4;\nconst MAX_WRITER_EVIDENCE_PER_CHAPTER: usize = 8;\nconst WRITER_GITHUB_EVIDENCE_CAP: usize = 4;\nconst WRITER_ARXIV_EVIDENCE_CAP: usize = 4;\nconst WRITER_ACADEMIC_INDEX_EVIDENCE_CAP: usize = 3;\nconst WRITER_BIBLIOGRAPHY_EVIDENCE_CAP: usize = 1;\nconst BATCH_FINDINGS_TARGET_CHARS: usize = 180;\nconst BATCH_PARAGRAPH_TARGET_CHARS: usize = 220;\nconst CHAPTER_PARAGRAPH_TARGET_CHARS: usize = 260;\nconst GLOBAL_SUMMARY_TARGET_CHARS: usize = 420;\nconst WRITER_TITLE_MAX_CHARS: usize = 180;\nconst WRITER_EVIDENCE_NOTE_MAX_CHARS: usize = 220;\nconst WRITER_GITHUB_SNIPPET_MAX_CHARS: usize = 900;\nconst WRITER_ACADEMIC_SNIPPET_MAX_CHARS: usize = 700;\nconst WRITER_BIBLIOGRAPHY_SNIPPET_MAX_CHARS: usize = 200;\nconst REPAIR_PREVIOUS_OUTPUT_MAX_CHARS: usize = 2_400;\nconst WRITER_MIN_OUTPUT_TOKENS: usize = 4096;\nconst WRITER_MAX_OUTPUT_TOKENS: usize = 8192;\n\npub async fn draft_report_with_llm(\n topic: &str,\n chapters: &[ChapterNode],\n memory: &EvidenceMemory,\n llm_config: &LlmConfig,\n) -> Result {\n let deepseek_config = DeepSeekConfig::from_llm_config(llm_config).ok_or_else(|| {\n AppError::InvalidConfig(\n \"Writer LLM requires an enabled DeepSeek config with API key; refusing to generate a template report.\"\n .to_string(),\n )\n })?;\n let client = DeepSeekClient::new(deepseek_config.clone())?;\n let mut chapter_drafts = Vec::with_capacity(chapters.len());\n\n for chapter in chapters {\n let evidence_items = memory.by_chapter(&chapter.id);\n if evidence_items.is_empty() {\n chapter_drafts.push(empty_chapter_draft(chapter));\n continue;\n }\n\n let draft = draft_chapter_with_llm(chapter, &evidence_items, &client, &deepseek_config)\n .await\n .map_err(|err| {\n AppError::Llm(format!(\n \"Writer LLM failed for chapter `{}`; refusing template fallback: {err}\",\n chapter.title_zh\n ))\n })?;\n chapter_drafts.push(draft);\n }\n\n let global_summary_zh = synthesize_global_summary_with_llm(\n topic,\n &chapter_drafts,\n memory.items.len(),\n &client,\n &deepseek_config,\n )\n .await\n .map_err(|err| {\n AppError::Llm(format!(\n \"Writer global summary failed; refusing deterministic fallback: {err}\"\n ))\n })?;\n\n Ok(ReportDraft {\n title_zh: format!(\"LitScout-RS 调研报告:{topic}\"),\n chapters: chapter_drafts,\n global_summary_zh,\n written_at: Utc::now(),\n })\n}\n\nasync fn draft_chapter_with_llm(\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n client: &DeepSeekClient,\n config: &DeepSeekConfig,\n) -> Result {\n let selected_evidence = select_evidence_for_writing(evidence_items);\n let batches = batch_chapter_evidence(&selected_evidence);\n debug!(\n \"Writer selected {}/{} evidence items and split chapter `{}` into {} evidence batches\",\n selected_evidence.len(),\n evidence_items.len(),\n chapter.title_zh,\n batches.len()\n );\n\n let mut batch_drafts = Vec::with_capacity(batches.len());\n for batch in &batches {\n let draft = draft_batch_with_llm(chapter, batch, batches.len(), client, config).await?;\n batch_drafts.push(draft);\n }\n\n synthesize_chapter_from_batches(chapter, &selected_evidence, &batch_drafts, client, config)\n .await\n}\n\nfn empty_chapter_draft(chapter: &ChapterNode) -> ChapterDraft {\n ChapterDraft {\n chapter_id: chapter.id.clone(),\n title_zh: chapter.title_zh.clone(),\n paragraphs: vec![ParagraphWithCitations {\n text_zh: format!(\n \"当前章节 `{}` 尚未获得与章节计划直接匹配的 GitHub/arXiv 证据,因此不生成分析段落。\",\n chapter.title_zh\n ),\n cited_evidence_ids: Vec::new(),\n }],\n }\n}\n\n#[derive(Debug, Clone)]\nstruct EvidenceBatch {\n batch_id: usize,\n evidence_items: Vec,\n context_chars: usize,\n}\n\n#[derive(Debug, Clone, Serialize)]\nstruct BatchDraft {\n batch_id: usize,\n findings_summary_zh: String,\n paragraphs: Vec,\n}\n\nfn batch_chapter_evidence(evidence_items: &[EvidenceItem]) -> Vec {\n let mut batches = Vec::new();\n let mut current_items = Vec::new();\n let mut current_chars = 0usize;\n\n for item in evidence_items {\n let item_chars = evidence_context_chars(item);\n let count_limit_reached = current_items.len() >= MAX_EVIDENCE_PER_BATCH;\n let char_limit_reached =\n !current_items.is_empty() && current_chars + item_chars > MAX_BATCH_CONTEXT_CHARS;\n\n if count_limit_reached || char_limit_reached {\n batches.push(EvidenceBatch {\n batch_id: batches.len() + 1,\n evidence_items: current_items,\n context_chars: current_chars,\n });\n current_items = Vec::new();\n current_chars = 0;\n }\n\n current_chars += item_chars;\n current_items.push(item.clone());\n }\n\n if !current_items.is_empty() {\n batches.push(EvidenceBatch {\n batch_id: batches.len() + 1,\n evidence_items: current_items,\n context_chars: current_chars,\n });\n }\n\n batches\n}\n\nfn select_evidence_for_writing(evidence_items: &[EvidenceItem]) -> Vec {\n let mut selected = Vec::new();\n let has_stronger_sources = evidence_items\n .iter()\n .any(|item| item.source_kind != SourceKind::Bibliography);\n\n append_evidence_by_kind(\n evidence_items,\n &mut selected,\n SourceKind::Arxiv,\n WRITER_ARXIV_EVIDENCE_CAP,\n );\n append_evidence_by_kind(\n evidence_items,\n &mut selected,\n SourceKind::AcademicIndex,\n WRITER_ACADEMIC_INDEX_EVIDENCE_CAP,\n );\n append_evidence_by_kind(\n evidence_items,\n &mut selected,\n SourceKind::GitHub,\n WRITER_GITHUB_EVIDENCE_CAP,\n );\n if !has_stronger_sources {\n append_evidence_by_kind(\n evidence_items,\n &mut selected,\n SourceKind::Bibliography,\n WRITER_BIBLIOGRAPHY_EVIDENCE_CAP,\n );\n }\n\n selected\n}\n\nfn append_evidence_by_kind(\n evidence_items: &[EvidenceItem],\n selected: &mut Vec,\n source_kind: SourceKind,\n source_cap: usize,\n) {\n let mut source_count = 0usize;\n for item in evidence_items\n .iter()\n .filter(|item| item.source_kind == source_kind)\n {\n if selected.len() >= MAX_WRITER_EVIDENCE_PER_CHAPTER || source_count >= source_cap {\n break;\n }\n selected.push(item.clone());\n source_count += 1;\n }\n}\n\nfn evidence_context_chars(item: &EvidenceItem) -> usize {\n let input = EvidenceSourceInput::from(item);\n input.evidence_id.chars().count()\n + input.title.chars().count()\n + input.url.chars().count()\n + input.evidence_note_zh.chars().count()\n + input.evidence_snippet.chars().count()\n}\n\nfn excerpt_chars(text: &str, max_chars: usize) -> String {\n let trimmed = text.trim();\n if trimmed.chars().count() <= max_chars {\n return trimmed.to_string();\n }\n\n let mut out = trimmed.chars().take(max_chars).collect::();\n out.push_str(\"...\");\n out\n}\n\nfn parse_batch_draft_content(content: &str, batch: &EvidenceBatch) -> Result {\n let output =\n serde_json::from_str::(strip_json_fence(content)).map_err(|err| {\n AppError::Llm(format!(\n \"Writer response was not valid BatchDraft JSON: {err}\"\n ))\n })?;\n let findings_summary_zh = output.findings_summary_zh.trim().to_string();\n if findings_summary_zh.is_empty() {\n return Err(AppError::Llm(\n \"Writer batch response contained empty findings_summary_zh\".to_string(),\n ));\n }\n reject_template_residue(&findings_summary_zh)?;\n let paragraphs = parse_paragraph_outputs(\n output.paragraphs,\n &batch.evidence_items,\n MAX_BATCH_PARAGRAPHS,\n )?;\n\n Ok(BatchDraft {\n batch_id: batch.batch_id,\n findings_summary_zh,\n paragraphs,\n })\n}\n\nasync fn draft_batch_with_llm(\n chapter: &ChapterNode,\n batch: &EvidenceBatch,\n total_batches: usize,\n client: &DeepSeekClient,\n config: &DeepSeekConfig,\n) -> Result {\n let request = build_batch_draft_request(chapter, batch, total_batches, config)?;\n let response = client.chat_completions_with_retry(request).await?;\n let content = response.first_content()?;\n\n match parse_batch_draft_content(content, batch) {\n Ok(draft) => Ok(draft),\n Err(first_err) => {\n warn!(\n \"Writer LLM JSON validation failed for chapter `{}` batch {}; requesting one repair: {first_err}\",\n chapter.title_zh, batch.batch_id\n );\n let repair_request = build_batch_draft_repair_request(\n chapter,\n batch,\n total_batches,\n config,\n content,\n &first_err.to_string(),\n )?;\n let repaired_response = client.chat_completions_with_retry(repair_request).await?;\n let repaired_content = repaired_response.first_content()?;\n parse_batch_draft_content(repaired_content, batch)\n }\n }\n}\n\nfn build_batch_draft_request(\n chapter: &ChapterNode,\n batch: &EvidenceBatch,\n total_batches: usize,\n config: &DeepSeekConfig,\n) -> Result {\n let context = BatchDraftInput {\n chapter_title: chapter.title_zh.clone(),\n research_question: chapter.research_question.clone(),\n batch_id: batch.batch_id,\n total_batches,\n batch_context_chars: batch.context_chars,\n evidence_sources: batch\n .evidence_items\n .iter()\n .map(EvidenceSourceInput::from)\n .collect(),\n };\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: batch_writer_system_prompt(&chapter.title_zh),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请为下面章节的第 {}/{} 个证据批次生成局部分析草稿。只返回 JSON 对象,不要输出 Markdown 代码围栏之外的解释。\\n\\n{context_json}\",\n batch.batch_id, total_batches\n ),\n },\n ],\n temperature: Some(0.3),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn build_batch_draft_repair_request(\n chapter: &ChapterNode,\n batch: &EvidenceBatch,\n total_batches: usize,\n config: &DeepSeekConfig,\n previous_output: &str,\n validation_error: &str,\n) -> Result {\n let context = BatchDraftInput {\n chapter_title: chapter.title_zh.clone(),\n research_question: chapter.research_question.clone(),\n batch_id: batch.batch_id,\n total_batches,\n batch_context_chars: batch.context_chars,\n evidence_sources: batch\n .evidence_items\n .iter()\n .map(EvidenceSourceInput::from)\n .collect(),\n };\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n let previous_excerpt = excerpt_chars(previous_output, REPAIR_PREVIOUS_OUTPUT_MAX_CHARS);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: batch_writer_system_prompt(&chapter.title_zh),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请修复上一次第 {}/{} 个证据批次的输出,使其满足 JSON schema、引用约束和长度约束。只返回修复后的 JSON 对象。\\n\\n校验错误:\\n{validation_error}\\n\\n上一次输出节选:\\n{previous_excerpt}\\n\\n允许使用的 evidence:\\n{context_json}\",\n batch.batch_id, total_batches\n ),\n },\n ],\n temperature: Some(0.1),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nasync fn synthesize_chapter_from_batches(\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n batch_drafts: &[BatchDraft],\n client: &DeepSeekClient,\n config: &DeepSeekConfig,\n) -> Result {\n let request = build_chapter_synthesis_request(chapter, evidence_items, batch_drafts, config)?;\n let response = client.chat_completions_with_retry(request).await?;\n let content = response.first_content()?;\n\n match parse_chapter_draft_content(content, chapter, evidence_items) {\n Ok(draft) => Ok(draft),\n Err(first_err) => {\n warn!(\n \"Writer LLM chapter synthesis validation failed for chapter `{}`; requesting one repair: {first_err}\",\n chapter.title_zh\n );\n let repair_request = build_chapter_synthesis_repair_request(\n chapter,\n evidence_items,\n batch_drafts,\n config,\n content,\n &first_err.to_string(),\n )?;\n let repaired_response = client.chat_completions_with_retry(repair_request).await?;\n let repaired_content = repaired_response.first_content()?;\n parse_chapter_draft_content(repaired_content, chapter, evidence_items)\n }\n }\n}\n\nfn batch_writer_system_prompt(chapter_title: &str) -> String {\n format!(\n \"你是 LitScout-RS 的技术报告撰稿人,正在为 `{chapter_title}` 章节处理一个 evidence batch。\\n\\\n规则:\\n\\\n1. 使用中文撰写。\\n\\\n2. findings_summary_zh 不超过 {BATCH_FINDINGS_TARGET_CHARS} 个汉字,只总结本批证据的关键发现、共性和边界。\\n\\\n3. paragraphs 输出 1-{MAX_BATCH_PARAGRAPHS} 段,每段不超过 {BATCH_PARAGRAPH_TARGET_CHARS} 个汉字;提炼项目/论文简介、核心亮点或设计思想,以及与章节问题的具体关联。\\n\\\n4. 可合并多个来源做对比分析,但每个段落必须引用至少一个 evidence_id。\\n\\\n5. 只能使用用户提供的 evidence_id 和 URL,不得新增来源、不得编造事实。\\n\\\n6. 不要只重复原文,不要输出“来源链接”模板句。\\n\\\n7. 只返回 JSON:{{\\\"findings_summary_zh\\\":\\\"...\\\",\\\"paragraphs\\\":[{{\\\"text_zh\\\":\\\"...\\\",\\\"cited_evidence_ids\\\":[\\\"ev-C1\\\"]}}]}}。\"\n )\n}\n\nfn writer_max_tokens(config: &DeepSeekConfig) -> u32 {\n config\n .max_tokens\n .max(WRITER_MIN_OUTPUT_TOKENS)\n .min(WRITER_MAX_OUTPUT_TOKENS)\n .min(u32::MAX as usize) as u32\n}\n\nfn build_chapter_synthesis_request(\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n batch_drafts: &[BatchDraft],\n config: &DeepSeekConfig,\n) -> Result {\n let context = ChapterSynthesisInput {\n chapter_title: chapter.title_zh.clone(),\n research_question: chapter.research_question.clone(),\n allowed_evidence_ids: evidence_items\n .iter()\n .map(|item| item.evidence_id.clone())\n .collect(),\n batch_drafts: batch_drafts.to_vec(),\n };\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: chapter_synthesizer_system_prompt(&chapter.title_zh),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请基于已验证的 batch findings 合成章节终稿。只返回 JSON 对象,不要输出 Markdown 代码围栏之外的解释。\\n\\n{context_json}\"\n ),\n },\n ],\n temperature: Some(0.25),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn build_chapter_synthesis_repair_request(\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n batch_drafts: &[BatchDraft],\n config: &DeepSeekConfig,\n previous_output: &str,\n validation_error: &str,\n) -> Result {\n let context = ChapterSynthesisInput {\n chapter_title: chapter.title_zh.clone(),\n research_question: chapter.research_question.clone(),\n allowed_evidence_ids: evidence_items\n .iter()\n .map(|item| item.evidence_id.clone())\n .collect(),\n batch_drafts: batch_drafts.to_vec(),\n };\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n let previous_excerpt = excerpt_chars(previous_output, REPAIR_PREVIOUS_OUTPUT_MAX_CHARS);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: chapter_synthesizer_system_prompt(&chapter.title_zh),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请修复上一次章节综合输出,使其满足 JSON schema、引用约束和长度约束。只返回修复后的 JSON 对象。\\n\\n校验错误:\\n{validation_error}\\n\\n上一次输出节选:\\n{previous_excerpt}\\n\\n允许使用的 batch findings:\\n{context_json}\",\n ),\n },\n ],\n temperature: Some(0.1),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn chapter_synthesizer_system_prompt(chapter_title: &str) -> String {\n format!(\n \"你是 LitScout-RS 的章节综合编辑,正在合成 `{chapter_title}` 章节终稿。\\n\\\n规则:\\n\\\n1. 使用中文撰写自然、分析性的技术报告段落。\\n\\\n2. 只能使用用户提供的 batch findings、batch paragraphs 和 allowed_evidence_ids。\\n\\\n3. 合并重复信息,按主题组织,不要机械罗列每条来源。\\n\\\n4. 已有 evidence 时,不得用“尚未收集到足够证据”替代分析内容。\\n\\\n5. 每个段落必须引用至少一个 allowed_evidence_ids 中的 evidence_id。\\n\\\n6. 不得新增来源、URL 或 evidence_id,不得编造事实。\\n\\\n7. 输出 1-{MAX_CHAPTER_PARAGRAPHS} 段,每段不超过 {CHAPTER_PARAGRAPH_TARGET_CHARS} 个汉字。\\n\\\n8. 不要输出“来源链接”模板句或“是本章节的关键来源之一”。\\n\\\n9. 只返回 JSON:{{\\\"paragraphs\\\":[{{\\\"text_zh\\\":\\\"...\\\",\\\"cited_evidence_ids\\\":[\\\"ev-C1\\\"]}}]}}。\"\n )\n}\n\nasync fn synthesize_global_summary_with_llm(\n topic: &str,\n chapter_drafts: &[ChapterDraft],\n evidence_count: usize,\n client: &DeepSeekClient,\n config: &DeepSeekConfig,\n) -> Result {\n let request = build_global_summary_request(topic, chapter_drafts, evidence_count, config)?;\n let response = client.chat_completions_with_retry(request).await?;\n let content = response.first_content()?;\n\n match parse_global_summary_content(content) {\n Ok(summary) => Ok(summary),\n Err(first_err) => {\n warn!(\n \"Writer LLM global summary validation failed; requesting one repair: {first_err}\"\n );\n let repair_request = build_global_summary_repair_request(\n topic,\n chapter_drafts,\n evidence_count,\n config,\n content,\n &first_err.to_string(),\n )?;\n let repaired_response = client.chat_completions_with_retry(repair_request).await?;\n let repaired_content = repaired_response.first_content()?;\n parse_global_summary_content(repaired_content)\n }\n }\n}\n\nfn build_global_summary_request(\n topic: &str,\n chapter_drafts: &[ChapterDraft],\n evidence_count: usize,\n config: &DeepSeekConfig,\n) -> Result {\n let context = GlobalSummaryInput::from_report(topic, chapter_drafts, evidence_count);\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: global_synthesizer_system_prompt(),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请基于章节终稿生成报告的全局摘要。只返回 JSON 对象,不要输出 Markdown 代码围栏之外的解释。\\n\\n{context_json}\"\n ),\n },\n ],\n temperature: Some(0.25),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn build_global_summary_repair_request(\n topic: &str,\n chapter_drafts: &[ChapterDraft],\n evidence_count: usize,\n config: &DeepSeekConfig,\n previous_output: &str,\n validation_error: &str,\n) -> Result {\n let context = GlobalSummaryInput::from_report(topic, chapter_drafts, evidence_count);\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n let previous_excerpt = excerpt_chars(previous_output, REPAIR_PREVIOUS_OUTPUT_MAX_CHARS);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: global_synthesizer_system_prompt(),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请修复上一次全局摘要输出,使其满足 JSON schema 和写作约束。只返回修复后的 JSON 对象。\\n\\n校验错误:\\n{validation_error}\\n\\n上一次输出节选:\\n{previous_excerpt}\\n\\n章节终稿上下文:\\n{context_json}\",\n ),\n },\n ],\n temperature: Some(0.1),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn parse_global_summary_content(content: &str) -> Result {\n let output =\n serde_json::from_str::(strip_json_fence(content)).map_err(|err| {\n AppError::Llm(format!(\n \"Writer response was not valid GlobalSummary JSON: {err}\"\n ))\n })?;\n let global_summary_zh = output.global_summary_zh.trim().to_string();\n if global_summary_zh.is_empty() {\n return Err(AppError::Llm(\n \"Writer global summary response contained empty global_summary_zh\".to_string(),\n ));\n }\n reject_template_residue(&global_summary_zh)?;\n\n Ok(global_summary_zh)\n}\n\nfn global_synthesizer_system_prompt() -> String {\n format!(\n \"你是 LitScout-RS 的报告总编辑,负责基于章节终稿生成全局摘要。\\n\\\n规则:\\n\\\n1. 使用中文撰写 1-2 段自然的全局摘要,总长度不超过 {GLOBAL_SUMMARY_TARGET_CHARS} 个汉字。\\n\\\n2. 概括报告覆盖范围、主要发现、证据边界和不足。\\n\\\n3. 只能使用用户提供的章节终稿和 citation id,不得新增来源、URL 或事实。\\n\\\n4. 不要输出固定模板句,不要写“来源链接”,不要写“是本章节的关键来源之一”。\\n\\\n5. 不要把引用账本逐条改写成摘要。\\n\\\n6. 只返回 JSON:{{\\\"global_summary_zh\\\":\\\"...\\\"}}。\"\n )\n}\n\nfn parse_chapter_draft_content(\n content: &str,\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n) -> Result {\n let output =\n serde_json::from_str::(strip_json_fence(content)).map_err(|err| {\n AppError::Llm(format!(\n \"Writer response was not valid ChapterDraft JSON: {err}\"\n ))\n })?;\n let paragraphs =\n parse_paragraph_outputs(output.paragraphs, evidence_items, MAX_CHAPTER_PARAGRAPHS)?;\n\n Ok(ChapterDraft {\n chapter_id: chapter.id.clone(),\n title_zh: chapter.title_zh.clone(),\n paragraphs,\n })\n}\n\nfn parse_paragraph_outputs(\n paragraph_outputs: Vec,\n evidence_items: &[EvidenceItem],\n max_paragraphs: usize,\n) -> Result> {\n let allowed_ids = evidence_items\n .iter()\n .map(|item| item.evidence_id.as_str())\n .collect::>();\n let mut paragraphs = Vec::new();\n\n for paragraph in paragraph_outputs.into_iter().take(max_paragraphs) {\n let text_zh = paragraph.text_zh.trim().to_string();\n if text_zh.is_empty() {\n continue;\n }\n reject_template_residue(&text_zh)?;\n let cited_evidence_ids = paragraph\n .cited_evidence_ids\n .into_iter()\n .filter_map(|id| {\n let trimmed = id.trim();\n (!trimmed.is_empty()).then(|| trimmed.to_string())\n })\n .collect::>();\n if cited_evidence_ids.is_empty() {\n return Err(AppError::Llm(\n \"Writer paragraph did not cite any evidence ID\".to_string(),\n ));\n }\n for id in &cited_evidence_ids {\n if !allowed_ids.contains(id.as_str()) {\n return Err(AppError::Llm(format!(\n \"Writer paragraph referenced unknown evidence id `{id}`\"\n )));\n }\n }\n paragraphs.push(ParagraphWithCitations {\n text_zh,\n cited_evidence_ids,\n });\n }\n\n if paragraphs.is_empty() {\n return Err(AppError::Llm(\n \"Writer response contained no usable paragraphs\".to_string(),\n ));\n }\n\n Ok(paragraphs)\n}\n\nfn reject_template_residue(text: &str) -> Result<()> {\n for marker in [\n \"是本章节的关键来源之一\",\n \"来源链接:\",\n \"提供了与主题相关的证据\",\n ] {\n if text.contains(marker) {\n return Err(AppError::Llm(format!(\n \"Writer output still contains legacy template marker `{marker}`\"\n )));\n }\n }\n Ok(())\n}\n\npub fn render_report_markdown(draft: &ReportDraft, citations: &CitationLedger) -> String {\n let mut out = String::new();\n out.push_str(&format!(\"# {}\\n\\n\", draft.title_zh));\n out.push_str(\"## 1. 全局摘要\\n\\n\");\n out.push_str(&draft.global_summary_zh);\n out.push_str(\"\\n\\n\");\n\n for (index, chapter) in draft.chapters.iter().enumerate() {\n out.push_str(&format!(\"## {}. {}\\n\\n\", index + 2, chapter.title_zh));\n for paragraph in &chapter.paragraphs {\n out.push_str(¶graph.text_zh);\n if !paragraph.cited_evidence_ids.is_empty() {\n out.push(' ');\n out.push_str(\n ¶graph\n .cited_evidence_ids\n .iter()\n .map(|id| format!(\"`{id}`\"))\n .collect::>()\n .join(\" \"),\n );\n }\n out.push_str(\"\\n\\n\");\n }\n }\n\n out.push_str(\"## 引用账本\\n\\n\");\n if citations.citations.is_empty() {\n out.push_str(\"- 暂无引用。\\n\");\n } else {\n for citation in &citations.citations {\n out.push_str(&format!(\n \"- `{}` [{}]({}) ({:?})\\n\",\n citation.id, citation.title, citation.url, citation.source_kind\n ));\n }\n }\n out\n}\n\n#[derive(Debug, Serialize)]\nstruct BatchDraftInput {\n chapter_title: String,\n research_question: String,\n batch_id: usize,\n total_batches: usize,\n batch_context_chars: usize,\n evidence_sources: Vec,\n}\n\n#[derive(Debug, Serialize)]\nstruct ChapterSynthesisInput {\n chapter_title: String,\n research_question: String,\n allowed_evidence_ids: Vec,\n batch_drafts: Vec,\n}\n\n#[derive(Debug, Serialize)]\nstruct GlobalSummaryInput {\n topic: String,\n chapter_count: usize,\n evidence_count: usize,\n chapters: Vec,\n}\n\nimpl GlobalSummaryInput {\n fn from_report(topic: &str, chapter_drafts: &[ChapterDraft], evidence_count: usize) -> Self {\n Self {\n topic: topic.to_string(),\n chapter_count: chapter_drafts.len(),\n evidence_count,\n chapters: chapter_drafts\n .iter()\n .map(GlobalSummaryChapterInput::from)\n .collect(),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct GlobalSummaryChapterInput {\n chapter_id: String,\n title_zh: String,\n paragraph_count: usize,\n cited_evidence_ids: Vec,\n paragraph_summaries: Vec,\n}\n\nimpl From<&ChapterDraft> for GlobalSummaryChapterInput {\n fn from(chapter: &ChapterDraft) -> Self {\n let cited_evidence_ids = chapter\n .paragraphs\n .iter()\n .flat_map(|paragraph| paragraph.cited_evidence_ids.iter().cloned())\n .collect::>()\n .into_iter()\n .collect();\n Self {\n chapter_id: chapter.chapter_id.clone(),\n title_zh: chapter.title_zh.clone(),\n paragraph_count: chapter.paragraphs.len(),\n cited_evidence_ids,\n paragraph_summaries: chapter\n .paragraphs\n .iter()\n .map(|paragraph| excerpt_chars(¶graph.text_zh, 500))\n .collect(),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct EvidenceSourceInput {\n evidence_id: String,\n title: String,\n url: String,\n source_kind: SourceKind,\n evidence_note_zh: String,\n evidence_snippet: String,\n}\n\nimpl From<&EvidenceItem> for EvidenceSourceInput {\n fn from(item: &EvidenceItem) -> Self {\n Self {\n evidence_id: item.evidence_id.clone(),\n title: excerpt_chars(&item.title, WRITER_TITLE_MAX_CHARS),\n url: item.url.clone(),\n source_kind: item.source_kind,\n evidence_note_zh: excerpt_chars(&item.evidence_note_zh, WRITER_EVIDENCE_NOTE_MAX_CHARS),\n evidence_snippet: excerpt_chars(\n &item.evidence_snippet,\n writer_snippet_max_chars(item.source_kind),\n ),\n }\n }\n}\n\nfn writer_snippet_max_chars(source_kind: SourceKind) -> usize {\n match source_kind {\n SourceKind::GitHub => WRITER_GITHUB_SNIPPET_MAX_CHARS,\n SourceKind::Arxiv | SourceKind::AcademicIndex => WRITER_ACADEMIC_SNIPPET_MAX_CHARS,\n SourceKind::Bibliography => WRITER_BIBLIOGRAPHY_SNIPPET_MAX_CHARS,\n }\n}\n\n#[derive(Debug, Deserialize)]\nstruct ChapterDraftOutput {\n #[serde(default)]\n paragraphs: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct BatchDraftOutput {\n #[serde(default)]\n findings_summary_zh: String,\n #[serde(default)]\n paragraphs: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GlobalSummaryOutput {\n #[serde(default)]\n global_summary_zh: String,\n}\n\n#[derive(Debug, Deserialize)]\nstruct ChapterParagraphOutput {\n text_zh: String,\n #[serde(default)]\n cited_evidence_ids: Vec,\n}\n\n#[cfg(test)]\nmod tests {\n use chrono::{DateTime, Utc};\n\n use crate::llm::deepseek::DeepSeekConfig;\n use crate::model::{\n ChapterDraft, ChapterNode, EvidenceItem, EvidenceMemory, ParagraphWithCitations, SourceKind,\n };\n\n use super::{\n batch_chapter_evidence, build_batch_draft_request, build_chapter_synthesis_request,\n build_global_summary_request, draft_report_with_llm, empty_chapter_draft,\n parse_batch_draft_content, parse_chapter_draft_content, parse_global_summary_content,\n select_evidence_for_writing, writer_max_tokens, BatchDraft, MAX_BATCH_CONTEXT_CHARS,\n MAX_EVIDENCE_PER_BATCH, MAX_WRITER_EVIDENCE_PER_CHAPTER, WRITER_BIBLIOGRAPHY_EVIDENCE_CAP,\n WRITER_MIN_OUTPUT_TOKENS,\n };\n\n #[test]\n fn writer_drafts_empty_chapter_with_warning_text() {\n let chapters = vec![ChapterNode {\n id: \"ch-1\".to_string(),\n parent_id: None,\n title_zh: \"核心方向\".to_string(),\n research_question: \"测试\".to_string(),\n required_evidence_kinds: vec![\"github\".to_string()],\n evidence_quota: 1,\n sort_order: 1,\n }];\n\n let draft = empty_chapter_draft(&chapters[0]);\n\n assert_eq!(draft.chapter_id, \"ch-1\");\n assert!(draft.paragraphs[0]\n .text_zh\n .contains(\"尚未获得与章节计划直接匹配\"));\n }\n\n #[tokio::test]\n async fn llm_writer_refuses_to_template_when_llm_is_disabled() {\n let chapters = vec![sample_chapter()];\n let memory = EvidenceMemory {\n items: vec![sample_evidence(\"ev-C1\", \"ch-1\")],\n query_attempts: Vec::new(),\n source_lineage: Vec::new(),\n selection_report: Default::default(),\n };\n\n let err = draft_report_with_llm(\n \"Rust Agent\",\n &chapters,\n &memory,\n &crate::config::LlmConfig::from_env(false, 30),\n )\n .await\n .expect_err(\"disabled LLM should not produce a template report\");\n\n assert!(err\n .to_string()\n .contains(\"refusing to generate a template report\"));\n }\n\n #[tokio::test]\n #[ignore = \"requires a real DeepSeek API key in DEEPSEEK_API_KEY\"]\n async fn writer_real_deepseek_smoke() {\n let chapters = vec![sample_chapter()];\n let memory = EvidenceMemory {\n items: vec![sample_evidence(\"ev-C1\", \"ch-1\")],\n query_attempts: Vec::new(),\n source_lineage: Vec::new(),\n selection_report: Default::default(),\n };\n let config = crate::config::LlmConfig::from_env(true, 120);\n\n assert!(\n config.api_key.is_some(),\n \"DEEPSEEK_API_KEY must be set for this ignored smoke test\"\n );\n\n let draft = draft_report_with_llm(\"TTS 入门级项目简单调研\", &chapters, &memory, &config)\n .await\n .expect(\"real DeepSeek writer smoke should complete\");\n\n assert!(!draft.global_summary_zh.trim().is_empty());\n assert_eq!(draft.chapters.len(), 1);\n assert!(!draft.chapters[0].paragraphs.is_empty());\n assert!(draft.chapters[0].paragraphs[0]\n .cited_evidence_ids\n .contains(&\"ev-C1\".to_string()));\n println!(\n \"writer_real_deepseek_smoke ok: global_summary_chars={}, chapter_paragraphs={}\",\n draft.global_summary_zh.chars().count(),\n draft.chapters[0].paragraphs.len()\n );\n }\n\n #[test]\n fn parses_llm_chapter_draft_json() {\n let chapter = sample_chapter();\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let content = r#\"{\n \"paragraphs\": [\n {\n \"text_zh\": \"该项目展示了从 README 提炼出的 ReAct 主循环和工具调用结构,适合作为入门实现分析对象。\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let draft = parse_chapter_draft_content(content, &chapter, &evidence)\n .expect(\"valid chapter JSON should parse\");\n\n assert_eq!(draft.chapter_id, \"ch-1\");\n assert_eq!(draft.paragraphs[0].cited_evidence_ids, vec![\"ev-C1\"]);\n assert!(draft.paragraphs[0].text_zh.contains(\"ReAct\"));\n }\n\n #[test]\n fn rejects_llm_chapter_draft_with_unknown_evidence_id() {\n let chapter = sample_chapter();\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let content = r#\"{\n \"paragraphs\": [\n {\n \"text_zh\": \"引用了不存在的证据。\",\n \"cited_evidence_ids\": [\"ev-C9\"]\n }\n ]\n }\"#;\n\n let err = parse_chapter_draft_content(content, &chapter, &evidence)\n .expect_err(\"unknown evidence id should fail\");\n\n assert!(err.to_string().contains(\"unknown evidence id\"));\n }\n\n #[test]\n fn rejects_llm_chapter_draft_with_template_residue() {\n let chapter = sample_chapter();\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let content = r#\"{\n \"paragraphs\": [\n {\n \"text_zh\": \"acme/rust-agent 是本章节的关键来源之一。来源链接:https://github.com/acme/rust-agent\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let err = parse_chapter_draft_content(content, &chapter, &evidence)\n .expect_err(\"legacy template residue should fail\");\n\n assert!(err.to_string().contains(\"legacy template marker\"));\n }\n\n #[test]\n fn batches_small_chapter_evidence_into_one_batch() {\n let evidence = vec![\n sample_evidence(\"ev-C1\", \"ch-1\"),\n sample_evidence(\"ev-C2\", \"ch-1\"),\n sample_evidence(\"ev-C3\", \"ch-1\"),\n ];\n\n let batches = batch_chapter_evidence(&evidence);\n\n assert_eq!(batches.len(), 1);\n assert_eq!(batches[0].batch_id, 1);\n assert_eq!(batches[0].evidence_items.len(), 3);\n assert!(batches[0].context_chars > 0);\n }\n\n #[test]\n fn selects_bounded_mixed_evidence_for_writing() {\n let mut evidence = Vec::new();\n evidence.extend((1..=5).map(|index| {\n sample_evidence_with_kind(&format!(\"ev-A{index}\"), \"ch-1\", SourceKind::Arxiv)\n }));\n evidence.extend((1..=4).map(|index| {\n sample_evidence_with_kind(&format!(\"ev-S{index}\"), \"ch-1\", SourceKind::AcademicIndex)\n }));\n evidence.extend((1..=4).map(|index| {\n sample_evidence_with_kind(&format!(\"ev-G{index}\"), \"ch-1\", SourceKind::GitHub)\n }));\n evidence.extend((1..=2).map(|index| {\n sample_evidence_with_kind(&format!(\"ev-B{index}\"), \"ch-1\", SourceKind::Bibliography)\n }));\n\n let selected = select_evidence_for_writing(&evidence);\n let bibliography_count = selected\n .iter()\n .filter(|item| item.source_kind == SourceKind::Bibliography)\n .count();\n\n assert_eq!(selected.len(), MAX_WRITER_EVIDENCE_PER_CHAPTER);\n assert_eq!(bibliography_count, 0);\n assert_eq!(selected[0].source_kind, SourceKind::Arxiv);\n assert_eq!(selected[4].source_kind, SourceKind::AcademicIndex);\n assert_eq!(selected[7].source_kind, SourceKind::GitHub);\n }\n\n #[test]\n fn selects_bibliography_only_when_no_stronger_sources_exist() {\n let evidence = (1..=3)\n .map(|index| {\n sample_evidence_with_kind(&format!(\"ev-B{index}\"), \"ch-1\", SourceKind::Bibliography)\n })\n .collect::>();\n\n let selected = select_evidence_for_writing(&evidence);\n\n assert_eq!(selected.len(), WRITER_BIBLIOGRAPHY_EVIDENCE_CAP);\n assert_eq!(selected[0].source_kind, SourceKind::Bibliography);\n }\n\n #[test]\n fn batches_chapter_evidence_by_count() {\n let evidence = (1..=9)\n .map(|index| sample_evidence(&format!(\"ev-C{index}\"), \"ch-1\"))\n .collect::>();\n\n let batches = batch_chapter_evidence(&evidence);\n\n assert_eq!(batches.len(), 3);\n assert_eq!(batches[0].evidence_items.len(), MAX_EVIDENCE_PER_BATCH);\n assert_eq!(batches[1].evidence_items.len(), MAX_EVIDENCE_PER_BATCH);\n assert_eq!(batches[2].evidence_items.len(), MAX_EVIDENCE_PER_BATCH);\n assert_eq!(batches[2].batch_id, 3);\n }\n\n #[test]\n fn batches_chapter_evidence_by_sanitized_context_chars() {\n let mut first = sample_evidence(\"ev-C1\", \"ch-1\");\n first.evidence_snippet = \"a\".repeat(MAX_BATCH_CONTEXT_CHARS);\n let mut second = sample_evidence(\"ev-C2\", \"ch-1\");\n second.evidence_snippet = \"b\".repeat(MAX_BATCH_CONTEXT_CHARS);\n let mut third = sample_evidence(\"ev-C3\", \"ch-1\");\n third.evidence_snippet = \"c\".repeat(MAX_BATCH_CONTEXT_CHARS);\n let mut fourth = sample_evidence(\"ev-C4\", \"ch-1\");\n fourth.evidence_snippet = \"d\".repeat(MAX_BATCH_CONTEXT_CHARS);\n\n let batches = batch_chapter_evidence(&[first, second, third, fourth]);\n\n assert_eq!(batches.len(), 2);\n assert_eq!(batches[0].evidence_items.len(), 3);\n assert_eq!(batches[1].evidence_items.len(), 1);\n assert!(batches[0].context_chars <= MAX_BATCH_CONTEXT_CHARS);\n }\n\n #[test]\n fn batch_request_truncates_long_github_snippets_for_writer_context() {\n let chapter = sample_chapter();\n let mut evidence = sample_evidence(\"ev-C1\", \"ch-1\");\n evidence.evidence_snippet = format!(\"{}{}\", \"A\".repeat(1500), \"UNTRUNCATED_TAIL\");\n let batches = batch_chapter_evidence(&[evidence]);\n\n let request = build_batch_draft_request(\n &chapter,\n &batches[0],\n batches.len(),\n &sample_deepseek_config(4096),\n )\n .expect(\"request should build\");\n let user_message = &request.messages[1].content;\n\n assert!(user_message.contains(&\"A\".repeat(900)));\n assert!(!user_message.contains(\"UNTRUNCATED_TAIL\"));\n }\n\n #[test]\n fn parses_llm_batch_draft_json() {\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batches = batch_chapter_evidence(&evidence);\n let content = r#\"{\n \"findings_summary_zh\": \"该批证据说明了项目 README 中的 ReAct 主循环、工具注册和入门示例。\",\n \"paragraphs\": [\n {\n \"text_zh\": \"该项目的 README 将 ReAct 主循环和工具注册拆成清晰的入门实现,适合用于说明基础 agent 架构。\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let draft =\n parse_batch_draft_content(content, &batches[0]).expect(\"valid batch JSON should parse\");\n\n assert_eq!(draft.batch_id, 1);\n assert!(draft.findings_summary_zh.contains(\"ReAct\"));\n assert_eq!(draft.paragraphs[0].cited_evidence_ids, vec![\"ev-C1\"]);\n }\n\n #[test]\n fn rejects_llm_batch_draft_with_empty_findings() {\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batches = batch_chapter_evidence(&evidence);\n let content = r#\"{\n \"findings_summary_zh\": \"\",\n \"paragraphs\": [\n {\n \"text_zh\": \"该项目展示了一个基础实现。\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let err = parse_batch_draft_content(content, &batches[0])\n .expect_err(\"empty findings should fail\");\n\n assert!(err.to_string().contains(\"empty findings_summary_zh\"));\n }\n\n #[test]\n fn rejects_llm_batch_draft_with_unknown_evidence_id() {\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batches = batch_chapter_evidence(&evidence);\n let content = r#\"{\n \"findings_summary_zh\": \"该批证据有一个段落引用了不存在的证据。\",\n \"paragraphs\": [\n {\n \"text_zh\": \"这里引用了不存在的 evidence id。\",\n \"cited_evidence_ids\": [\"ev-C2\"]\n }\n ]\n }\"#;\n\n let err = parse_batch_draft_content(content, &batches[0])\n .expect_err(\"unknown evidence id should fail\");\n\n assert!(err.to_string().contains(\"unknown evidence id\"));\n }\n\n #[test]\n fn rejects_llm_batch_draft_with_template_residue() {\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batches = batch_chapter_evidence(&evidence);\n let content = r#\"{\n \"findings_summary_zh\": \"acme/rust-agent 是本章节的关键来源之一。\",\n \"paragraphs\": [\n {\n \"text_zh\": \"该项目展示了一个基础实现。\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let err = parse_batch_draft_content(content, &batches[0])\n .expect_err(\"legacy template residue should fail\");\n\n assert!(err.to_string().contains(\"legacy template marker\"));\n }\n\n #[test]\n fn batch_request_only_contains_current_batch_evidence() {\n let chapter = sample_chapter();\n let evidence = (1..=5)\n .map(|index| sample_evidence(&format!(\"ev-C{index}\"), \"ch-1\"))\n .collect::>();\n let batches = batch_chapter_evidence(&evidence);\n\n let request = build_batch_draft_request(\n &chapter,\n &batches[1],\n batches.len(),\n &sample_deepseek_config(2048),\n )\n .expect(\"request should build\");\n let user_message = &request.messages[1].content;\n\n assert!(user_message.contains(\"\\\"batch_id\\\": 2\"));\n assert!(user_message.contains(\"ev-C5\"));\n assert!(!user_message.contains(\"ev-C1\"));\n assert_eq!(request.max_tokens, Some(WRITER_MIN_OUTPUT_TOKENS as u32));\n }\n\n #[test]\n fn writer_max_tokens_raises_too_small_config_for_reasoning_models() {\n let config = sample_deepseek_config(32);\n\n assert_eq!(writer_max_tokens(&config), WRITER_MIN_OUTPUT_TOKENS as u32);\n }\n\n #[test]\n fn chapter_synthesis_request_uses_batch_findings_instead_of_raw_evidence() {\n let chapter = sample_chapter();\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batch_drafts = vec![BatchDraft {\n batch_id: 1,\n findings_summary_zh: \"该批证据说明 README 中的 ReAct 主循环适合作为入门样例。\"\n .to_string(),\n paragraphs: vec![ParagraphWithCitations {\n text_zh: \"该项目展示了 ReAct 主循环和工具注册。\".to_string(),\n cited_evidence_ids: vec![\"ev-C1\".to_string()],\n }],\n }];\n\n let request = build_chapter_synthesis_request(\n &chapter,\n &evidence,\n &batch_drafts,\n &sample_deepseek_config(4096),\n )\n .expect(\"request should build\");\n let user_message = &request.messages[1].content;\n\n assert!(user_message.contains(\"batch_drafts\"));\n assert!(user_message.contains(\"allowed_evidence_ids\"));\n assert!(user_message.contains(\"ReAct 主循环适合作为入门样例\"));\n assert!(!user_message.contains(\"evidence_snippet\"));\n assert!(!user_message.contains(\"README explains ReAct loop\"));\n }\n\n #[test]\n fn parses_llm_global_summary_json() {\n let content = r#\"{\n \"global_summary_zh\": \"本报告显示,可控 TTS 入门调研应同时关注基础项目、说话人控制和模型框架边界;现有证据主要来自 GitHub README 与 arXiv 摘要,因此结论应限定在这些来源范围内。\"\n }\"#;\n\n let summary =\n parse_global_summary_content(content).expect(\"valid global summary should parse\");\n\n assert!(summary.contains(\"可控 TTS\"));\n assert!(summary.contains(\"来源范围\"));\n }\n\n #[test]\n fn rejects_llm_global_summary_with_template_residue() {\n let content = r#\"{\n \"global_summary_zh\": \"harinandanmv/text-to-speech 是本章节的关键来源之一。\"\n }\"#;\n\n let err =\n parse_global_summary_content(content).expect_err(\"legacy template marker should fail\");\n\n assert!(err.to_string().contains(\"legacy template marker\"));\n }\n\n #[test]\n fn global_summary_request_uses_chapter_drafts() {\n let chapter_drafts = vec![ChapterDraft {\n chapter_id: \"ch-1\".to_string(),\n title_zh: \"可控 TTS 基础模型\".to_string(),\n paragraphs: vec![ParagraphWithCitations {\n text_zh: \"该章节围绕基础 TTS 模型、说话人控制和入门项目进行综合分析。\".to_string(),\n cited_evidence_ids: vec![\"ev-C1\".to_string()],\n }],\n }];\n\n let request = build_global_summary_request(\n \"TTS 入门级项目简单调研\",\n &chapter_drafts,\n 1,\n &sample_deepseek_config(4096),\n )\n .expect(\"request should build\");\n let user_message = &request.messages[1].content;\n\n assert!(user_message.contains(\"TTS 入门级项目简单调研\"));\n assert!(user_message.contains(\"可控 TTS 基础模型\"));\n assert!(user_message.contains(\"ev-C1\"));\n assert!(user_message.contains(\"paragraph_summaries\"));\n }\n\n fn sample_chapter() -> ChapterNode {\n ChapterNode {\n id: \"ch-1\".to_string(),\n parent_id: None,\n title_zh: \"核心方向\".to_string(),\n research_question: \"测试\".to_string(),\n required_evidence_kinds: vec![\"github\".to_string()],\n evidence_quota: 1,\n sort_order: 1,\n }\n }\n\n fn sample_evidence(evidence_id: &str, chapter_id: &str) -> EvidenceItem {\n sample_evidence_with_kind(evidence_id, chapter_id, SourceKind::GitHub)\n }\n\n fn sample_evidence_with_kind(\n evidence_id: &str,\n chapter_id: &str,\n source_kind: SourceKind,\n ) -> EvidenceItem {\n EvidenceItem {\n evidence_id: evidence_id.to_string(),\n source_item_id: format!(\"{source_kind:?}:acme/rust-agent\"),\n citation_id: \"C1\".to_string(),\n chapter_ids: vec![chapter_id.to_string()],\n query_attempt_ids: vec![\"gh-1\".to_string()],\n source_kind,\n title: \"acme/rust-agent\".to_string(),\n url: \"https://github.com/acme/rust-agent\".to_string(),\n evidence_note_zh: \"GitHub 仓库 `acme/rust-agent`:Rust agent framework\".to_string(),\n evidence_snippet: \"README explains ReAct loop, tool registry, and examples.\"\n .to_string(),\n support_score: None,\n }\n }\n\n fn sample_deepseek_config(max_tokens: usize) -> DeepSeekConfig {\n DeepSeekConfig {\n api_key: \"sk-test\".to_string(),\n base_url: \"https://api.deepseek.com\".to_string(),\n main_model: \"deepseek-v4-pro\".to_string(),\n side_model: \"deepseek-v4-flash\".to_string(),\n max_tokens,\n timeout_secs: 120,\n }\n }\n\n fn _dt() -> DateTime {\n DateTime::parse_from_rfc3339(\"2026-05-30T12:00:00Z\")\n .unwrap()\n .with_timezone(&Utc)\n }\n}\n"}, "b8f76fdd9fdfaaf0957a9293477eb193d392c98f": {"t": 1785750659.2451828, "n": 50022, "h": "1c25fd4fdd794826", "c": "use crate::config::LlmConfig;\nuse crate::error::{AppError, Result};\nuse crate::llm::deepseek::{\n strip_json_fence, ChatCompletionsRequest, ChatMessage, DeepSeekClient, DeepSeekConfig,\n ResponseFormat,\n};\nuse crate::model::{\n ChapterDraft, ChapterNode, CitationLedger, EvidenceItem, EvidenceMemory,\n ParagraphWithCitations, ReportDraft, SourceKind,\n};\nuse chrono::Utc;\nuse serde::{Deserialize, Serialize};\nuse std::collections::BTreeSet;\nuse tracing::{debug, warn};\n\nconst MAX_EVIDENCE_PER_BATCH: usize = 3;\nconst MAX_BATCH_CONTEXT_CHARS: usize = 3_500;\nconst MAX_BATCH_PARAGRAPHS: usize = 1;\nconst MAX_CHAPTER_PARAGRAPHS: usize = 4;\nconst MAX_WRITER_EVIDENCE_PER_CHAPTER: usize = 8;\nconst WRITER_GITHUB_EVIDENCE_CAP: usize = 4;\nconst WRITER_ARXIV_EVIDENCE_CAP: usize = 4;\nconst WRITER_ACADEMIC_INDEX_EVIDENCE_CAP: usize = 3;\nconst WRITER_BIBLIOGRAPHY_EVIDENCE_CAP: usize = 1;\nconst BATCH_FINDINGS_TARGET_CHARS: usize = 180;\nconst BATCH_PARAGRAPH_TARGET_CHARS: usize = 220;\nconst CHAPTER_PARAGRAPH_TARGET_CHARS: usize = 260;\nconst GLOBAL_SUMMARY_TARGET_CHARS: usize = 420;\nconst WRITER_TITLE_MAX_CHARS: usize = 180;\nconst WRITER_EVIDENCE_NOTE_MAX_CHARS: usize = 220;\nconst WRITER_GITHUB_SNIPPET_MAX_CHARS: usize = 900;\nconst WRITER_ACADEMIC_SNIPPET_MAX_CHARS: usize = 700;\nconst WRITER_BIBLIOGRAPHY_SNIPPET_MAX_CHARS: usize = 200;\nconst REPAIR_PREVIOUS_OUTPUT_MAX_CHARS: usize = 2_400;\nconst WRITER_MIN_OUTPUT_TOKENS: usize = 4096;\nconst WRITER_MAX_OUTPUT_TOKENS: usize = 8192;\n\npub async fn draft_report_with_llm(\n topic: &str,\n chapters: &[ChapterNode],\n memory: &EvidenceMemory,\n llm_config: &LlmConfig,\n) -> Result {\n let deepseek_config = DeepSeekConfig::from_llm_config(llm_config).ok_or_else(|| {\n AppError::InvalidConfig(\n \"Writer LLM requires an enabled DeepSeek config with API key; refusing to generate a template report.\"\n .to_string(),\n )\n })?;\n let client = DeepSeekClient::new(deepseek_config.clone())?;\n let mut chapter_drafts = Vec::with_capacity(chapters.len());\n\n for chapter in chapters {\n let evidence_items = memory.by_chapter(&chapter.id);\n if evidence_items.is_empty() {\n chapter_drafts.push(empty_chapter_draft(chapter));\n continue;\n }\n\n let draft = draft_chapter_with_llm(chapter, &evidence_items, &client, &deepseek_config)\n .await\n .map_err(|err| {\n AppError::Llm(format!(\n \"Writer LLM failed for chapter `{}`; refusing template fallback: {err}\",\n chapter.title_zh\n ))\n })?;\n chapter_drafts.push(draft);\n }\n\n let global_summary_zh = synthesize_global_summary_with_llm(\n topic,\n &chapter_drafts,\n memory.items.len(),\n &client,\n &deepseek_config,\n )\n .await\n .map_err(|err| {\n AppError::Llm(format!(\n \"Writer global summary failed; refusing deterministic fallback: {err}\"\n ))\n })?;\n\n Ok(ReportDraft {\n title_zh: format!(\"LitScout-RS 调研报告:{topic}\"),\n chapters: chapter_drafts,\n global_summary_zh,\n written_at: Utc::now(),\n })\n}\n\nasync fn draft_chapter_with_llm(\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n client: &DeepSeekClient,\n config: &DeepSeekConfig,\n) -> Result {\n let selected_evidence = select_evidence_for_writing(evidence_items);\n let batches = batch_chapter_evidence(&selected_evidence);\n debug!(\n \"Writer selected {}/{} evidence items and split chapter `{}` into {} evidence batches\",\n selected_evidence.len(),\n evidence_items.len(),\n chapter.title_zh,\n batches.len()\n );\n\n let mut batch_drafts = Vec::with_capacity(batches.len());\n for batch in &batches {\n let draft = draft_batch_with_llm(chapter, batch, batches.len(), client, config).await?;\n batch_drafts.push(draft);\n }\n\n synthesize_chapter_from_batches(chapter, &selected_evidence, &batch_drafts, client, config)\n .await\n}\n\nfn empty_chapter_draft(chapter: &ChapterNode) -> ChapterDraft {\n ChapterDraft {\n chapter_id: chapter.id.clone(),\n title_zh: chapter.title_zh.clone(),\n paragraphs: vec![ParagraphWithCitations {\n text_zh: format!(\n \"当前章节 `{}` 尚未获得与章节计划直接匹配的 GitHub/arXiv 证据,因此不生成分析段落。\",\n chapter.title_zh\n ),\n cited_evidence_ids: Vec::new(),\n }],\n }\n}\n\n#[derive(Debug, Clone)]\nstruct EvidenceBatch {\n batch_id: usize,\n evidence_items: Vec,\n context_chars: usize,\n}\n\n#[derive(Debug, Clone, Serialize)]\nstruct BatchDraft {\n batch_id: usize,\n findings_summary_zh: String,\n paragraphs: Vec,\n}\n\nfn batch_chapter_evidence(evidence_items: &[EvidenceItem]) -> Vec {\n let mut batches = Vec::new();\n let mut current_items = Vec::new();\n let mut current_chars = 0usize;\n\n for item in evidence_items {\n let item_chars = evidence_context_chars(item);\n let count_limit_reached = current_items.len() >= MAX_EVIDENCE_PER_BATCH;\n let char_limit_reached =\n !current_items.is_empty() && current_chars + item_chars > MAX_BATCH_CONTEXT_CHARS;\n\n if count_limit_reached || char_limit_reached {\n batches.push(EvidenceBatch {\n batch_id: batches.len() + 1,\n evidence_items: current_items,\n context_chars: current_chars,\n });\n current_items = Vec::new();\n current_chars = 0;\n }\n\n current_chars += item_chars;\n current_items.push(item.clone());\n }\n\n if !current_items.is_empty() {\n batches.push(EvidenceBatch {\n batch_id: batches.len() + 1,\n evidence_items: current_items,\n context_chars: current_chars,\n });\n }\n\n batches\n}\n\nfn select_evidence_for_writing(evidence_items: &[EvidenceItem]) -> Vec {\n let mut selected = Vec::new();\n let has_stronger_sources = evidence_items\n .iter()\n .any(|item| item.source_kind != SourceKind::Bibliography);\n\n append_evidence_by_kind(\n evidence_items,\n &mut selected,\n SourceKind::Arxiv,\n WRITER_ARXIV_EVIDENCE_CAP,\n );\n append_evidence_by_kind(\n evidence_items,\n &mut selected,\n SourceKind::AcademicIndex,\n WRITER_ACADEMIC_INDEX_EVIDENCE_CAP,\n );\n append_evidence_by_kind(\n evidence_items,\n &mut selected,\n SourceKind::GitHub,\n WRITER_GITHUB_EVIDENCE_CAP,\n );\n if !has_stronger_sources {\n append_evidence_by_kind(\n evidence_items,\n &mut selected,\n SourceKind::Bibliography,\n WRITER_BIBLIOGRAPHY_EVIDENCE_CAP,\n );\n }\n\n selected\n}\n\nfn append_evidence_by_kind(\n evidence_items: &[EvidenceItem],\n selected: &mut Vec,\n source_kind: SourceKind,\n source_cap: usize,\n) {\n let mut source_count = 0usize;\n for item in evidence_items\n .iter()\n .filter(|item| item.source_kind == source_kind)\n {\n if selected.len() >= MAX_WRITER_EVIDENCE_PER_CHAPTER || source_count >= source_cap {\n break;\n }\n selected.push(item.clone());\n source_count += 1;\n }\n}\n\nfn evidence_context_chars(item: &EvidenceItem) -> usize {\n let input = EvidenceSourceInput::from(item);\n input.evidence_id.chars().count()\n + input.title.chars().count()\n + input.url.chars().count()\n + input.evidence_note_zh.chars().count()\n + input.evidence_snippet.chars().count()\n}\n\nfn excerpt_chars(text: &str, max_chars: usize) -> String {\n let trimmed = text.trim();\n if trimmed.chars().count() <= max_chars {\n return trimmed.to_string();\n }\n\n let mut out = trimmed.chars().take(max_chars).collect::();\n out.push_str(\"...\");\n out\n}\n\nfn parse_batch_draft_content(content: &str, batch: &EvidenceBatch) -> Result {\n let output =\n serde_json::from_str::(strip_json_fence(content)).map_err(|err| {\n AppError::Llm(format!(\n \"Writer response was not valid BatchDraft JSON: {err}\"\n ))\n })?;\n let findings_summary_zh = output.findings_summary_zh.trim().to_string();\n if findings_summary_zh.is_empty() {\n return Err(AppError::Llm(\n \"Writer batch response contained empty findings_summary_zh\".to_string(),\n ));\n }\n reject_template_residue(&findings_summary_zh)?;\n let paragraphs = parse_paragraph_outputs(\n output.paragraphs,\n &batch.evidence_items,\n MAX_BATCH_PARAGRAPHS,\n )?;\n\n Ok(BatchDraft {\n batch_id: batch.batch_id,\n findings_summary_zh,\n paragraphs,\n })\n}\n\nasync fn draft_batch_with_llm(\n chapter: &ChapterNode,\n batch: &EvidenceBatch,\n total_batches: usize,\n client: &DeepSeekClient,\n config: &DeepSeekConfig,\n) -> Result {\n let request = build_batch_draft_request(chapter, batch, total_batches, config)?;\n let response = client.chat_completions_with_retry(request).await?;\n let content = response.first_content()?;\n\n match parse_batch_draft_content(content, batch) {\n Ok(draft) => Ok(draft),\n Err(first_err) => {\n warn!(\n \"Writer LLM JSON validation failed for chapter `{}` batch {}; requesting one repair: {first_err}\",\n chapter.title_zh, batch.batch_id\n );\n let repair_request = build_batch_draft_repair_request(\n chapter,\n batch,\n total_batches,\n config,\n content,\n &first_err.to_string(),\n )?;\n let repaired_response = client.chat_completions_with_retry(repair_request).await?;\n let repaired_content = repaired_response.first_content()?;\n parse_batch_draft_content(repaired_content, batch)\n }\n }\n}\n\nfn build_batch_draft_request(\n chapter: &ChapterNode,\n batch: &EvidenceBatch,\n total_batches: usize,\n config: &DeepSeekConfig,\n) -> Result {\n let context = BatchDraftInput {\n chapter_title: chapter.title_zh.clone(),\n research_question: chapter.research_question.clone(),\n batch_id: batch.batch_id,\n total_batches,\n batch_context_chars: batch.context_chars,\n evidence_sources: batch\n .evidence_items\n .iter()\n .map(EvidenceSourceInput::from)\n .collect(),\n };\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: batch_writer_system_prompt(&chapter.title_zh),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请为下面章节的第 {}/{} 个证据批次生成局部分析草稿。只返回 JSON 对象,不要输出 Markdown 代码围栏之外的解释。\\n\\n{context_json}\",\n batch.batch_id, total_batches\n ),\n },\n ],\n temperature: Some(0.3),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn build_batch_draft_repair_request(\n chapter: &ChapterNode,\n batch: &EvidenceBatch,\n total_batches: usize,\n config: &DeepSeekConfig,\n previous_output: &str,\n validation_error: &str,\n) -> Result {\n let context = BatchDraftInput {\n chapter_title: chapter.title_zh.clone(),\n research_question: chapter.research_question.clone(),\n batch_id: batch.batch_id,\n total_batches,\n batch_context_chars: batch.context_chars,\n evidence_sources: batch\n .evidence_items\n .iter()\n .map(EvidenceSourceInput::from)\n .collect(),\n };\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n let previous_excerpt = excerpt_chars(previous_output, REPAIR_PREVIOUS_OUTPUT_MAX_CHARS);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: batch_writer_system_prompt(&chapter.title_zh),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请修复上一次第 {}/{} 个证据批次的输出,使其满足 JSON schema、引用约束和长度约束。只返回修复后的 JSON 对象。\\n\\n校验错误:\\n{validation_error}\\n\\n上一次输出节选:\\n{previous_excerpt}\\n\\n允许使用的 evidence:\\n{context_json}\",\n batch.batch_id, total_batches\n ),\n },\n ],\n temperature: Some(0.1),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nasync fn synthesize_chapter_from_batches(\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n batch_drafts: &[BatchDraft],\n client: &DeepSeekClient,\n config: &DeepSeekConfig,\n) -> Result {\n let request = build_chapter_synthesis_request(chapter, evidence_items, batch_drafts, config)?;\n let response = client.chat_completions_with_retry(request).await?;\n let content = response.first_content()?;\n\n match parse_chapter_draft_content(content, chapter, evidence_items) {\n Ok(draft) => Ok(draft),\n Err(first_err) => {\n warn!(\n \"Writer LLM chapter synthesis validation failed for chapter `{}`; requesting one repair: {first_err}\",\n chapter.title_zh\n );\n let repair_request = build_chapter_synthesis_repair_request(\n chapter,\n evidence_items,\n batch_drafts,\n config,\n content,\n &first_err.to_string(),\n )?;\n let repaired_response = client.chat_completions_with_retry(repair_request).await?;\n let repaired_content = repaired_response.first_content()?;\n parse_chapter_draft_content(repaired_content, chapter, evidence_items)\n }\n }\n}\n\nfn batch_writer_system_prompt(chapter_title: &str) -> String {\n format!(\n \"你是 LitScout-RS 的技术报告撰稿人,正在为 `{chapter_title}` 章节处理一个 evidence batch。\\n\\\n规则:\\n\\\n1. 使用中文撰写。\\n\\\n2. findings_summary_zh 不超过 {BATCH_FINDINGS_TARGET_CHARS} 个汉字,只总结本批证据的关键发现、共性和边界。\\n\\\n3. paragraphs 输出 1-{MAX_BATCH_PARAGRAPHS} 段,每段不超过 {BATCH_PARAGRAPH_TARGET_CHARS} 个汉字;提炼项目/论文简介、核心亮点或设计思想,以及与章节问题的具体关联。\\n\\\n4. 可合并多个来源做对比分析,但每个段落必须引用至少一个 evidence_id。\\n\\\n5. 只能使用用户提供的 evidence_id 和 URL,不得新增来源、不得编造事实。\\n\\\n6. 不要只重复原文,不要输出“来源链接”模板句。\\n\\\n7. 只返回 JSON:{{\\\"findings_summary_zh\\\":\\\"...\\\",\\\"paragraphs\\\":[{{\\\"text_zh\\\":\\\"...\\\",\\\"cited_evidence_ids\\\":[\\\"ev-C1\\\"]}}]}}。\"\n )\n}\n\nfn writer_max_tokens(config: &DeepSeekConfig) -> u32 {\n config\n .max_tokens\n .max(WRITER_MIN_OUTPUT_TOKENS)\n .min(WRITER_MAX_OUTPUT_TOKENS)\n .min(u32::MAX as usize) as u32\n}\n\nfn build_chapter_synthesis_request(\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n batch_drafts: &[BatchDraft],\n config: &DeepSeekConfig,\n) -> Result {\n let context = ChapterSynthesisInput {\n chapter_title: chapter.title_zh.clone(),\n research_question: chapter.research_question.clone(),\n allowed_evidence_ids: evidence_items\n .iter()\n .map(|item| item.evidence_id.clone())\n .collect(),\n batch_drafts: batch_drafts.to_vec(),\n };\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: chapter_synthesizer_system_prompt(&chapter.title_zh),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请基于已验证的 batch findings 合成章节终稿。只返回 JSON 对象,不要输出 Markdown 代码围栏之外的解释。\\n\\n{context_json}\"\n ),\n },\n ],\n temperature: Some(0.25),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn build_chapter_synthesis_repair_request(\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n batch_drafts: &[BatchDraft],\n config: &DeepSeekConfig,\n previous_output: &str,\n validation_error: &str,\n) -> Result {\n let context = ChapterSynthesisInput {\n chapter_title: chapter.title_zh.clone(),\n research_question: chapter.research_question.clone(),\n allowed_evidence_ids: evidence_items\n .iter()\n .map(|item| item.evidence_id.clone())\n .collect(),\n batch_drafts: batch_drafts.to_vec(),\n };\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n let previous_excerpt = excerpt_chars(previous_output, REPAIR_PREVIOUS_OUTPUT_MAX_CHARS);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: chapter_synthesizer_system_prompt(&chapter.title_zh),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请修复上一次章节综合输出,使其满足 JSON schema、引用约束和长度约束。只返回修复后的 JSON 对象。\\n\\n校验错误:\\n{validation_error}\\n\\n上一次输出节选:\\n{previous_excerpt}\\n\\n允许使用的 batch findings:\\n{context_json}\",\n ),\n },\n ],\n temperature: Some(0.1),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn chapter_synthesizer_system_prompt(chapter_title: &str) -> String {\n format!(\n \"你是 LitScout-RS 的章节综合编辑,正在合成 `{chapter_title}` 章节终稿。\\n\\\n规则:\\n\\\n1. 使用中文撰写自然、分析性的技术报告段落。\\n\\\n2. 只能使用用户提供的 batch findings、batch paragraphs 和 allowed_evidence_ids。\\n\\\n3. 合并重复信息,按主题组织,不要机械罗列每条来源。\\n\\\n4. 已有 evidence 时,不得用“尚未收集到足够证据”替代分析内容。\\n\\\n5. 每个段落必须引用至少一个 allowed_evidence_ids 中的 evidence_id。\\n\\\n6. 不得新增来源、URL 或 evidence_id,不得编造事实。\\n\\\n7. 输出 1-{MAX_CHAPTER_PARAGRAPHS} 段,每段不超过 {CHAPTER_PARAGRAPH_TARGET_CHARS} 个汉字。\\n\\\n8. 不要输出“来源链接”模板句或“是本章节的关键来源之一”。\\n\\\n9. 只返回 JSON:{{\\\"paragraphs\\\":[{{\\\"text_zh\\\":\\\"...\\\",\\\"cited_evidence_ids\\\":[\\\"ev-C1\\\"]}}]}}。\"\n )\n}\n\nasync fn synthesize_global_summary_with_llm(\n topic: &str,\n chapter_drafts: &[ChapterDraft],\n evidence_count: usize,\n client: &DeepSeekClient,\n config: &DeepSeekConfig,\n) -> Result {\n let request = build_global_summary_request(topic, chapter_drafts, evidence_count, config)?;\n let response = client.chat_completions_with_retry(request).await?;\n let content = response.first_content()?;\n\n match parse_global_summary_content(content) {\n Ok(summary) => Ok(summary),\n Err(first_err) => {\n warn!(\n \"Writer LLM global summary validation failed; requesting one repair: {first_err}\"\n );\n let repair_request = build_global_summary_repair_request(\n topic,\n chapter_drafts,\n evidence_count,\n config,\n content,\n &first_err.to_string(),\n )?;\n let repaired_response = client.chat_completions_with_retry(repair_request).await?;\n let repaired_content = repaired_response.first_content()?;\n parse_global_summary_content(repaired_content)\n }\n }\n}\n\nfn build_global_summary_request(\n topic: &str,\n chapter_drafts: &[ChapterDraft],\n evidence_count: usize,\n config: &DeepSeekConfig,\n) -> Result {\n let context = GlobalSummaryInput::from_report(topic, chapter_drafts, evidence_count);\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: global_synthesizer_system_prompt(),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请基于章节终稿生成报告的全局摘要。只返回 JSON 对象,不要输出 Markdown 代码围栏之外的解释。\\n\\n{context_json}\"\n ),\n },\n ],\n temperature: Some(0.25),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn build_global_summary_repair_request(\n topic: &str,\n chapter_drafts: &[ChapterDraft],\n evidence_count: usize,\n config: &DeepSeekConfig,\n previous_output: &str,\n validation_error: &str,\n) -> Result {\n let context = GlobalSummaryInput::from_report(topic, chapter_drafts, evidence_count);\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n let previous_excerpt = excerpt_chars(previous_output, REPAIR_PREVIOUS_OUTPUT_MAX_CHARS);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: global_synthesizer_system_prompt(),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请修复上一次全局摘要输出,使其满足 JSON schema 和写作约束。只返回修复后的 JSON 对象。\\n\\n校验错误:\\n{validation_error}\\n\\n上一次输出节选:\\n{previous_excerpt}\\n\\n章节终稿上下文:\\n{context_json}\",\n ),\n },\n ],\n temperature: Some(0.1),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn parse_global_summary_content(content: &str) -> Result {\n let output =\n serde_json::from_str::(strip_json_fence(content)).map_err(|err| {\n AppError::Llm(format!(\n \"Writer response was not valid GlobalSummary JSON: {err}\"\n ))\n })?;\n let global_summary_zh = output.global_summary_zh.trim().to_string();\n if global_summary_zh.is_empty() {\n return Err(AppError::Llm(\n \"Writer global summary response contained empty global_summary_zh\".to_string(),\n ));\n }\n reject_template_residue(&global_summary_zh)?;\n\n Ok(global_summary_zh)\n}\n\nfn global_synthesizer_system_prompt() -> String {\n format!(\n \"你是 LitScout-RS 的报告总编辑,负责基于章节终稿生成全局摘要。\\n\\\n规则:\\n\\\n1. 使用中文撰写 1-2 段自然的全局摘要,总长度不超过 {GLOBAL_SUMMARY_TARGET_CHARS} 个汉字。\\n\\\n2. 概括报告覆盖范围、主要发现、证据边界和不足。\\n\\\n3. 只能使用用户提供的章节终稿和 citation id,不得新增来源、URL 或事实。\\n\\\n4. 不要输出固定模板句,不要写“来源链接”,不要写“是本章节的关键来源之一”。\\n\\\n5. 不要把引用账本逐条改写成摘要。\\n\\\n6. 只返回 JSON:{{\\\"global_summary_zh\\\":\\\"...\\\"}}。\"\n )\n}\n\nfn parse_chapter_draft_content(\n content: &str,\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n) -> Result {\n let output =\n serde_json::from_str::(strip_json_fence(content)).map_err(|err| {\n AppError::Llm(format!(\n \"Writer response was not valid ChapterDraft JSON: {err}\"\n ))\n })?;\n let paragraphs =\n parse_paragraph_outputs(output.paragraphs, evidence_items, MAX_CHAPTER_PARAGRAPHS)?;\n\n Ok(ChapterDraft {\n chapter_id: chapter.id.clone(),\n title_zh: chapter.title_zh.clone(),\n paragraphs,\n })\n}\n\nfn parse_paragraph_outputs(\n paragraph_outputs: Vec,\n evidence_items: &[EvidenceItem],\n max_paragraphs: usize,\n) -> Result> {\n let allowed_ids = evidence_items\n .iter()\n .map(|item| item.evidence_id.as_str())\n .collect::>();\n let mut paragraphs = Vec::new();\n\n for paragraph in paragraph_outputs.into_iter().take(max_paragraphs) {\n let text_zh = paragraph.text_zh.trim().to_string();\n if text_zh.is_empty() {\n continue;\n }\n reject_template_residue(&text_zh)?;\n let cited_evidence_ids = paragraph\n .cited_evidence_ids\n .into_iter()\n .filter_map(|id| {\n let trimmed = id.trim();\n (!trimmed.is_empty()).then(|| trimmed.to_string())\n })\n .collect::>();\n if cited_evidence_ids.is_empty() {\n return Err(AppError::Llm(\n \"Writer paragraph did not cite any evidence ID\".to_string(),\n ));\n }\n for id in &cited_evidence_ids {\n if !allowed_ids.contains(id.as_str()) {\n return Err(AppError::Llm(format!(\n \"Writer paragraph referenced unknown evidence id `{id}`\"\n )));\n }\n }\n paragraphs.push(ParagraphWithCitations {\n text_zh,\n cited_evidence_ids,\n });\n }\n\n if paragraphs.is_empty() {\n return Err(AppError::Llm(\n \"Writer response contained no usable paragraphs\".to_string(),\n ));\n }\n\n Ok(paragraphs)\n}\n\nfn reject_template_residue(text: &str) -> Result<()> {\n for marker in [\n \"是本章节的关键来源之一\",\n \"来源链接:\",\n \"提供了与主题相关的证据\",\n ] {\n if text.contains(marker) {\n return Err(AppError::Llm(format!(\n \"Writer output still contains legacy template marker `{marker}`\"\n )));\n }\n }\n Ok(())\n}\n\npub fn render_report_markdown(draft: &ReportDraft, citations: &CitationLedger) -> String {\n let mut out = String::new();\n out.push_str(&format!(\"# {}\\n\\n\", draft.title_zh));\n out.push_str(\"## 1. 全局摘要\\n\\n\");\n out.push_str(&draft.global_summary_zh);\n out.push_str(\"\\n\\n\");\n\n for (index, chapter) in draft.chapters.iter().enumerate() {\n out.push_str(&format!(\"## {}. {}\\n\\n\", index + 2, chapter.title_zh));\n for paragraph in &chapter.paragraphs {\n out.push_str(¶graph.text_zh);\n if !paragraph.cited_evidence_ids.is_empty() {\n out.push(' ');\n out.push_str(\n ¶graph\n .cited_evidence_ids\n .iter()\n .map(|id| format!(\"`{id}`\"))\n .collect::>()\n .join(\" \"),\n );\n }\n out.push_str(\"\\n\\n\");\n }\n }\n\n out.push_str(\"## 引用账本\\n\\n\");\n if citations.citations.is_empty() {\n out.push_str(\"- 暂无引用。\\n\");\n } else {\n for citation in &citations.citations {\n out.push_str(&format!(\n \"- `{}` [{}]({}) ({:?})\\n\",\n citation.id, citation.title, citation.url, citation.source_kind\n ));\n }\n }\n out\n}\n\n#[derive(Debug, Serialize)]\nstruct BatchDraftInput {\n chapter_title: String,\n research_question: String,\n batch_id: usize,\n total_batches: usize,\n batch_context_chars: usize,\n evidence_sources: Vec,\n}\n\n#[derive(Debug, Serialize)]\nstruct ChapterSynthesisInput {\n chapter_title: String,\n research_question: String,\n allowed_evidence_ids: Vec,\n batch_drafts: Vec,\n}\n\n#[derive(Debug, Serialize)]\nstruct GlobalSummaryInput {\n topic: String,\n chapter_count: usize,\n evidence_count: usize,\n chapters: Vec,\n}\n\nimpl GlobalSummaryInput {\n fn from_report(topic: &str, chapter_drafts: &[ChapterDraft], evidence_count: usize) -> Self {\n Self {\n topic: topic.to_string(),\n chapter_count: chapter_drafts.len(),\n evidence_count,\n chapters: chapter_drafts\n .iter()\n .map(GlobalSummaryChapterInput::from)\n .collect(),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct GlobalSummaryChapterInput {\n chapter_id: String,\n title_zh: String,\n paragraph_count: usize,\n cited_evidence_ids: Vec,\n paragraph_summaries: Vec,\n}\n\nimpl From<&ChapterDraft> for GlobalSummaryChapterInput {\n fn from(chapter: &ChapterDraft) -> Self {\n let cited_evidence_ids = chapter\n .paragraphs\n .iter()\n .flat_map(|paragraph| paragraph.cited_evidence_ids.iter().cloned())\n .collect::>()\n .into_iter()\n .collect();\n Self {\n chapter_id: chapter.chapter_id.clone(),\n title_zh: chapter.title_zh.clone(),\n paragraph_count: chapter.paragraphs.len(),\n cited_evidence_ids,\n paragraph_summaries: chapter\n .paragraphs\n .iter()\n .map(|paragraph| excerpt_chars(¶graph.text_zh, 500))\n .collect(),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct EvidenceSourceInput {\n evidence_id: String,\n title: String,\n url: String,\n source_kind: SourceKind,\n evidence_note_zh: String,\n evidence_snippet: String,\n}\n\nimpl From<&EvidenceItem> for EvidenceSourceInput {\n fn from(item: &EvidenceItem) -> Self {\n Self {\n evidence_id: item.evidence_id.clone(),\n title: excerpt_chars(&item.title, WRITER_TITLE_MAX_CHARS),\n url: item.url.clone(),\n source_kind: item.source_kind,\n evidence_note_zh: excerpt_chars(&item.evidence_note_zh, WRITER_EVIDENCE_NOTE_MAX_CHARS),\n evidence_snippet: excerpt_chars(\n &item.evidence_snippet,\n writer_snippet_max_chars(item.source_kind),\n ),\n }\n }\n}\n\nfn writer_snippet_max_chars(source_kind: SourceKind) -> usize {\n match source_kind {\n SourceKind::GitHub => WRITER_GITHUB_SNIPPET_MAX_CHARS,\n SourceKind::Arxiv | SourceKind::AcademicIndex => WRITER_ACADEMIC_SNIPPET_MAX_CHARS,\n SourceKind::Bibliography => WRITER_BIBLIOGRAPHY_SNIPPET_MAX_CHARS,\n }\n}\n\n#[derive(Debug, Deserialize)]\nstruct ChapterDraftOutput {\n #[serde(default)]\n paragraphs: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct BatchDraftOutput {\n #[serde(default)]\n findings_summary_zh: String,\n #[serde(default)]\n paragraphs: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GlobalSummaryOutput {\n #[serde(default)]\n global_summary_zh: String,\n}\n\n#[derive(Debug, Deserialize)]\nstruct ChapterParagraphOutput {\n text_zh: String,\n #[serde(default)]\n cited_evidence_ids: Vec,\n}\n\n#[cfg(test)]\nmod tests {\n use chrono::{DateTime, Utc};\n\n use crate::llm::deepseek::DeepSeekConfig;\n use crate::model::{\n ChapterDraft, ChapterNode, EvidenceItem, EvidenceMemory, ParagraphWithCitations, SourceKind,\n };\n\n use super::{\n batch_chapter_evidence, build_batch_draft_request, build_chapter_synthesis_request,\n build_global_summary_request, draft_report_with_llm, empty_chapter_draft,\n parse_batch_draft_content, parse_chapter_draft_content, parse_global_summary_content,\n select_evidence_for_writing, writer_max_tokens, BatchDraft, MAX_BATCH_CONTEXT_CHARS,\n MAX_EVIDENCE_PER_BATCH, MAX_WRITER_EVIDENCE_PER_CHAPTER, WRITER_BIBLIOGRAPHY_EVIDENCE_CAP,\n WRITER_MIN_OUTPUT_TOKENS,\n };\n\n #[test]\n fn writer_drafts_empty_chapter_with_warning_text() {\n let chapters = vec![ChapterNode {\n id: \"ch-1\".to_string(),\n parent_id: None,\n title_zh: \"核心方向\".to_string(),\n research_question: \"测试\".to_string(),\n required_evidence_kinds: vec![\"github\".to_string()],\n evidence_quota: 1,\n sort_order: 1,\n }];\n\n let draft = empty_chapter_draft(&chapters[0]);\n\n assert_eq!(draft.chapter_id, \"ch-1\");\n assert!(draft.paragraphs[0]\n .text_zh\n .contains(\"尚未获得与章节计划直接匹配\"));\n }\n\n #[tokio::test]\n async fn llm_writer_refuses_to_template_when_llm_is_disabled() {\n let chapters = vec![sample_chapter()];\n let memory = EvidenceMemory {\n items: vec![sample_evidence(\"ev-C1\", \"ch-1\")],\n query_attempts: Vec::new(),\n source_lineage: Vec::new(),\n selection_report: Default::default(),\n };\n\n let err = draft_report_with_llm(\n \"Rust Agent\",\n &chapters,\n &memory,\n &crate::config::LlmConfig::from_env(false, 30),\n )\n .await\n .expect_err(\"disabled LLM should not produce a template report\");\n\n assert!(err\n .to_string()\n .contains(\"refusing to generate a template report\"));\n }\n\n #[tokio::test]\n #[ignore = \"requires a real DeepSeek API key in DEEPSEEK_API_KEY\"]\n async fn writer_real_deepseek_smoke() {\n let chapters = vec![sample_chapter()];\n let memory = EvidenceMemory {\n items: vec![sample_evidence(\"ev-C1\", \"ch-1\")],\n query_attempts: Vec::new(),\n source_lineage: Vec::new(),\n selection_report: Default::default(),\n };\n let config = crate::config::LlmConfig::from_env(true, 120);\n\n assert!(\n config.api_key.is_some(),\n \"DEEPSEEK_API_KEY must be set for this ignored smoke test\"\n );\n\n let draft = draft_report_with_llm(\"TTS 入门级项目简单调研\", &chapters, &memory, &config)\n .await\n .expect(\"real DeepSeek writer smoke should complete\");\n\n assert!(!draft.global_summary_zh.trim().is_empty());\n assert_eq!(draft.chapters.len(), 1);\n assert!(!draft.chapters[0].paragraphs.is_empty());\n assert!(draft.chapters[0].paragraphs[0]\n .cited_evidence_ids\n .contains(&\"ev-C1\".to_string()));\n println!(\n \"writer_real_deepseek_smoke ok: global_summary_chars={}, chapter_paragraphs={}\",\n draft.global_summary_zh.chars().count(),\n draft.chapters[0].paragraphs.len()\n );\n }\n\n #[test]\n fn parses_llm_chapter_draft_json() {\n let chapter = sample_chapter();\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let content = r#\"{\n \"paragraphs\": [\n {\n \"text_zh\": \"该项目展示了从 README 提炼出的 ReAct 主循环和工具调用结构,适合作为入门实现分析对象。\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let draft = parse_chapter_draft_content(content, &chapter, &evidence)\n .expect(\"valid chapter JSON should parse\");\n\n assert_eq!(draft.chapter_id, \"ch-1\");\n assert_eq!(draft.paragraphs[0].cited_evidence_ids, vec![\"ev-C1\"]);\n assert!(draft.paragraphs[0].text_zh.contains(\"ReAct\"));\n }\n\n #[test]\n fn rejects_llm_chapter_draft_with_unknown_evidence_id() {\n let chapter = sample_chapter();\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let content = r#\"{\n \"paragraphs\": [\n {\n \"text_zh\": \"引用了不存在的证据。\",\n \"cited_evidence_ids\": [\"ev-C9\"]\n }\n ]\n }\"#;\n\n let err = parse_chapter_draft_content(content, &chapter, &evidence)\n .expect_err(\"unknown evidence id should fail\");\n\n assert!(err.to_string().contains(\"unknown evidence id\"));\n }\n\n #[test]\n fn rejects_llm_chapter_draft_with_template_residue() {\n let chapter = sample_chapter();\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let content = r#\"{\n \"paragraphs\": [\n {\n \"text_zh\": \"acme/rust-agent 是本章节的关键来源之一。来源链接:https://github.com/acme/rust-agent\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let err = parse_chapter_draft_content(content, &chapter, &evidence)\n .expect_err(\"legacy template residue should fail\");\n\n assert!(err.to_string().contains(\"legacy template marker\"));\n }\n\n #[test]\n fn batches_small_chapter_evidence_into_one_batch() {\n let evidence = vec![\n sample_evidence(\"ev-C1\", \"ch-1\"),\n sample_evidence(\"ev-C2\", \"ch-1\"),\n sample_evidence(\"ev-C3\", \"ch-1\"),\n ];\n\n let batches = batch_chapter_evidence(&evidence);\n\n assert_eq!(batches.len(), 1);\n assert_eq!(batches[0].batch_id, 1);\n assert_eq!(batches[0].evidence_items.len(), 3);\n assert!(batches[0].context_chars > 0);\n }\n\n #[test]\n fn selects_bounded_mixed_evidence_for_writing() {\n let mut evidence = Vec::new();\n evidence.extend((1..=5).map(|index| {\n sample_evidence_with_kind(&format!(\"ev-A{index}\"), \"ch-1\", SourceKind::Arxiv)\n }));\n evidence.extend((1..=4).map(|index| {\n sample_evidence_with_kind(&format!(\"ev-S{index}\"), \"ch-1\", SourceKind::AcademicIndex)\n }));\n evidence.extend((1..=4).map(|index| {\n sample_evidence_with_kind(&format!(\"ev-G{index}\"), \"ch-1\", SourceKind::GitHub)\n }));\n evidence.extend((1..=2).map(|index| {\n sample_evidence_with_kind(&format!(\"ev-B{index}\"), \"ch-1\", SourceKind::Bibliography)\n }));\n\n let selected = select_evidence_for_writing(&evidence);\n let bibliography_count = selected\n .iter()\n .filter(|item| item.source_kind == SourceKind::Bibliography)\n .count();\n\n assert_eq!(selected.len(), MAX_WRITER_EVIDENCE_PER_CHAPTER);\n assert_eq!(bibliography_count, 0);\n assert_eq!(selected[0].source_kind, SourceKind::Arxiv);\n assert_eq!(selected[4].source_kind, SourceKind::AcademicIndex);\n assert_eq!(selected[7].source_kind, SourceKind::GitHub);\n }\n\n #[test]\n fn selects_bibliography_only_when_no_stronger_sources_exist() {\n let evidence = (1..=3)\n .map(|index| {\n sample_evidence_with_kind(&format!(\"ev-B{index}\"), \"ch-1\", SourceKind::Bibliography)\n })\n .collect::>();\n\n let selected = select_evidence_for_writing(&evidence);\n\n assert_eq!(selected.len(), WRITER_BIBLIOGRAPHY_EVIDENCE_CAP);\n assert_eq!(selected[0].source_kind, SourceKind::Bibliography);\n }\n\n #[test]\n fn batches_chapter_evidence_by_count() {\n let evidence = (1..=9)\n .map(|index| sample_evidence(&format!(\"ev-C{index}\"), \"ch-1\"))\n .collect::>();\n\n let batches = batch_chapter_evidence(&evidence);\n\n assert_eq!(batches.len(), 3);\n assert_eq!(batches[0].evidence_items.len(), MAX_EVIDENCE_PER_BATCH);\n assert_eq!(batches[1].evidence_items.len(), MAX_EVIDENCE_PER_BATCH);\n assert_eq!(batches[2].evidence_items.len(), MAX_EVIDENCE_PER_BATCH);\n assert_eq!(batches[2].batch_id, 3);\n }\n\n #[test]\n fn batches_chapter_evidence_by_sanitized_context_chars() {\n let mut first = sample_evidence(\"ev-C1\", \"ch-1\");\n first.evidence_snippet = \"a\".repeat(MAX_BATCH_CONTEXT_CHARS);\n let mut second = sample_evidence(\"ev-C2\", \"ch-1\");\n second.evidence_snippet = \"b\".repeat(MAX_BATCH_CONTEXT_CHARS);\n let mut third = sample_evidence(\"ev-C3\", \"ch-1\");\n third.evidence_snippet = \"c\".repeat(MAX_BATCH_CONTEXT_CHARS);\n let mut fourth = sample_evidence(\"ev-C4\", \"ch-1\");\n fourth.evidence_snippet = \"d\".repeat(MAX_BATCH_CONTEXT_CHARS);\n\n let batches = batch_chapter_evidence(&[first, second, third, fourth]);\n\n assert_eq!(batches.len(), 2);\n assert_eq!(batches[0].evidence_items.len(), 3);\n assert_eq!(batches[1].evidence_items.len(), 1);\n assert!(batches[0].context_chars <= MAX_BATCH_CONTEXT_CHARS);\n }\n\n #[test]\n fn batch_request_truncates_long_github_snippets_for_writer_context() {\n let chapter = sample_chapter();\n let mut evidence = sample_evidence(\"ev-C1\", \"ch-1\");\n evidence.evidence_snippet = format!(\"{}{}\", \"A\".repeat(1500), \"UNTRUNCATED_TAIL\");\n let batches = batch_chapter_evidence(&[evidence]);\n\n let request = build_batch_draft_request(\n &chapter,\n &batches[0],\n batches.len(),\n &sample_deepseek_config(4096),\n )\n .expect(\"request should build\");\n let user_message = &request.messages[1].content;\n\n assert!(user_message.contains(&\"A\".repeat(900)));\n assert!(!user_message.contains(\"UNTRUNCATED_TAIL\"));\n }\n\n #[test]\n fn parses_llm_batch_draft_json() {\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batches = batch_chapter_evidence(&evidence);\n let content = r#\"{\n \"findings_summary_zh\": \"该批证据说明了项目 README 中的 ReAct 主循环、工具注册和入门示例。\",\n \"paragraphs\": [\n {\n \"text_zh\": \"该项目的 README 将 ReAct 主循环和工具注册拆成清晰的入门实现,适合用于说明基础 agent 架构。\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let draft =\n parse_batch_draft_content(content, &batches[0]).expect(\"valid batch JSON should parse\");\n\n assert_eq!(draft.batch_id, 1);\n assert!(draft.findings_summary_zh.contains(\"ReAct\"));\n assert_eq!(draft.paragraphs[0].cited_evidence_ids, vec![\"ev-C1\"]);\n }\n\n #[test]\n fn rejects_llm_batch_draft_with_empty_findings() {\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batches = batch_chapter_evidence(&evidence);\n let content = r#\"{\n \"findings_summary_zh\": \"\",\n \"paragraphs\": [\n {\n \"text_zh\": \"该项目展示了一个基础实现。\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let err = parse_batch_draft_content(content, &batches[0])\n .expect_err(\"empty findings should fail\");\n\n assert!(err.to_string().contains(\"empty findings_summary_zh\"));\n }\n\n #[test]\n fn rejects_llm_batch_draft_with_unknown_evidence_id() {\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batches = batch_chapter_evidence(&evidence);\n let content = r#\"{\n \"findings_summary_zh\": \"该批证据有一个段落引用了不存在的证据。\",\n \"paragraphs\": [\n {\n \"text_zh\": \"这里引用了不存在的 evidence id。\",\n \"cited_evidence_ids\": [\"ev-C2\"]\n }\n ]\n }\"#;\n\n let err = parse_batch_draft_content(content, &batches[0])\n .expect_err(\"unknown evidence id should fail\");\n\n assert!(err.to_string().contains(\"unknown evidence id\"));\n }\n\n #[test]\n fn rejects_llm_batch_draft_with_template_residue() {\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batches = batch_chapter_evidence(&evidence);\n let content = r#\"{\n \"findings_summary_zh\": \"acme/rust-agent 是本章节的关键来源之一。\",\n \"paragraphs\": [\n {\n \"text_zh\": \"该项目展示了一个基础实现。\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let err = parse_batch_draft_content(content, &batches[0])\n .expect_err(\"legacy template residue should fail\");\n\n assert!(err.to_string().contains(\"legacy template marker\"));\n }\n\n #[test]\n fn batch_request_only_contains_current_batch_evidence() {\n let chapter = sample_chapter();\n let evidence = (1..=5)\n .map(|index| sample_evidence(&format!(\"ev-C{index}\"), \"ch-1\"))\n .collect::>();\n let batches = batch_chapter_evidence(&evidence);\n\n let request = build_batch_draft_request(\n &chapter,\n &batches[1],\n batches.len(),\n &sample_deepseek_config(2048),\n )\n .expect(\"request should build\");\n let user_message = &request.messages[1].content;\n\n assert!(user_message.contains(\"\\\"batch_id\\\": 2\"));\n assert!(user_message.contains(\"ev-C5\"));\n assert!(!user_message.contains(\"ev-C1\"));\n assert_eq!(request.max_tokens, Some(WRITER_MIN_OUTPUT_TOKENS as u32));\n }\n\n #[test]\n fn writer_max_tokens_raises_too_small_config_for_reasoning_models() {\n let config = sample_deepseek_config(32);\n\n assert_eq!(writer_max_tokens(&config), WRITER_MIN_OUTPUT_TOKENS as u32);\n }\n\n #[test]\n fn chapter_synthesis_request_uses_batch_findings_instead_of_raw_evidence() {\n let chapter = sample_chapter();\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batch_drafts = vec![BatchDraft {\n batch_id: 1,\n findings_summary_zh: \"该批证据说明 README 中的 ReAct 主循环适合作为入门样例。\"\n .to_string(),\n paragraphs: vec![ParagraphWithCitations {\n text_zh: \"该项目展示了 ReAct 主循环和工具注册。\".to_string(),\n cited_evidence_ids: vec![\"ev-C1\".to_string()],\n }],\n }];\n\n let request = build_chapter_synthesis_request(\n &chapter,\n &evidence,\n &batch_drafts,\n &sample_deepseek_config(4096),\n )\n .expect(\"request should build\");\n let user_message = &request.messages[1].content;\n\n assert!(user_message.contains(\"batch_drafts\"));\n assert!(user_message.contains(\"allowed_evidence_ids\"));\n assert!(user_message.contains(\"ReAct 主循环适合作为入门样例\"));\n assert!(!user_message.contains(\"evidence_snippet\"));\n assert!(!user_message.contains(\"README explains ReAct loop\"));\n }\n\n #[test]\n fn parses_llm_global_summary_json() {\n let content = r#\"{\n \"global_summary_zh\": \"本报告显示,可控 TTS 入门调研应同时关注基础项目、说话人控制和模型框架边界;现有证据主要来自 GitHub README 与 arXiv 摘要,因此结论应限定在这些来源范围内。\"\n }\"#;\n\n let summary =\n parse_global_summary_content(content).expect(\"valid global summary should parse\");\n\n assert!(summary.contains(\"可控 TTS\"));\n assert!(summary.contains(\"来源范围\"));\n }\n\n #[test]\n fn rejects_llm_global_summary_with_template_residue() {\n let content = r#\"{\n \"global_summary_zh\": \"harinandanmv/text-to-speech 是本章节的关键来源之一。\"\n }\"#;\n\n let err =\n parse_global_summary_content(content).expect_err(\"legacy template marker should fail\");\n\n assert!(err.to_string().contains(\"legacy template marker\"));\n }\n\n #[test]\n fn global_summary_request_uses_chapter_drafts() {\n let chapter_drafts = vec![ChapterDraft {\n chapter_id: \"ch-1\".to_string(),\n title_zh: \"可控 TTS 基础模型\".to_string(),\n paragraphs: vec![ParagraphWithCitations {\n text_zh: \"该章节围绕基础 TTS 模型、说话人控制和入门项目进行综合分析。\".to_string(),\n cited_evidence_ids: vec![\"ev-C1\".to_string()],\n }],\n }];\n\n let request = build_global_summary_request(\n \"TTS 入门级项目简单调研\",\n &chapter_drafts,\n 1,\n &sample_deepseek_config(4096),\n )\n .expect(\"request should build\");\n let user_message = &request.messages[1].content;\n\n assert!(user_message.contains(\"TTS 入门级项目简单调研\"));\n assert!(user_message.contains(\"可控 TTS 基础模型\"));\n assert!(user_message.contains(\"ev-C1\"));\n assert!(user_message.contains(\"paragraph_summaries\"));\n }\n\n fn sample_chapter() -> ChapterNode {\n ChapterNode {\n id: \"ch-1\".to_string(),\n parent_id: None,\n title_zh: \"核心方向\".to_string(),\n research_question: \"测试\".to_string(),\n required_evidence_kinds: vec![\"github\".to_string()],\n evidence_quota: 1,\n sort_order: 1,\n }\n }\n\n fn sample_evidence(evidence_id: &str, chapter_id: &str) -> EvidenceItem {\n sample_evidence_with_kind(evidence_id, chapter_id, SourceKind::GitHub)\n }\n\n fn sample_evidence_with_kind(\n evidence_id: &str,\n chapter_id: &str,\n source_kind: SourceKind,\n ) -> EvidenceItem {\n EvidenceItem {\n evidence_id: evidence_id.to_string(),\n source_item_id: format!(\"{source_kind:?}:acme/rust-agent\"),\n citation_id: \"C1\".to_string(),\n chapter_ids: vec![chapter_id.to_string()],\n query_attempt_ids: vec![\"gh-1\".to_string()],\n source_kind,\n title: \"acme/rust-agent\".to_string(),\n url: \"https://github.com/acme/rust-agent\".to_string(),\n evidence_note_zh: \"GitHub 仓库 `acme/rust-agent`:Rust agent framework\".to_string(),\n evidence_snippet: \"README explains ReAct loop, tool registry, and examples.\"\n .to_string(),\n support_score: None,\n }\n }\n\n fn sample_deepseek_config(max_tokens: usize) -> DeepSeekConfig {\n DeepSeekConfig {\n api_key: \"sk-test\".to_string(),\n base_url: \"https://api.deepseek.com\".to_string(),\n main_model: \"deepseek-v4-pro\".to_string(),\n side_model: \"deepseek-v4-flash\".to_string(),\n max_tokens,\n timeout_secs: 120,\n }\n }\n\n fn _dt() -> DateTime {\n DateTime::parse_from_rfc3339(\"2026-05-30T12:00:00Z\")\n .unwrap()\n .with_timezone(&Utc)\n }\n}\n"}, "f0e381df64f2448f45cfc7566949703029fd6216": {"t": 1785750660.7074842, "n": 49922, "h": "76c4cc62091cd5a3", "c": "use crate::config::LlmConfig;\nuse crate::error::{AppError, Result};\nuse crate::llm::deepseek::{\n strip_json_fence, ChatCompletionsRequest, ChatMessage, DeepSeekClient, DeepSeekConfig,\n ResponseFormat,\n};\nuse crate::model::{\n ChapterDraft, ChapterNode, CitationLedger, EvidenceItem, EvidenceMemory,\n ParagraphWithCitations, ReportDraft, SourceKind,\n};\nuse chrono::Utc;\nuse serde::{Deserialize, Serialize};\nuse std::collections::BTreeSet;\nuse tracing::{debug, warn};\n\nconst MAX_EVIDENCE_PER_BATCH: usize = 3;\nconst MAX_BATCH_CONTEXT_CHARS: usize = 3_500;\nconst MAX_BATCH_PARAGRAPHS: usize = 1;\nconst MAX_CHAPTER_PARAGRAPHS: usize = 4;\nconst MAX_WRITER_EVIDENCE_PER_CHAPTER: usize = 8;\nconst WRITER_GITHUB_EVIDENCE_CAP: usize = 4;\nconst WRITER_ARXIV_EVIDENCE_CAP: usize = 4;\nconst WRITER_ACADEMIC_INDEX_EVIDENCE_CAP: usize = 3;\nconst WRITER_BIBLIOGRAPHY_EVIDENCE_CAP: usize = 1;\nconst BATCH_FINDINGS_TARGET_CHARS: usize = 180;\nconst BATCH_PARAGRAPH_TARGET_CHARS: usize = 220;\nconst CHAPTER_PARAGRAPH_TARGET_CHARS: usize = 260;\nconst GLOBAL_SUMMARY_TARGET_CHARS: usize = 420;\nconst WRITER_TITLE_MAX_CHARS: usize = 180;\nconst WRITER_EVIDENCE_NOTE_MAX_CHARS: usize = 220;\nconst WRITER_GITHUB_SNIPPET_MAX_CHARS: usize = 900;\nconst WRITER_ACADEMIC_SNIPPET_MAX_CHARS: usize = 700;\nconst WRITER_BIBLIOGRAPHY_SNIPPET_MAX_CHARS: usize = 200;\nconst REPAIR_PREVIOUS_OUTPUT_MAX_CHARS: usize = 2_400;\nconst WRITER_MIN_OUTPUT_TOKENS: usize = 4096;\nconst WRITER_MAX_OUTPUT_TOKENS: usize = 8192;\n\npub async fn draft_report_with_llm(\n topic: &str,\n chapters: &[ChapterNode],\n memory: &EvidenceMemory,\n llm_config: &LlmConfig,\n) -> Result {\n let deepseek_config = DeepSeekConfig::from_llm_config(llm_config).ok_or_else(|| {\n AppError::InvalidConfig(\n \"Writer LLM requires an enabled DeepSeek config with API key; refusing to generate a template report.\"\n .to_string(),\n )\n })?;\n let client = DeepSeekClient::new(deepseek_config.clone())?;\n let mut chapter_drafts = Vec::with_capacity(chapters.len());\n\n for chapter in chapters {\n let evidence_items = memory.by_chapter(&chapter.id);\n if evidence_items.is_empty() {\n chapter_drafts.push(empty_chapter_draft(chapter));\n continue;\n }\n\n let draft = draft_chapter_with_llm(chapter, &evidence_items, &client, &deepseek_config)\n .await\n .map_err(|err| {\n AppError::Llm(format!(\n \"Writer LLM failed for chapter `{}`; refusing template fallback: {err}\",\n chapter.title_zh\n ))\n })?;\n chapter_drafts.push(draft);\n }\n\n let global_summary_zh = synthesize_global_summary_with_llm(\n topic,\n &chapter_drafts,\n memory.items.len(),\n &client,\n &deepseek_config,\n )\n .await\n .map_err(|err| {\n AppError::Llm(format!(\n \"Writer global summary failed; refusing deterministic fallback: {err}\"\n ))\n })?;\n\n Ok(ReportDraft {\n title_zh: format!(\"LitScout-RS 调研报告:{topic}\"),\n chapters: chapter_drafts,\n global_summary_zh,\n written_at: Utc::now(),\n })\n}\n\nasync fn draft_chapter_with_llm(\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n client: &DeepSeekClient,\n config: &DeepSeekConfig,\n) -> Result {\n let selected_evidence = select_evidence_for_writing(evidence_items);\n let batches = batch_chapter_evidence(&selected_evidence);\n debug!(\n \"Writer selected {}/{} evidence items and split chapter `{}` into {} evidence batches\",\n selected_evidence.len(),\n evidence_items.len(),\n chapter.title_zh,\n batches.len()\n );\n\n let mut batch_drafts = Vec::with_capacity(batches.len());\n for batch in &batches {\n let draft = draft_batch_with_llm(chapter, batch, batches.len(), client, config).await?;\n batch_drafts.push(draft);\n }\n\n synthesize_chapter_from_batches(chapter, &selected_evidence, &batch_drafts, client, config)\n .await\n}\n\nfn empty_chapter_draft(chapter: &ChapterNode) -> ChapterDraft {\n ChapterDraft {\n chapter_id: chapter.id.clone(),\n title_zh: chapter.title_zh.clone(),\n paragraphs: vec![ParagraphWithCitations {\n text_zh: format!(\n \"当前章节 `{}` 尚未获得与章节计划直接匹配的 GitHub/arXiv 证据,因此不生成分析段落。\",\n chapter.title_zh\n ),\n cited_evidence_ids: Vec::new(),\n }],\n }\n}\n\n#[derive(Debug, Clone)]\nstruct EvidenceBatch {\n batch_id: usize,\n evidence_items: Vec,\n context_chars: usize,\n}\n\n#[derive(Debug, Clone, Serialize)]\nstruct BatchDraft {\n batch_id: usize,\n findings_summary_zh: String,\n paragraphs: Vec,\n}\n\nfn batch_chapter_evidence(evidence_items: &[EvidenceItem]) -> Vec {\n let mut batches = Vec::new();\n let mut current_items = Vec::new();\n let mut current_chars = 0usize;\n\n for item in evidence_items {\n let item_chars = evidence_context_chars(item);\n let count_limit_reached = current_items.len() >= MAX_EVIDENCE_PER_BATCH;\n let char_limit_reached =\n !current_items.is_empty() && current_chars + item_chars > MAX_BATCH_CONTEXT_CHARS;\n\n if count_limit_reached || char_limit_reached {\n batches.push(EvidenceBatch {\n batch_id: batches.len() + 1,\n evidence_items: current_items,\n context_chars: current_chars,\n });\n current_items = Vec::new();\n current_chars = 0;\n }\n\n current_chars += item_chars;\n current_items.push(item.clone());\n }\n\n if !current_items.is_empty() {\n batches.push(EvidenceBatch {\n batch_id: batches.len() + 1,\n evidence_items: current_items,\n context_chars: current_chars,\n });\n }\n\n batches\n}\n\nfn select_evidence_for_writing(evidence_items: &[EvidenceItem]) -> Vec {\n let mut selected = Vec::new();\n let has_stronger_sources = evidence_items\n .iter()\n .any(|item| item.source_kind != SourceKind::Bibliography);\n\n append_evidence_by_kind(\n evidence_items,\n &mut selected,\n SourceKind::Arxiv,\n WRITER_ARXIV_EVIDENCE_CAP,\n );\n append_evidence_by_kind(\n evidence_items,\n &mut selected,\n SourceKind::AcademicIndex,\n WRITER_ACADEMIC_INDEX_EVIDENCE_CAP,\n );\n append_evidence_by_kind(\n evidence_items,\n &mut selected,\n SourceKind::GitHub,\n WRITER_GITHUB_EVIDENCE_CAP,\n );\n if !has_stronger_sources {\n append_evidence_by_kind(\n evidence_items,\n &mut selected,\n SourceKind::Bibliography,\n WRITER_BIBLIOGRAPHY_EVIDENCE_CAP,\n );\n }\n\n selected\n}\n\nfn append_evidence_by_kind(\n evidence_items: &[EvidenceItem],\n selected: &mut Vec,\n source_kind: SourceKind,\n source_cap: usize,\n) {\n let mut source_count = 0usize;\n for item in evidence_items\n .iter()\n .filter(|item| item.source_kind == source_kind)\n {\n if selected.len() >= MAX_WRITER_EVIDENCE_PER_CHAPTER || source_count >= source_cap {\n break;\n }\n selected.push(item.clone());\n source_count += 1;\n }\n}\n\nfn evidence_context_chars(item: &EvidenceItem) -> usize {\n let input = EvidenceSourceInput::from(item);\n input.evidence_id.chars().count()\n + input.title.chars().count()\n + input.url.chars().count()\n + input.evidence_note_zh.chars().count()\n + input.evidence_snippet.chars().count()\n}\n\nfn excerpt_chars(text: &str, max_chars: usize) -> String {\n let trimmed = text.trim();\n if trimmed.chars().count() <= max_chars {\n return trimmed.to_string();\n }\n\n let mut out = trimmed.chars().take(max_chars).collect::();\n out.push_str(\"...\");\n out\n}\n\nfn parse_batch_draft_content(content: &str, batch: &EvidenceBatch) -> Result {\n let output =\n serde_json::from_str::(strip_json_fence(content)).map_err(|err| {\n AppError::Llm(format!(\n \"Writer response was not valid BatchDraft JSON: {err}\"\n ))\n })?;\n let findings_summary_zh = output.findings_summary_zh.trim().to_string();\n if findings_summary_zh.is_empty() {\n return Err(AppError::Llm(\n \"Writer batch response contained empty findings_summary_zh\".to_string(),\n ));\n }\n reject_template_residue(&findings_summary_zh)?;\n let paragraphs = parse_paragraph_outputs(\n output.paragraphs,\n &batch.evidence_items,\n MAX_BATCH_PARAGRAPHS,\n )?;\n\n Ok(BatchDraft {\n batch_id: batch.batch_id,\n findings_summary_zh,\n paragraphs,\n })\n}\n\nasync fn draft_batch_with_llm(\n chapter: &ChapterNode,\n batch: &EvidenceBatch,\n total_batches: usize,\n client: &DeepSeekClient,\n config: &DeepSeekConfig,\n) -> Result {\n let request = build_batch_draft_request(chapter, batch, total_batches, config)?;\n let response = client.chat_completions_with_retry(request).await?;\n let content = response.first_content()?;\n\n match parse_batch_draft_content(content, batch) {\n Ok(draft) => Ok(draft),\n Err(first_err) => {\n warn!(\n \"Writer LLM JSON validation failed for chapter `{}` batch {}; requesting one repair: {first_err}\",\n chapter.title_zh, batch.batch_id\n );\n let repair_request = build_batch_draft_repair_request(\n chapter,\n batch,\n total_batches,\n config,\n content,\n &first_err.to_string(),\n )?;\n let repaired_response = client.chat_completions_with_retry(repair_request).await?;\n let repaired_content = repaired_response.first_content()?;\n parse_batch_draft_content(repaired_content, batch)\n }\n }\n}\n\nfn build_batch_draft_request(\n chapter: &ChapterNode,\n batch: &EvidenceBatch,\n total_batches: usize,\n config: &DeepSeekConfig,\n) -> Result {\n let context = BatchDraftInput {\n chapter_title: chapter.title_zh.clone(),\n research_question: chapter.research_question.clone(),\n batch_id: batch.batch_id,\n total_batches,\n batch_context_chars: batch.context_chars,\n evidence_sources: batch\n .evidence_items\n .iter()\n .map(EvidenceSourceInput::from)\n .collect(),\n };\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: batch_writer_system_prompt(&chapter.title_zh),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请为下面章节的第 {}/{} 个证据批次生成局部分析草稿。只返回 JSON 对象,不要输出 Markdown 代码围栏之外的解释。\\n\\n{context_json}\",\n batch.batch_id, total_batches\n ),\n },\n ],\n temperature: Some(0.3),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn build_batch_draft_repair_request(\n chapter: &ChapterNode,\n batch: &EvidenceBatch,\n total_batches: usize,\n config: &DeepSeekConfig,\n previous_output: &str,\n validation_error: &str,\n) -> Result {\n let context = BatchDraftInput {\n chapter_title: chapter.title_zh.clone(),\n research_question: chapter.research_question.clone(),\n batch_id: batch.batch_id,\n total_batches,\n batch_context_chars: batch.context_chars,\n evidence_sources: batch\n .evidence_items\n .iter()\n .map(EvidenceSourceInput::from)\n .collect(),\n };\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n let previous_excerpt = excerpt_chars(previous_output, REPAIR_PREVIOUS_OUTPUT_MAX_CHARS);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: batch_writer_system_prompt(&chapter.title_zh),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请修复上一次第 {}/{} 个证据批次的输出,使其满足 JSON schema、引用约束和长度约束。只返回修复后的 JSON 对象。\\n\\n校验错误:\\n{validation_error}\\n\\n上一次输出节选:\\n{previous_excerpt}\\n\\n允许使用的 evidence:\\n{context_json}\",\n batch.batch_id, total_batches\n ),\n },\n ],\n temperature: Some(0.1),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nasync fn synthesize_chapter_from_batches(\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n batch_drafts: &[BatchDraft],\n client: &DeepSeekClient,\n config: &DeepSeekConfig,\n) -> Result {\n let request = build_chapter_synthesis_request(chapter, evidence_items, batch_drafts, config)?;\n let response = client.chat_completions_with_retry(request).await?;\n let content = response.first_content()?;\n\n match parse_chapter_draft_content(content, chapter, evidence_items) {\n Ok(draft) => Ok(draft),\n Err(first_err) => {\n warn!(\n \"Writer LLM chapter synthesis validation failed for chapter `{}`; requesting one repair: {first_err}\",\n chapter.title_zh\n );\n let repair_request = build_chapter_synthesis_repair_request(\n chapter,\n evidence_items,\n batch_drafts,\n config,\n content,\n &first_err.to_string(),\n )?;\n let repaired_response = client.chat_completions_with_retry(repair_request).await?;\n let repaired_content = repaired_response.first_content()?;\n parse_chapter_draft_content(repaired_content, chapter, evidence_items)\n }\n }\n}\n\nfn batch_writer_system_prompt(chapter_title: &str) -> String {\n format!(\n \"你是 LitScout-RS 的技术报告撰稿人,正在为 `{chapter_title}` 章节处理一个 evidence batch。\\n\\\n规则:\\n\\\n1. 使用中文撰写。\\n\\\n2. findings_summary_zh 不超过 {BATCH_FINDINGS_TARGET_CHARS} 个汉字,只总结本批证据的关键发现、共性和边界。\\n\\\n3. paragraphs 输出 1-{MAX_BATCH_PARAGRAPHS} 段,每段不超过 {BATCH_PARAGRAPH_TARGET_CHARS} 个汉字;提炼项目/论文简介、核心亮点或设计思想,以及与章节问题的具体关联。\\n\\\n4. 可合并多个来源做对比分析,但每个段落必须引用至少一个 evidence_id。\\n\\\n5. 只能使用用户提供的 evidence_id 和 URL,不得新增来源、不得编造事实。\\n\\\n6. 不要只重复原文,不要输出“来源链接”模板句。\\n\\\n7. 只返回 JSON:{{\\\"findings_summary_zh\\\":\\\"...\\\",\\\"paragraphs\\\":[{{\\\"text_zh\\\":\\\"...\\\",\\\"cited_evidence_ids\\\":[\\\"ev-C1\\\"]}}]}}。\"\n )\n}\n\nfn writer_max_tokens(config: &DeepSeekConfig) -> u32 {\n config\n .max_tokens\n .max(WRITER_MIN_OUTPUT_TOKENS)\n .min(WRITER_MAX_OUTPUT_TOKENS)\n .min(u32::MAX as usize) as u32\n}\n\nfn build_chapter_synthesis_request(\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n batch_drafts: &[BatchDraft],\n config: &DeepSeekConfig,\n) -> Result {\n let context = ChapterSynthesisInput {\n chapter_title: chapter.title_zh.clone(),\n research_question: chapter.research_question.clone(),\n allowed_evidence_ids: evidence_items\n .iter()\n .map(|item| item.evidence_id.clone())\n .collect(),\n batch_drafts: batch_drafts.to_vec(),\n };\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: chapter_synthesizer_system_prompt(&chapter.title_zh),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请基于已验证的 batch findings 合成章节终稿。只返回 JSON 对象,不要输出 Markdown 代码围栏之外的解释。\\n\\n{context_json}\"\n ),\n },\n ],\n temperature: Some(0.25),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn build_chapter_synthesis_repair_request(\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n batch_drafts: &[BatchDraft],\n config: &DeepSeekConfig,\n previous_output: &str,\n validation_error: &str,\n) -> Result {\n let context = ChapterSynthesisInput {\n chapter_title: chapter.title_zh.clone(),\n research_question: chapter.research_question.clone(),\n allowed_evidence_ids: evidence_items\n .iter()\n .map(|item| item.evidence_id.clone())\n .collect(),\n batch_drafts: batch_drafts.to_vec(),\n };\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n let previous_excerpt = excerpt_chars(previous_output, REPAIR_PREVIOUS_OUTPUT_MAX_CHARS);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: chapter_synthesizer_system_prompt(&chapter.title_zh),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请修复上一次章节综合输出,使其满足 JSON schema、引用约束和长度约束。只返回修复后的 JSON 对象。\\n\\n校验错误:\\n{validation_error}\\n\\n上一次输出节选:\\n{previous_excerpt}\\n\\n允许使用的 batch findings:\\n{context_json}\",\n ),\n },\n ],\n temperature: Some(0.1),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn chapter_synthesizer_system_prompt(chapter_title: &str) -> String {\n format!(\n \"你是 LitScout-RS 的章节综合编辑,正在合成 `{chapter_title}` 章节终稿。\\n\\\n规则:\\n\\\n1. 使用中文撰写自然、分析性的技术报告段落。\\n\\\n2. 只能使用用户提供的 batch findings、batch paragraphs 和 allowed_evidence_ids。\\n\\\n3. 合并重复信息,按主题组织,不要机械罗列每条来源。\\n\\\n4. 已有 evidence 时,不得用“尚未收集到足够证据”替代分析内容。\\n\\\n5. 每个段落必须引用至少一个 allowed_evidence_ids 中的 evidence_id。\\n\\\n6. 不得新增来源、URL 或 evidence_id,不得编造事实。\\n\\\n7. 输出 1-{MAX_CHAPTER_PARAGRAPHS} 段,每段不超过 {CHAPTER_PARAGRAPH_TARGET_CHARS} 个汉字。\\n\\\n8. 不要输出“来源链接”模板句或“是本章节的关键来源之一”。\\n\\\n9. 只返回 JSON:{{\\\"paragraphs\\\":[{{\\\"text_zh\\\":\\\"...\\\",\\\"cited_evidence_ids\\\":[\\\"ev-C1\\\"]}}]}}。\"\n )\n}\n\nasync fn synthesize_global_summary_with_llm(\n topic: &str,\n chapter_drafts: &[ChapterDraft],\n evidence_count: usize,\n client: &DeepSeekClient,\n config: &DeepSeekConfig,\n) -> Result {\n let request = build_global_summary_request(topic, chapter_drafts, evidence_count, config)?;\n let response = client.chat_completions_with_retry(request).await?;\n let content = response.first_content()?;\n\n match parse_global_summary_content(content) {\n Ok(summary) => Ok(summary),\n Err(first_err) => {\n warn!(\n \"Writer LLM global summary validation failed; requesting one repair: {first_err}\"\n );\n let repair_request = build_global_summary_repair_request(\n topic,\n chapter_drafts,\n evidence_count,\n config,\n content,\n &first_err.to_string(),\n )?;\n let repaired_response = client.chat_completions_with_retry(repair_request).await?;\n let repaired_content = repaired_response.first_content()?;\n parse_global_summary_content(repaired_content)\n }\n }\n}\n\nfn build_global_summary_request(\n topic: &str,\n chapter_drafts: &[ChapterDraft],\n evidence_count: usize,\n config: &DeepSeekConfig,\n) -> Result {\n let context = GlobalSummaryInput::from_report(topic, chapter_drafts, evidence_count);\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: global_synthesizer_system_prompt(),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请基于章节终稿生成报告的全局摘要。只返回 JSON 对象,不要输出 Markdown 代码围栏之外的解释。\\n\\n{context_json}\"\n ),\n },\n ],\n temperature: Some(0.25),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn build_global_summary_repair_request(\n topic: &str,\n chapter_drafts: &[ChapterDraft],\n evidence_count: usize,\n config: &DeepSeekConfig,\n previous_output: &str,\n validation_error: &str,\n) -> Result {\n let context = GlobalSummaryInput::from_report(topic, chapter_drafts, evidence_count);\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n let previous_excerpt = excerpt_chars(previous_output, REPAIR_PREVIOUS_OUTPUT_MAX_CHARS);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: global_synthesizer_system_prompt(),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请修复上一次全局摘要输出,使其满足 JSON schema 和写作约束。只返回修复后的 JSON 对象。\\n\\n校验错误:\\n{validation_error}\\n\\n上一次输出节选:\\n{previous_excerpt}\\n\\n章节终稿上下文:\\n{context_json}\",\n ),\n },\n ],\n temperature: Some(0.1),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn parse_global_summary_content(content: &str) -> Result {\n let output =\n serde_json::from_str::(strip_json_fence(content)).map_err(|err| {\n AppError::Llm(format!(\n \"Writer response was not valid GlobalSummary JSON: {err}\"\n ))\n })?;\n let global_summary_zh = output.global_summary_zh.trim().to_string();\n if global_summary_zh.is_empty() {\n return Err(AppError::Llm(\n \"Writer global summary response contained empty global_summary_zh\".to_string(),\n ));\n }\n reject_template_residue(&global_summary_zh)?;\n\n Ok(global_summary_zh)\n}\n\nfn global_synthesizer_system_prompt() -> String {\n format!(\n \"你是 LitScout-RS 的报告总编辑,负责基于章节终稿生成全局摘要。\\n\\\n规则:\\n\\\n1. 使用中文撰写 1-2 段自然的全局摘要,总长度不超过 {GLOBAL_SUMMARY_TARGET_CHARS} 个汉字。\\n\\\n2. 概括报告覆盖范围、主要发现、证据边界和不足。\\n\\\n3. 只能使用用户提供的章节终稿和 citation id,不得新增来源、URL 或事实。\\n\\\n4. 不要输出固定模板句,不要写“来源链接”,不要写“是本章节的关键来源之一”。\\n\\\n5. 不要把引用账本逐条改写成摘要。\\n\\\n6. 只返回 JSON:{{\\\"global_summary_zh\\\":\\\"...\\\"}}。\"\n )\n}\n\nfn parse_chapter_draft_content(\n content: &str,\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n) -> Result {\n let output =\n serde_json::from_str::(strip_json_fence(content)).map_err(|err| {\n AppError::Llm(format!(\n \"Writer response was not valid ChapterDraft JSON: {err}\"\n ))\n })?;\n let paragraphs =\n parse_paragraph_outputs(output.paragraphs, evidence_items, MAX_CHAPTER_PARAGRAPHS)?;\n\n Ok(ChapterDraft {\n chapter_id: chapter.id.clone(),\n title_zh: chapter.title_zh.clone(),\n paragraphs,\n })\n}\n\nfn parse_paragraph_outputs(\n paragraph_outputs: Vec,\n evidence_items: &[EvidenceItem],\n max_paragraphs: usize,\n) -> Result> {\n let allowed_ids = evidence_items\n .iter()\n .map(|item| item.evidence_id.as_str())\n .collect::>();\n let mut paragraphs = Vec::new();\n\n for paragraph in paragraph_outputs.into_iter().take(max_paragraphs) {\n let text_zh = paragraph.text_zh.trim().to_string();\n if text_zh.is_empty() {\n continue;\n }\n reject_template_residue(&text_zh)?;\n let cited_evidence_ids = paragraph\n .cited_evidence_ids\n .into_iter()\n .filter_map(|id| {\n let trimmed = id.trim();\n (!trimmed.is_empty()).then(|| trimmed.to_string())\n })\n .collect::>();\n if cited_evidence_ids.is_empty() {\n return Err(AppError::Llm(\n \"Writer paragraph did not cite any evidence ID\".to_string(),\n ));\n }\n for id in &cited_evidence_ids {\n if !allowed_ids.contains(id.as_str()) {\n return Err(AppError::Llm(format!(\n \"Writer paragraph referenced unknown evidence id `{id}`\"\n )));\n }\n }\n paragraphs.push(ParagraphWithCitations {\n text_zh,\n cited_evidence_ids,\n });\n }\n\n if paragraphs.is_empty() {\n return Err(AppError::Llm(\n \"Writer response contained no usable paragraphs\".to_string(),\n ));\n }\n\n Ok(paragraphs)\n}\n\nfn reject_template_residue(text: &str) -> Result<()> {\n for marker in [\n \"是本章节的关键来源之一\",\n \"来源链接:\",\n \"提供了与主题相关的证据\",\n ] {\n if text.contains(marker) {\n return Err(AppError::Llm(format!(\n \"Writer output still contains legacy template marker `{marker}`\"\n )));\n }\n }\n Ok(())\n}\n\npub fn render_report_markdown(draft: &ReportDraft, citations: &CitationLedger) -> String {\n let mut out = String::new();\n out.push_str(&format!(\"# {}\\n\\n\", draft.title_zh));\n out.push_str(\"## 1. 全局摘要\\n\\n\");\n out.push_str(&draft.global_summary_zh);\n out.push_str(\"\\n\\n\");\n\n for (index, chapter) in draft.chapters.iter().enumerate() {\n out.push_str(&format!(\"## {}. {}\\n\\n\", index + 2, chapter.title_zh));\n for paragraph in &chapter.paragraphs {\n out.push_str(¶graph.text_zh);\n if !paragraph.cited_evidence_ids.is_empty() {\n out.push(' ');\n out.push_str(\n ¶graph\n .cited_evidence_ids\n .iter()\n .map(|id| format!(\"`{id}`\"))\n .collect::>()\n .join(\" \"),\n );\n }\n out.push_str(\"\\n\\n\");\n }\n }\n\n out.push_str(\"## 引用账本\\n\\n\");\n if citations.citations.is_empty() {\n out.push_str(\"- 暂无引用。\\n\");\n } else {\n for citation in &citations.citations {\n out.push_str(&format!(\n \"- `{}` [{}]({}) ({:?})\\n\",\n citation.id, citation.title, citation.url, citation.source_kind\n ));\n }\n }\n out\n}\n\n#[derive(Debug, Serialize)]\nstruct BatchDraftInput {\n chapter_title: String,\n research_question: String,\n batch_id: usize,\n total_batches: usize,\n batch_context_chars: usize,\n evidence_sources: Vec,\n}\n\n#[derive(Debug, Serialize)]\nstruct ChapterSynthesisInput {\n chapter_title: String,\n research_question: String,\n allowed_evidence_ids: Vec,\n batch_drafts: Vec,\n}\n\n#[derive(Debug, Serialize)]\nstruct GlobalSummaryInput {\n topic: String,\n chapter_count: usize,\n evidence_count: usize,\n chapters: Vec,\n}\n\nimpl GlobalSummaryInput {\n fn from_report(topic: &str, chapter_drafts: &[ChapterDraft], evidence_count: usize) -> Self {\n Self {\n topic: topic.to_string(),\n chapter_count: chapter_drafts.len(),\n evidence_count,\n chapters: chapter_drafts\n .iter()\n .map(GlobalSummaryChapterInput::from)\n .collect(),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct GlobalSummaryChapterInput {\n chapter_id: String,\n title_zh: String,\n paragraph_count: usize,\n cited_evidence_ids: Vec,\n paragraph_summaries: Vec,\n}\n\nimpl From<&ChapterDraft> for GlobalSummaryChapterInput {\n fn from(chapter: &ChapterDraft) -> Self {\n let cited_evidence_ids = chapter\n .paragraphs\n .iter()\n .flat_map(|paragraph| paragraph.cited_evidence_ids.iter().cloned())\n .collect::>()\n .into_iter()\n .collect();\n Self {\n chapter_id: chapter.chapter_id.clone(),\n title_zh: chapter.title_zh.clone(),\n paragraph_count: chapter.paragraphs.len(),\n cited_evidence_ids,\n paragraph_summaries: chapter\n .paragraphs\n .iter()\n .map(|paragraph| excerpt_chars(¶graph.text_zh, 500))\n .collect(),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct EvidenceSourceInput {\n evidence_id: String,\n title: String,\n url: String,\n source_kind: SourceKind,\n evidence_note_zh: String,\n evidence_snippet: String,\n}\n\nimpl From<&EvidenceItem> for EvidenceSourceInput {\n fn from(item: &EvidenceItem) -> Self {\n Self {\n evidence_id: item.evidence_id.clone(),\n title: excerpt_chars(&item.title, WRITER_TITLE_MAX_CHARS),\n url: item.url.clone(),\n source_kind: item.source_kind,\n evidence_note_zh: excerpt_chars(&item.evidence_note_zh, WRITER_EVIDENCE_NOTE_MAX_CHARS),\n evidence_snippet: excerpt_chars(\n &item.evidence_snippet,\n writer_snippet_max_chars(item.source_kind),\n ),\n }\n }\n}\n\nfn writer_snippet_max_chars(source_kind: SourceKind) -> usize {\n match source_kind {\n SourceKind::GitHub => WRITER_GITHUB_SNIPPET_MAX_CHARS,\n SourceKind::Arxiv | SourceKind::AcademicIndex => WRITER_ACADEMIC_SNIPPET_MAX_CHARS,\n SourceKind::Bibliography => WRITER_BIBLIOGRAPHY_SNIPPET_MAX_CHARS,\n }\n}\n\n#[derive(Debug, Deserialize)]\nstruct ChapterDraftOutput {\n #[serde(default)]\n paragraphs: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct BatchDraftOutput {\n #[serde(default)]\n findings_summary_zh: String,\n #[serde(default)]\n paragraphs: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GlobalSummaryOutput {\n #[serde(default)]\n global_summary_zh: String,\n}\n\n#[derive(Debug, Deserialize)]\nstruct ChapterParagraphOutput {\n text_zh: String,\n #[serde(default)]\n cited_evidence_ids: Vec,\n}\n\n#[cfg(test)]\nmod tests {\n use chrono::{DateTime, Utc};\n\n use crate::llm::deepseek::DeepSeekConfig;\n use crate::model::{\n ChapterDraft, ChapterNode, EvidenceItem, EvidenceMemory, ParagraphWithCitations, SourceKind,\n };\n\n use super::{\n batch_chapter_evidence, build_batch_draft_request, build_chapter_synthesis_request,\n build_global_summary_request, draft_report_with_llm, empty_chapter_draft,\n parse_batch_draft_content, parse_chapter_draft_content, parse_global_summary_content,\n select_evidence_for_writing, writer_max_tokens, BatchDraft, MAX_BATCH_CONTEXT_CHARS,\n MAX_EVIDENCE_PER_BATCH, MAX_WRITER_EVIDENCE_PER_CHAPTER, WRITER_BIBLIOGRAPHY_EVIDENCE_CAP,\n WRITER_MIN_OUTPUT_TOKENS,\n };\n\n #[test]\n fn writer_drafts_empty_chapter_with_warning_text() {\n let chapters = vec![ChapterNode {\n id: \"ch-1\".to_string(),\n parent_id: None,\n title_zh: \"核心方向\".to_string(),\n research_question: \"测试\".to_string(),\n required_evidence_kinds: vec![\"github\".to_string()],\n evidence_quota: 1,\n sort_order: 1,\n }];\n\n let draft = empty_chapter_draft(&chapters[0]);\n\n assert_eq!(draft.chapter_id, \"ch-1\");\n assert!(draft.paragraphs[0]\n .text_zh\n .contains(\"尚未获得与章节计划直接匹配\"));\n }\n\n #[tokio::test]\n async fn llm_writer_refuses_to_template_when_llm_is_disabled() {\n let chapters = vec![sample_chapter()];\n let memory = EvidenceMemory {\n items: vec![sample_evidence(\"ev-C1\", \"ch-1\")],\n query_attempts: Vec::new(),\n source_lineage: Vec::new(),\n };\n\n let err = draft_report_with_llm(\n \"Rust Agent\",\n &chapters,\n &memory,\n &crate::config::LlmConfig::from_env(false, 30),\n )\n .await\n .expect_err(\"disabled LLM should not produce a template report\");\n\n assert!(err\n .to_string()\n .contains(\"refusing to generate a template report\"));\n }\n\n #[tokio::test]\n #[ignore = \"requires a real DeepSeek API key in DEEPSEEK_API_KEY\"]\n async fn writer_real_deepseek_smoke() {\n let chapters = vec![sample_chapter()];\n let memory = EvidenceMemory {\n items: vec![sample_evidence(\"ev-C1\", \"ch-1\")],\n query_attempts: Vec::new(),\n source_lineage: Vec::new(),\n };\n let config = crate::config::LlmConfig::from_env(true, 120);\n\n assert!(\n config.api_key.is_some(),\n \"DEEPSEEK_API_KEY must be set for this ignored smoke test\"\n );\n\n let draft = draft_report_with_llm(\"TTS 入门级项目简单调研\", &chapters, &memory, &config)\n .await\n .expect(\"real DeepSeek writer smoke should complete\");\n\n assert!(!draft.global_summary_zh.trim().is_empty());\n assert_eq!(draft.chapters.len(), 1);\n assert!(!draft.chapters[0].paragraphs.is_empty());\n assert!(draft.chapters[0].paragraphs[0]\n .cited_evidence_ids\n .contains(&\"ev-C1\".to_string()));\n println!(\n \"writer_real_deepseek_smoke ok: global_summary_chars={}, chapter_paragraphs={}\",\n draft.global_summary_zh.chars().count(),\n draft.chapters[0].paragraphs.len()\n );\n }\n\n #[test]\n fn parses_llm_chapter_draft_json() {\n let chapter = sample_chapter();\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let content = r#\"{\n \"paragraphs\": [\n {\n \"text_zh\": \"该项目展示了从 README 提炼出的 ReAct 主循环和工具调用结构,适合作为入门实现分析对象。\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let draft = parse_chapter_draft_content(content, &chapter, &evidence)\n .expect(\"valid chapter JSON should parse\");\n\n assert_eq!(draft.chapter_id, \"ch-1\");\n assert_eq!(draft.paragraphs[0].cited_evidence_ids, vec![\"ev-C1\"]);\n assert!(draft.paragraphs[0].text_zh.contains(\"ReAct\"));\n }\n\n #[test]\n fn rejects_llm_chapter_draft_with_unknown_evidence_id() {\n let chapter = sample_chapter();\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let content = r#\"{\n \"paragraphs\": [\n {\n \"text_zh\": \"引用了不存在的证据。\",\n \"cited_evidence_ids\": [\"ev-C9\"]\n }\n ]\n }\"#;\n\n let err = parse_chapter_draft_content(content, &chapter, &evidence)\n .expect_err(\"unknown evidence id should fail\");\n\n assert!(err.to_string().contains(\"unknown evidence id\"));\n }\n\n #[test]\n fn rejects_llm_chapter_draft_with_template_residue() {\n let chapter = sample_chapter();\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let content = r#\"{\n \"paragraphs\": [\n {\n \"text_zh\": \"acme/rust-agent 是本章节的关键来源之一。来源链接:https://github.com/acme/rust-agent\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let err = parse_chapter_draft_content(content, &chapter, &evidence)\n .expect_err(\"legacy template residue should fail\");\n\n assert!(err.to_string().contains(\"legacy template marker\"));\n }\n\n #[test]\n fn batches_small_chapter_evidence_into_one_batch() {\n let evidence = vec![\n sample_evidence(\"ev-C1\", \"ch-1\"),\n sample_evidence(\"ev-C2\", \"ch-1\"),\n sample_evidence(\"ev-C3\", \"ch-1\"),\n ];\n\n let batches = batch_chapter_evidence(&evidence);\n\n assert_eq!(batches.len(), 1);\n assert_eq!(batches[0].batch_id, 1);\n assert_eq!(batches[0].evidence_items.len(), 3);\n assert!(batches[0].context_chars > 0);\n }\n\n #[test]\n fn selects_bounded_mixed_evidence_for_writing() {\n let mut evidence = Vec::new();\n evidence.extend((1..=5).map(|index| {\n sample_evidence_with_kind(&format!(\"ev-A{index}\"), \"ch-1\", SourceKind::Arxiv)\n }));\n evidence.extend((1..=4).map(|index| {\n sample_evidence_with_kind(&format!(\"ev-S{index}\"), \"ch-1\", SourceKind::AcademicIndex)\n }));\n evidence.extend((1..=4).map(|index| {\n sample_evidence_with_kind(&format!(\"ev-G{index}\"), \"ch-1\", SourceKind::GitHub)\n }));\n evidence.extend((1..=2).map(|index| {\n sample_evidence_with_kind(&format!(\"ev-B{index}\"), \"ch-1\", SourceKind::Bibliography)\n }));\n\n let selected = select_evidence_for_writing(&evidence);\n let bibliography_count = selected\n .iter()\n .filter(|item| item.source_kind == SourceKind::Bibliography)\n .count();\n\n assert_eq!(selected.len(), MAX_WRITER_EVIDENCE_PER_CHAPTER);\n assert_eq!(bibliography_count, 0);\n assert_eq!(selected[0].source_kind, SourceKind::Arxiv);\n assert_eq!(selected[4].source_kind, SourceKind::AcademicIndex);\n assert_eq!(selected[7].source_kind, SourceKind::GitHub);\n }\n\n #[test]\n fn selects_bibliography_only_when_no_stronger_sources_exist() {\n let evidence = (1..=3)\n .map(|index| {\n sample_evidence_with_kind(&format!(\"ev-B{index}\"), \"ch-1\", SourceKind::Bibliography)\n })\n .collect::>();\n\n let selected = select_evidence_for_writing(&evidence);\n\n assert_eq!(selected.len(), WRITER_BIBLIOGRAPHY_EVIDENCE_CAP);\n assert_eq!(selected[0].source_kind, SourceKind::Bibliography);\n }\n\n #[test]\n fn batches_chapter_evidence_by_count() {\n let evidence = (1..=9)\n .map(|index| sample_evidence(&format!(\"ev-C{index}\"), \"ch-1\"))\n .collect::>();\n\n let batches = batch_chapter_evidence(&evidence);\n\n assert_eq!(batches.len(), 3);\n assert_eq!(batches[0].evidence_items.len(), MAX_EVIDENCE_PER_BATCH);\n assert_eq!(batches[1].evidence_items.len(), MAX_EVIDENCE_PER_BATCH);\n assert_eq!(batches[2].evidence_items.len(), MAX_EVIDENCE_PER_BATCH);\n assert_eq!(batches[2].batch_id, 3);\n }\n\n #[test]\n fn batches_chapter_evidence_by_sanitized_context_chars() {\n let mut first = sample_evidence(\"ev-C1\", \"ch-1\");\n first.evidence_snippet = \"a\".repeat(MAX_BATCH_CONTEXT_CHARS);\n let mut second = sample_evidence(\"ev-C2\", \"ch-1\");\n second.evidence_snippet = \"b\".repeat(MAX_BATCH_CONTEXT_CHARS);\n let mut third = sample_evidence(\"ev-C3\", \"ch-1\");\n third.evidence_snippet = \"c\".repeat(MAX_BATCH_CONTEXT_CHARS);\n let mut fourth = sample_evidence(\"ev-C4\", \"ch-1\");\n fourth.evidence_snippet = \"d\".repeat(MAX_BATCH_CONTEXT_CHARS);\n\n let batches = batch_chapter_evidence(&[first, second, third, fourth]);\n\n assert_eq!(batches.len(), 2);\n assert_eq!(batches[0].evidence_items.len(), 3);\n assert_eq!(batches[1].evidence_items.len(), 1);\n assert!(batches[0].context_chars <= MAX_BATCH_CONTEXT_CHARS);\n }\n\n #[test]\n fn batch_request_truncates_long_github_snippets_for_writer_context() {\n let chapter = sample_chapter();\n let mut evidence = sample_evidence(\"ev-C1\", \"ch-1\");\n evidence.evidence_snippet = format!(\"{}{}\", \"A\".repeat(1500), \"UNTRUNCATED_TAIL\");\n let batches = batch_chapter_evidence(&[evidence]);\n\n let request = build_batch_draft_request(\n &chapter,\n &batches[0],\n batches.len(),\n &sample_deepseek_config(4096),\n )\n .expect(\"request should build\");\n let user_message = &request.messages[1].content;\n\n assert!(user_message.contains(&\"A\".repeat(900)));\n assert!(!user_message.contains(\"UNTRUNCATED_TAIL\"));\n }\n\n #[test]\n fn parses_llm_batch_draft_json() {\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batches = batch_chapter_evidence(&evidence);\n let content = r#\"{\n \"findings_summary_zh\": \"该批证据说明了项目 README 中的 ReAct 主循环、工具注册和入门示例。\",\n \"paragraphs\": [\n {\n \"text_zh\": \"该项目的 README 将 ReAct 主循环和工具注册拆成清晰的入门实现,适合用于说明基础 agent 架构。\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let draft =\n parse_batch_draft_content(content, &batches[0]).expect(\"valid batch JSON should parse\");\n\n assert_eq!(draft.batch_id, 1);\n assert!(draft.findings_summary_zh.contains(\"ReAct\"));\n assert_eq!(draft.paragraphs[0].cited_evidence_ids, vec![\"ev-C1\"]);\n }\n\n #[test]\n fn rejects_llm_batch_draft_with_empty_findings() {\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batches = batch_chapter_evidence(&evidence);\n let content = r#\"{\n \"findings_summary_zh\": \"\",\n \"paragraphs\": [\n {\n \"text_zh\": \"该项目展示了一个基础实现。\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let err = parse_batch_draft_content(content, &batches[0])\n .expect_err(\"empty findings should fail\");\n\n assert!(err.to_string().contains(\"empty findings_summary_zh\"));\n }\n\n #[test]\n fn rejects_llm_batch_draft_with_unknown_evidence_id() {\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batches = batch_chapter_evidence(&evidence);\n let content = r#\"{\n \"findings_summary_zh\": \"该批证据有一个段落引用了不存在的证据。\",\n \"paragraphs\": [\n {\n \"text_zh\": \"这里引用了不存在的 evidence id。\",\n \"cited_evidence_ids\": [\"ev-C2\"]\n }\n ]\n }\"#;\n\n let err = parse_batch_draft_content(content, &batches[0])\n .expect_err(\"unknown evidence id should fail\");\n\n assert!(err.to_string().contains(\"unknown evidence id\"));\n }\n\n #[test]\n fn rejects_llm_batch_draft_with_template_residue() {\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batches = batch_chapter_evidence(&evidence);\n let content = r#\"{\n \"findings_summary_zh\": \"acme/rust-agent 是本章节的关键来源之一。\",\n \"paragraphs\": [\n {\n \"text_zh\": \"该项目展示了一个基础实现。\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let err = parse_batch_draft_content(content, &batches[0])\n .expect_err(\"legacy template residue should fail\");\n\n assert!(err.to_string().contains(\"legacy template marker\"));\n }\n\n #[test]\n fn batch_request_only_contains_current_batch_evidence() {\n let chapter = sample_chapter();\n let evidence = (1..=5)\n .map(|index| sample_evidence(&format!(\"ev-C{index}\"), \"ch-1\"))\n .collect::>();\n let batches = batch_chapter_evidence(&evidence);\n\n let request = build_batch_draft_request(\n &chapter,\n &batches[1],\n batches.len(),\n &sample_deepseek_config(2048),\n )\n .expect(\"request should build\");\n let user_message = &request.messages[1].content;\n\n assert!(user_message.contains(\"\\\"batch_id\\\": 2\"));\n assert!(user_message.contains(\"ev-C5\"));\n assert!(!user_message.contains(\"ev-C1\"));\n assert_eq!(request.max_tokens, Some(WRITER_MIN_OUTPUT_TOKENS as u32));\n }\n\n #[test]\n fn writer_max_tokens_raises_too_small_config_for_reasoning_models() {\n let config = sample_deepseek_config(32);\n\n assert_eq!(writer_max_tokens(&config), WRITER_MIN_OUTPUT_TOKENS as u32);\n }\n\n #[test]\n fn chapter_synthesis_request_uses_batch_findings_instead_of_raw_evidence() {\n let chapter = sample_chapter();\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batch_drafts = vec![BatchDraft {\n batch_id: 1,\n findings_summary_zh: \"该批证据说明 README 中的 ReAct 主循环适合作为入门样例。\"\n .to_string(),\n paragraphs: vec![ParagraphWithCitations {\n text_zh: \"该项目展示了 ReAct 主循环和工具注册。\".to_string(),\n cited_evidence_ids: vec![\"ev-C1\".to_string()],\n }],\n }];\n\n let request = build_chapter_synthesis_request(\n &chapter,\n &evidence,\n &batch_drafts,\n &sample_deepseek_config(4096),\n )\n .expect(\"request should build\");\n let user_message = &request.messages[1].content;\n\n assert!(user_message.contains(\"batch_drafts\"));\n assert!(user_message.contains(\"allowed_evidence_ids\"));\n assert!(user_message.contains(\"ReAct 主循环适合作为入门样例\"));\n assert!(!user_message.contains(\"evidence_snippet\"));\n assert!(!user_message.contains(\"README explains ReAct loop\"));\n }\n\n #[test]\n fn parses_llm_global_summary_json() {\n let content = r#\"{\n \"global_summary_zh\": \"本报告显示,可控 TTS 入门调研应同时关注基础项目、说话人控制和模型框架边界;现有证据主要来自 GitHub README 与 arXiv 摘要,因此结论应限定在这些来源范围内。\"\n }\"#;\n\n let summary =\n parse_global_summary_content(content).expect(\"valid global summary should parse\");\n\n assert!(summary.contains(\"可控 TTS\"));\n assert!(summary.contains(\"来源范围\"));\n }\n\n #[test]\n fn rejects_llm_global_summary_with_template_residue() {\n let content = r#\"{\n \"global_summary_zh\": \"harinandanmv/text-to-speech 是本章节的关键来源之一。\"\n }\"#;\n\n let err =\n parse_global_summary_content(content).expect_err(\"legacy template marker should fail\");\n\n assert!(err.to_string().contains(\"legacy template marker\"));\n }\n\n #[test]\n fn global_summary_request_uses_chapter_drafts() {\n let chapter_drafts = vec![ChapterDraft {\n chapter_id: \"ch-1\".to_string(),\n title_zh: \"可控 TTS 基础模型\".to_string(),\n paragraphs: vec![ParagraphWithCitations {\n text_zh: \"该章节围绕基础 TTS 模型、说话人控制和入门项目进行综合分析。\".to_string(),\n cited_evidence_ids: vec![\"ev-C1\".to_string()],\n }],\n }];\n\n let request = build_global_summary_request(\n \"TTS 入门级项目简单调研\",\n &chapter_drafts,\n 1,\n &sample_deepseek_config(4096),\n )\n .expect(\"request should build\");\n let user_message = &request.messages[1].content;\n\n assert!(user_message.contains(\"TTS 入门级项目简单调研\"));\n assert!(user_message.contains(\"可控 TTS 基础模型\"));\n assert!(user_message.contains(\"ev-C1\"));\n assert!(user_message.contains(\"paragraph_summaries\"));\n }\n\n fn sample_chapter() -> ChapterNode {\n ChapterNode {\n id: \"ch-1\".to_string(),\n parent_id: None,\n title_zh: \"核心方向\".to_string(),\n research_question: \"测试\".to_string(),\n required_evidence_kinds: vec![\"github\".to_string()],\n evidence_quota: 1,\n sort_order: 1,\n }\n }\n\n fn sample_evidence(evidence_id: &str, chapter_id: &str) -> EvidenceItem {\n sample_evidence_with_kind(evidence_id, chapter_id, SourceKind::GitHub)\n }\n\n fn sample_evidence_with_kind(\n evidence_id: &str,\n chapter_id: &str,\n source_kind: SourceKind,\n ) -> EvidenceItem {\n EvidenceItem {\n evidence_id: evidence_id.to_string(),\n source_item_id: format!(\"{source_kind:?}:acme/rust-agent\"),\n citation_id: \"C1\".to_string(),\n chapter_ids: vec![chapter_id.to_string()],\n query_attempt_ids: vec![\"gh-1\".to_string()],\n source_kind,\n title: \"acme/rust-agent\".to_string(),\n url: \"https://github.com/acme/rust-agent\".to_string(),\n evidence_note_zh: \"GitHub 仓库 `acme/rust-agent`:Rust agent framework\".to_string(),\n evidence_snippet: \"README explains ReAct loop, tool registry, and examples.\"\n .to_string(),\n support_score: None,\n }\n }\n\n fn sample_deepseek_config(max_tokens: usize) -> DeepSeekConfig {\n DeepSeekConfig {\n api_key: \"sk-test\".to_string(),\n base_url: \"https://api.deepseek.com\".to_string(),\n main_model: \"deepseek-v4-pro\".to_string(),\n side_model: \"deepseek-v4-flash\".to_string(),\n max_tokens,\n timeout_secs: 120,\n }\n }\n\n fn _dt() -> DateTime {\n DateTime::parse_from_rfc3339(\"2026-05-30T12:00:00Z\")\n .unwrap()\n .with_timezone(&Utc)\n }\n}\n"}, "bccd8c422afb8962e69d703d2ddcbdac277a8101": {"t": 1785750666.5143292, "n": 43384, "h": "e05aa6561b6b8927", "c": "use crate::config::LlmConfig;\nuse crate::error::{AppError, Result};\nuse crate::llm::deepseek::{\n strip_json_fence, ChatCompletionsRequest, ChatMessage, DeepSeekClient, DeepSeekConfig,\n ResponseFormat,\n};\nuse crate::model::{\n ChapterDraft, ChapterNode, CitationLedger, EvidenceItem, EvidenceMemory,\n ParagraphWithCitations, ReportDraft, SourceKind,\n};\nuse chrono::Utc;\nuse serde::{Deserialize, Serialize};\nuse std::collections::BTreeSet;\nuse tracing::{debug, warn};\n\nconst MAX_EVIDENCE_PER_BATCH: usize = 4;\nconst MAX_BATCH_CONTEXT_CHARS: usize = 10_000;\nconst MAX_BATCH_PARAGRAPHS: usize = 4;\nconst MAX_CHAPTER_PARAGRAPHS: usize = 6;\nconst WRITER_MIN_OUTPUT_TOKENS: usize = 4096;\nconst WRITER_MAX_OUTPUT_TOKENS: usize = 8192;\n\npub async fn draft_report_with_llm(\n topic: &str,\n chapters: &[ChapterNode],\n memory: &EvidenceMemory,\n llm_config: &LlmConfig,\n) -> Result {\n let deepseek_config = DeepSeekConfig::from_llm_config(llm_config).ok_or_else(|| {\n AppError::InvalidConfig(\n \"Writer LLM requires an enabled DeepSeek config with API key; refusing to generate a template report.\"\n .to_string(),\n )\n })?;\n let client = DeepSeekClient::new(deepseek_config.clone())?;\n let mut chapter_drafts = Vec::with_capacity(chapters.len());\n\n for chapter in chapters {\n let evidence_items = memory.by_chapter(&chapter.id);\n if evidence_items.is_empty() {\n chapter_drafts.push(empty_chapter_draft(chapter));\n continue;\n }\n\n let draft = draft_chapter_with_llm(chapter, &evidence_items, &client, &deepseek_config)\n .await\n .map_err(|err| {\n AppError::Llm(format!(\n \"Writer LLM failed for chapter `{}`; refusing template fallback: {err}\",\n chapter.title_zh\n ))\n })?;\n chapter_drafts.push(draft);\n }\n\n let global_summary_zh = synthesize_global_summary_with_llm(\n topic,\n &chapter_drafts,\n memory.items.len(),\n &client,\n &deepseek_config,\n )\n .await\n .map_err(|err| {\n AppError::Llm(format!(\n \"Writer global summary failed; refusing deterministic fallback: {err}\"\n ))\n })?;\n\n Ok(ReportDraft {\n title_zh: format!(\"LitScout-RS 调研报告:{topic}\"),\n chapters: chapter_drafts,\n global_summary_zh,\n written_at: Utc::now(),\n })\n}\n\nasync fn draft_chapter_with_llm(\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n client: &DeepSeekClient,\n config: &DeepSeekConfig,\n) -> Result {\n let batches = batch_chapter_evidence(evidence_items);\n debug!(\n \"Writer split chapter `{}` into {} evidence batches\",\n chapter.title_zh,\n batches.len()\n );\n\n let mut batch_drafts = Vec::with_capacity(batches.len());\n for batch in &batches {\n let draft = draft_batch_with_llm(chapter, batch, batches.len(), client, config).await?;\n batch_drafts.push(draft);\n }\n\n synthesize_chapter_from_batches(chapter, evidence_items, &batch_drafts, client, config).await\n}\n\nfn empty_chapter_draft(chapter: &ChapterNode) -> ChapterDraft {\n ChapterDraft {\n chapter_id: chapter.id.clone(),\n title_zh: chapter.title_zh.clone(),\n paragraphs: vec![ParagraphWithCitations {\n text_zh: format!(\n \"当前章节 `{}` 尚未获得与章节计划直接匹配的 GitHub/arXiv 证据,因此不生成分析段落。\",\n chapter.title_zh\n ),\n cited_evidence_ids: Vec::new(),\n }],\n }\n}\n\n#[derive(Debug, Clone)]\nstruct EvidenceBatch {\n batch_id: usize,\n evidence_items: Vec,\n context_chars: usize,\n}\n\n#[derive(Debug, Clone, Serialize)]\nstruct BatchDraft {\n batch_id: usize,\n findings_summary_zh: String,\n paragraphs: Vec,\n}\n\nfn batch_chapter_evidence(evidence_items: &[EvidenceItem]) -> Vec {\n let mut batches = Vec::new();\n let mut current_items = Vec::new();\n let mut current_chars = 0usize;\n\n for item in evidence_items {\n let item_chars = evidence_context_chars(item);\n let count_limit_reached = current_items.len() >= MAX_EVIDENCE_PER_BATCH;\n let char_limit_reached =\n !current_items.is_empty() && current_chars + item_chars > MAX_BATCH_CONTEXT_CHARS;\n\n if count_limit_reached || char_limit_reached {\n batches.push(EvidenceBatch {\n batch_id: batches.len() + 1,\n evidence_items: current_items,\n context_chars: current_chars,\n });\n current_items = Vec::new();\n current_chars = 0;\n }\n\n current_chars += item_chars;\n current_items.push(item.clone());\n }\n\n if !current_items.is_empty() {\n batches.push(EvidenceBatch {\n batch_id: batches.len() + 1,\n evidence_items: current_items,\n context_chars: current_chars,\n });\n }\n\n batches\n}\n\nfn evidence_context_chars(item: &EvidenceItem) -> usize {\n item.evidence_id.chars().count()\n + item.title.chars().count()\n + item.url.chars().count()\n + item.evidence_note_zh.chars().count()\n + item.evidence_snippet.chars().count()\n}\n\nfn excerpt_chars(text: &str, max_chars: usize) -> String {\n let trimmed = text.trim();\n if trimmed.chars().count() <= max_chars {\n return trimmed.to_string();\n }\n\n let mut out = trimmed.chars().take(max_chars).collect::();\n out.push_str(\"...\");\n out\n}\n\nfn parse_batch_draft_content(content: &str, batch: &EvidenceBatch) -> Result {\n let output =\n serde_json::from_str::(strip_json_fence(content)).map_err(|err| {\n AppError::Llm(format!(\n \"Writer response was not valid BatchDraft JSON: {err}\"\n ))\n })?;\n let findings_summary_zh = output.findings_summary_zh.trim().to_string();\n if findings_summary_zh.is_empty() {\n return Err(AppError::Llm(\n \"Writer batch response contained empty findings_summary_zh\".to_string(),\n ));\n }\n reject_template_residue(&findings_summary_zh)?;\n let paragraphs = parse_paragraph_outputs(\n output.paragraphs,\n &batch.evidence_items,\n MAX_BATCH_PARAGRAPHS,\n )?;\n\n Ok(BatchDraft {\n batch_id: batch.batch_id,\n findings_summary_zh,\n paragraphs,\n })\n}\n\nasync fn draft_batch_with_llm(\n chapter: &ChapterNode,\n batch: &EvidenceBatch,\n total_batches: usize,\n client: &DeepSeekClient,\n config: &DeepSeekConfig,\n) -> Result {\n let request = build_batch_draft_request(chapter, batch, total_batches, config)?;\n let response = client.chat_completions_with_retry(request).await?;\n let content = response.first_content()?;\n\n match parse_batch_draft_content(content, batch) {\n Ok(draft) => Ok(draft),\n Err(first_err) => {\n warn!(\n \"Writer LLM JSON validation failed for chapter `{}` batch {}; requesting one repair: {first_err}\",\n chapter.title_zh, batch.batch_id\n );\n let repair_request = build_batch_draft_repair_request(\n chapter,\n batch,\n total_batches,\n config,\n content,\n &first_err.to_string(),\n )?;\n let repaired_response = client.chat_completions_with_retry(repair_request).await?;\n let repaired_content = repaired_response.first_content()?;\n parse_batch_draft_content(repaired_content, batch)\n }\n }\n}\n\nfn build_batch_draft_request(\n chapter: &ChapterNode,\n batch: &EvidenceBatch,\n total_batches: usize,\n config: &DeepSeekConfig,\n) -> Result {\n let context = BatchDraftInput {\n chapter_title: chapter.title_zh.clone(),\n research_question: chapter.research_question.clone(),\n batch_id: batch.batch_id,\n total_batches,\n batch_context_chars: batch.context_chars,\n evidence_sources: batch\n .evidence_items\n .iter()\n .map(EvidenceSourceInput::from)\n .collect(),\n };\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: batch_writer_system_prompt(&chapter.title_zh),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请为下面章节的第 {}/{} 个证据批次生成局部分析草稿。只返回 JSON 对象,不要输出 Markdown 代码围栏之外的解释。\\n\\n{context_json}\",\n batch.batch_id, total_batches\n ),\n },\n ],\n temperature: Some(0.3),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn build_batch_draft_repair_request(\n chapter: &ChapterNode,\n batch: &EvidenceBatch,\n total_batches: usize,\n config: &DeepSeekConfig,\n previous_output: &str,\n validation_error: &str,\n) -> Result {\n let context = BatchDraftInput {\n chapter_title: chapter.title_zh.clone(),\n research_question: chapter.research_question.clone(),\n batch_id: batch.batch_id,\n total_batches,\n batch_context_chars: batch.context_chars,\n evidence_sources: batch\n .evidence_items\n .iter()\n .map(EvidenceSourceInput::from)\n .collect(),\n };\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: batch_writer_system_prompt(&chapter.title_zh),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请修复上一次第 {}/{} 个证据批次的输出,使其满足 JSON schema 和引用约束。只返回修复后的 JSON 对象。\\n\\n校验错误:\\n{validation_error}\\n\\n上一次输出:\\n{previous_output}\\n\\n允许使用的 evidence:\\n{context_json}\",\n batch.batch_id, total_batches\n ),\n },\n ],\n temperature: Some(0.1),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nasync fn synthesize_chapter_from_batches(\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n batch_drafts: &[BatchDraft],\n client: &DeepSeekClient,\n config: &DeepSeekConfig,\n) -> Result {\n let request = build_chapter_synthesis_request(chapter, evidence_items, batch_drafts, config)?;\n let response = client.chat_completions_with_retry(request).await?;\n let content = response.first_content()?;\n\n match parse_chapter_draft_content(content, chapter, evidence_items) {\n Ok(draft) => Ok(draft),\n Err(first_err) => {\n warn!(\n \"Writer LLM chapter synthesis validation failed for chapter `{}`; requesting one repair: {first_err}\",\n chapter.title_zh\n );\n let repair_request = build_chapter_synthesis_repair_request(\n chapter,\n evidence_items,\n batch_drafts,\n config,\n content,\n &first_err.to_string(),\n )?;\n let repaired_response = client.chat_completions_with_retry(repair_request).await?;\n let repaired_content = repaired_response.first_content()?;\n parse_chapter_draft_content(repaired_content, chapter, evidence_items)\n }\n }\n}\n\nfn batch_writer_system_prompt(chapter_title: &str) -> String {\n format!(\n \"你是 LitScout-RS 的技术报告撰稿人,正在为 `{chapter_title}` 章节处理一个 evidence batch。\\n\\\n规则:\\n\\\n1. 使用中文撰写。\\n\\\n2. 先在 findings_summary_zh 中总结本批证据的关键发现、共性和边界。\\n\\\n3. 再对本批来源提炼项目/论文简介、核心亮点或设计思想,以及与章节问题的具体关联。\\n\\\n4. 可合并多个来源做对比分析,但每个段落必须引用至少一个 evidence_id。\\n\\\n5. 只能使用用户提供的 evidence_id 和 URL,不得新增来源、不得编造事实。\\n\\\n6. 不要只重复原文,不要输出“来源链接”模板句。\\n\\\n7. 只返回 JSON:{{\\\"findings_summary_zh\\\":\\\"...\\\",\\\"paragraphs\\\":[{{\\\"text_zh\\\":\\\"...\\\",\\\"cited_evidence_ids\\\":[\\\"ev-C1\\\"]}}]}}。\"\n )\n}\n\nfn writer_max_tokens(config: &DeepSeekConfig) -> u32 {\n config\n .max_tokens\n .max(WRITER_MIN_OUTPUT_TOKENS)\n .min(WRITER_MAX_OUTPUT_TOKENS)\n .min(u32::MAX as usize) as u32\n}\n\nfn build_chapter_synthesis_request(\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n batch_drafts: &[BatchDraft],\n config: &DeepSeekConfig,\n) -> Result {\n let context = ChapterSynthesisInput {\n chapter_title: chapter.title_zh.clone(),\n research_question: chapter.research_question.clone(),\n allowed_evidence_ids: evidence_items\n .iter()\n .map(|item| item.evidence_id.clone())\n .collect(),\n batch_drafts: batch_drafts.to_vec(),\n };\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: chapter_synthesizer_system_prompt(&chapter.title_zh),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请基于已验证的 batch findings 合成章节终稿。只返回 JSON 对象,不要输出 Markdown 代码围栏之外的解释。\\n\\n{context_json}\"\n ),\n },\n ],\n temperature: Some(0.25),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn build_chapter_synthesis_repair_request(\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n batch_drafts: &[BatchDraft],\n config: &DeepSeekConfig,\n previous_output: &str,\n validation_error: &str,\n) -> Result {\n let context = ChapterSynthesisInput {\n chapter_title: chapter.title_zh.clone(),\n research_question: chapter.research_question.clone(),\n allowed_evidence_ids: evidence_items\n .iter()\n .map(|item| item.evidence_id.clone())\n .collect(),\n batch_drafts: batch_drafts.to_vec(),\n };\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: chapter_synthesizer_system_prompt(&chapter.title_zh),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请修复上一次章节综合输出,使其满足 JSON schema 和引用约束。只返回修复后的 JSON 对象。\\n\\n校验错误:\\n{validation_error}\\n\\n上一次输出:\\n{previous_output}\\n\\n允许使用的 batch findings:\\n{context_json}\"\n ),\n },\n ],\n temperature: Some(0.1),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn chapter_synthesizer_system_prompt(chapter_title: &str) -> String {\n format!(\n \"你是 LitScout-RS 的章节综合编辑,正在合成 `{chapter_title}` 章节终稿。\\n\\\n规则:\\n\\\n1. 使用中文撰写自然、分析性的技术报告段落。\\n\\\n2. 只能使用用户提供的 batch findings、batch paragraphs 和 allowed_evidence_ids。\\n\\\n3. 合并重复信息,按主题组织,不要机械罗列每条来源。\\n\\\n4. 已有 evidence 时,不得用“尚未收集到足够证据”替代分析内容。\\n\\\n5. 每个段落必须引用至少一个 allowed_evidence_ids 中的 evidence_id。\\n\\\n6. 不得新增来源、URL 或 evidence_id,不得编造事实。\\n\\\n7. 不要输出“来源链接”模板句或“是本章节的关键来源之一”。\\n\\\n8. 只返回 JSON:{{\\\"paragraphs\\\":[{{\\\"text_zh\\\":\\\"...\\\",\\\"cited_evidence_ids\\\":[\\\"ev-C1\\\"]}}]}}。\"\n )\n}\n\nasync fn synthesize_global_summary_with_llm(\n topic: &str,\n chapter_drafts: &[ChapterDraft],\n evidence_count: usize,\n client: &DeepSeekClient,\n config: &DeepSeekConfig,\n) -> Result {\n let request = build_global_summary_request(topic, chapter_drafts, evidence_count, config)?;\n let response = client.chat_completions_with_retry(request).await?;\n let content = response.first_content()?;\n\n match parse_global_summary_content(content) {\n Ok(summary) => Ok(summary),\n Err(first_err) => {\n warn!(\n \"Writer LLM global summary validation failed; requesting one repair: {first_err}\"\n );\n let repair_request = build_global_summary_repair_request(\n topic,\n chapter_drafts,\n evidence_count,\n config,\n content,\n &first_err.to_string(),\n )?;\n let repaired_response = client.chat_completions_with_retry(repair_request).await?;\n let repaired_content = repaired_response.first_content()?;\n parse_global_summary_content(repaired_content)\n }\n }\n}\n\nfn build_global_summary_request(\n topic: &str,\n chapter_drafts: &[ChapterDraft],\n evidence_count: usize,\n config: &DeepSeekConfig,\n) -> Result {\n let context = GlobalSummaryInput::from_report(topic, chapter_drafts, evidence_count);\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: global_synthesizer_system_prompt(),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请基于章节终稿生成报告的全局摘要。只返回 JSON 对象,不要输出 Markdown 代码围栏之外的解释。\\n\\n{context_json}\"\n ),\n },\n ],\n temperature: Some(0.25),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn build_global_summary_repair_request(\n topic: &str,\n chapter_drafts: &[ChapterDraft],\n evidence_count: usize,\n config: &DeepSeekConfig,\n previous_output: &str,\n validation_error: &str,\n) -> Result {\n let context = GlobalSummaryInput::from_report(topic, chapter_drafts, evidence_count);\n let context_json = serde_json::to_string_pretty(&context)?;\n let max_tokens = writer_max_tokens(config);\n\n Ok(ChatCompletionsRequest {\n model: config.main_model.clone(),\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: global_synthesizer_system_prompt(),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content: format!(\n \"请修复上一次全局摘要输出,使其满足 JSON schema 和写作约束。只返回修复后的 JSON 对象。\\n\\n校验错误:\\n{validation_error}\\n\\n上一次输出:\\n{previous_output}\\n\\n章节终稿上下文:\\n{context_json}\"\n ),\n },\n ],\n temperature: Some(0.1),\n max_tokens: Some(max_tokens),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n })\n}\n\nfn parse_global_summary_content(content: &str) -> Result {\n let output =\n serde_json::from_str::(strip_json_fence(content)).map_err(|err| {\n AppError::Llm(format!(\n \"Writer response was not valid GlobalSummary JSON: {err}\"\n ))\n })?;\n let global_summary_zh = output.global_summary_zh.trim().to_string();\n if global_summary_zh.is_empty() {\n return Err(AppError::Llm(\n \"Writer global summary response contained empty global_summary_zh\".to_string(),\n ));\n }\n reject_template_residue(&global_summary_zh)?;\n\n Ok(global_summary_zh)\n}\n\nfn global_synthesizer_system_prompt() -> String {\n \"你是 LitScout-RS 的报告总编辑,负责基于章节终稿生成全局摘要。\\n\\\n规则:\\n\\\n1. 使用中文撰写 1-2 段自然的全局摘要。\\n\\\n2. 概括报告覆盖范围、主要发现、证据边界和不足。\\n\\\n3. 只能使用用户提供的章节终稿和 citation id,不得新增来源、URL 或事实。\\n\\\n4. 不要输出固定模板句,不要写“来源链接”,不要写“是本章节的关键来源之一”。\\n\\\n5. 不要把引用账本逐条改写成摘要。\\n\\\n6. 只返回 JSON:{\\\"global_summary_zh\\\":\\\"...\\\"}。\"\n .to_string()\n}\n\nfn parse_chapter_draft_content(\n content: &str,\n chapter: &ChapterNode,\n evidence_items: &[EvidenceItem],\n) -> Result {\n let output =\n serde_json::from_str::(strip_json_fence(content)).map_err(|err| {\n AppError::Llm(format!(\n \"Writer response was not valid ChapterDraft JSON: {err}\"\n ))\n })?;\n let paragraphs =\n parse_paragraph_outputs(output.paragraphs, evidence_items, MAX_CHAPTER_PARAGRAPHS)?;\n\n Ok(ChapterDraft {\n chapter_id: chapter.id.clone(),\n title_zh: chapter.title_zh.clone(),\n paragraphs,\n })\n}\n\nfn parse_paragraph_outputs(\n paragraph_outputs: Vec,\n evidence_items: &[EvidenceItem],\n max_paragraphs: usize,\n) -> Result> {\n let allowed_ids = evidence_items\n .iter()\n .map(|item| item.evidence_id.as_str())\n .collect::>();\n let mut paragraphs = Vec::new();\n\n for paragraph in paragraph_outputs.into_iter().take(max_paragraphs) {\n let text_zh = paragraph.text_zh.trim().to_string();\n if text_zh.is_empty() {\n continue;\n }\n reject_template_residue(&text_zh)?;\n let cited_evidence_ids = paragraph\n .cited_evidence_ids\n .into_iter()\n .filter_map(|id| {\n let trimmed = id.trim();\n (!trimmed.is_empty()).then(|| trimmed.to_string())\n })\n .collect::>();\n if cited_evidence_ids.is_empty() {\n return Err(AppError::Llm(\n \"Writer paragraph did not cite any evidence ID\".to_string(),\n ));\n }\n for id in &cited_evidence_ids {\n if !allowed_ids.contains(id.as_str()) {\n return Err(AppError::Llm(format!(\n \"Writer paragraph referenced unknown evidence id `{id}`\"\n )));\n }\n }\n paragraphs.push(ParagraphWithCitations {\n text_zh,\n cited_evidence_ids,\n });\n }\n\n if paragraphs.is_empty() {\n return Err(AppError::Llm(\n \"Writer response contained no usable paragraphs\".to_string(),\n ));\n }\n\n Ok(paragraphs)\n}\n\nfn reject_template_residue(text: &str) -> Result<()> {\n for marker in [\n \"是本章节的关键来源之一\",\n \"来源链接:\",\n \"提供了与主题相关的证据\",\n ] {\n if text.contains(marker) {\n return Err(AppError::Llm(format!(\n \"Writer output still contains legacy template marker `{marker}`\"\n )));\n }\n }\n Ok(())\n}\n\npub fn render_report_markdown(draft: &ReportDraft, citations: &CitationLedger) -> String {\n let mut out = String::new();\n out.push_str(&format!(\"# {}\\n\\n\", draft.title_zh));\n out.push_str(\"## 1. 全局摘要\\n\\n\");\n out.push_str(&draft.global_summary_zh);\n out.push_str(\"\\n\\n\");\n\n for (index, chapter) in draft.chapters.iter().enumerate() {\n out.push_str(&format!(\"## {}. {}\\n\\n\", index + 2, chapter.title_zh));\n for paragraph in &chapter.paragraphs {\n out.push_str(¶graph.text_zh);\n if !paragraph.cited_evidence_ids.is_empty() {\n out.push(' ');\n out.push_str(\n ¶graph\n .cited_evidence_ids\n .iter()\n .map(|id| format!(\"`{id}`\"))\n .collect::>()\n .join(\" \"),\n );\n }\n out.push_str(\"\\n\\n\");\n }\n }\n\n out.push_str(\"## 引用账本\\n\\n\");\n if citations.citations.is_empty() {\n out.push_str(\"- 暂无引用。\\n\");\n } else {\n for citation in &citations.citations {\n out.push_str(&format!(\n \"- `{}` [{}]({}) ({:?})\\n\",\n citation.id, citation.title, citation.url, citation.source_kind\n ));\n }\n }\n out\n}\n\n#[derive(Debug, Serialize)]\nstruct BatchDraftInput {\n chapter_title: String,\n research_question: String,\n batch_id: usize,\n total_batches: usize,\n batch_context_chars: usize,\n evidence_sources: Vec,\n}\n\n#[derive(Debug, Serialize)]\nstruct ChapterSynthesisInput {\n chapter_title: String,\n research_question: String,\n allowed_evidence_ids: Vec,\n batch_drafts: Vec,\n}\n\n#[derive(Debug, Serialize)]\nstruct GlobalSummaryInput {\n topic: String,\n chapter_count: usize,\n evidence_count: usize,\n chapters: Vec,\n}\n\nimpl GlobalSummaryInput {\n fn from_report(topic: &str, chapter_drafts: &[ChapterDraft], evidence_count: usize) -> Self {\n Self {\n topic: topic.to_string(),\n chapter_count: chapter_drafts.len(),\n evidence_count,\n chapters: chapter_drafts\n .iter()\n .map(GlobalSummaryChapterInput::from)\n .collect(),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct GlobalSummaryChapterInput {\n chapter_id: String,\n title_zh: String,\n paragraph_count: usize,\n cited_evidence_ids: Vec,\n paragraph_summaries: Vec,\n}\n\nimpl From<&ChapterDraft> for GlobalSummaryChapterInput {\n fn from(chapter: &ChapterDraft) -> Self {\n let cited_evidence_ids = chapter\n .paragraphs\n .iter()\n .flat_map(|paragraph| paragraph.cited_evidence_ids.iter().cloned())\n .collect::>()\n .into_iter()\n .collect();\n Self {\n chapter_id: chapter.chapter_id.clone(),\n title_zh: chapter.title_zh.clone(),\n paragraph_count: chapter.paragraphs.len(),\n cited_evidence_ids,\n paragraph_summaries: chapter\n .paragraphs\n .iter()\n .map(|paragraph| excerpt_chars(¶graph.text_zh, 500))\n .collect(),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct EvidenceSourceInput {\n evidence_id: String,\n title: String,\n url: String,\n source_kind: SourceKind,\n evidence_note_zh: String,\n evidence_snippet: String,\n}\n\nimpl From<&EvidenceItem> for EvidenceSourceInput {\n fn from(item: &EvidenceItem) -> Self {\n Self {\n evidence_id: item.evidence_id.clone(),\n title: item.title.clone(),\n url: item.url.clone(),\n source_kind: item.source_kind,\n evidence_note_zh: item.evidence_note_zh.clone(),\n evidence_snippet: item.evidence_snippet.clone(),\n }\n }\n}\n\n#[derive(Debug, Deserialize)]\nstruct ChapterDraftOutput {\n #[serde(default)]\n paragraphs: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct BatchDraftOutput {\n #[serde(default)]\n findings_summary_zh: String,\n #[serde(default)]\n paragraphs: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GlobalSummaryOutput {\n #[serde(default)]\n global_summary_zh: String,\n}\n\n#[derive(Debug, Deserialize)]\nstruct ChapterParagraphOutput {\n text_zh: String,\n #[serde(default)]\n cited_evidence_ids: Vec,\n}\n\n#[cfg(test)]\nmod tests {\n use chrono::{DateTime, Utc};\n\n use crate::llm::deepseek::DeepSeekConfig;\n use crate::model::{\n ChapterDraft, ChapterNode, EvidenceItem, EvidenceMemory, ParagraphWithCitations, SourceKind,\n };\n\n use super::{\n batch_chapter_evidence, build_batch_draft_request, build_chapter_synthesis_request,\n build_global_summary_request, draft_report_with_llm, empty_chapter_draft,\n parse_batch_draft_content, parse_chapter_draft_content, parse_global_summary_content,\n writer_max_tokens, BatchDraft, MAX_BATCH_CONTEXT_CHARS, MAX_EVIDENCE_PER_BATCH,\n WRITER_MIN_OUTPUT_TOKENS,\n };\n\n #[test]\n fn writer_drafts_empty_chapter_with_warning_text() {\n let chapters = vec![ChapterNode {\n id: \"ch-1\".to_string(),\n parent_id: None,\n title_zh: \"核心方向\".to_string(),\n research_question: \"测试\".to_string(),\n required_evidence_kinds: vec![\"github\".to_string()],\n evidence_quota: 1,\n sort_order: 1,\n }];\n\n let draft = empty_chapter_draft(&chapters[0]);\n\n assert_eq!(draft.chapter_id, \"ch-1\");\n assert!(draft.paragraphs[0]\n .text_zh\n .contains(\"尚未获得与章节计划直接匹配\"));\n }\n\n #[tokio::test]\n async fn llm_writer_refuses_to_template_when_llm_is_disabled() {\n let chapters = vec![sample_chapter()];\n let memory = EvidenceMemory {\n items: vec![sample_evidence(\"ev-C1\", \"ch-1\")],\n query_attempts: Vec::new(),\n source_lineage: Vec::new(),\n };\n\n let err = draft_report_with_llm(\n \"Rust Agent\",\n &chapters,\n &memory,\n &crate::config::LlmConfig::from_env(false, 30),\n )\n .await\n .expect_err(\"disabled LLM should not produce a template report\");\n\n assert!(err\n .to_string()\n .contains(\"refusing to generate a template report\"));\n }\n\n #[tokio::test]\n #[ignore = \"requires a real DeepSeek API key in DEEPSEEK_API_KEY\"]\n async fn writer_real_deepseek_smoke() {\n let chapters = vec![sample_chapter()];\n let memory = EvidenceMemory {\n items: vec![sample_evidence(\"ev-C1\", \"ch-1\")],\n query_attempts: Vec::new(),\n source_lineage: Vec::new(),\n };\n let config = crate::config::LlmConfig::from_env(true, 120);\n\n assert!(\n config.api_key.is_some(),\n \"DEEPSEEK_API_KEY must be set for this ignored smoke test\"\n );\n\n let draft = draft_report_with_llm(\"TTS 入门级项目简单调研\", &chapters, &memory, &config)\n .await\n .expect(\"real DeepSeek writer smoke should complete\");\n\n assert!(!draft.global_summary_zh.trim().is_empty());\n assert_eq!(draft.chapters.len(), 1);\n assert!(!draft.chapters[0].paragraphs.is_empty());\n assert!(draft.chapters[0].paragraphs[0]\n .cited_evidence_ids\n .contains(&\"ev-C1\".to_string()));\n println!(\n \"writer_real_deepseek_smoke ok: global_summary_chars={}, chapter_paragraphs={}\",\n draft.global_summary_zh.chars().count(),\n draft.chapters[0].paragraphs.len()\n );\n }\n\n #[test]\n fn parses_llm_chapter_draft_json() {\n let chapter = sample_chapter();\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let content = r#\"{\n \"paragraphs\": [\n {\n \"text_zh\": \"该项目展示了从 README 提炼出的 ReAct 主循环和工具调用结构,适合作为入门实现分析对象。\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let draft = parse_chapter_draft_content(content, &chapter, &evidence)\n .expect(\"valid chapter JSON should parse\");\n\n assert_eq!(draft.chapter_id, \"ch-1\");\n assert_eq!(draft.paragraphs[0].cited_evidence_ids, vec![\"ev-C1\"]);\n assert!(draft.paragraphs[0].text_zh.contains(\"ReAct\"));\n }\n\n #[test]\n fn rejects_llm_chapter_draft_with_unknown_evidence_id() {\n let chapter = sample_chapter();\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let content = r#\"{\n \"paragraphs\": [\n {\n \"text_zh\": \"引用了不存在的证据。\",\n \"cited_evidence_ids\": [\"ev-C9\"]\n }\n ]\n }\"#;\n\n let err = parse_chapter_draft_content(content, &chapter, &evidence)\n .expect_err(\"unknown evidence id should fail\");\n\n assert!(err.to_string().contains(\"unknown evidence id\"));\n }\n\n #[test]\n fn rejects_llm_chapter_draft_with_template_residue() {\n let chapter = sample_chapter();\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let content = r#\"{\n \"paragraphs\": [\n {\n \"text_zh\": \"acme/rust-agent 是本章节的关键来源之一。来源链接:https://github.com/acme/rust-agent\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let err = parse_chapter_draft_content(content, &chapter, &evidence)\n .expect_err(\"legacy template residue should fail\");\n\n assert!(err.to_string().contains(\"legacy template marker\"));\n }\n\n #[test]\n fn batches_small_chapter_evidence_into_one_batch() {\n let evidence = vec![\n sample_evidence(\"ev-C1\", \"ch-1\"),\n sample_evidence(\"ev-C2\", \"ch-1\"),\n sample_evidence(\"ev-C3\", \"ch-1\"),\n ];\n\n let batches = batch_chapter_evidence(&evidence);\n\n assert_eq!(batches.len(), 1);\n assert_eq!(batches[0].batch_id, 1);\n assert_eq!(batches[0].evidence_items.len(), 3);\n assert!(batches[0].context_chars > 0);\n }\n\n #[test]\n fn batches_chapter_evidence_by_count() {\n let evidence = (1..=9)\n .map(|index| sample_evidence(&format!(\"ev-C{index}\"), \"ch-1\"))\n .collect::>();\n\n let batches = batch_chapter_evidence(&evidence);\n\n assert_eq!(batches.len(), 3);\n assert_eq!(batches[0].evidence_items.len(), MAX_EVIDENCE_PER_BATCH);\n assert_eq!(batches[1].evidence_items.len(), MAX_EVIDENCE_PER_BATCH);\n assert_eq!(batches[2].evidence_items.len(), 1);\n assert_eq!(batches[2].batch_id, 3);\n }\n\n #[test]\n fn batches_chapter_evidence_by_context_chars() {\n let mut first = sample_evidence(\"ev-C1\", \"ch-1\");\n first.evidence_snippet = \"a\".repeat(MAX_BATCH_CONTEXT_CHARS / 2 + 200);\n let mut second = sample_evidence(\"ev-C2\", \"ch-1\");\n second.evidence_snippet = \"b\".repeat(MAX_BATCH_CONTEXT_CHARS / 2 + 200);\n\n let batches = batch_chapter_evidence(&[first, second]);\n\n assert_eq!(batches.len(), 2);\n assert_eq!(batches[0].evidence_items[0].evidence_id, \"ev-C1\");\n assert_eq!(batches[1].evidence_items[0].evidence_id, \"ev-C2\");\n assert!(batches[0].context_chars > MAX_BATCH_CONTEXT_CHARS / 2);\n }\n\n #[test]\n fn parses_llm_batch_draft_json() {\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batches = batch_chapter_evidence(&evidence);\n let content = r#\"{\n \"findings_summary_zh\": \"该批证据说明了项目 README 中的 ReAct 主循环、工具注册和入门示例。\",\n \"paragraphs\": [\n {\n \"text_zh\": \"该项目的 README 将 ReAct 主循环和工具注册拆成清晰的入门实现,适合用于说明基础 agent 架构。\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let draft =\n parse_batch_draft_content(content, &batches[0]).expect(\"valid batch JSON should parse\");\n\n assert_eq!(draft.batch_id, 1);\n assert!(draft.findings_summary_zh.contains(\"ReAct\"));\n assert_eq!(draft.paragraphs[0].cited_evidence_ids, vec![\"ev-C1\"]);\n }\n\n #[test]\n fn rejects_llm_batch_draft_with_empty_findings() {\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batches = batch_chapter_evidence(&evidence);\n let content = r#\"{\n \"findings_summary_zh\": \"\",\n \"paragraphs\": [\n {\n \"text_zh\": \"该项目展示了一个基础实现。\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let err = parse_batch_draft_content(content, &batches[0])\n .expect_err(\"empty findings should fail\");\n\n assert!(err.to_string().contains(\"empty findings_summary_zh\"));\n }\n\n #[test]\n fn rejects_llm_batch_draft_with_unknown_evidence_id() {\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batches = batch_chapter_evidence(&evidence);\n let content = r#\"{\n \"findings_summary_zh\": \"该批证据有一个段落引用了不存在的证据。\",\n \"paragraphs\": [\n {\n \"text_zh\": \"这里引用了不存在的 evidence id。\",\n \"cited_evidence_ids\": [\"ev-C2\"]\n }\n ]\n }\"#;\n\n let err = parse_batch_draft_content(content, &batches[0])\n .expect_err(\"unknown evidence id should fail\");\n\n assert!(err.to_string().contains(\"unknown evidence id\"));\n }\n\n #[test]\n fn rejects_llm_batch_draft_with_template_residue() {\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batches = batch_chapter_evidence(&evidence);\n let content = r#\"{\n \"findings_summary_zh\": \"acme/rust-agent 是本章节的关键来源之一。\",\n \"paragraphs\": [\n {\n \"text_zh\": \"该项目展示了一个基础实现。\",\n \"cited_evidence_ids\": [\"ev-C1\"]\n }\n ]\n }\"#;\n\n let err = parse_batch_draft_content(content, &batches[0])\n .expect_err(\"legacy template residue should fail\");\n\n assert!(err.to_string().contains(\"legacy template marker\"));\n }\n\n #[test]\n fn batch_request_only_contains_current_batch_evidence() {\n let chapter = sample_chapter();\n let evidence = (1..=5)\n .map(|index| sample_evidence(&format!(\"ev-C{index}\"), \"ch-1\"))\n .collect::>();\n let batches = batch_chapter_evidence(&evidence);\n\n let request = build_batch_draft_request(\n &chapter,\n &batches[1],\n batches.len(),\n &sample_deepseek_config(2048),\n )\n .expect(\"request should build\");\n let user_message = &request.messages[1].content;\n\n assert!(user_message.contains(\"\\\"batch_id\\\": 2\"));\n assert!(user_message.contains(\"ev-C5\"));\n assert!(!user_message.contains(\"ev-C1\"));\n assert_eq!(request.max_tokens, Some(WRITER_MIN_OUTPUT_TOKENS as u32));\n }\n\n #[test]\n fn writer_max_tokens_raises_too_small_config_for_reasoning_models() {\n let config = sample_deepseek_config(32);\n\n assert_eq!(writer_max_tokens(&config), WRITER_MIN_OUTPUT_TOKENS as u32);\n }\n\n #[test]\n fn chapter_synthesis_request_uses_batch_findings_instead_of_raw_evidence() {\n let chapter = sample_chapter();\n let evidence = vec![sample_evidence(\"ev-C1\", \"ch-1\")];\n let batch_drafts = vec![BatchDraft {\n batch_id: 1,\n findings_summary_zh: \"该批证据说明 README 中的 ReAct 主循环适合作为入门样例。\"\n .to_string(),\n paragraphs: vec![ParagraphWithCitations {\n text_zh: \"该项目展示了 ReAct 主循环和工具注册。\".to_string(),\n cited_evidence_ids: vec![\"ev-C1\".to_string()],\n }],\n }];\n\n let request = build_chapter_synthesis_request(\n &chapter,\n &evidence,\n &batch_drafts,\n &sample_deepseek_config(4096),\n )\n .expect(\"request should build\");\n let user_message = &request.messages[1].content;\n\n assert!(user_message.contains(\"batch_drafts\"));\n assert!(user_message.contains(\"allowed_evidence_ids\"));\n assert!(user_message.contains(\"ReAct 主循环适合作为入门样例\"));\n assert!(!user_message.contains(\"evidence_snippet\"));\n assert!(!user_message.contains(\"README explains ReAct loop\"));\n }\n\n #[test]\n fn parses_llm_global_summary_json() {\n let content = r#\"{\n \"global_summary_zh\": \"本报告显示,可控 TTS 入门调研应同时关注基础项目、说话人控制和模型框架边界;现有证据主要来自 GitHub README 与 arXiv 摘要,因此结论应限定在这些来源范围内。\"\n }\"#;\n\n let summary =\n parse_global_summary_content(content).expect(\"valid global summary should parse\");\n\n assert!(summary.contains(\"可控 TTS\"));\n assert!(summary.contains(\"来源范围\"));\n }\n\n #[test]\n fn rejects_llm_global_summary_with_template_residue() {\n let content = r#\"{\n \"global_summary_zh\": \"harinandanmv/text-to-speech 是本章节的关键来源之一。\"\n }\"#;\n\n let err =\n parse_global_summary_content(content).expect_err(\"legacy template marker should fail\");\n\n assert!(err.to_string().contains(\"legacy template marker\"));\n }\n\n #[test]\n fn global_summary_request_uses_chapter_drafts() {\n let chapter_drafts = vec![ChapterDraft {\n chapter_id: \"ch-1\".to_string(),\n title_zh: \"可控 TTS 基础模型\".to_string(),\n paragraphs: vec![ParagraphWithCitations {\n text_zh: \"该章节围绕基础 TTS 模型、说话人控制和入门项目进行综合分析。\".to_string(),\n cited_evidence_ids: vec![\"ev-C1\".to_string()],\n }],\n }];\n\n let request = build_global_summary_request(\n \"TTS 入门级项目简单调研\",\n &chapter_drafts,\n 1,\n &sample_deepseek_config(4096),\n )\n .expect(\"request should build\");\n let user_message = &request.messages[1].content;\n\n assert!(user_message.contains(\"TTS 入门级项目简单调研\"));\n assert!(user_message.contains(\"可控 TTS 基础模型\"));\n assert!(user_message.contains(\"ev-C1\"));\n assert!(user_message.contains(\"paragraph_summaries\"));\n }\n\n fn sample_chapter() -> ChapterNode {\n ChapterNode {\n id: \"ch-1\".to_string(),\n parent_id: None,\n title_zh: \"核心方向\".to_string(),\n research_question: \"测试\".to_string(),\n required_evidence_kinds: vec![\"github\".to_string()],\n evidence_quota: 1,\n sort_order: 1,\n }\n }\n\n fn sample_evidence(evidence_id: &str, chapter_id: &str) -> EvidenceItem {\n EvidenceItem {\n evidence_id: evidence_id.to_string(),\n source_item_id: \"github:acme/rust-agent\".to_string(),\n citation_id: \"C1\".to_string(),\n chapter_ids: vec![chapter_id.to_string()],\n query_attempt_ids: vec![\"gh-1\".to_string()],\n source_kind: SourceKind::GitHub,\n title: \"acme/rust-agent\".to_string(),\n url: \"https://github.com/acme/rust-agent\".to_string(),\n evidence_note_zh: \"GitHub 仓库 `acme/rust-agent`:Rust agent framework\".to_string(),\n evidence_snippet: \"README explains ReAct loop, tool registry, and examples.\"\n .to_string(),\n support_score: None,\n }\n }\n\n fn sample_deepseek_config(max_tokens: usize) -> DeepSeekConfig {\n DeepSeekConfig {\n api_key: \"sk-test\".to_string(),\n base_url: \"https://api.deepseek.com\".to_string(),\n main_model: \"deepseek-v4-pro\".to_string(),\n side_model: \"deepseek-v4-flash\".to_string(),\n max_tokens,\n timeout_secs: 120,\n }\n }\n\n fn _dt() -> DateTime {\n DateTime::parse_from_rfc3339(\"2026-05-30T12:00:00Z\")\n .unwrap()\n .with_timezone(&Utc)\n }\n}\n"}, "1d352f50bc41fcab4e021216bca591d876481974": {"t": 1785750667.6021082, "n": 3718, "h": "f933505dac8bd5ac", "c": "use chrono::Utc;\n\nuse crate::model::{\n ChapterDraft, ChapterNode, CitationLedger, EvidenceItem, EvidenceMemory,\n ParagraphWithCitations, ReportDraft,\n};\n\npub fn draft_report(topic: &str, chapters: &[ChapterNode], memory: &EvidenceMemory) -> ReportDraft {\n let chapter_drafts = chapters\n .iter()\n .map(|chapter| draft_chapter(chapter, &memory.by_chapter(&chapter.id)))\n .collect::>();\n let global_summary_zh =\n format!(\"本报告围绕 `{topic}`,基于当前程序已经抓取到的 GitHub 与 arXiv 证据生成。\");\n ReportDraft {\n title_zh: format!(\"LitScout-RS 调研报告:{topic}\"),\n chapters: chapter_drafts,\n global_summary_zh,\n written_at: Utc::now(),\n }\n}\n\nfn draft_chapter(chapter: &ChapterNode, evidence_items: &[EvidenceItem]) -> ChapterDraft {\n let paragraphs = if evidence_items.is_empty() {\n vec![ParagraphWithCitations {\n text_zh: format!(\n \"当前章节 `{}` 尚未收集到足够证据,建议查看 CoverageCritic 的缺口说明后决定是否增量研究。\",\n chapter.title_zh\n ),\n cited_evidence_ids: Vec::new(),\n }]\n } else {\n evidence_items\n .iter()\n .take(4)\n .map(|item| ParagraphWithCitations {\n text_zh: format!(\n \"`{}` 是本章节的关键来源之一。{} 来源链接:{}\",\n item.title, item.evidence_note_zh, item.url\n ),\n cited_evidence_ids: vec![item.evidence_id.clone()],\n })\n .collect()\n };\n\n ChapterDraft {\n chapter_id: chapter.id.clone(),\n title_zh: chapter.title_zh.clone(),\n paragraphs,\n }\n}\n\npub fn render_report_markdown(draft: &ReportDraft, citations: &CitationLedger) -> String {\n let mut out = String::new();\n out.push_str(&format!(\"# {}\\n\\n\", draft.title_zh));\n out.push_str(\"## 1. 全局摘要\\n\\n\");\n out.push_str(&draft.global_summary_zh);\n out.push_str(\"\\n\\n\");\n\n for (index, chapter) in draft.chapters.iter().enumerate() {\n out.push_str(&format!(\"## {}. {}\\n\\n\", index + 2, chapter.title_zh));\n for paragraph in &chapter.paragraphs {\n out.push_str(¶graph.text_zh);\n if !paragraph.cited_evidence_ids.is_empty() {\n out.push(' ');\n out.push_str(\n ¶graph\n .cited_evidence_ids\n .iter()\n .map(|id| format!(\"`{id}`\"))\n .collect::>()\n .join(\" \"),\n );\n }\n out.push_str(\"\\n\\n\");\n }\n }\n\n out.push_str(\"## 引用账本\\n\\n\");\n if citations.citations.is_empty() {\n out.push_str(\"- 暂无引用。\\n\");\n } else {\n for citation in &citations.citations {\n out.push_str(&format!(\n \"- `{}` [{}]({}) ({:?})\\n\",\n citation.id, citation.title, citation.url, citation.source_kind\n ));\n }\n }\n out\n}\n\n#[cfg(test)]\nmod tests {\n use crate::model::{ChapterNode, EvidenceMemory};\n\n use super::draft_report;\n\n #[test]\n fn writer_drafts_empty_chapter_with_warning_text() {\n let chapters = vec![ChapterNode {\n id: \"ch-1\".to_string(),\n parent_id: None,\n title_zh: \"核心方向\".to_string(),\n research_question: \"测试\".to_string(),\n required_evidence_kinds: vec![\"github\".to_string()],\n evidence_quota: 1,\n sort_order: 1,\n }];\n\n let draft = draft_report(\"Rust Agent\", &chapters, &EvidenceMemory::default());\n\n assert_eq!(draft.chapters.len(), 1);\n assert!(draft.chapters[0].paragraphs[0]\n .text_zh\n .contains(\"尚未收集到足够证据\"));\n }\n}\n"}, "7c0c9f55db1e84d4039b524e6a5c6712e65c973e": {"t": 1785750668.6996644, "n": 3722, "h": "c9c343cdf26f8b77", "c": "use chrono::Utc;\n\nuse crate::model::{\n ChapterDraft, ChapterNode, CitationLedger, EvidenceItem, EvidenceMemory,\n ParagraphWithCitations, ReportDraft,\n};\n\npub fn draft_report(topic: &str, chapters: &[ChapterNode], memory: &EvidenceMemory) -> ReportDraft {\n let chapter_drafts = chapters\n .iter()\n .map(|chapter| draft_chapter(chapter, &memory.by_chapter(&chapter.id)))\n .collect::>();\n let global_summary_zh =\n format!(\"本报告围绕 `{topic}`,基于当前程序已经抓取到的 GitHub 与 arXiv 证据生成。\");\n ReportDraft {\n title_zh: format!(\"LitScout-RS 调研报告:{topic}\"),\n chapters: chapter_drafts,\n global_summary_zh,\n written_at: Utc::now(),\n }\n}\n\nfn draft_chapter(chapter: &ChapterNode, evidence_items: &[EvidenceItem]) -> ChapterDraft {\n let paragraphs = if evidence_items.is_empty() {\n vec![ParagraphWithCitations {\n text_zh: format!(\n \"当前章节 `{}` 尚未收集到足够证据,建议查看 CoverageCritic 的缺口说明后决定是否增量研究。\",\n chapter.title_zh\n ),\n cited_evidence_ids: Vec::new(),\n }]\n } else {\n evidence_items\n .iter()\n .take(4)\n .map(|item| ParagraphWithCitations {\n text_zh: format!(\n \"`{}` 是本章节的关键来源之一。{} 来源链接:{}\",\n item.title, item.evidence_note_zh, item.url\n ),\n cited_evidence_ids: vec![item.evidence_id.clone()],\n })\n .collect()\n };\n\n ChapterDraft {\n chapter_id: chapter.id.clone(),\n title_zh: chapter.title_zh.clone(),\n paragraphs,\n }\n}\n\npub fn render_report_markdown(draft: &ReportDraft, citations: &CitationLedger) -> String {\n let mut out = String::new();\n out.push_str(&format!(\"# {}\\n\\n\", draft.title_zh));\n out.push_str(\"## 1. 全局摘要\\n\\n\");\n out.push_str(&draft.global_summary_zh);\n out.push_str(\"\\n\\n\");\n\n for (index, chapter) in draft.chapters.iter().enumerate() {\n out.push_str(&format!(\"## {}. {}\\n\\n\", index + 2, chapter.title_zh));\n for paragraph in &chapter.paragraphs {\n out.push_str(¶graph.text_zh);\n if !paragraph.cited_evidence_ids.is_empty() {\n out.push_str(\" \");\n out.push_str(\n ¶graph\n .cited_evidence_ids\n .iter()\n .map(|id| format!(\"`{id}`\"))\n .collect::>()\n .join(\" \"),\n );\n }\n out.push_str(\"\\n\\n\");\n }\n }\n\n out.push_str(\"## 引用账本\\n\\n\");\n if citations.citations.is_empty() {\n out.push_str(\"- 暂无引用。\\n\");\n } else {\n for citation in &citations.citations {\n out.push_str(&format!(\n \"- `{}` [{}]({}) ({:?})\\n\",\n citation.id, citation.title, citation.url, citation.source_kind\n ));\n }\n }\n out\n}\n\n#[cfg(test)]\nmod tests {\n use crate::model::{ChapterNode, EvidenceMemory};\n\n use super::draft_report;\n\n #[test]\n fn writer_drafts_empty_chapter_with_warning_text() {\n let chapters = vec![ChapterNode {\n id: \"ch-1\".to_string(),\n parent_id: None,\n title_zh: \"核心方向\".to_string(),\n research_question: \"测试\".to_string(),\n required_evidence_kinds: vec![\"github\".to_string()],\n evidence_quota: 1,\n sort_order: 1,\n }];\n\n let draft = draft_report(\"Rust Agent\", &chapters, &EvidenceMemory::default());\n\n assert_eq!(draft.chapters.len(), 1);\n assert!(draft.chapters[0].paragraphs[0]\n .text_zh\n .contains(\"尚未收集到足够证据\"));\n }\n}\n"}}, "src/agent/orchestrator.rs": {"df730e4a09eb4f1c5830a9f3deacc3ae94c1aa19": {"t": 1785746247.5815625, "n": 32597, "h": "d7a917273aee943f", "c": "use std::path::{Path, PathBuf};\n\nuse chrono::Utc;\nuse tokio::sync::mpsc;\nuse uuid::Uuid;\n\nuse crate::agent::{\n academic_scout, arxiv_scout, citation_auditor, coverage_critic, evidence, github_scout,\n middleware::{\n AgentContext, AgentMiddleware, CitationGuard, JsonSchemaGuard, TokenBudgetTracker,\n },\n plan_critic, planner, scoper, writer,\n};\nuse crate::checkpoint::{self, Checkpoint, CheckpointSnapshot};\nuse crate::config::{AppConfig, LlmConfig};\nuse crate::error::{AppError, Result};\nuse crate::model::{\n ChapterNode, CoverageReport, EvidenceMemory, QueryAttempt, QueryPortfolio, SearchQuery,\n};\nuse crate::run_policy::RunPolicy;\nuse crate::trace::{stable_hash, token_estimate, TraceEvent, TraceWriter};\nuse crate::workflow_state::{ResearchRunRecord, ResearchRunState};\n\n#[derive(Debug, Clone, serde::Serialize)]\n#[serde(tag = \"event\", content = \"data\", rename_all = \"snake_case\")]\npub enum StatefulRunEvent {\n StateChanged {\n state: ResearchRunState,\n },\n CheckpointCreated {\n checkpoint_id: String,\n state: ResearchRunState,\n },\n EvidenceReady {\n total: usize,\n },\n CoverageReady {\n gaps: usize,\n },\n CitationAuditReady {\n citation_coverage_ratio: f64,\n },\n Completed {\n run_id: String,\n },\n Failed {\n error: String,\n },\n}\n\npub async fn create_run(\n topic: String,\n app_config: AppConfig,\n llm_config: LlmConfig,\n policy: RunPolicy,\n) -> Result {\n let run_id = format!(\"run-{}\", Uuid::new_v4());\n let run_dir = run_dir(&app_config, &run_id);\n tokio::fs::create_dir_all(&run_dir).await?;\n let trace = TraceWriter::new(&run_dir).await?;\n let mut run = ResearchRunRecord::new(run_id, topic.clone(), policy.bounded());\n save_run(&app_config, &run).await?;\n let token_budget = TokenBudgetTracker::new();\n let json_guard = JsonSchemaGuard;\n\n let scoper_input = format!(\"topic={topic}; policy={:?}\", run.policy);\n let scoper_ctx = agent_context(\"scoper\", scoper_input.clone(), &run.policy, Vec::new());\n let brief = scoper::generate_research_brief(&topic, &run.policy, &llm_config).await?;\n let brief_json = serde_json::to_string(&brief)?;\n json_guard.after_llm_call(&scoper_ctx, &brief_json)?;\n trace_agent_decision(\n &trace,\n \"scoper\",\n &scoper_input,\n &brief_json,\n \"deterministic ResearchBrief scoping\",\n )\n .await?;\n\n let planner_input = serde_json::to_string(&brief)?;\n let planner_ctx = agent_context(\"planner\", planner_input.clone(), &run.policy, Vec::new());\n let plan = if llm_config_can_call(&llm_config) {\n token_budget.before_llm_call(&planner_ctx)?;\n trace_llm_started(\n &trace,\n \"planner\",\n side_model_name(&llm_config),\n &planner_input,\n )\n .await?;\n match planner::generate_chapter_plan(&brief, &run.policy, &llm_config).await {\n Ok(plan) => {\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n token_budget.after_llm_call(&planner_ctx, &plan_json)?;\n trace_llm_finished(&trace, \"planner\", &planner_input, &plan_json).await?;\n plan\n }\n Err(err) => {\n let reason = err.to_string();\n trace\n .append(&TraceEvent::QualityWarning {\n message: format!(\"Planner LLM 调用失败,已降级为确定性默认计划:{reason}\"),\n at: Utc::now(),\n })\n .await?;\n let plan = fallback_chapter_plan(&brief.topic, &run.policy, Some(&reason));\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n trace_agent_decision(\n &trace,\n \"planner_fallback\",\n &planner_input,\n &plan_json,\n \"planner LLM failed; deterministic default plan\",\n )\n .await?;\n plan\n }\n }\n } else {\n let reason = llm_config\n .enabled\n .then_some(\"LLM 已启用但缺少 API key,已使用确定性默认计划。\");\n let plan = fallback_chapter_plan(&brief.topic, &run.policy, reason);\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n trace_agent_decision(\n &trace,\n \"planner\",\n &planner_input,\n &plan_json,\n \"deterministic default plan\",\n )\n .await?;\n plan\n };\n\n let critic_input = serde_json::to_string(&(&brief, &plan.chapters, &plan.query_portfolio))?;\n let critic_ctx = agent_context(\"plan_critic\", critic_input.clone(), &run.policy, Vec::new());\n let critique =\n plan_critic::critique_plan(&brief, &plan.chapters, &plan.query_portfolio, &run.policy);\n let critique_json = serde_json::to_string(&critique)?;\n json_guard.after_llm_call(&critic_ctx, &critique_json)?;\n trace_agent_decision(\n &trace,\n \"plan_critic\",\n &critic_input,\n &critique_json,\n \"deterministic plan consistency checks\",\n )\n .await?;\n let from = run.state.clone();\n if !run.transition_to(ResearchRunState::PlanReady) {\n return Err(invalid_transition(&from, &ResearchRunState::PlanReady));\n }\n run.brief = Some(brief);\n run.chapters = plan.chapters;\n run.query_portfolio = plan.query_portfolio;\n run.plan_warnings = plan\n .warnings\n .into_iter()\n .chain(critique.warnings)\n .chain(critique.suggestions)\n .collect();\n run.warnings.push(format!(\n \"Stage 3 agent planning used {} real LLM request attempt(s); deterministic nodes are traced as AgentDecision events.\",\n token_budget.calls()\n ));\n trace_transition(&trace, from, ResearchRunState::PlanReady).await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, &run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(&app_config, &run).await?;\n Ok(run)\n}\n\npub async fn continue_run(\n run_id: &str,\n app_config: AppConfig,\n llm_config: LlmConfig,\n tx: Option>,\n) -> Result {\n let mut run = load_run(&app_config, run_id).await?;\n match run.state {\n ResearchRunState::PlanReady => {\n continue_from_plan_ready(&mut run, app_config, llm_config, tx).await\n }\n ResearchRunState::Completed => Ok(run),\n _ => Err(AppError::Workflow(format!(\n \"run `{run_id}` is in state {:?} and cannot continue from this endpoint\",\n run.state\n ))),\n }\n}\n\nasync fn continue_from_plan_ready(\n run: &mut ResearchRunRecord,\n app_config: AppConfig,\n llm_config: LlmConfig,\n tx: Option>,\n) -> Result {\n let run_dir = run_dir(&app_config, &run.run_id);\n let trace = TraceWriter::new(&run_dir).await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Fetching,\n tx.as_ref(),\n )\n .await?;\n trace_planned_tool_calls(\n &trace,\n &run.query_portfolio,\n run.policy.academic_extra_enabled,\n )\n .await?;\n\n let mut github_config = app_config.clone();\n github_config.enrich = app_config.enrich || run.policy.allow_github_enrich;\n let (github_result, arxiv_result) = tokio::join!(\n github_scout::scout_github(&run.query_portfolio, &github_config, 1),\n arxiv_scout::scout_arxiv(&run.query_portfolio, &app_config, 1)\n );\n let (github_repos, github_attempts, github_lineage) = github_result?;\n let (arxiv_papers, arxiv_attempts, arxiv_lineage) = arxiv_result?;\n let (academic_items, academic_attempts, academic_lineage) = if run.policy.academic_extra_enabled\n {\n academic_scout::scout_academic_extra(\n &run.query_portfolio,\n &app_config,\n run.policy.academic_budget,\n 1,\n )\n .await?\n } else {\n (Vec::new(), Vec::new(), Vec::new())\n };\n let query_attempts = github_attempts\n .into_iter()\n .chain(arxiv_attempts)\n .chain(academic_attempts)\n .collect::>();\n let source_lineage = github_lineage\n .into_iter()\n .chain(arxiv_lineage)\n .chain(academic_lineage)\n .collect::>();\n for attempt in &query_attempts {\n trace\n .append(&TraceEvent::ToolCallFinished {\n tool: attempt.source.clone(),\n result_count: attempt.result_count,\n error: attempt.error.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n if github_repos.is_empty() && arxiv_papers.is_empty() && academic_items.is_empty() {\n let message =\n \"GitHub、arXiv 与已启用的扩展学术源均未返回可用结果,无法生成 stateful report。\"\n .to_string();\n run.warnings.push(message.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::Workflow(message));\n }\n\n let query = SearchQuery {\n topic: run.topic.clone(),\n github_limit: run.policy.github_budget,\n arxiv_limit: run.policy.arxiv_budget,\n };\n let evidence = evidence::build_evidence_memory(\n &query,\n &app_config,\n github_repos,\n arxiv_papers,\n academic_items,\n query_attempts,\n source_lineage,\n )?;\n let ranked_total = evidence.ranked_items.len();\n let group_total = evidence.groups.len();\n let selected_total = evidence.memory.items.len();\n let rejected_total = evidence.rejected_items.len();\n let top_rejection_reasons = evidence\n .selection_report\n .rejection_reasons\n .iter()\n .take(3)\n .map(|reason| format!(\"{}={}\", reason.reason, reason.count))\n .collect::>()\n .join(\", \");\n if run.policy.academic_extra_enabled\n && evidence.selection_report.rejected_item_count > 0\n && evidence.memory.items.iter().all(|item| {\n !matches!(\n item.source_kind,\n crate::model::SourceKind::AcademicIndex | crate::model::SourceKind::Bibliography\n )\n })\n {\n run.warnings.push(\n \"扩展学术源返回了候选结果,但 EvidenceQualityGate 未筛出可引用证据;报告将继续使用 GitHub/arXiv 证据。\"\n .to_string(),\n );\n }\n let coverage = if run.policy.skip_coverage_critic {\n CoverageReport::pass()\n } else {\n coverage_critic::evaluate_coverage(\n &run.chapters,\n &run.query_portfolio,\n &evidence.memory,\n &run.policy,\n )\n };\n for gap in &coverage.gaps {\n trace\n .append(&TraceEvent::CoverageGapDetected {\n chapter_id: gap.chapter_id.clone(),\n gap_kind: format!(\"{:?}\", gap.gap_kind),\n at: Utc::now(),\n })\n .await?;\n }\n run.evidence_memory = Some(evidence.memory.clone());\n run.coverage_report = Some(coverage.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::EvidenceReady,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::EvidenceReady {\n total: evidence.memory.items.len(),\n })\n .await;\n let _ = tx\n .send(StatefulRunEvent::CoverageReady {\n gaps: coverage.gaps.len(),\n })\n .await;\n }\n trace\n .append(&TraceEvent::QualityWarning {\n message: format!(\n \"EvidenceQualityGate ranked {ranked_total} item(s), accepted {selected_total}, rejected {rejected_total}, grouped {group_total}. Top rejection reasons: {}\",\n if top_rejection_reasons.is_empty() {\n \"none\".to_string()\n } else {\n top_rejection_reasons\n }\n ),\n at: Utc::now(),\n })\n .await?;\n\n let writer_input = serde_json::to_string(&(&run.topic, &run.chapters, &evidence.memory))?;\n let writer_ctx = agent_context(\"writer\", writer_input.clone(), &run.policy, Vec::new());\n if !llm_config_can_call(&llm_config) {\n let message = \"Writer LLM 需要 DeepSeek API key;拒绝生成模板化最终报告。\".to_string();\n run.warnings.push(message.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::InvalidConfig(message));\n }\n let writer_model = llm_config.main_model.clone();\n let writer_budget = TokenBudgetTracker::new();\n writer_budget.before_llm_call(&writer_ctx)?;\n trace_llm_started(&trace, \"writer\", writer_model, &writer_input).await?;\n let draft_result =\n writer::draft_report_with_llm(&run.topic, &run.chapters, &evidence.memory, &llm_config)\n .await;\n let draft = match draft_result {\n Ok(draft) => draft,\n Err(err) => {\n let message = format!(\"Writer LLM 写作失败,拒绝生成模板化最终报告:{err}\");\n run.warnings.push(message.clone());\n trace\n .append(&TraceEvent::QualityWarning {\n message: message.clone(),\n at: Utc::now(),\n })\n .await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::Llm(message));\n }\n };\n let draft_json = serde_json::to_string(&draft)?;\n trace_llm_finished(&trace, \"writer\", &writer_input, &draft_json).await?;\n let audit = if run.policy.require_citation_audit {\n citation_auditor::audit_citations(&draft, &evidence.citations, &evidence.memory)\n } else {\n crate::model::CitationAuditReport::pass()\n };\n let markdown = writer::render_report_markdown(&draft, &evidence.citations);\n let citation_ctx = agent_context(\n \"writer\",\n \"render_report_markdown\".to_string(),\n &run.policy,\n evidence\n .citations\n .citations\n .iter()\n .map(|citation| citation.url.clone())\n .collect(),\n );\n CitationGuard.after_llm_call(&citation_ctx, &markdown)?;\n run.report_draft = Some(draft);\n run.citation_audit = Some(audit.clone());\n run.report_markdown = Some(markdown.clone());\n if !audit.url_whitelist_passed {\n run.warnings\n .push(\"Citation audit failed URL whitelist check.\".to_string());\n }\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::SynthesisReady,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::CitationAuditReady {\n citation_coverage_ratio: audit.citation_coverage_ratio,\n })\n .await;\n }\n\n write_report_file(&app_config, run, &markdown).await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Completed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::Completed {\n run_id: run.run_id.clone(),\n })\n .await;\n }\n Ok(run.clone())\n}\n\npub async fn load_run(app_config: &AppConfig, run_id: &str) -> Result {\n let body = tokio::fs::read_to_string(run_summary_path(app_config, run_id)).await?;\n Ok(serde_json::from_str(&body)?)\n}\n\npub async fn save_run(app_config: &AppConfig, run: &ResearchRunRecord) -> Result<()> {\n tokio::fs::create_dir_all(&app_config.session_dir).await?;\n let body = serde_json::to_string_pretty(run)?;\n tokio::fs::write(run_summary_path(app_config, &run.run_id), body).await?;\n Ok(())\n}\n\npub async fn evidence_for_run(app_config: &AppConfig, run_id: &str) -> Result {\n load_run(app_config, run_id)\n .await?\n .evidence_memory\n .ok_or_else(|| AppError::Workflow(\"run has no EvidenceMemory yet\".to_string()))\n}\n\npub async fn coverage_for_run(app_config: &AppConfig, run_id: &str) -> Result {\n load_run(app_config, run_id)\n .await?\n .coverage_report\n .ok_or_else(|| AppError::Workflow(\"run has no CoverageReport yet\".to_string()))\n}\n\npub async fn revise_plan(\n app_config: &AppConfig,\n run_id: &str,\n chapters: Option>,\n query_portfolio: Option>,\n user_feedback: Option,\n) -> Result {\n let mut run = load_run(app_config, run_id).await?;\n if !matches!(run.state, ResearchRunState::PlanReady) {\n return Err(AppError::Workflow(\n \"only PlanReady runs can revise the Stage 3 plan\".to_string(),\n ));\n }\n if let Some(chapters) = chapters {\n if chapters.is_empty() {\n return Err(AppError::Workflow(\n \"revised chapter plan cannot be empty\".to_string(),\n ));\n }\n run.chapters = chapters;\n }\n if let Some(query_portfolio) = query_portfolio {\n if query_portfolio.is_empty() {\n return Err(AppError::Workflow(\n \"revised query portfolio cannot be empty\".to_string(),\n ));\n }\n run.query_portfolio = query_portfolio;\n }\n if let Some(feedback) = user_feedback.map(|value| value.trim().to_string()) {\n if !feedback.is_empty() {\n run.plan_warnings\n .push(format!(\"用户修订计划备注:{feedback}\"));\n }\n }\n run.updated_at = Utc::now();\n let run_dir = run_dir(app_config, run_id);\n let trace = TraceWriter::new(&run_dir).await?;\n trace\n .append(&TraceEvent::QualityWarning {\n message: \"用户已修订 PlanReady 搜索计划,并创建新的 checkpoint。\".to_string(),\n at: Utc::now(),\n })\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, &run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(app_config, &run).await?;\n Ok(run)\n}\n\npub async fn branch_from_plan_ready(\n app_config: AppConfig,\n run_id: &str,\n checkpoint_id: &str,\n) -> Result {\n let snapshot = load_checkpoint_snapshot_by_id(&app_config, run_id, checkpoint_id).await?;\n if !snapshot.checkpoint.rollback_allowed\n || !matches!(snapshot.checkpoint.state, ResearchRunState::PlanReady)\n {\n return Err(AppError::Workflow(\n \"Stage 3 currently supports rollback branches only from PlanReady checkpoints\"\n .to_string(),\n ));\n }\n let mut branch = snapshot.run;\n branch.run_id = format!(\"run-{}\", Uuid::new_v4());\n branch.created_at = Utc::now();\n branch.updated_at = Utc::now();\n branch.state = ResearchRunState::PlanReady;\n branch.origin_run_id = Some(run_id.to_string());\n branch.origin_checkpoint_id = Some(checkpoint_id.to_string());\n branch.evidence_memory = None;\n branch.coverage_report = None;\n branch.report_draft = None;\n branch.citation_audit = None;\n branch.report_markdown = None;\n branch.output_report = None;\n let branch_dir = run_dir(&app_config, &branch.run_id);\n tokio::fs::create_dir_all(&branch_dir).await?;\n let trace = TraceWriter::new(&branch_dir).await?;\n trace\n .append(&TraceEvent::RollbackBranchCreated {\n origin_run_id: run_id.to_string(),\n new_run_id: branch.run_id.clone(),\n at: Utc::now(),\n })\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&branch_dir, &branch).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(&app_config, &branch).await?;\n Ok(branch)\n}\n\nasync fn load_checkpoint_snapshot_by_id(\n app_config: &AppConfig,\n run_id: &str,\n checkpoint_id: &str,\n) -> Result {\n let run_dir = run_dir(app_config, run_id);\n let checkpoint_dir = run_dir.join(\"checkpoints\");\n let mut entries = tokio::fs::read_dir(checkpoint_dir).await?;\n while let Some(entry) = entries.next_entry().await? {\n let body = tokio::fs::read_to_string(entry.path()).await?;\n let snapshot: CheckpointSnapshot = serde_json::from_str(&body)?;\n if snapshot.checkpoint.checkpoint_id == checkpoint_id {\n return checkpoint::read_checkpoint(&run_dir, &snapshot.checkpoint).await;\n }\n }\n Err(AppError::Workflow(format!(\n \"checkpoint `{checkpoint_id}` was not found for run `{run_id}`\"\n )))\n}\n\nasync fn transition(\n run: &mut ResearchRunRecord,\n trace: &TraceWriter,\n app_config: &AppConfig,\n next: ResearchRunState,\n tx: Option<&mpsc::Sender>,\n) -> Result<()> {\n let from = run.state.clone();\n if !run.transition_to(next.clone()) {\n return Err(invalid_transition(&from, &next));\n }\n trace_transition(trace, from, next.clone()).await?;\n save_run(app_config, run).await?;\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::StateChanged { state: next })\n .await;\n }\n Ok(())\n}\n\nasync fn trace_transition(\n trace: &TraceWriter,\n from: ResearchRunState,\n to: ResearchRunState,\n) -> Result<()> {\n trace\n .append(&TraceEvent::StateTransition {\n from,\n to,\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_llm_started(\n trace: &TraceWriter,\n actor: &str,\n model: String,\n input: &str,\n) -> Result<()> {\n trace\n .append(&TraceEvent::LlmRequestStarted {\n actor: actor.to_string(),\n model,\n input_hash: stable_hash(input),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_llm_finished(\n trace: &TraceWriter,\n actor: &str,\n input: &str,\n output: &str,\n) -> Result<()> {\n let (_input_hash, output_hash, tokens) = decision_hash(input, output);\n trace\n .append(&TraceEvent::LlmRequestFinished {\n actor: actor.to_string(),\n output_hash,\n token_estimate: tokens,\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_agent_decision(\n trace: &TraceWriter,\n actor: &str,\n input: &str,\n output: &str,\n rationale: &str,\n) -> Result<()> {\n let (input_hash, output_hash, _tokens) = decision_hash(input, output);\n trace\n .append(&TraceEvent::AgentDecision {\n actor: actor.to_string(),\n input_hash,\n output_hash,\n rationale: rationale.to_string(),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_checkpoint(trace: &TraceWriter, checkpoint: &Checkpoint) -> Result<()> {\n trace\n .append(&TraceEvent::CheckpointCreated {\n checkpoint_id: checkpoint.checkpoint_id.clone(),\n state: checkpoint.state.clone(),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn send_checkpoint(tx: Option<&mpsc::Sender>, checkpoint: &Checkpoint) {\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::CheckpointCreated {\n checkpoint_id: checkpoint.checkpoint_id.clone(),\n state: checkpoint.state.clone(),\n })\n .await;\n }\n}\n\nasync fn trace_planned_tool_calls(\n trace: &TraceWriter,\n portfolio: &[QueryPortfolio],\n include_academic_extra: bool,\n) -> Result<()> {\n for item in portfolio {\n for query in &item.github_queries {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"github\".to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n for query in &item.arxiv_queries {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"arxiv\".to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n if include_academic_extra {\n for query in academic_queries_for_trace(item) {\n for source in academic_scout::academic_source_names() {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: source.to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n }\n }\n }\n Ok(())\n}\n\nfn academic_queries_for_trace(item: &QueryPortfolio) -> Vec {\n let mut queries = item.arxiv_queries.clone();\n for query in &item.github_queries {\n if !queries.iter().any(|existing| existing == query) {\n queries.push(query.clone());\n }\n }\n queries\n}\n\nfn llm_config_can_call(llm_config: &LlmConfig) -> bool {\n llm_config.enabled\n && llm_config\n .api_key\n .as_deref()\n .map(str::trim)\n .is_some_and(|key| !key.is_empty())\n}\n\nfn fallback_chapter_plan(\n topic: &str,\n policy: &RunPolicy,\n warning: Option<&str>,\n) -> planner::ChapterPlanOutput {\n let default_plan = planner::default_plan(topic, policy.github_budget, policy.arxiv_budget);\n let mut plan = planner::chapter_plan_from_plan_output(&default_plan, policy);\n if let Some(warning) = warning {\n plan.warnings.push(warning.to_string());\n }\n plan\n}\n\nfn agent_context(\n actor: &str,\n input: String,\n policy: &RunPolicy,\n allowed_urls: Vec,\n) -> AgentContext {\n AgentContext {\n actor: actor.to_string(),\n input,\n allowed_urls,\n max_llm_calls: policy.max_llm_calls_per_run,\n }\n}\n\nfn side_model_name(llm_config: &LlmConfig) -> String {\n llm_config\n .side_model\n .clone()\n .unwrap_or_else(|| llm_config.main_model.clone())\n}\n\nfn invalid_transition(from: &ResearchRunState, to: &ResearchRunState) -> AppError {\n AppError::Workflow(format!(\"invalid state transition from {from:?} to {to:?}\"))\n}\n\nfn run_summary_path(app_config: &AppConfig, run_id: &str) -> PathBuf {\n app_config.session_dir.join(format!(\"{run_id}.json\"))\n}\n\nfn run_dir(app_config: &AppConfig, run_id: &str) -> PathBuf {\n app_config.session_dir.join(run_id)\n}\n\nasync fn write_report_file(\n app_config: &AppConfig,\n run: &mut ResearchRunRecord,\n markdown: &str,\n) -> Result<()> {\n let path = resolve_report_path(&app_config.output, &run.topic);\n if let Some(parent) = path.parent() {\n tokio::fs::create_dir_all(parent).await?;\n }\n tokio::fs::write(&path, markdown).await?;\n run.output_report = Some(path.display().to_string());\n Ok(())\n}\n\nfn resolve_report_path(output: &Path, topic: &str) -> PathBuf {\n if output.extension().is_some() {\n return output.to_path_buf();\n }\n output.join(format!(\n \"{}-stage3-{}.md\",\n slugify(topic),\n Utc::now().format(\"%Y%m%d-%H%M%S\")\n ))\n}\n\nfn slugify(topic: &str) -> String {\n let mut slug = String::new();\n let mut last_dash = false;\n for ch in topic.chars().flat_map(char::to_lowercase) {\n if ch.is_ascii_alphanumeric() {\n slug.push(ch);\n last_dash = false;\n } else if !last_dash {\n slug.push('-');\n last_dash = true;\n }\n }\n let slug = slug.trim_matches('-');\n if slug.is_empty() {\n \"report\".to_string()\n } else {\n slug.to_string()\n }\n}\n\npub fn decision_hash(input: &str, output: &str) -> (String, String, usize) {\n (\n stable_hash(input),\n stable_hash(output),\n token_estimate(input) + token_estimate(output),\n )\n}\n\n#[cfg(test)]\nmod tests {\n use std::time::{SystemTime, UNIX_EPOCH};\n\n use super::create_run;\n use crate::config::{AppConfig, LlmConfig};\n use crate::run_policy::RunPolicy;\n use crate::workflow_state::ResearchRunState;\n\n #[tokio::test]\n async fn create_run_falls_back_without_llm_key_and_traces_rule_nodes() {\n let root = temp_root(\"stage3-create-run-fallback\");\n let app_config = AppConfig {\n github_token: None,\n semantic_scholar_api_key: None,\n openalex_api_key: None,\n crossref_mailto: None,\n output: root.join(\"reports\"),\n cache_dir: root.join(\"cache\"),\n session_dir: root.join(\"sessions\"),\n tags_file: None,\n use_cache: false,\n cache_ttl_hours: 24,\n timeout_secs: 30,\n enrich: false,\n };\n let llm_config = LlmConfig {\n enabled: true,\n api_key: None,\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n main_model: \"deepseek-v4-pro\".to_string(),\n side_model: Some(\"deepseek-v4-flash\".to_string()),\n max_tokens: 4096,\n timeout_secs: 30,\n };\n\n let run = create_run(\n \"Rust agent framework\".to_string(),\n app_config.clone(),\n llm_config,\n RunPolicy::default(),\n )\n .await\n .expect(\"create_run should degrade to a deterministic plan\");\n\n assert_eq!(run.state, ResearchRunState::PlanReady);\n assert!(run\n .plan_warnings\n .iter()\n .any(|warning| warning.contains(\"API key\")));\n assert!(run\n .warnings\n .iter()\n .any(|warning| warning.contains(\"0 real LLM request attempt\")));\n\n let trace_path = app_config.session_dir.join(&run.run_id).join(\"trace.jsonl\");\n let trace = std::fs::read_to_string(trace_path).expect(\"trace should be readable\");\n assert!(trace.contains(\"\\\"event\\\":\\\"agent_decision\\\"\"));\n assert!(trace.contains(\"\\\"actor\\\":\\\"scoper\\\"\"));\n assert!(trace.contains(\"\\\"actor\\\":\\\"plan_critic\\\"\"));\n assert!(!trace.contains(\"\\\"event\\\":\\\"llm_request_started\\\"\"));\n }\n\n fn temp_root(name: &str) -> std::path::PathBuf {\n let unique = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n std::env::temp_dir().join(format!(\n \"litscout-rs-{name}-{}-{unique}\",\n std::process::id()\n ))\n }\n}\n"}, "b8f76fdd9fdfaaf0957a9293477eb193d392c98f": {"t": 1785750657.6714153, "n": 32597, "h": "d7a917273aee943f", "c": "use std::path::{Path, PathBuf};\n\nuse chrono::Utc;\nuse tokio::sync::mpsc;\nuse uuid::Uuid;\n\nuse crate::agent::{\n academic_scout, arxiv_scout, citation_auditor, coverage_critic, evidence, github_scout,\n middleware::{\n AgentContext, AgentMiddleware, CitationGuard, JsonSchemaGuard, TokenBudgetTracker,\n },\n plan_critic, planner, scoper, writer,\n};\nuse crate::checkpoint::{self, Checkpoint, CheckpointSnapshot};\nuse crate::config::{AppConfig, LlmConfig};\nuse crate::error::{AppError, Result};\nuse crate::model::{\n ChapterNode, CoverageReport, EvidenceMemory, QueryAttempt, QueryPortfolio, SearchQuery,\n};\nuse crate::run_policy::RunPolicy;\nuse crate::trace::{stable_hash, token_estimate, TraceEvent, TraceWriter};\nuse crate::workflow_state::{ResearchRunRecord, ResearchRunState};\n\n#[derive(Debug, Clone, serde::Serialize)]\n#[serde(tag = \"event\", content = \"data\", rename_all = \"snake_case\")]\npub enum StatefulRunEvent {\n StateChanged {\n state: ResearchRunState,\n },\n CheckpointCreated {\n checkpoint_id: String,\n state: ResearchRunState,\n },\n EvidenceReady {\n total: usize,\n },\n CoverageReady {\n gaps: usize,\n },\n CitationAuditReady {\n citation_coverage_ratio: f64,\n },\n Completed {\n run_id: String,\n },\n Failed {\n error: String,\n },\n}\n\npub async fn create_run(\n topic: String,\n app_config: AppConfig,\n llm_config: LlmConfig,\n policy: RunPolicy,\n) -> Result {\n let run_id = format!(\"run-{}\", Uuid::new_v4());\n let run_dir = run_dir(&app_config, &run_id);\n tokio::fs::create_dir_all(&run_dir).await?;\n let trace = TraceWriter::new(&run_dir).await?;\n let mut run = ResearchRunRecord::new(run_id, topic.clone(), policy.bounded());\n save_run(&app_config, &run).await?;\n let token_budget = TokenBudgetTracker::new();\n let json_guard = JsonSchemaGuard;\n\n let scoper_input = format!(\"topic={topic}; policy={:?}\", run.policy);\n let scoper_ctx = agent_context(\"scoper\", scoper_input.clone(), &run.policy, Vec::new());\n let brief = scoper::generate_research_brief(&topic, &run.policy, &llm_config).await?;\n let brief_json = serde_json::to_string(&brief)?;\n json_guard.after_llm_call(&scoper_ctx, &brief_json)?;\n trace_agent_decision(\n &trace,\n \"scoper\",\n &scoper_input,\n &brief_json,\n \"deterministic ResearchBrief scoping\",\n )\n .await?;\n\n let planner_input = serde_json::to_string(&brief)?;\n let planner_ctx = agent_context(\"planner\", planner_input.clone(), &run.policy, Vec::new());\n let plan = if llm_config_can_call(&llm_config) {\n token_budget.before_llm_call(&planner_ctx)?;\n trace_llm_started(\n &trace,\n \"planner\",\n side_model_name(&llm_config),\n &planner_input,\n )\n .await?;\n match planner::generate_chapter_plan(&brief, &run.policy, &llm_config).await {\n Ok(plan) => {\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n token_budget.after_llm_call(&planner_ctx, &plan_json)?;\n trace_llm_finished(&trace, \"planner\", &planner_input, &plan_json).await?;\n plan\n }\n Err(err) => {\n let reason = err.to_string();\n trace\n .append(&TraceEvent::QualityWarning {\n message: format!(\"Planner LLM 调用失败,已降级为确定性默认计划:{reason}\"),\n at: Utc::now(),\n })\n .await?;\n let plan = fallback_chapter_plan(&brief.topic, &run.policy, Some(&reason));\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n trace_agent_decision(\n &trace,\n \"planner_fallback\",\n &planner_input,\n &plan_json,\n \"planner LLM failed; deterministic default plan\",\n )\n .await?;\n plan\n }\n }\n } else {\n let reason = llm_config\n .enabled\n .then_some(\"LLM 已启用但缺少 API key,已使用确定性默认计划。\");\n let plan = fallback_chapter_plan(&brief.topic, &run.policy, reason);\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n trace_agent_decision(\n &trace,\n \"planner\",\n &planner_input,\n &plan_json,\n \"deterministic default plan\",\n )\n .await?;\n plan\n };\n\n let critic_input = serde_json::to_string(&(&brief, &plan.chapters, &plan.query_portfolio))?;\n let critic_ctx = agent_context(\"plan_critic\", critic_input.clone(), &run.policy, Vec::new());\n let critique =\n plan_critic::critique_plan(&brief, &plan.chapters, &plan.query_portfolio, &run.policy);\n let critique_json = serde_json::to_string(&critique)?;\n json_guard.after_llm_call(&critic_ctx, &critique_json)?;\n trace_agent_decision(\n &trace,\n \"plan_critic\",\n &critic_input,\n &critique_json,\n \"deterministic plan consistency checks\",\n )\n .await?;\n let from = run.state.clone();\n if !run.transition_to(ResearchRunState::PlanReady) {\n return Err(invalid_transition(&from, &ResearchRunState::PlanReady));\n }\n run.brief = Some(brief);\n run.chapters = plan.chapters;\n run.query_portfolio = plan.query_portfolio;\n run.plan_warnings = plan\n .warnings\n .into_iter()\n .chain(critique.warnings)\n .chain(critique.suggestions)\n .collect();\n run.warnings.push(format!(\n \"Stage 3 agent planning used {} real LLM request attempt(s); deterministic nodes are traced as AgentDecision events.\",\n token_budget.calls()\n ));\n trace_transition(&trace, from, ResearchRunState::PlanReady).await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, &run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(&app_config, &run).await?;\n Ok(run)\n}\n\npub async fn continue_run(\n run_id: &str,\n app_config: AppConfig,\n llm_config: LlmConfig,\n tx: Option>,\n) -> Result {\n let mut run = load_run(&app_config, run_id).await?;\n match run.state {\n ResearchRunState::PlanReady => {\n continue_from_plan_ready(&mut run, app_config, llm_config, tx).await\n }\n ResearchRunState::Completed => Ok(run),\n _ => Err(AppError::Workflow(format!(\n \"run `{run_id}` is in state {:?} and cannot continue from this endpoint\",\n run.state\n ))),\n }\n}\n\nasync fn continue_from_plan_ready(\n run: &mut ResearchRunRecord,\n app_config: AppConfig,\n llm_config: LlmConfig,\n tx: Option>,\n) -> Result {\n let run_dir = run_dir(&app_config, &run.run_id);\n let trace = TraceWriter::new(&run_dir).await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Fetching,\n tx.as_ref(),\n )\n .await?;\n trace_planned_tool_calls(\n &trace,\n &run.query_portfolio,\n run.policy.academic_extra_enabled,\n )\n .await?;\n\n let mut github_config = app_config.clone();\n github_config.enrich = app_config.enrich || run.policy.allow_github_enrich;\n let (github_result, arxiv_result) = tokio::join!(\n github_scout::scout_github(&run.query_portfolio, &github_config, 1),\n arxiv_scout::scout_arxiv(&run.query_portfolio, &app_config, 1)\n );\n let (github_repos, github_attempts, github_lineage) = github_result?;\n let (arxiv_papers, arxiv_attempts, arxiv_lineage) = arxiv_result?;\n let (academic_items, academic_attempts, academic_lineage) = if run.policy.academic_extra_enabled\n {\n academic_scout::scout_academic_extra(\n &run.query_portfolio,\n &app_config,\n run.policy.academic_budget,\n 1,\n )\n .await?\n } else {\n (Vec::new(), Vec::new(), Vec::new())\n };\n let query_attempts = github_attempts\n .into_iter()\n .chain(arxiv_attempts)\n .chain(academic_attempts)\n .collect::>();\n let source_lineage = github_lineage\n .into_iter()\n .chain(arxiv_lineage)\n .chain(academic_lineage)\n .collect::>();\n for attempt in &query_attempts {\n trace\n .append(&TraceEvent::ToolCallFinished {\n tool: attempt.source.clone(),\n result_count: attempt.result_count,\n error: attempt.error.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n if github_repos.is_empty() && arxiv_papers.is_empty() && academic_items.is_empty() {\n let message =\n \"GitHub、arXiv 与已启用的扩展学术源均未返回可用结果,无法生成 stateful report。\"\n .to_string();\n run.warnings.push(message.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::Workflow(message));\n }\n\n let query = SearchQuery {\n topic: run.topic.clone(),\n github_limit: run.policy.github_budget,\n arxiv_limit: run.policy.arxiv_budget,\n };\n let evidence = evidence::build_evidence_memory(\n &query,\n &app_config,\n github_repos,\n arxiv_papers,\n academic_items,\n query_attempts,\n source_lineage,\n )?;\n let ranked_total = evidence.ranked_items.len();\n let group_total = evidence.groups.len();\n let selected_total = evidence.memory.items.len();\n let rejected_total = evidence.rejected_items.len();\n let top_rejection_reasons = evidence\n .selection_report\n .rejection_reasons\n .iter()\n .take(3)\n .map(|reason| format!(\"{}={}\", reason.reason, reason.count))\n .collect::>()\n .join(\", \");\n if run.policy.academic_extra_enabled\n && evidence.selection_report.rejected_item_count > 0\n && evidence.memory.items.iter().all(|item| {\n !matches!(\n item.source_kind,\n crate::model::SourceKind::AcademicIndex | crate::model::SourceKind::Bibliography\n )\n })\n {\n run.warnings.push(\n \"扩展学术源返回了候选结果,但 EvidenceQualityGate 未筛出可引用证据;报告将继续使用 GitHub/arXiv 证据。\"\n .to_string(),\n );\n }\n let coverage = if run.policy.skip_coverage_critic {\n CoverageReport::pass()\n } else {\n coverage_critic::evaluate_coverage(\n &run.chapters,\n &run.query_portfolio,\n &evidence.memory,\n &run.policy,\n )\n };\n for gap in &coverage.gaps {\n trace\n .append(&TraceEvent::CoverageGapDetected {\n chapter_id: gap.chapter_id.clone(),\n gap_kind: format!(\"{:?}\", gap.gap_kind),\n at: Utc::now(),\n })\n .await?;\n }\n run.evidence_memory = Some(evidence.memory.clone());\n run.coverage_report = Some(coverage.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::EvidenceReady,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::EvidenceReady {\n total: evidence.memory.items.len(),\n })\n .await;\n let _ = tx\n .send(StatefulRunEvent::CoverageReady {\n gaps: coverage.gaps.len(),\n })\n .await;\n }\n trace\n .append(&TraceEvent::QualityWarning {\n message: format!(\n \"EvidenceQualityGate ranked {ranked_total} item(s), accepted {selected_total}, rejected {rejected_total}, grouped {group_total}. Top rejection reasons: {}\",\n if top_rejection_reasons.is_empty() {\n \"none\".to_string()\n } else {\n top_rejection_reasons\n }\n ),\n at: Utc::now(),\n })\n .await?;\n\n let writer_input = serde_json::to_string(&(&run.topic, &run.chapters, &evidence.memory))?;\n let writer_ctx = agent_context(\"writer\", writer_input.clone(), &run.policy, Vec::new());\n if !llm_config_can_call(&llm_config) {\n let message = \"Writer LLM 需要 DeepSeek API key;拒绝生成模板化最终报告。\".to_string();\n run.warnings.push(message.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::InvalidConfig(message));\n }\n let writer_model = llm_config.main_model.clone();\n let writer_budget = TokenBudgetTracker::new();\n writer_budget.before_llm_call(&writer_ctx)?;\n trace_llm_started(&trace, \"writer\", writer_model, &writer_input).await?;\n let draft_result =\n writer::draft_report_with_llm(&run.topic, &run.chapters, &evidence.memory, &llm_config)\n .await;\n let draft = match draft_result {\n Ok(draft) => draft,\n Err(err) => {\n let message = format!(\"Writer LLM 写作失败,拒绝生成模板化最终报告:{err}\");\n run.warnings.push(message.clone());\n trace\n .append(&TraceEvent::QualityWarning {\n message: message.clone(),\n at: Utc::now(),\n })\n .await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::Llm(message));\n }\n };\n let draft_json = serde_json::to_string(&draft)?;\n trace_llm_finished(&trace, \"writer\", &writer_input, &draft_json).await?;\n let audit = if run.policy.require_citation_audit {\n citation_auditor::audit_citations(&draft, &evidence.citations, &evidence.memory)\n } else {\n crate::model::CitationAuditReport::pass()\n };\n let markdown = writer::render_report_markdown(&draft, &evidence.citations);\n let citation_ctx = agent_context(\n \"writer\",\n \"render_report_markdown\".to_string(),\n &run.policy,\n evidence\n .citations\n .citations\n .iter()\n .map(|citation| citation.url.clone())\n .collect(),\n );\n CitationGuard.after_llm_call(&citation_ctx, &markdown)?;\n run.report_draft = Some(draft);\n run.citation_audit = Some(audit.clone());\n run.report_markdown = Some(markdown.clone());\n if !audit.url_whitelist_passed {\n run.warnings\n .push(\"Citation audit failed URL whitelist check.\".to_string());\n }\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::SynthesisReady,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::CitationAuditReady {\n citation_coverage_ratio: audit.citation_coverage_ratio,\n })\n .await;\n }\n\n write_report_file(&app_config, run, &markdown).await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Completed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::Completed {\n run_id: run.run_id.clone(),\n })\n .await;\n }\n Ok(run.clone())\n}\n\npub async fn load_run(app_config: &AppConfig, run_id: &str) -> Result {\n let body = tokio::fs::read_to_string(run_summary_path(app_config, run_id)).await?;\n Ok(serde_json::from_str(&body)?)\n}\n\npub async fn save_run(app_config: &AppConfig, run: &ResearchRunRecord) -> Result<()> {\n tokio::fs::create_dir_all(&app_config.session_dir).await?;\n let body = serde_json::to_string_pretty(run)?;\n tokio::fs::write(run_summary_path(app_config, &run.run_id), body).await?;\n Ok(())\n}\n\npub async fn evidence_for_run(app_config: &AppConfig, run_id: &str) -> Result {\n load_run(app_config, run_id)\n .await?\n .evidence_memory\n .ok_or_else(|| AppError::Workflow(\"run has no EvidenceMemory yet\".to_string()))\n}\n\npub async fn coverage_for_run(app_config: &AppConfig, run_id: &str) -> Result {\n load_run(app_config, run_id)\n .await?\n .coverage_report\n .ok_or_else(|| AppError::Workflow(\"run has no CoverageReport yet\".to_string()))\n}\n\npub async fn revise_plan(\n app_config: &AppConfig,\n run_id: &str,\n chapters: Option>,\n query_portfolio: Option>,\n user_feedback: Option,\n) -> Result {\n let mut run = load_run(app_config, run_id).await?;\n if !matches!(run.state, ResearchRunState::PlanReady) {\n return Err(AppError::Workflow(\n \"only PlanReady runs can revise the Stage 3 plan\".to_string(),\n ));\n }\n if let Some(chapters) = chapters {\n if chapters.is_empty() {\n return Err(AppError::Workflow(\n \"revised chapter plan cannot be empty\".to_string(),\n ));\n }\n run.chapters = chapters;\n }\n if let Some(query_portfolio) = query_portfolio {\n if query_portfolio.is_empty() {\n return Err(AppError::Workflow(\n \"revised query portfolio cannot be empty\".to_string(),\n ));\n }\n run.query_portfolio = query_portfolio;\n }\n if let Some(feedback) = user_feedback.map(|value| value.trim().to_string()) {\n if !feedback.is_empty() {\n run.plan_warnings\n .push(format!(\"用户修订计划备注:{feedback}\"));\n }\n }\n run.updated_at = Utc::now();\n let run_dir = run_dir(app_config, run_id);\n let trace = TraceWriter::new(&run_dir).await?;\n trace\n .append(&TraceEvent::QualityWarning {\n message: \"用户已修订 PlanReady 搜索计划,并创建新的 checkpoint。\".to_string(),\n at: Utc::now(),\n })\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, &run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(app_config, &run).await?;\n Ok(run)\n}\n\npub async fn branch_from_plan_ready(\n app_config: AppConfig,\n run_id: &str,\n checkpoint_id: &str,\n) -> Result {\n let snapshot = load_checkpoint_snapshot_by_id(&app_config, run_id, checkpoint_id).await?;\n if !snapshot.checkpoint.rollback_allowed\n || !matches!(snapshot.checkpoint.state, ResearchRunState::PlanReady)\n {\n return Err(AppError::Workflow(\n \"Stage 3 currently supports rollback branches only from PlanReady checkpoints\"\n .to_string(),\n ));\n }\n let mut branch = snapshot.run;\n branch.run_id = format!(\"run-{}\", Uuid::new_v4());\n branch.created_at = Utc::now();\n branch.updated_at = Utc::now();\n branch.state = ResearchRunState::PlanReady;\n branch.origin_run_id = Some(run_id.to_string());\n branch.origin_checkpoint_id = Some(checkpoint_id.to_string());\n branch.evidence_memory = None;\n branch.coverage_report = None;\n branch.report_draft = None;\n branch.citation_audit = None;\n branch.report_markdown = None;\n branch.output_report = None;\n let branch_dir = run_dir(&app_config, &branch.run_id);\n tokio::fs::create_dir_all(&branch_dir).await?;\n let trace = TraceWriter::new(&branch_dir).await?;\n trace\n .append(&TraceEvent::RollbackBranchCreated {\n origin_run_id: run_id.to_string(),\n new_run_id: branch.run_id.clone(),\n at: Utc::now(),\n })\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&branch_dir, &branch).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(&app_config, &branch).await?;\n Ok(branch)\n}\n\nasync fn load_checkpoint_snapshot_by_id(\n app_config: &AppConfig,\n run_id: &str,\n checkpoint_id: &str,\n) -> Result {\n let run_dir = run_dir(app_config, run_id);\n let checkpoint_dir = run_dir.join(\"checkpoints\");\n let mut entries = tokio::fs::read_dir(checkpoint_dir).await?;\n while let Some(entry) = entries.next_entry().await? {\n let body = tokio::fs::read_to_string(entry.path()).await?;\n let snapshot: CheckpointSnapshot = serde_json::from_str(&body)?;\n if snapshot.checkpoint.checkpoint_id == checkpoint_id {\n return checkpoint::read_checkpoint(&run_dir, &snapshot.checkpoint).await;\n }\n }\n Err(AppError::Workflow(format!(\n \"checkpoint `{checkpoint_id}` was not found for run `{run_id}`\"\n )))\n}\n\nasync fn transition(\n run: &mut ResearchRunRecord,\n trace: &TraceWriter,\n app_config: &AppConfig,\n next: ResearchRunState,\n tx: Option<&mpsc::Sender>,\n) -> Result<()> {\n let from = run.state.clone();\n if !run.transition_to(next.clone()) {\n return Err(invalid_transition(&from, &next));\n }\n trace_transition(trace, from, next.clone()).await?;\n save_run(app_config, run).await?;\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::StateChanged { state: next })\n .await;\n }\n Ok(())\n}\n\nasync fn trace_transition(\n trace: &TraceWriter,\n from: ResearchRunState,\n to: ResearchRunState,\n) -> Result<()> {\n trace\n .append(&TraceEvent::StateTransition {\n from,\n to,\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_llm_started(\n trace: &TraceWriter,\n actor: &str,\n model: String,\n input: &str,\n) -> Result<()> {\n trace\n .append(&TraceEvent::LlmRequestStarted {\n actor: actor.to_string(),\n model,\n input_hash: stable_hash(input),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_llm_finished(\n trace: &TraceWriter,\n actor: &str,\n input: &str,\n output: &str,\n) -> Result<()> {\n let (_input_hash, output_hash, tokens) = decision_hash(input, output);\n trace\n .append(&TraceEvent::LlmRequestFinished {\n actor: actor.to_string(),\n output_hash,\n token_estimate: tokens,\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_agent_decision(\n trace: &TraceWriter,\n actor: &str,\n input: &str,\n output: &str,\n rationale: &str,\n) -> Result<()> {\n let (input_hash, output_hash, _tokens) = decision_hash(input, output);\n trace\n .append(&TraceEvent::AgentDecision {\n actor: actor.to_string(),\n input_hash,\n output_hash,\n rationale: rationale.to_string(),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_checkpoint(trace: &TraceWriter, checkpoint: &Checkpoint) -> Result<()> {\n trace\n .append(&TraceEvent::CheckpointCreated {\n checkpoint_id: checkpoint.checkpoint_id.clone(),\n state: checkpoint.state.clone(),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn send_checkpoint(tx: Option<&mpsc::Sender>, checkpoint: &Checkpoint) {\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::CheckpointCreated {\n checkpoint_id: checkpoint.checkpoint_id.clone(),\n state: checkpoint.state.clone(),\n })\n .await;\n }\n}\n\nasync fn trace_planned_tool_calls(\n trace: &TraceWriter,\n portfolio: &[QueryPortfolio],\n include_academic_extra: bool,\n) -> Result<()> {\n for item in portfolio {\n for query in &item.github_queries {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"github\".to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n for query in &item.arxiv_queries {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"arxiv\".to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n if include_academic_extra {\n for query in academic_queries_for_trace(item) {\n for source in academic_scout::academic_source_names() {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: source.to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n }\n }\n }\n Ok(())\n}\n\nfn academic_queries_for_trace(item: &QueryPortfolio) -> Vec {\n let mut queries = item.arxiv_queries.clone();\n for query in &item.github_queries {\n if !queries.iter().any(|existing| existing == query) {\n queries.push(query.clone());\n }\n }\n queries\n}\n\nfn llm_config_can_call(llm_config: &LlmConfig) -> bool {\n llm_config.enabled\n && llm_config\n .api_key\n .as_deref()\n .map(str::trim)\n .is_some_and(|key| !key.is_empty())\n}\n\nfn fallback_chapter_plan(\n topic: &str,\n policy: &RunPolicy,\n warning: Option<&str>,\n) -> planner::ChapterPlanOutput {\n let default_plan = planner::default_plan(topic, policy.github_budget, policy.arxiv_budget);\n let mut plan = planner::chapter_plan_from_plan_output(&default_plan, policy);\n if let Some(warning) = warning {\n plan.warnings.push(warning.to_string());\n }\n plan\n}\n\nfn agent_context(\n actor: &str,\n input: String,\n policy: &RunPolicy,\n allowed_urls: Vec,\n) -> AgentContext {\n AgentContext {\n actor: actor.to_string(),\n input,\n allowed_urls,\n max_llm_calls: policy.max_llm_calls_per_run,\n }\n}\n\nfn side_model_name(llm_config: &LlmConfig) -> String {\n llm_config\n .side_model\n .clone()\n .unwrap_or_else(|| llm_config.main_model.clone())\n}\n\nfn invalid_transition(from: &ResearchRunState, to: &ResearchRunState) -> AppError {\n AppError::Workflow(format!(\"invalid state transition from {from:?} to {to:?}\"))\n}\n\nfn run_summary_path(app_config: &AppConfig, run_id: &str) -> PathBuf {\n app_config.session_dir.join(format!(\"{run_id}.json\"))\n}\n\nfn run_dir(app_config: &AppConfig, run_id: &str) -> PathBuf {\n app_config.session_dir.join(run_id)\n}\n\nasync fn write_report_file(\n app_config: &AppConfig,\n run: &mut ResearchRunRecord,\n markdown: &str,\n) -> Result<()> {\n let path = resolve_report_path(&app_config.output, &run.topic);\n if let Some(parent) = path.parent() {\n tokio::fs::create_dir_all(parent).await?;\n }\n tokio::fs::write(&path, markdown).await?;\n run.output_report = Some(path.display().to_string());\n Ok(())\n}\n\nfn resolve_report_path(output: &Path, topic: &str) -> PathBuf {\n if output.extension().is_some() {\n return output.to_path_buf();\n }\n output.join(format!(\n \"{}-stage3-{}.md\",\n slugify(topic),\n Utc::now().format(\"%Y%m%d-%H%M%S\")\n ))\n}\n\nfn slugify(topic: &str) -> String {\n let mut slug = String::new();\n let mut last_dash = false;\n for ch in topic.chars().flat_map(char::to_lowercase) {\n if ch.is_ascii_alphanumeric() {\n slug.push(ch);\n last_dash = false;\n } else if !last_dash {\n slug.push('-');\n last_dash = true;\n }\n }\n let slug = slug.trim_matches('-');\n if slug.is_empty() {\n \"report\".to_string()\n } else {\n slug.to_string()\n }\n}\n\npub fn decision_hash(input: &str, output: &str) -> (String, String, usize) {\n (\n stable_hash(input),\n stable_hash(output),\n token_estimate(input) + token_estimate(output),\n )\n}\n\n#[cfg(test)]\nmod tests {\n use std::time::{SystemTime, UNIX_EPOCH};\n\n use super::create_run;\n use crate::config::{AppConfig, LlmConfig};\n use crate::run_policy::RunPolicy;\n use crate::workflow_state::ResearchRunState;\n\n #[tokio::test]\n async fn create_run_falls_back_without_llm_key_and_traces_rule_nodes() {\n let root = temp_root(\"stage3-create-run-fallback\");\n let app_config = AppConfig {\n github_token: None,\n semantic_scholar_api_key: None,\n openalex_api_key: None,\n crossref_mailto: None,\n output: root.join(\"reports\"),\n cache_dir: root.join(\"cache\"),\n session_dir: root.join(\"sessions\"),\n tags_file: None,\n use_cache: false,\n cache_ttl_hours: 24,\n timeout_secs: 30,\n enrich: false,\n };\n let llm_config = LlmConfig {\n enabled: true,\n api_key: None,\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n main_model: \"deepseek-v4-pro\".to_string(),\n side_model: Some(\"deepseek-v4-flash\".to_string()),\n max_tokens: 4096,\n timeout_secs: 30,\n };\n\n let run = create_run(\n \"Rust agent framework\".to_string(),\n app_config.clone(),\n llm_config,\n RunPolicy::default(),\n )\n .await\n .expect(\"create_run should degrade to a deterministic plan\");\n\n assert_eq!(run.state, ResearchRunState::PlanReady);\n assert!(run\n .plan_warnings\n .iter()\n .any(|warning| warning.contains(\"API key\")));\n assert!(run\n .warnings\n .iter()\n .any(|warning| warning.contains(\"0 real LLM request attempt\")));\n\n let trace_path = app_config.session_dir.join(&run.run_id).join(\"trace.jsonl\");\n let trace = std::fs::read_to_string(trace_path).expect(\"trace should be readable\");\n assert!(trace.contains(\"\\\"event\\\":\\\"agent_decision\\\"\"));\n assert!(trace.contains(\"\\\"actor\\\":\\\"scoper\\\"\"));\n assert!(trace.contains(\"\\\"actor\\\":\\\"plan_critic\\\"\"));\n assert!(!trace.contains(\"\\\"event\\\":\\\"llm_request_started\\\"\"));\n }\n\n fn temp_root(name: &str) -> std::path::PathBuf {\n let unique = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n std::env::temp_dir().join(format!(\n \"litscout-rs-{name}-{}-{unique}\",\n std::process::id()\n ))\n }\n}\n"}, "f0e381df64f2448f45cfc7566949703029fd6216": {"t": 1785750658.9217067, "n": 31486, "h": "21e9ff2c1f1ca676", "c": "use std::path::{Path, PathBuf};\n\nuse chrono::Utc;\nuse tokio::sync::mpsc;\nuse uuid::Uuid;\n\nuse crate::agent::{\n academic_scout, arxiv_scout, citation_auditor, coverage_critic, evidence, github_scout,\n middleware::{\n AgentContext, AgentMiddleware, CitationGuard, JsonSchemaGuard, TokenBudgetTracker,\n },\n plan_critic, planner, scoper, writer,\n};\nuse crate::checkpoint::{self, Checkpoint, CheckpointSnapshot};\nuse crate::config::{AppConfig, LlmConfig};\nuse crate::error::{AppError, Result};\nuse crate::model::{\n ChapterNode, CoverageReport, EvidenceMemory, QueryAttempt, QueryPortfolio, SearchQuery,\n};\nuse crate::run_policy::RunPolicy;\nuse crate::trace::{stable_hash, token_estimate, TraceEvent, TraceWriter};\nuse crate::workflow_state::{ResearchRunRecord, ResearchRunState};\n\n#[derive(Debug, Clone, serde::Serialize)]\n#[serde(tag = \"event\", content = \"data\", rename_all = \"snake_case\")]\npub enum StatefulRunEvent {\n StateChanged {\n state: ResearchRunState,\n },\n CheckpointCreated {\n checkpoint_id: String,\n state: ResearchRunState,\n },\n EvidenceReady {\n total: usize,\n },\n CoverageReady {\n gaps: usize,\n },\n CitationAuditReady {\n citation_coverage_ratio: f64,\n },\n Completed {\n run_id: String,\n },\n Failed {\n error: String,\n },\n}\n\npub async fn create_run(\n topic: String,\n app_config: AppConfig,\n llm_config: LlmConfig,\n policy: RunPolicy,\n) -> Result {\n let run_id = format!(\"run-{}\", Uuid::new_v4());\n let run_dir = run_dir(&app_config, &run_id);\n tokio::fs::create_dir_all(&run_dir).await?;\n let trace = TraceWriter::new(&run_dir).await?;\n let mut run = ResearchRunRecord::new(run_id, topic.clone(), policy.bounded());\n save_run(&app_config, &run).await?;\n let token_budget = TokenBudgetTracker::new();\n let json_guard = JsonSchemaGuard;\n\n let scoper_input = format!(\"topic={topic}; policy={:?}\", run.policy);\n let scoper_ctx = agent_context(\"scoper\", scoper_input.clone(), &run.policy, Vec::new());\n let brief = scoper::generate_research_brief(&topic, &run.policy, &llm_config).await?;\n let brief_json = serde_json::to_string(&brief)?;\n json_guard.after_llm_call(&scoper_ctx, &brief_json)?;\n trace_agent_decision(\n &trace,\n \"scoper\",\n &scoper_input,\n &brief_json,\n \"deterministic ResearchBrief scoping\",\n )\n .await?;\n\n let planner_input = serde_json::to_string(&brief)?;\n let planner_ctx = agent_context(\"planner\", planner_input.clone(), &run.policy, Vec::new());\n let plan = if llm_config_can_call(&llm_config) {\n token_budget.before_llm_call(&planner_ctx)?;\n trace_llm_started(\n &trace,\n \"planner\",\n side_model_name(&llm_config),\n &planner_input,\n )\n .await?;\n match planner::generate_chapter_plan(&brief, &run.policy, &llm_config).await {\n Ok(plan) => {\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n token_budget.after_llm_call(&planner_ctx, &plan_json)?;\n trace_llm_finished(&trace, \"planner\", &planner_input, &plan_json).await?;\n plan\n }\n Err(err) => {\n let reason = err.to_string();\n trace\n .append(&TraceEvent::QualityWarning {\n message: format!(\"Planner LLM 调用失败,已降级为确定性默认计划:{reason}\"),\n at: Utc::now(),\n })\n .await?;\n let plan = fallback_chapter_plan(&brief.topic, &run.policy, Some(&reason));\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n trace_agent_decision(\n &trace,\n \"planner_fallback\",\n &planner_input,\n &plan_json,\n \"planner LLM failed; deterministic default plan\",\n )\n .await?;\n plan\n }\n }\n } else {\n let reason = llm_config\n .enabled\n .then_some(\"LLM 已启用但缺少 API key,已使用确定性默认计划。\");\n let plan = fallback_chapter_plan(&brief.topic, &run.policy, reason);\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n trace_agent_decision(\n &trace,\n \"planner\",\n &planner_input,\n &plan_json,\n \"deterministic default plan\",\n )\n .await?;\n plan\n };\n\n let critic_input = serde_json::to_string(&(&brief, &plan.chapters, &plan.query_portfolio))?;\n let critic_ctx = agent_context(\"plan_critic\", critic_input.clone(), &run.policy, Vec::new());\n let critique =\n plan_critic::critique_plan(&brief, &plan.chapters, &plan.query_portfolio, &run.policy);\n let critique_json = serde_json::to_string(&critique)?;\n json_guard.after_llm_call(&critic_ctx, &critique_json)?;\n trace_agent_decision(\n &trace,\n \"plan_critic\",\n &critic_input,\n &critique_json,\n \"deterministic plan consistency checks\",\n )\n .await?;\n let from = run.state.clone();\n if !run.transition_to(ResearchRunState::PlanReady) {\n return Err(invalid_transition(&from, &ResearchRunState::PlanReady));\n }\n run.brief = Some(brief);\n run.chapters = plan.chapters;\n run.query_portfolio = plan.query_portfolio;\n run.plan_warnings = plan\n .warnings\n .into_iter()\n .chain(critique.warnings)\n .chain(critique.suggestions)\n .collect();\n run.warnings.push(format!(\n \"Stage 3 agent planning used {} real LLM request attempt(s); deterministic nodes are traced as AgentDecision events.\",\n token_budget.calls()\n ));\n trace_transition(&trace, from, ResearchRunState::PlanReady).await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, &run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(&app_config, &run).await?;\n Ok(run)\n}\n\npub async fn continue_run(\n run_id: &str,\n app_config: AppConfig,\n llm_config: LlmConfig,\n tx: Option>,\n) -> Result {\n let mut run = load_run(&app_config, run_id).await?;\n match run.state {\n ResearchRunState::PlanReady => {\n continue_from_plan_ready(&mut run, app_config, llm_config, tx).await\n }\n ResearchRunState::Completed => Ok(run),\n _ => Err(AppError::Workflow(format!(\n \"run `{run_id}` is in state {:?} and cannot continue from this endpoint\",\n run.state\n ))),\n }\n}\n\nasync fn continue_from_plan_ready(\n run: &mut ResearchRunRecord,\n app_config: AppConfig,\n llm_config: LlmConfig,\n tx: Option>,\n) -> Result {\n let run_dir = run_dir(&app_config, &run.run_id);\n let trace = TraceWriter::new(&run_dir).await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Fetching,\n tx.as_ref(),\n )\n .await?;\n trace_planned_tool_calls(\n &trace,\n &run.query_portfolio,\n run.policy.academic_extra_enabled,\n )\n .await?;\n\n let mut github_config = app_config.clone();\n github_config.enrich = app_config.enrich || run.policy.allow_github_enrich;\n let (github_result, arxiv_result) = tokio::join!(\n github_scout::scout_github(&run.query_portfolio, &github_config, 1),\n arxiv_scout::scout_arxiv(&run.query_portfolio, &app_config, 1)\n );\n let (github_repos, github_attempts, github_lineage) = github_result?;\n let (arxiv_papers, arxiv_attempts, arxiv_lineage) = arxiv_result?;\n let (academic_items, academic_attempts, academic_lineage) = if run.policy.academic_extra_enabled\n {\n academic_scout::scout_academic_extra(\n &run.query_portfolio,\n &app_config,\n run.policy.academic_budget,\n 1,\n )\n .await?\n } else {\n (Vec::new(), Vec::new(), Vec::new())\n };\n let query_attempts = github_attempts\n .into_iter()\n .chain(arxiv_attempts)\n .chain(academic_attempts)\n .collect::>();\n let source_lineage = github_lineage\n .into_iter()\n .chain(arxiv_lineage)\n .chain(academic_lineage)\n .collect::>();\n for attempt in &query_attempts {\n trace\n .append(&TraceEvent::ToolCallFinished {\n tool: attempt.source.clone(),\n result_count: attempt.result_count,\n error: attempt.error.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n if github_repos.is_empty() && arxiv_papers.is_empty() && academic_items.is_empty() {\n let message =\n \"GitHub、arXiv 与已启用的扩展学术源均未返回可用结果,无法生成 stateful report。\"\n .to_string();\n run.warnings.push(message.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::Workflow(message));\n }\n\n let query = SearchQuery {\n topic: run.topic.clone(),\n github_limit: run.policy.github_budget,\n arxiv_limit: run.policy.arxiv_budget,\n };\n let evidence = evidence::build_evidence_memory(\n &query,\n &app_config,\n github_repos,\n arxiv_papers,\n academic_items,\n query_attempts,\n source_lineage,\n )?;\n let ranked_total = evidence.ranked_items.len();\n let group_total = evidence.groups.len();\n let coverage = if run.policy.skip_coverage_critic {\n CoverageReport::pass()\n } else {\n coverage_critic::evaluate_coverage(\n &run.chapters,\n &run.query_portfolio,\n &evidence.memory,\n &run.policy,\n )\n };\n for gap in &coverage.gaps {\n trace\n .append(&TraceEvent::CoverageGapDetected {\n chapter_id: gap.chapter_id.clone(),\n gap_kind: format!(\"{:?}\", gap.gap_kind),\n at: Utc::now(),\n })\n .await?;\n }\n run.evidence_memory = Some(evidence.memory.clone());\n run.coverage_report = Some(coverage.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::EvidenceReady,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::EvidenceReady {\n total: evidence.memory.items.len(),\n })\n .await;\n let _ = tx\n .send(StatefulRunEvent::CoverageReady {\n gaps: coverage.gaps.len(),\n })\n .await;\n }\n trace\n .append(&TraceEvent::QualityWarning {\n message: format!(\n \"EvidenceBuilder ranked {ranked_total} item(s) into {group_total} group(s).\"\n ),\n at: Utc::now(),\n })\n .await?;\n\n let writer_input = serde_json::to_string(&(&run.topic, &run.chapters, &evidence.memory))?;\n let writer_ctx = agent_context(\"writer\", writer_input.clone(), &run.policy, Vec::new());\n if !llm_config_can_call(&llm_config) {\n let message = \"Writer LLM 需要 DeepSeek API key;拒绝生成模板化最终报告。\".to_string();\n run.warnings.push(message.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::InvalidConfig(message));\n }\n let writer_model = llm_config.main_model.clone();\n let writer_budget = TokenBudgetTracker::new();\n writer_budget.before_llm_call(&writer_ctx)?;\n trace_llm_started(&trace, \"writer\", writer_model, &writer_input).await?;\n let draft_result =\n writer::draft_report_with_llm(&run.topic, &run.chapters, &evidence.memory, &llm_config)\n .await;\n let draft = match draft_result {\n Ok(draft) => draft,\n Err(err) => {\n let message = format!(\"Writer LLM 写作失败,拒绝生成模板化最终报告:{err}\");\n run.warnings.push(message.clone());\n trace\n .append(&TraceEvent::QualityWarning {\n message: message.clone(),\n at: Utc::now(),\n })\n .await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::Llm(message));\n }\n };\n let draft_json = serde_json::to_string(&draft)?;\n trace_llm_finished(&trace, \"writer\", &writer_input, &draft_json).await?;\n let audit = if run.policy.require_citation_audit {\n citation_auditor::audit_citations(&draft, &evidence.citations, &evidence.memory)\n } else {\n crate::model::CitationAuditReport::pass()\n };\n let markdown = writer::render_report_markdown(&draft, &evidence.citations);\n let citation_ctx = agent_context(\n \"writer\",\n \"render_report_markdown\".to_string(),\n &run.policy,\n evidence\n .citations\n .citations\n .iter()\n .map(|citation| citation.url.clone())\n .collect(),\n );\n CitationGuard.after_llm_call(&citation_ctx, &markdown)?;\n run.report_draft = Some(draft);\n run.citation_audit = Some(audit.clone());\n run.report_markdown = Some(markdown.clone());\n if !audit.url_whitelist_passed {\n run.warnings\n .push(\"Citation audit failed URL whitelist check.\".to_string());\n }\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::SynthesisReady,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::CitationAuditReady {\n citation_coverage_ratio: audit.citation_coverage_ratio,\n })\n .await;\n }\n\n write_report_file(&app_config, run, &markdown).await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Completed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::Completed {\n run_id: run.run_id.clone(),\n })\n .await;\n }\n Ok(run.clone())\n}\n\npub async fn load_run(app_config: &AppConfig, run_id: &str) -> Result {\n let body = tokio::fs::read_to_string(run_summary_path(app_config, run_id)).await?;\n Ok(serde_json::from_str(&body)?)\n}\n\npub async fn save_run(app_config: &AppConfig, run: &ResearchRunRecord) -> Result<()> {\n tokio::fs::create_dir_all(&app_config.session_dir).await?;\n let body = serde_json::to_string_pretty(run)?;\n tokio::fs::write(run_summary_path(app_config, &run.run_id), body).await?;\n Ok(())\n}\n\npub async fn evidence_for_run(app_config: &AppConfig, run_id: &str) -> Result {\n load_run(app_config, run_id)\n .await?\n .evidence_memory\n .ok_or_else(|| AppError::Workflow(\"run has no EvidenceMemory yet\".to_string()))\n}\n\npub async fn coverage_for_run(app_config: &AppConfig, run_id: &str) -> Result {\n load_run(app_config, run_id)\n .await?\n .coverage_report\n .ok_or_else(|| AppError::Workflow(\"run has no CoverageReport yet\".to_string()))\n}\n\npub async fn revise_plan(\n app_config: &AppConfig,\n run_id: &str,\n chapters: Option>,\n query_portfolio: Option>,\n user_feedback: Option,\n) -> Result {\n let mut run = load_run(app_config, run_id).await?;\n if !matches!(run.state, ResearchRunState::PlanReady) {\n return Err(AppError::Workflow(\n \"only PlanReady runs can revise the Stage 3 plan\".to_string(),\n ));\n }\n if let Some(chapters) = chapters {\n if chapters.is_empty() {\n return Err(AppError::Workflow(\n \"revised chapter plan cannot be empty\".to_string(),\n ));\n }\n run.chapters = chapters;\n }\n if let Some(query_portfolio) = query_portfolio {\n if query_portfolio.is_empty() {\n return Err(AppError::Workflow(\n \"revised query portfolio cannot be empty\".to_string(),\n ));\n }\n run.query_portfolio = query_portfolio;\n }\n if let Some(feedback) = user_feedback.map(|value| value.trim().to_string()) {\n if !feedback.is_empty() {\n run.plan_warnings\n .push(format!(\"用户修订计划备注:{feedback}\"));\n }\n }\n run.updated_at = Utc::now();\n let run_dir = run_dir(app_config, run_id);\n let trace = TraceWriter::new(&run_dir).await?;\n trace\n .append(&TraceEvent::QualityWarning {\n message: \"用户已修订 PlanReady 搜索计划,并创建新的 checkpoint。\".to_string(),\n at: Utc::now(),\n })\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, &run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(app_config, &run).await?;\n Ok(run)\n}\n\npub async fn branch_from_plan_ready(\n app_config: AppConfig,\n run_id: &str,\n checkpoint_id: &str,\n) -> Result {\n let snapshot = load_checkpoint_snapshot_by_id(&app_config, run_id, checkpoint_id).await?;\n if !snapshot.checkpoint.rollback_allowed\n || !matches!(snapshot.checkpoint.state, ResearchRunState::PlanReady)\n {\n return Err(AppError::Workflow(\n \"Stage 3 currently supports rollback branches only from PlanReady checkpoints\"\n .to_string(),\n ));\n }\n let mut branch = snapshot.run;\n branch.run_id = format!(\"run-{}\", Uuid::new_v4());\n branch.created_at = Utc::now();\n branch.updated_at = Utc::now();\n branch.state = ResearchRunState::PlanReady;\n branch.origin_run_id = Some(run_id.to_string());\n branch.origin_checkpoint_id = Some(checkpoint_id.to_string());\n branch.evidence_memory = None;\n branch.coverage_report = None;\n branch.report_draft = None;\n branch.citation_audit = None;\n branch.report_markdown = None;\n branch.output_report = None;\n let branch_dir = run_dir(&app_config, &branch.run_id);\n tokio::fs::create_dir_all(&branch_dir).await?;\n let trace = TraceWriter::new(&branch_dir).await?;\n trace\n .append(&TraceEvent::RollbackBranchCreated {\n origin_run_id: run_id.to_string(),\n new_run_id: branch.run_id.clone(),\n at: Utc::now(),\n })\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&branch_dir, &branch).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(&app_config, &branch).await?;\n Ok(branch)\n}\n\nasync fn load_checkpoint_snapshot_by_id(\n app_config: &AppConfig,\n run_id: &str,\n checkpoint_id: &str,\n) -> Result {\n let run_dir = run_dir(app_config, run_id);\n let checkpoint_dir = run_dir.join(\"checkpoints\");\n let mut entries = tokio::fs::read_dir(checkpoint_dir).await?;\n while let Some(entry) = entries.next_entry().await? {\n let body = tokio::fs::read_to_string(entry.path()).await?;\n let snapshot: CheckpointSnapshot = serde_json::from_str(&body)?;\n if snapshot.checkpoint.checkpoint_id == checkpoint_id {\n return checkpoint::read_checkpoint(&run_dir, &snapshot.checkpoint).await;\n }\n }\n Err(AppError::Workflow(format!(\n \"checkpoint `{checkpoint_id}` was not found for run `{run_id}`\"\n )))\n}\n\nasync fn transition(\n run: &mut ResearchRunRecord,\n trace: &TraceWriter,\n app_config: &AppConfig,\n next: ResearchRunState,\n tx: Option<&mpsc::Sender>,\n) -> Result<()> {\n let from = run.state.clone();\n if !run.transition_to(next.clone()) {\n return Err(invalid_transition(&from, &next));\n }\n trace_transition(trace, from, next.clone()).await?;\n save_run(app_config, run).await?;\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::StateChanged { state: next })\n .await;\n }\n Ok(())\n}\n\nasync fn trace_transition(\n trace: &TraceWriter,\n from: ResearchRunState,\n to: ResearchRunState,\n) -> Result<()> {\n trace\n .append(&TraceEvent::StateTransition {\n from,\n to,\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_llm_started(\n trace: &TraceWriter,\n actor: &str,\n model: String,\n input: &str,\n) -> Result<()> {\n trace\n .append(&TraceEvent::LlmRequestStarted {\n actor: actor.to_string(),\n model,\n input_hash: stable_hash(input),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_llm_finished(\n trace: &TraceWriter,\n actor: &str,\n input: &str,\n output: &str,\n) -> Result<()> {\n let (_input_hash, output_hash, tokens) = decision_hash(input, output);\n trace\n .append(&TraceEvent::LlmRequestFinished {\n actor: actor.to_string(),\n output_hash,\n token_estimate: tokens,\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_agent_decision(\n trace: &TraceWriter,\n actor: &str,\n input: &str,\n output: &str,\n rationale: &str,\n) -> Result<()> {\n let (input_hash, output_hash, _tokens) = decision_hash(input, output);\n trace\n .append(&TraceEvent::AgentDecision {\n actor: actor.to_string(),\n input_hash,\n output_hash,\n rationale: rationale.to_string(),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_checkpoint(trace: &TraceWriter, checkpoint: &Checkpoint) -> Result<()> {\n trace\n .append(&TraceEvent::CheckpointCreated {\n checkpoint_id: checkpoint.checkpoint_id.clone(),\n state: checkpoint.state.clone(),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn send_checkpoint(tx: Option<&mpsc::Sender>, checkpoint: &Checkpoint) {\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::CheckpointCreated {\n checkpoint_id: checkpoint.checkpoint_id.clone(),\n state: checkpoint.state.clone(),\n })\n .await;\n }\n}\n\nasync fn trace_planned_tool_calls(\n trace: &TraceWriter,\n portfolio: &[QueryPortfolio],\n include_academic_extra: bool,\n) -> Result<()> {\n for item in portfolio {\n for query in &item.github_queries {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"github\".to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n for query in &item.arxiv_queries {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"arxiv\".to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n if include_academic_extra {\n for query in academic_queries_for_trace(item) {\n for source in academic_scout::academic_source_names() {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: source.to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n }\n }\n }\n Ok(())\n}\n\nfn academic_queries_for_trace(item: &QueryPortfolio) -> Vec {\n let mut queries = item.arxiv_queries.clone();\n for query in &item.github_queries {\n if !queries.iter().any(|existing| existing == query) {\n queries.push(query.clone());\n }\n }\n queries\n}\n\nfn llm_config_can_call(llm_config: &LlmConfig) -> bool {\n llm_config.enabled\n && llm_config\n .api_key\n .as_deref()\n .map(str::trim)\n .is_some_and(|key| !key.is_empty())\n}\n\nfn fallback_chapter_plan(\n topic: &str,\n policy: &RunPolicy,\n warning: Option<&str>,\n) -> planner::ChapterPlanOutput {\n let default_plan = planner::default_plan(topic, policy.github_budget, policy.arxiv_budget);\n let mut plan = planner::chapter_plan_from_plan_output(&default_plan, policy);\n if let Some(warning) = warning {\n plan.warnings.push(warning.to_string());\n }\n plan\n}\n\nfn agent_context(\n actor: &str,\n input: String,\n policy: &RunPolicy,\n allowed_urls: Vec,\n) -> AgentContext {\n AgentContext {\n actor: actor.to_string(),\n input,\n allowed_urls,\n max_llm_calls: policy.max_llm_calls_per_run,\n }\n}\n\nfn side_model_name(llm_config: &LlmConfig) -> String {\n llm_config\n .side_model\n .clone()\n .unwrap_or_else(|| llm_config.main_model.clone())\n}\n\nfn invalid_transition(from: &ResearchRunState, to: &ResearchRunState) -> AppError {\n AppError::Workflow(format!(\"invalid state transition from {from:?} to {to:?}\"))\n}\n\nfn run_summary_path(app_config: &AppConfig, run_id: &str) -> PathBuf {\n app_config.session_dir.join(format!(\"{run_id}.json\"))\n}\n\nfn run_dir(app_config: &AppConfig, run_id: &str) -> PathBuf {\n app_config.session_dir.join(run_id)\n}\n\nasync fn write_report_file(\n app_config: &AppConfig,\n run: &mut ResearchRunRecord,\n markdown: &str,\n) -> Result<()> {\n let path = resolve_report_path(&app_config.output, &run.topic);\n if let Some(parent) = path.parent() {\n tokio::fs::create_dir_all(parent).await?;\n }\n tokio::fs::write(&path, markdown).await?;\n run.output_report = Some(path.display().to_string());\n Ok(())\n}\n\nfn resolve_report_path(output: &Path, topic: &str) -> PathBuf {\n if output.extension().is_some() {\n return output.to_path_buf();\n }\n output.join(format!(\n \"{}-stage3-{}.md\",\n slugify(topic),\n Utc::now().format(\"%Y%m%d-%H%M%S\")\n ))\n}\n\nfn slugify(topic: &str) -> String {\n let mut slug = String::new();\n let mut last_dash = false;\n for ch in topic.chars().flat_map(char::to_lowercase) {\n if ch.is_ascii_alphanumeric() {\n slug.push(ch);\n last_dash = false;\n } else if !last_dash {\n slug.push('-');\n last_dash = true;\n }\n }\n let slug = slug.trim_matches('-');\n if slug.is_empty() {\n \"report\".to_string()\n } else {\n slug.to_string()\n }\n}\n\npub fn decision_hash(input: &str, output: &str) -> (String, String, usize) {\n (\n stable_hash(input),\n stable_hash(output),\n token_estimate(input) + token_estimate(output),\n )\n}\n\n#[cfg(test)]\nmod tests {\n use std::time::{SystemTime, UNIX_EPOCH};\n\n use super::create_run;\n use crate::config::{AppConfig, LlmConfig};\n use crate::run_policy::RunPolicy;\n use crate::workflow_state::ResearchRunState;\n\n #[tokio::test]\n async fn create_run_falls_back_without_llm_key_and_traces_rule_nodes() {\n let root = temp_root(\"stage3-create-run-fallback\");\n let app_config = AppConfig {\n github_token: None,\n semantic_scholar_api_key: None,\n openalex_api_key: None,\n crossref_mailto: None,\n output: root.join(\"reports\"),\n cache_dir: root.join(\"cache\"),\n session_dir: root.join(\"sessions\"),\n tags_file: None,\n use_cache: false,\n cache_ttl_hours: 24,\n timeout_secs: 30,\n enrich: false,\n };\n let llm_config = LlmConfig {\n enabled: true,\n api_key: None,\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n main_model: \"deepseek-v4-pro\".to_string(),\n side_model: Some(\"deepseek-v4-flash\".to_string()),\n max_tokens: 4096,\n timeout_secs: 30,\n };\n\n let run = create_run(\n \"Rust agent framework\".to_string(),\n app_config.clone(),\n llm_config,\n RunPolicy::default(),\n )\n .await\n .expect(\"create_run should degrade to a deterministic plan\");\n\n assert_eq!(run.state, ResearchRunState::PlanReady);\n assert!(run\n .plan_warnings\n .iter()\n .any(|warning| warning.contains(\"API key\")));\n assert!(run\n .warnings\n .iter()\n .any(|warning| warning.contains(\"0 real LLM request attempt\")));\n\n let trace_path = app_config.session_dir.join(&run.run_id).join(\"trace.jsonl\");\n let trace = std::fs::read_to_string(trace_path).expect(\"trace should be readable\");\n assert!(trace.contains(\"\\\"event\\\":\\\"agent_decision\\\"\"));\n assert!(trace.contains(\"\\\"actor\\\":\\\"scoper\\\"\"));\n assert!(trace.contains(\"\\\"actor\\\":\\\"plan_critic\\\"\"));\n assert!(!trace.contains(\"\\\"event\\\":\\\"llm_request_started\\\"\"));\n }\n\n fn temp_root(name: &str) -> std::path::PathBuf {\n let unique = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n std::env::temp_dir().join(format!(\n \"litscout-rs-{name}-{}-{unique}\",\n std::process::id()\n ))\n }\n}\n"}, "89599bc6feef2b153f24871f481493e359e6ff87": {"t": 1785750660.2229273, "n": 31563, "h": "2c1fee48845b3f72", "c": "use std::path::{Path, PathBuf};\n\nuse chrono::Utc;\nuse tokio::sync::mpsc;\nuse uuid::Uuid;\n\nuse crate::agent::{\n academic_scout, arxiv_scout, citation_auditor, coverage_critic, evidence, github_scout,\n middleware::{\n AgentContext, AgentMiddleware, CitationGuard, JsonSchemaGuard, TokenBudgetTracker,\n },\n plan_critic, planner, scoper, writer,\n};\nuse crate::checkpoint::{self, Checkpoint, CheckpointSnapshot};\nuse crate::config::{AppConfig, LlmConfig};\nuse crate::error::{AppError, Result};\nuse crate::model::{\n ChapterNode, CoverageReport, EvidenceMemory, QueryAttempt, QueryPortfolio, SearchQuery,\n};\nuse crate::run_policy::RunPolicy;\nuse crate::trace::{stable_hash, token_estimate, TraceEvent, TraceWriter};\nuse crate::workflow_state::{ResearchRunRecord, ResearchRunState};\n\n#[derive(Debug, Clone, serde::Serialize)]\n#[serde(tag = \"event\", content = \"data\", rename_all = \"snake_case\")]\npub enum StatefulRunEvent {\n StateChanged {\n state: ResearchRunState,\n },\n CheckpointCreated {\n checkpoint_id: String,\n state: ResearchRunState,\n },\n EvidenceReady {\n total: usize,\n },\n CoverageReady {\n gaps: usize,\n },\n CitationAuditReady {\n citation_coverage_ratio: f64,\n },\n Completed {\n run_id: String,\n },\n Failed {\n error: String,\n },\n}\n\npub async fn create_run(\n topic: String,\n app_config: AppConfig,\n llm_config: LlmConfig,\n policy: RunPolicy,\n) -> Result {\n let run_id = format!(\"run-{}\", Uuid::new_v4());\n let run_dir = run_dir(&app_config, &run_id);\n tokio::fs::create_dir_all(&run_dir).await?;\n let trace = TraceWriter::new(&run_dir).await?;\n let mut run = ResearchRunRecord::new(run_id, topic.clone(), policy.bounded());\n save_run(&app_config, &run).await?;\n let token_budget = TokenBudgetTracker::new();\n let json_guard = JsonSchemaGuard;\n\n let scoper_input = format!(\"topic={topic}; policy={:?}\", run.policy);\n let scoper_ctx = agent_context(\"scoper\", scoper_input.clone(), &run.policy, Vec::new());\n let brief = scoper::generate_research_brief(&topic, &run.policy, &llm_config).await?;\n let brief_json = serde_json::to_string(&brief)?;\n json_guard.after_llm_call(&scoper_ctx, &brief_json)?;\n trace_agent_decision(\n &trace,\n \"scoper\",\n &scoper_input,\n &brief_json,\n \"deterministic ResearchBrief scoping\",\n )\n .await?;\n\n let planner_input = serde_json::to_string(&brief)?;\n let planner_ctx = agent_context(\"planner\", planner_input.clone(), &run.policy, Vec::new());\n let plan = if llm_config_can_call(&llm_config) {\n token_budget.before_llm_call(&planner_ctx)?;\n trace_llm_started(\n &trace,\n \"planner\",\n side_model_name(&llm_config),\n &planner_input,\n )\n .await?;\n match planner::generate_chapter_plan(&brief, &run.policy, &llm_config).await {\n Ok(plan) => {\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n token_budget.after_llm_call(&planner_ctx, &plan_json)?;\n trace_llm_finished(&trace, \"planner\", &planner_input, &plan_json).await?;\n plan\n }\n Err(err) => {\n let reason = err.to_string();\n trace\n .append(&TraceEvent::QualityWarning {\n message: format!(\"Planner LLM 调用失败,已降级为确定性默认计划:{reason}\"),\n at: Utc::now(),\n })\n .await?;\n let plan = fallback_chapter_plan(&brief.topic, &run.policy, Some(&reason));\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n trace_agent_decision(\n &trace,\n \"planner_fallback\",\n &planner_input,\n &plan_json,\n \"planner LLM failed; deterministic default plan\",\n )\n .await?;\n plan\n }\n }\n } else {\n let reason = llm_config\n .enabled\n .then_some(\"LLM 已启用但缺少 API key,已使用确定性默认计划。\");\n let plan = fallback_chapter_plan(&brief.topic, &run.policy, reason);\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n trace_agent_decision(\n &trace,\n \"planner\",\n &planner_input,\n &plan_json,\n \"deterministic default plan\",\n )\n .await?;\n plan\n };\n\n let critic_input = serde_json::to_string(&(&brief, &plan.chapters, &plan.query_portfolio))?;\n let critic_ctx = agent_context(\"plan_critic\", critic_input.clone(), &run.policy, Vec::new());\n let critique =\n plan_critic::critique_plan(&brief, &plan.chapters, &plan.query_portfolio, &run.policy);\n let critique_json = serde_json::to_string(&critique)?;\n json_guard.after_llm_call(&critic_ctx, &critique_json)?;\n trace_agent_decision(\n &trace,\n \"plan_critic\",\n &critic_input,\n &critique_json,\n \"deterministic plan consistency checks\",\n )\n .await?;\n let from = run.state.clone();\n if !run.transition_to(ResearchRunState::PlanReady) {\n return Err(invalid_transition(&from, &ResearchRunState::PlanReady));\n }\n run.brief = Some(brief);\n run.chapters = plan.chapters;\n run.query_portfolio = plan.query_portfolio;\n run.plan_warnings = plan\n .warnings\n .into_iter()\n .chain(critique.warnings)\n .chain(critique.suggestions)\n .collect();\n run.warnings.push(format!(\n \"Stage 3 agent planning used {} real LLM request attempt(s); deterministic nodes are traced as AgentDecision events.\",\n token_budget.calls()\n ));\n trace_transition(&trace, from, ResearchRunState::PlanReady).await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, &run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(&app_config, &run).await?;\n Ok(run)\n}\n\npub async fn continue_run(\n run_id: &str,\n app_config: AppConfig,\n llm_config: LlmConfig,\n tx: Option>,\n) -> Result {\n let mut run = load_run(&app_config, run_id).await?;\n match run.state {\n ResearchRunState::PlanReady => {\n continue_from_plan_ready(&mut run, app_config, llm_config, tx).await\n }\n ResearchRunState::Completed => Ok(run),\n _ => Err(AppError::Workflow(format!(\n \"run `{run_id}` is in state {:?} and cannot continue from this endpoint\",\n run.state\n ))),\n }\n}\n\nasync fn continue_from_plan_ready(\n run: &mut ResearchRunRecord,\n app_config: AppConfig,\n llm_config: LlmConfig,\n tx: Option>,\n) -> Result {\n let run_dir = run_dir(&app_config, &run.run_id);\n let trace = TraceWriter::new(&run_dir).await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Fetching,\n tx.as_ref(),\n )\n .await?;\n trace_planned_tool_calls(\n &trace,\n &run.query_portfolio,\n run.policy.academic_extra_enabled,\n )\n .await?;\n\n let mut github_config = app_config.clone();\n github_config.enrich = app_config.enrich || run.policy.allow_github_enrich;\n let (github_result, arxiv_result) = tokio::join!(\n github_scout::scout_github(&run.query_portfolio, &github_config, 1),\n arxiv_scout::scout_arxiv(&run.query_portfolio, &app_config, 1)\n );\n let (github_repos, github_attempts, github_lineage) = github_result?;\n let (arxiv_papers, arxiv_attempts, arxiv_lineage) = arxiv_result?;\n let (academic_items, academic_attempts, academic_lineage) = if run.policy.academic_extra_enabled\n {\n academic_scout::scout_academic_extra(\n &run.query_portfolio,\n &app_config,\n run.policy.academic_budget,\n 1,\n )\n .await?\n } else {\n (Vec::new(), Vec::new(), Vec::new())\n };\n let query_attempts = github_attempts\n .into_iter()\n .chain(arxiv_attempts)\n .chain(academic_attempts)\n .collect::>();\n let source_lineage = github_lineage\n .into_iter()\n .chain(arxiv_lineage)\n .chain(academic_lineage)\n .collect::>();\n for attempt in &query_attempts {\n trace\n .append(&TraceEvent::ToolCallFinished {\n tool: attempt.source.clone(),\n result_count: attempt.result_count,\n error: attempt.error.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n if github_repos.is_empty() && arxiv_papers.is_empty() && academic_items.is_empty() {\n let message =\n \"GitHub、arXiv 与已启用的扩展学术源均未返回可用结果,无法生成 stateful report。\"\n .to_string();\n run.warnings.push(message.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::Workflow(message));\n }\n\n let query = SearchQuery {\n topic: run.topic.clone(),\n github_limit: run.policy.github_budget,\n arxiv_limit: run.policy.arxiv_budget,\n };\n let evidence = evidence::build_evidence_memory(\n &query,\n &app_config,\n github_repos,\n arxiv_papers,\n academic_items,\n query_attempts,\n source_lineage,\n )?;\n let ranked_total = evidence.ranked_items.len();\n let group_total = evidence.groups.len();\n let coverage = if run.policy.skip_coverage_critic {\n CoverageReport::pass()\n } else {\n coverage_critic::evaluate_coverage(\n &run.chapters,\n &run.query_portfolio,\n &evidence.memory,\n &run.policy,\n )\n };\n for gap in &coverage.gaps {\n trace\n .append(&TraceEvent::CoverageGapDetected {\n chapter_id: gap.chapter_id.clone(),\n gap_kind: format!(\"{:?}\", gap.gap_kind),\n at: Utc::now(),\n })\n .await?;\n }\n run.evidence_memory = Some(evidence.memory.clone());\n run.coverage_report = Some(coverage.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::EvidenceReady,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::EvidenceReady {\n total: evidence.memory.items.len(),\n })\n .await;\n let _ = tx\n .send(StatefulRunEvent::CoverageReady {\n gaps: coverage.gaps.len(),\n })\n .await;\n }\n trace\n .append(&TraceEvent::QualityWarning {\n message: format!(\n \"EvidenceBuilder ranked {ranked_total} item(s) into {group_total} group(s).\"\n ),\n at: Utc::now(),\n })\n .await?;\n\n let writer_input = serde_json::to_string(&(&run.topic, &run.chapters, &evidence.memory))?;\n let writer_ctx = agent_context(\"writer\", writer_input.clone(), &run.policy, Vec::new());\n if !llm_config_can_call(&llm_config) {\n let message = \"Writer LLM 需要 DeepSeek API key;拒绝生成模板化最终报告。\".to_string();\n run.warnings.push(message.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::InvalidConfig(message));\n }\n let writer_model = llm_config.main_model.clone();\n let writer_budget = TokenBudgetTracker::new();\n writer_budget.before_llm_call(&writer_ctx)?;\n trace_llm_started(&trace, \"writer\", writer_model, &writer_input).await?;\n let draft_result =\n writer::draft_report_with_llm(&run.topic, &run.chapters, &evidence.memory, &llm_config)\n .await;\n let draft = match draft_result {\n Ok(draft) => draft,\n Err(err) => {\n let message = format!(\"Writer LLM 写作失败,拒绝生成模板化最终报告:{err}\");\n run.warnings.push(message.clone());\n trace\n .append(&TraceEvent::QualityWarning {\n message: message.clone(),\n at: Utc::now(),\n })\n .await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::Llm(message));\n }\n };\n let draft_json = serde_json::to_string(&draft)?;\n trace_llm_finished(&trace, \"writer\", &writer_input, &draft_json).await?;\n let audit = if run.policy.require_citation_audit {\n citation_auditor::audit_citations(&draft, &evidence.citations, &evidence.memory)\n } else {\n crate::model::CitationAuditReport::pass()\n };\n let markdown = writer::render_report_markdown(&draft, &evidence.citations);\n let citation_ctx = agent_context(\n \"writer\",\n \"render_report_markdown\".to_string(),\n &run.policy,\n evidence\n .citations\n .citations\n .iter()\n .map(|citation| citation.url.clone())\n .collect(),\n );\n CitationGuard.after_llm_call(&citation_ctx, &markdown)?;\n run.report_draft = Some(draft);\n run.citation_audit = Some(audit.clone());\n run.report_markdown = Some(markdown.clone());\n if !audit.url_whitelist_passed {\n run.warnings\n .push(\"Citation audit failed URL whitelist check.\".to_string());\n }\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::SynthesisReady,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::CitationAuditReady {\n citation_coverage_ratio: audit.citation_coverage_ratio,\n })\n .await;\n }\n\n write_report_file(&app_config, run, &markdown).await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Completed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::Completed {\n run_id: run.run_id.clone(),\n })\n .await;\n }\n Ok(run.clone())\n}\n\npub async fn load_run(app_config: &AppConfig, run_id: &str) -> Result {\n let body = tokio::fs::read_to_string(run_summary_path(app_config, run_id)).await?;\n Ok(serde_json::from_str(&body)?)\n}\n\npub async fn save_run(app_config: &AppConfig, run: &ResearchRunRecord) -> Result<()> {\n tokio::fs::create_dir_all(&app_config.session_dir).await?;\n let body = serde_json::to_string_pretty(run)?;\n tokio::fs::write(run_summary_path(app_config, &run.run_id), body).await?;\n Ok(())\n}\n\npub async fn evidence_for_run(app_config: &AppConfig, run_id: &str) -> Result {\n load_run(app_config, run_id)\n .await?\n .evidence_memory\n .ok_or_else(|| AppError::Workflow(\"run has no EvidenceMemory yet\".to_string()))\n}\n\npub async fn coverage_for_run(app_config: &AppConfig, run_id: &str) -> Result {\n load_run(app_config, run_id)\n .await?\n .coverage_report\n .ok_or_else(|| AppError::Workflow(\"run has no CoverageReport yet\".to_string()))\n}\n\npub async fn revise_plan(\n app_config: &AppConfig,\n run_id: &str,\n chapters: Option>,\n query_portfolio: Option>,\n user_feedback: Option,\n) -> Result {\n let mut run = load_run(app_config, run_id).await?;\n if !matches!(run.state, ResearchRunState::PlanReady) {\n return Err(AppError::Workflow(\n \"only PlanReady runs can revise the Stage 3 plan\".to_string(),\n ));\n }\n if let Some(chapters) = chapters {\n if chapters.is_empty() {\n return Err(AppError::Workflow(\n \"revised chapter plan cannot be empty\".to_string(),\n ));\n }\n run.chapters = chapters;\n }\n if let Some(query_portfolio) = query_portfolio {\n if query_portfolio.is_empty() {\n return Err(AppError::Workflow(\n \"revised query portfolio cannot be empty\".to_string(),\n ));\n }\n run.query_portfolio = query_portfolio;\n }\n if let Some(feedback) = user_feedback.map(|value| value.trim().to_string()) {\n if !feedback.is_empty() {\n run.plan_warnings\n .push(format!(\"用户修订计划备注:{feedback}\"));\n }\n }\n run.updated_at = Utc::now();\n let run_dir = run_dir(app_config, run_id);\n let trace = TraceWriter::new(&run_dir).await?;\n trace\n .append(&TraceEvent::QualityWarning {\n message: \"用户已修订 PlanReady 搜索计划,并创建新的 checkpoint。\".to_string(),\n at: Utc::now(),\n })\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, &run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(app_config, &run).await?;\n Ok(run)\n}\n\npub async fn branch_from_plan_ready(\n app_config: AppConfig,\n run_id: &str,\n checkpoint_id: &str,\n) -> Result {\n let snapshot = load_checkpoint_snapshot_by_id(&app_config, run_id, checkpoint_id).await?;\n if !snapshot.checkpoint.rollback_allowed\n || !matches!(snapshot.checkpoint.state, ResearchRunState::PlanReady)\n {\n return Err(AppError::Workflow(\n \"Stage 3 currently supports rollback branches only from PlanReady checkpoints\"\n .to_string(),\n ));\n }\n let mut branch = snapshot.run;\n branch.run_id = format!(\"run-{}\", Uuid::new_v4());\n branch.created_at = Utc::now();\n branch.updated_at = Utc::now();\n branch.state = ResearchRunState::PlanReady;\n branch.origin_run_id = Some(run_id.to_string());\n branch.origin_checkpoint_id = Some(checkpoint_id.to_string());\n branch.evidence_memory = None;\n branch.coverage_report = None;\n branch.report_draft = None;\n branch.citation_audit = None;\n branch.report_markdown = None;\n branch.output_report = None;\n let branch_dir = run_dir(&app_config, &branch.run_id);\n tokio::fs::create_dir_all(&branch_dir).await?;\n let trace = TraceWriter::new(&branch_dir).await?;\n trace\n .append(&TraceEvent::RollbackBranchCreated {\n origin_run_id: run_id.to_string(),\n new_run_id: branch.run_id.clone(),\n at: Utc::now(),\n })\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&branch_dir, &branch).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(&app_config, &branch).await?;\n Ok(branch)\n}\n\nasync fn load_checkpoint_snapshot_by_id(\n app_config: &AppConfig,\n run_id: &str,\n checkpoint_id: &str,\n) -> Result {\n let run_dir = run_dir(app_config, run_id);\n let checkpoint_dir = run_dir.join(\"checkpoints\");\n let mut entries = tokio::fs::read_dir(checkpoint_dir).await?;\n while let Some(entry) = entries.next_entry().await? {\n let body = tokio::fs::read_to_string(entry.path()).await?;\n let snapshot: CheckpointSnapshot = serde_json::from_str(&body)?;\n if snapshot.checkpoint.checkpoint_id == checkpoint_id {\n return checkpoint::read_checkpoint(&run_dir, &snapshot.checkpoint).await;\n }\n }\n Err(AppError::Workflow(format!(\n \"checkpoint `{checkpoint_id}` was not found for run `{run_id}`\"\n )))\n}\n\nasync fn transition(\n run: &mut ResearchRunRecord,\n trace: &TraceWriter,\n app_config: &AppConfig,\n next: ResearchRunState,\n tx: Option<&mpsc::Sender>,\n) -> Result<()> {\n let from = run.state.clone();\n if !run.transition_to(next.clone()) {\n return Err(invalid_transition(&from, &next));\n }\n trace_transition(trace, from, next.clone()).await?;\n save_run(app_config, run).await?;\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::StateChanged { state: next })\n .await;\n }\n Ok(())\n}\n\nasync fn trace_transition(\n trace: &TraceWriter,\n from: ResearchRunState,\n to: ResearchRunState,\n) -> Result<()> {\n trace\n .append(&TraceEvent::StateTransition {\n from,\n to,\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_llm_started(\n trace: &TraceWriter,\n actor: &str,\n model: String,\n input: &str,\n) -> Result<()> {\n trace\n .append(&TraceEvent::LlmRequestStarted {\n actor: actor.to_string(),\n model,\n input_hash: stable_hash(input),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_llm_finished(\n trace: &TraceWriter,\n actor: &str,\n input: &str,\n output: &str,\n) -> Result<()> {\n let (_input_hash, output_hash, tokens) = decision_hash(input, output);\n trace\n .append(&TraceEvent::LlmRequestFinished {\n actor: actor.to_string(),\n output_hash,\n token_estimate: tokens,\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_agent_decision(\n trace: &TraceWriter,\n actor: &str,\n input: &str,\n output: &str,\n rationale: &str,\n) -> Result<()> {\n let (input_hash, output_hash, _tokens) = decision_hash(input, output);\n trace\n .append(&TraceEvent::AgentDecision {\n actor: actor.to_string(),\n input_hash,\n output_hash,\n rationale: rationale.to_string(),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_checkpoint(trace: &TraceWriter, checkpoint: &Checkpoint) -> Result<()> {\n trace\n .append(&TraceEvent::CheckpointCreated {\n checkpoint_id: checkpoint.checkpoint_id.clone(),\n state: checkpoint.state.clone(),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn send_checkpoint(tx: Option<&mpsc::Sender>, checkpoint: &Checkpoint) {\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::CheckpointCreated {\n checkpoint_id: checkpoint.checkpoint_id.clone(),\n state: checkpoint.state.clone(),\n })\n .await;\n }\n}\n\nasync fn trace_planned_tool_calls(\n trace: &TraceWriter,\n portfolio: &[QueryPortfolio],\n include_academic_extra: bool,\n) -> Result<()> {\n for item in portfolio {\n for query in &item.github_queries {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"github\".to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n for query in &item.arxiv_queries {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"arxiv\".to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n if include_academic_extra {\n for query in academic_queries_for_trace(item) {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"semantic_scholar\".to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"dblp\".to_string(),\n query,\n at: Utc::now(),\n })\n .await?;\n }\n }\n }\n Ok(())\n}\n\nfn academic_queries_for_trace(item: &QueryPortfolio) -> Vec {\n let mut queries = item.arxiv_queries.clone();\n for query in &item.github_queries {\n if !queries.iter().any(|existing| existing == query) {\n queries.push(query.clone());\n }\n }\n queries\n}\n\nfn llm_config_can_call(llm_config: &LlmConfig) -> bool {\n llm_config.enabled\n && llm_config\n .api_key\n .as_deref()\n .map(str::trim)\n .is_some_and(|key| !key.is_empty())\n}\n\nfn fallback_chapter_plan(\n topic: &str,\n policy: &RunPolicy,\n warning: Option<&str>,\n) -> planner::ChapterPlanOutput {\n let default_plan = planner::default_plan(topic, policy.github_budget, policy.arxiv_budget);\n let mut plan = planner::chapter_plan_from_plan_output(&default_plan, policy);\n if let Some(warning) = warning {\n plan.warnings.push(warning.to_string());\n }\n plan\n}\n\nfn agent_context(\n actor: &str,\n input: String,\n policy: &RunPolicy,\n allowed_urls: Vec,\n) -> AgentContext {\n AgentContext {\n actor: actor.to_string(),\n input,\n allowed_urls,\n max_llm_calls: policy.max_llm_calls_per_run,\n }\n}\n\nfn side_model_name(llm_config: &LlmConfig) -> String {\n llm_config\n .side_model\n .clone()\n .unwrap_or_else(|| llm_config.main_model.clone())\n}\n\nfn invalid_transition(from: &ResearchRunState, to: &ResearchRunState) -> AppError {\n AppError::Workflow(format!(\"invalid state transition from {from:?} to {to:?}\"))\n}\n\nfn run_summary_path(app_config: &AppConfig, run_id: &str) -> PathBuf {\n app_config.session_dir.join(format!(\"{run_id}.json\"))\n}\n\nfn run_dir(app_config: &AppConfig, run_id: &str) -> PathBuf {\n app_config.session_dir.join(run_id)\n}\n\nasync fn write_report_file(\n app_config: &AppConfig,\n run: &mut ResearchRunRecord,\n markdown: &str,\n) -> Result<()> {\n let path = resolve_report_path(&app_config.output, &run.topic);\n if let Some(parent) = path.parent() {\n tokio::fs::create_dir_all(parent).await?;\n }\n tokio::fs::write(&path, markdown).await?;\n run.output_report = Some(path.display().to_string());\n Ok(())\n}\n\nfn resolve_report_path(output: &Path, topic: &str) -> PathBuf {\n if output.extension().is_some() {\n return output.to_path_buf();\n }\n output.join(format!(\n \"{}-stage3-{}.md\",\n slugify(topic),\n Utc::now().format(\"%Y%m%d-%H%M%S\")\n ))\n}\n\nfn slugify(topic: &str) -> String {\n let mut slug = String::new();\n let mut last_dash = false;\n for ch in topic.chars().flat_map(char::to_lowercase) {\n if ch.is_ascii_alphanumeric() {\n slug.push(ch);\n last_dash = false;\n } else if !last_dash {\n slug.push('-');\n last_dash = true;\n }\n }\n let slug = slug.trim_matches('-');\n if slug.is_empty() {\n \"report\".to_string()\n } else {\n slug.to_string()\n }\n}\n\npub fn decision_hash(input: &str, output: &str) -> (String, String, usize) {\n (\n stable_hash(input),\n stable_hash(output),\n token_estimate(input) + token_estimate(output),\n )\n}\n\n#[cfg(test)]\nmod tests {\n use std::time::{SystemTime, UNIX_EPOCH};\n\n use super::create_run;\n use crate::config::{AppConfig, LlmConfig};\n use crate::run_policy::RunPolicy;\n use crate::workflow_state::ResearchRunState;\n\n #[tokio::test]\n async fn create_run_falls_back_without_llm_key_and_traces_rule_nodes() {\n let root = temp_root(\"stage3-create-run-fallback\");\n let app_config = AppConfig {\n github_token: None,\n semantic_scholar_api_key: None,\n output: root.join(\"reports\"),\n cache_dir: root.join(\"cache\"),\n session_dir: root.join(\"sessions\"),\n tags_file: None,\n use_cache: false,\n cache_ttl_hours: 24,\n timeout_secs: 30,\n enrich: false,\n };\n let llm_config = LlmConfig {\n enabled: true,\n api_key: None,\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n main_model: \"deepseek-v4-pro\".to_string(),\n side_model: Some(\"deepseek-v4-flash\".to_string()),\n max_tokens: 4096,\n timeout_secs: 30,\n };\n\n let run = create_run(\n \"Rust agent framework\".to_string(),\n app_config.clone(),\n llm_config,\n RunPolicy::default(),\n )\n .await\n .expect(\"create_run should degrade to a deterministic plan\");\n\n assert_eq!(run.state, ResearchRunState::PlanReady);\n assert!(run\n .plan_warnings\n .iter()\n .any(|warning| warning.contains(\"API key\")));\n assert!(run\n .warnings\n .iter()\n .any(|warning| warning.contains(\"0 real LLM request attempt\")));\n\n let trace_path = app_config.session_dir.join(&run.run_id).join(\"trace.jsonl\");\n let trace = std::fs::read_to_string(trace_path).expect(\"trace should be readable\");\n assert!(trace.contains(\"\\\"event\\\":\\\"agent_decision\\\"\"));\n assert!(trace.contains(\"\\\"actor\\\":\\\"scoper\\\"\"));\n assert!(trace.contains(\"\\\"actor\\\":\\\"plan_critic\\\"\"));\n assert!(!trace.contains(\"\\\"event\\\":\\\"llm_request_started\\\"\"));\n }\n\n fn temp_root(name: &str) -> std::path::PathBuf {\n let unique = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n std::env::temp_dir().join(format!(\n \"litscout-rs-{name}-{}-{unique}\",\n std::process::id()\n ))\n }\n}\n"}, "638e49c5b42c5e902379c23b653519ba0ed59bda": {"t": 1785750661.467633, "n": 29922, "h": "2c1615c4dedf12ef", "c": "use std::path::{Path, PathBuf};\n\nuse chrono::Utc;\nuse tokio::sync::mpsc;\nuse uuid::Uuid;\n\nuse crate::agent::{\n arxiv_scout, citation_auditor, coverage_critic, evidence, github_scout,\n middleware::{\n AgentContext, AgentMiddleware, CitationGuard, JsonSchemaGuard, TokenBudgetTracker,\n },\n plan_critic, planner, scoper, writer,\n};\nuse crate::checkpoint::{self, Checkpoint, CheckpointSnapshot};\nuse crate::config::{AppConfig, LlmConfig};\nuse crate::error::{AppError, Result};\nuse crate::model::{\n ChapterNode, CoverageReport, EvidenceMemory, QueryAttempt, QueryPortfolio, SearchQuery,\n};\nuse crate::run_policy::RunPolicy;\nuse crate::trace::{stable_hash, token_estimate, TraceEvent, TraceWriter};\nuse crate::workflow_state::{ResearchRunRecord, ResearchRunState};\n\n#[derive(Debug, Clone, serde::Serialize)]\n#[serde(tag = \"event\", content = \"data\", rename_all = \"snake_case\")]\npub enum StatefulRunEvent {\n StateChanged {\n state: ResearchRunState,\n },\n CheckpointCreated {\n checkpoint_id: String,\n state: ResearchRunState,\n },\n EvidenceReady {\n total: usize,\n },\n CoverageReady {\n gaps: usize,\n },\n CitationAuditReady {\n citation_coverage_ratio: f64,\n },\n Completed {\n run_id: String,\n },\n Failed {\n error: String,\n },\n}\n\npub async fn create_run(\n topic: String,\n app_config: AppConfig,\n llm_config: LlmConfig,\n policy: RunPolicy,\n) -> Result {\n let run_id = format!(\"run-{}\", Uuid::new_v4());\n let run_dir = run_dir(&app_config, &run_id);\n tokio::fs::create_dir_all(&run_dir).await?;\n let trace = TraceWriter::new(&run_dir).await?;\n let mut run = ResearchRunRecord::new(run_id, topic.clone(), policy.bounded());\n save_run(&app_config, &run).await?;\n let token_budget = TokenBudgetTracker::new();\n let json_guard = JsonSchemaGuard;\n\n let scoper_input = format!(\"topic={topic}; policy={:?}\", run.policy);\n let scoper_ctx = agent_context(\"scoper\", scoper_input.clone(), &run.policy, Vec::new());\n let brief = scoper::generate_research_brief(&topic, &run.policy, &llm_config).await?;\n let brief_json = serde_json::to_string(&brief)?;\n json_guard.after_llm_call(&scoper_ctx, &brief_json)?;\n trace_agent_decision(\n &trace,\n \"scoper\",\n &scoper_input,\n &brief_json,\n \"deterministic ResearchBrief scoping\",\n )\n .await?;\n\n let planner_input = serde_json::to_string(&brief)?;\n let planner_ctx = agent_context(\"planner\", planner_input.clone(), &run.policy, Vec::new());\n let plan = if llm_config_can_call(&llm_config) {\n token_budget.before_llm_call(&planner_ctx)?;\n trace_llm_started(\n &trace,\n \"planner\",\n side_model_name(&llm_config),\n &planner_input,\n )\n .await?;\n match planner::generate_chapter_plan(&brief, &run.policy, &llm_config).await {\n Ok(plan) => {\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n token_budget.after_llm_call(&planner_ctx, &plan_json)?;\n trace_llm_finished(&trace, \"planner\", &planner_input, &plan_json).await?;\n plan\n }\n Err(err) => {\n let reason = err.to_string();\n trace\n .append(&TraceEvent::QualityWarning {\n message: format!(\"Planner LLM 调用失败,已降级为确定性默认计划:{reason}\"),\n at: Utc::now(),\n })\n .await?;\n let plan = fallback_chapter_plan(&brief.topic, &run.policy, Some(&reason));\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n trace_agent_decision(\n &trace,\n \"planner_fallback\",\n &planner_input,\n &plan_json,\n \"planner LLM failed; deterministic default plan\",\n )\n .await?;\n plan\n }\n }\n } else {\n let reason = llm_config\n .enabled\n .then_some(\"LLM 已启用但缺少 API key,已使用确定性默认计划。\");\n let plan = fallback_chapter_plan(&brief.topic, &run.policy, reason);\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n trace_agent_decision(\n &trace,\n \"planner\",\n &planner_input,\n &plan_json,\n \"deterministic default plan\",\n )\n .await?;\n plan\n };\n\n let critic_input = serde_json::to_string(&(&brief, &plan.chapters, &plan.query_portfolio))?;\n let critic_ctx = agent_context(\"plan_critic\", critic_input.clone(), &run.policy, Vec::new());\n let critique =\n plan_critic::critique_plan(&brief, &plan.chapters, &plan.query_portfolio, &run.policy);\n let critique_json = serde_json::to_string(&critique)?;\n json_guard.after_llm_call(&critic_ctx, &critique_json)?;\n trace_agent_decision(\n &trace,\n \"plan_critic\",\n &critic_input,\n &critique_json,\n \"deterministic plan consistency checks\",\n )\n .await?;\n let from = run.state.clone();\n if !run.transition_to(ResearchRunState::PlanReady) {\n return Err(invalid_transition(&from, &ResearchRunState::PlanReady));\n }\n run.brief = Some(brief);\n run.chapters = plan.chapters;\n run.query_portfolio = plan.query_portfolio;\n run.plan_warnings = plan\n .warnings\n .into_iter()\n .chain(critique.warnings)\n .chain(critique.suggestions)\n .collect();\n run.warnings.push(format!(\n \"Stage 3 agent planning used {} real LLM request attempt(s); deterministic nodes are traced as AgentDecision events.\",\n token_budget.calls()\n ));\n trace_transition(&trace, from, ResearchRunState::PlanReady).await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, &run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(&app_config, &run).await?;\n Ok(run)\n}\n\npub async fn continue_run(\n run_id: &str,\n app_config: AppConfig,\n llm_config: LlmConfig,\n tx: Option>,\n) -> Result {\n let mut run = load_run(&app_config, run_id).await?;\n match run.state {\n ResearchRunState::PlanReady => {\n continue_from_plan_ready(&mut run, app_config, llm_config, tx).await\n }\n ResearchRunState::Completed => Ok(run),\n _ => Err(AppError::Workflow(format!(\n \"run `{run_id}` is in state {:?} and cannot continue from this endpoint\",\n run.state\n ))),\n }\n}\n\nasync fn continue_from_plan_ready(\n run: &mut ResearchRunRecord,\n app_config: AppConfig,\n llm_config: LlmConfig,\n tx: Option>,\n) -> Result {\n let run_dir = run_dir(&app_config, &run.run_id);\n let trace = TraceWriter::new(&run_dir).await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Fetching,\n tx.as_ref(),\n )\n .await?;\n trace_planned_tool_calls(&trace, &run.query_portfolio).await?;\n\n let mut github_config = app_config.clone();\n github_config.enrich = app_config.enrich || run.policy.allow_github_enrich;\n let (github_result, arxiv_result) = tokio::join!(\n github_scout::scout_github(&run.query_portfolio, &github_config, 1),\n arxiv_scout::scout_arxiv(&run.query_portfolio, &app_config, 1)\n );\n let (github_repos, github_attempts, github_lineage) = github_result?;\n let (arxiv_papers, arxiv_attempts, arxiv_lineage) = arxiv_result?;\n let query_attempts = github_attempts\n .into_iter()\n .chain(arxiv_attempts)\n .collect::>();\n let source_lineage = github_lineage\n .into_iter()\n .chain(arxiv_lineage)\n .collect::>();\n for attempt in &query_attempts {\n trace\n .append(&TraceEvent::ToolCallFinished {\n tool: attempt.source.clone(),\n result_count: attempt.result_count,\n error: attempt.error.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n if github_repos.is_empty() && arxiv_papers.is_empty() {\n let message = \"GitHub 与 arXiv 均未返回可用结果,无法生成 stateful report。\".to_string();\n run.warnings.push(message.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::Workflow(message));\n }\n\n let query = SearchQuery {\n topic: run.topic.clone(),\n github_limit: run.policy.github_budget,\n arxiv_limit: run.policy.arxiv_budget,\n };\n let evidence = evidence::build_evidence_memory(\n &query,\n &app_config,\n github_repos,\n arxiv_papers,\n query_attempts,\n source_lineage,\n )?;\n let ranked_total = evidence.ranked_items.len();\n let group_total = evidence.groups.len();\n let coverage = if run.policy.skip_coverage_critic {\n CoverageReport::pass()\n } else {\n coverage_critic::evaluate_coverage(\n &run.chapters,\n &run.query_portfolio,\n &evidence.memory,\n &run.policy,\n )\n };\n for gap in &coverage.gaps {\n trace\n .append(&TraceEvent::CoverageGapDetected {\n chapter_id: gap.chapter_id.clone(),\n gap_kind: format!(\"{:?}\", gap.gap_kind),\n at: Utc::now(),\n })\n .await?;\n }\n run.evidence_memory = Some(evidence.memory.clone());\n run.coverage_report = Some(coverage.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::EvidenceReady,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::EvidenceReady {\n total: evidence.memory.items.len(),\n })\n .await;\n let _ = tx\n .send(StatefulRunEvent::CoverageReady {\n gaps: coverage.gaps.len(),\n })\n .await;\n }\n trace\n .append(&TraceEvent::QualityWarning {\n message: format!(\n \"EvidenceBuilder ranked {ranked_total} item(s) into {group_total} group(s).\"\n ),\n at: Utc::now(),\n })\n .await?;\n\n let writer_input = serde_json::to_string(&(&run.topic, &run.chapters, &evidence.memory))?;\n let writer_ctx = agent_context(\"writer\", writer_input.clone(), &run.policy, Vec::new());\n if !llm_config_can_call(&llm_config) {\n let message = \"Writer LLM 需要 DeepSeek API key;拒绝生成模板化最终报告。\".to_string();\n run.warnings.push(message.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::InvalidConfig(message));\n }\n let writer_model = llm_config.main_model.clone();\n let writer_budget = TokenBudgetTracker::new();\n writer_budget.before_llm_call(&writer_ctx)?;\n trace_llm_started(&trace, \"writer\", writer_model, &writer_input).await?;\n let draft_result =\n writer::draft_report_with_llm(&run.topic, &run.chapters, &evidence.memory, &llm_config)\n .await;\n let draft = match draft_result {\n Ok(draft) => draft,\n Err(err) => {\n let message = format!(\"Writer LLM 写作失败,拒绝生成模板化最终报告:{err}\");\n run.warnings.push(message.clone());\n trace\n .append(&TraceEvent::QualityWarning {\n message: message.clone(),\n at: Utc::now(),\n })\n .await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::Llm(message));\n }\n };\n let draft_json = serde_json::to_string(&draft)?;\n trace_llm_finished(&trace, \"writer\", &writer_input, &draft_json).await?;\n let audit = if run.policy.require_citation_audit {\n citation_auditor::audit_citations(&draft, &evidence.citations, &evidence.memory)\n } else {\n crate::model::CitationAuditReport::pass()\n };\n let markdown = writer::render_report_markdown(&draft, &evidence.citations);\n let citation_ctx = agent_context(\n \"writer\",\n \"render_report_markdown\".to_string(),\n &run.policy,\n evidence\n .citations\n .citations\n .iter()\n .map(|citation| citation.url.clone())\n .collect(),\n );\n CitationGuard.after_llm_call(&citation_ctx, &markdown)?;\n run.report_draft = Some(draft);\n run.citation_audit = Some(audit.clone());\n run.report_markdown = Some(markdown.clone());\n if !audit.url_whitelist_passed {\n run.warnings\n .push(\"Citation audit failed URL whitelist check.\".to_string());\n }\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::SynthesisReady,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::CitationAuditReady {\n citation_coverage_ratio: audit.citation_coverage_ratio,\n })\n .await;\n }\n\n write_report_file(&app_config, run, &markdown).await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Completed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::Completed {\n run_id: run.run_id.clone(),\n })\n .await;\n }\n Ok(run.clone())\n}\n\npub async fn load_run(app_config: &AppConfig, run_id: &str) -> Result {\n let body = tokio::fs::read_to_string(run_summary_path(app_config, run_id)).await?;\n Ok(serde_json::from_str(&body)?)\n}\n\npub async fn save_run(app_config: &AppConfig, run: &ResearchRunRecord) -> Result<()> {\n tokio::fs::create_dir_all(&app_config.session_dir).await?;\n let body = serde_json::to_string_pretty(run)?;\n tokio::fs::write(run_summary_path(app_config, &run.run_id), body).await?;\n Ok(())\n}\n\npub async fn evidence_for_run(app_config: &AppConfig, run_id: &str) -> Result {\n load_run(app_config, run_id)\n .await?\n .evidence_memory\n .ok_or_else(|| AppError::Workflow(\"run has no EvidenceMemory yet\".to_string()))\n}\n\npub async fn coverage_for_run(app_config: &AppConfig, run_id: &str) -> Result {\n load_run(app_config, run_id)\n .await?\n .coverage_report\n .ok_or_else(|| AppError::Workflow(\"run has no CoverageReport yet\".to_string()))\n}\n\npub async fn revise_plan(\n app_config: &AppConfig,\n run_id: &str,\n chapters: Option>,\n query_portfolio: Option>,\n user_feedback: Option,\n) -> Result {\n let mut run = load_run(app_config, run_id).await?;\n if !matches!(run.state, ResearchRunState::PlanReady) {\n return Err(AppError::Workflow(\n \"only PlanReady runs can revise the Stage 3 plan\".to_string(),\n ));\n }\n if let Some(chapters) = chapters {\n if chapters.is_empty() {\n return Err(AppError::Workflow(\n \"revised chapter plan cannot be empty\".to_string(),\n ));\n }\n run.chapters = chapters;\n }\n if let Some(query_portfolio) = query_portfolio {\n if query_portfolio.is_empty() {\n return Err(AppError::Workflow(\n \"revised query portfolio cannot be empty\".to_string(),\n ));\n }\n run.query_portfolio = query_portfolio;\n }\n if let Some(feedback) = user_feedback.map(|value| value.trim().to_string()) {\n if !feedback.is_empty() {\n run.plan_warnings\n .push(format!(\"用户修订计划备注:{feedback}\"));\n }\n }\n run.updated_at = Utc::now();\n let run_dir = run_dir(app_config, run_id);\n let trace = TraceWriter::new(&run_dir).await?;\n trace\n .append(&TraceEvent::QualityWarning {\n message: \"用户已修订 PlanReady 搜索计划,并创建新的 checkpoint。\".to_string(),\n at: Utc::now(),\n })\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, &run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(app_config, &run).await?;\n Ok(run)\n}\n\npub async fn branch_from_plan_ready(\n app_config: AppConfig,\n run_id: &str,\n checkpoint_id: &str,\n) -> Result {\n let snapshot = load_checkpoint_snapshot_by_id(&app_config, run_id, checkpoint_id).await?;\n if !snapshot.checkpoint.rollback_allowed\n || !matches!(snapshot.checkpoint.state, ResearchRunState::PlanReady)\n {\n return Err(AppError::Workflow(\n \"Stage 3 currently supports rollback branches only from PlanReady checkpoints\"\n .to_string(),\n ));\n }\n let mut branch = snapshot.run;\n branch.run_id = format!(\"run-{}\", Uuid::new_v4());\n branch.created_at = Utc::now();\n branch.updated_at = Utc::now();\n branch.state = ResearchRunState::PlanReady;\n branch.origin_run_id = Some(run_id.to_string());\n branch.origin_checkpoint_id = Some(checkpoint_id.to_string());\n branch.evidence_memory = None;\n branch.coverage_report = None;\n branch.report_draft = None;\n branch.citation_audit = None;\n branch.report_markdown = None;\n branch.output_report = None;\n let branch_dir = run_dir(&app_config, &branch.run_id);\n tokio::fs::create_dir_all(&branch_dir).await?;\n let trace = TraceWriter::new(&branch_dir).await?;\n trace\n .append(&TraceEvent::RollbackBranchCreated {\n origin_run_id: run_id.to_string(),\n new_run_id: branch.run_id.clone(),\n at: Utc::now(),\n })\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&branch_dir, &branch).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(&app_config, &branch).await?;\n Ok(branch)\n}\n\nasync fn load_checkpoint_snapshot_by_id(\n app_config: &AppConfig,\n run_id: &str,\n checkpoint_id: &str,\n) -> Result {\n let run_dir = run_dir(app_config, run_id);\n let checkpoint_dir = run_dir.join(\"checkpoints\");\n let mut entries = tokio::fs::read_dir(checkpoint_dir).await?;\n while let Some(entry) = entries.next_entry().await? {\n let body = tokio::fs::read_to_string(entry.path()).await?;\n let snapshot: CheckpointSnapshot = serde_json::from_str(&body)?;\n if snapshot.checkpoint.checkpoint_id == checkpoint_id {\n return checkpoint::read_checkpoint(&run_dir, &snapshot.checkpoint).await;\n }\n }\n Err(AppError::Workflow(format!(\n \"checkpoint `{checkpoint_id}` was not found for run `{run_id}`\"\n )))\n}\n\nasync fn transition(\n run: &mut ResearchRunRecord,\n trace: &TraceWriter,\n app_config: &AppConfig,\n next: ResearchRunState,\n tx: Option<&mpsc::Sender>,\n) -> Result<()> {\n let from = run.state.clone();\n if !run.transition_to(next.clone()) {\n return Err(invalid_transition(&from, &next));\n }\n trace_transition(trace, from, next.clone()).await?;\n save_run(app_config, run).await?;\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::StateChanged { state: next })\n .await;\n }\n Ok(())\n}\n\nasync fn trace_transition(\n trace: &TraceWriter,\n from: ResearchRunState,\n to: ResearchRunState,\n) -> Result<()> {\n trace\n .append(&TraceEvent::StateTransition {\n from,\n to,\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_llm_started(\n trace: &TraceWriter,\n actor: &str,\n model: String,\n input: &str,\n) -> Result<()> {\n trace\n .append(&TraceEvent::LlmRequestStarted {\n actor: actor.to_string(),\n model,\n input_hash: stable_hash(input),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_llm_finished(\n trace: &TraceWriter,\n actor: &str,\n input: &str,\n output: &str,\n) -> Result<()> {\n let (_input_hash, output_hash, tokens) = decision_hash(input, output);\n trace\n .append(&TraceEvent::LlmRequestFinished {\n actor: actor.to_string(),\n output_hash,\n token_estimate: tokens,\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_agent_decision(\n trace: &TraceWriter,\n actor: &str,\n input: &str,\n output: &str,\n rationale: &str,\n) -> Result<()> {\n let (input_hash, output_hash, _tokens) = decision_hash(input, output);\n trace\n .append(&TraceEvent::AgentDecision {\n actor: actor.to_string(),\n input_hash,\n output_hash,\n rationale: rationale.to_string(),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_checkpoint(trace: &TraceWriter, checkpoint: &Checkpoint) -> Result<()> {\n trace\n .append(&TraceEvent::CheckpointCreated {\n checkpoint_id: checkpoint.checkpoint_id.clone(),\n state: checkpoint.state.clone(),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn send_checkpoint(tx: Option<&mpsc::Sender>, checkpoint: &Checkpoint) {\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::CheckpointCreated {\n checkpoint_id: checkpoint.checkpoint_id.clone(),\n state: checkpoint.state.clone(),\n })\n .await;\n }\n}\n\nasync fn trace_planned_tool_calls(trace: &TraceWriter, portfolio: &[QueryPortfolio]) -> Result<()> {\n for item in portfolio {\n for query in &item.github_queries {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"github\".to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n for query in &item.arxiv_queries {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"arxiv\".to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n }\n Ok(())\n}\n\nfn llm_config_can_call(llm_config: &LlmConfig) -> bool {\n llm_config.enabled\n && llm_config\n .api_key\n .as_deref()\n .map(str::trim)\n .is_some_and(|key| !key.is_empty())\n}\n\nfn fallback_chapter_plan(\n topic: &str,\n policy: &RunPolicy,\n warning: Option<&str>,\n) -> planner::ChapterPlanOutput {\n let default_plan = planner::default_plan(topic, policy.github_budget, policy.arxiv_budget);\n let mut plan = planner::chapter_plan_from_plan_output(&default_plan, policy);\n if let Some(warning) = warning {\n plan.warnings.push(warning.to_string());\n }\n plan\n}\n\nfn agent_context(\n actor: &str,\n input: String,\n policy: &RunPolicy,\n allowed_urls: Vec,\n) -> AgentContext {\n AgentContext {\n actor: actor.to_string(),\n input,\n allowed_urls,\n max_llm_calls: policy.max_llm_calls_per_run,\n }\n}\n\nfn side_model_name(llm_config: &LlmConfig) -> String {\n llm_config\n .side_model\n .clone()\n .unwrap_or_else(|| llm_config.main_model.clone())\n}\n\nfn invalid_transition(from: &ResearchRunState, to: &ResearchRunState) -> AppError {\n AppError::Workflow(format!(\"invalid state transition from {from:?} to {to:?}\"))\n}\n\nfn run_summary_path(app_config: &AppConfig, run_id: &str) -> PathBuf {\n app_config.session_dir.join(format!(\"{run_id}.json\"))\n}\n\nfn run_dir(app_config: &AppConfig, run_id: &str) -> PathBuf {\n app_config.session_dir.join(run_id)\n}\n\nasync fn write_report_file(\n app_config: &AppConfig,\n run: &mut ResearchRunRecord,\n markdown: &str,\n) -> Result<()> {\n let path = resolve_report_path(&app_config.output, &run.topic);\n if let Some(parent) = path.parent() {\n tokio::fs::create_dir_all(parent).await?;\n }\n tokio::fs::write(&path, markdown).await?;\n run.output_report = Some(path.display().to_string());\n Ok(())\n}\n\nfn resolve_report_path(output: &Path, topic: &str) -> PathBuf {\n if output.extension().is_some() {\n return output.to_path_buf();\n }\n output.join(format!(\n \"{}-stage3-{}.md\",\n slugify(topic),\n Utc::now().format(\"%Y%m%d-%H%M%S\")\n ))\n}\n\nfn slugify(topic: &str) -> String {\n let mut slug = String::new();\n let mut last_dash = false;\n for ch in topic.chars().flat_map(char::to_lowercase) {\n if ch.is_ascii_alphanumeric() {\n slug.push(ch);\n last_dash = false;\n } else if !last_dash {\n slug.push('-');\n last_dash = true;\n }\n }\n let slug = slug.trim_matches('-');\n if slug.is_empty() {\n \"report\".to_string()\n } else {\n slug.to_string()\n }\n}\n\npub fn decision_hash(input: &str, output: &str) -> (String, String, usize) {\n (\n stable_hash(input),\n stable_hash(output),\n token_estimate(input) + token_estimate(output),\n )\n}\n\n#[cfg(test)]\nmod tests {\n use std::time::{SystemTime, UNIX_EPOCH};\n\n use super::create_run;\n use crate::config::{AppConfig, LlmConfig};\n use crate::run_policy::RunPolicy;\n use crate::workflow_state::ResearchRunState;\n\n #[tokio::test]\n async fn create_run_falls_back_without_llm_key_and_traces_rule_nodes() {\n let root = temp_root(\"stage3-create-run-fallback\");\n let app_config = AppConfig {\n github_token: None,\n output: root.join(\"reports\"),\n cache_dir: root.join(\"cache\"),\n session_dir: root.join(\"sessions\"),\n tags_file: None,\n use_cache: false,\n cache_ttl_hours: 24,\n timeout_secs: 30,\n enrich: false,\n };\n let llm_config = LlmConfig {\n enabled: true,\n api_key: None,\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n main_model: \"deepseek-v4-pro\".to_string(),\n side_model: Some(\"deepseek-v4-flash\".to_string()),\n max_tokens: 4096,\n timeout_secs: 30,\n };\n\n let run = create_run(\n \"Rust agent framework\".to_string(),\n app_config.clone(),\n llm_config,\n RunPolicy::default(),\n )\n .await\n .expect(\"create_run should degrade to a deterministic plan\");\n\n assert_eq!(run.state, ResearchRunState::PlanReady);\n assert!(run\n .plan_warnings\n .iter()\n .any(|warning| warning.contains(\"API key\")));\n assert!(run\n .warnings\n .iter()\n .any(|warning| warning.contains(\"0 real LLM request attempt\")));\n\n let trace_path = app_config.session_dir.join(&run.run_id).join(\"trace.jsonl\");\n let trace = std::fs::read_to_string(trace_path).expect(\"trace should be readable\");\n assert!(trace.contains(\"\\\"event\\\":\\\"agent_decision\\\"\"));\n assert!(trace.contains(\"\\\"actor\\\":\\\"scoper\\\"\"));\n assert!(trace.contains(\"\\\"actor\\\":\\\"plan_critic\\\"\"));\n assert!(!trace.contains(\"\\\"event\\\":\\\"llm_request_started\\\"\"));\n }\n\n fn temp_root(name: &str) -> std::path::PathBuf {\n let unique = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n std::env::temp_dir().join(format!(\n \"litscout-rs-{name}-{}-{unique}\",\n std::process::id()\n ))\n }\n}\n"}, "bccd8c422afb8962e69d703d2ddcbdac277a8101": {"t": 1785750666.4549646, "n": 30434, "h": "b5423b0d0473eced", "c": "use std::path::{Path, PathBuf};\n\nuse chrono::Utc;\nuse tokio::sync::mpsc;\nuse uuid::Uuid;\n\nuse crate::agent::{\n arxiv_scout, citation_auditor, coverage_critic, evidence, followup_router, github_scout,\n middleware::{\n AgentContext, AgentMiddleware, CitationGuard, JsonSchemaGuard, TokenBudgetTracker,\n },\n plan_critic, planner, scoper, writer,\n};\nuse crate::checkpoint::{self, Checkpoint, CheckpointSnapshot};\nuse crate::config::{AppConfig, LlmConfig};\nuse crate::error::{AppError, Result};\nuse crate::model::{\n ChapterNode, CoverageReport, EvidenceMemory, QueryAttempt, QueryPortfolio, SearchQuery,\n};\nuse crate::run_policy::RunPolicy;\nuse crate::trace::{stable_hash, token_estimate, TraceEvent, TraceWriter};\nuse crate::workflow_state::{ResearchRunRecord, ResearchRunState};\n\n#[derive(Debug, Clone, serde::Serialize)]\n#[serde(tag = \"event\", content = \"data\", rename_all = \"snake_case\")]\npub enum StatefulRunEvent {\n StateChanged {\n state: ResearchRunState,\n },\n CheckpointCreated {\n checkpoint_id: String,\n state: ResearchRunState,\n },\n EvidenceReady {\n total: usize,\n },\n CoverageReady {\n gaps: usize,\n },\n CitationAuditReady {\n citation_coverage_ratio: f64,\n },\n Completed {\n run_id: String,\n },\n Failed {\n error: String,\n },\n}\n\npub async fn create_run(\n topic: String,\n app_config: AppConfig,\n llm_config: LlmConfig,\n policy: RunPolicy,\n) -> Result {\n let run_id = format!(\"run-{}\", Uuid::new_v4());\n let run_dir = run_dir(&app_config, &run_id);\n tokio::fs::create_dir_all(&run_dir).await?;\n let trace = TraceWriter::new(&run_dir).await?;\n let mut run = ResearchRunRecord::new(run_id, topic.clone(), policy.bounded());\n save_run(&app_config, &run).await?;\n let token_budget = TokenBudgetTracker::new();\n let json_guard = JsonSchemaGuard;\n\n let scoper_input = format!(\"topic={topic}; policy={:?}\", run.policy);\n let scoper_ctx = agent_context(\"scoper\", scoper_input.clone(), &run.policy, Vec::new());\n let brief = scoper::generate_research_brief(&topic, &run.policy, &llm_config).await?;\n let brief_json = serde_json::to_string(&brief)?;\n json_guard.after_llm_call(&scoper_ctx, &brief_json)?;\n trace_agent_decision(\n &trace,\n \"scoper\",\n &scoper_input,\n &brief_json,\n \"deterministic ResearchBrief scoping\",\n )\n .await?;\n\n let planner_input = serde_json::to_string(&brief)?;\n let planner_ctx = agent_context(\"planner\", planner_input.clone(), &run.policy, Vec::new());\n let plan = if llm_config_can_call(&llm_config) {\n token_budget.before_llm_call(&planner_ctx)?;\n trace_llm_started(\n &trace,\n \"planner\",\n side_model_name(&llm_config),\n &planner_input,\n )\n .await?;\n match planner::generate_chapter_plan(&brief, &run.policy, &llm_config).await {\n Ok(plan) => {\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n token_budget.after_llm_call(&planner_ctx, &plan_json)?;\n trace_llm_finished(&trace, \"planner\", &planner_input, &plan_json).await?;\n plan\n }\n Err(err) => {\n let reason = err.to_string();\n trace\n .append(&TraceEvent::QualityWarning {\n message: format!(\"Planner LLM 调用失败,已降级为确定性默认计划:{reason}\"),\n at: Utc::now(),\n })\n .await?;\n let plan = fallback_chapter_plan(&brief.topic, &run.policy, Some(&reason));\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n trace_agent_decision(\n &trace,\n \"planner_fallback\",\n &planner_input,\n &plan_json,\n \"planner LLM failed; deterministic default plan\",\n )\n .await?;\n plan\n }\n }\n } else {\n let reason = llm_config\n .enabled\n .then_some(\"LLM 已启用但缺少 API key,已使用确定性默认计划。\");\n let plan = fallback_chapter_plan(&brief.topic, &run.policy, reason);\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n trace_agent_decision(\n &trace,\n \"planner\",\n &planner_input,\n &plan_json,\n \"deterministic default plan\",\n )\n .await?;\n plan\n };\n\n let critic_input = serde_json::to_string(&(&brief, &plan.chapters, &plan.query_portfolio))?;\n let critic_ctx = agent_context(\"plan_critic\", critic_input.clone(), &run.policy, Vec::new());\n let critique =\n plan_critic::critique_plan(&brief, &plan.chapters, &plan.query_portfolio, &run.policy);\n let critique_json = serde_json::to_string(&critique)?;\n json_guard.after_llm_call(&critic_ctx, &critique_json)?;\n trace_agent_decision(\n &trace,\n \"plan_critic\",\n &critic_input,\n &critique_json,\n \"deterministic plan consistency checks\",\n )\n .await?;\n let from = run.state.clone();\n if !run.transition_to(ResearchRunState::PlanReady) {\n return Err(invalid_transition(&from, &ResearchRunState::PlanReady));\n }\n run.brief = Some(brief);\n run.chapters = plan.chapters;\n run.query_portfolio = plan.query_portfolio;\n run.plan_warnings = plan\n .warnings\n .into_iter()\n .chain(critique.warnings)\n .chain(critique.suggestions)\n .collect();\n run.warnings.push(format!(\n \"Stage 3 agent planning used {} real LLM request attempt(s); deterministic nodes are traced as AgentDecision events.\",\n token_budget.calls()\n ));\n trace_transition(&trace, from, ResearchRunState::PlanReady).await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, &run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(&app_config, &run).await?;\n Ok(run)\n}\n\npub async fn continue_run(\n run_id: &str,\n app_config: AppConfig,\n llm_config: LlmConfig,\n tx: Option>,\n) -> Result {\n let mut run = load_run(&app_config, run_id).await?;\n match run.state {\n ResearchRunState::PlanReady => {\n continue_from_plan_ready(&mut run, app_config, llm_config, tx).await\n }\n ResearchRunState::Completed => Ok(run),\n _ => Err(AppError::Workflow(format!(\n \"run `{run_id}` is in state {:?} and cannot continue from this endpoint\",\n run.state\n ))),\n }\n}\n\nasync fn continue_from_plan_ready(\n run: &mut ResearchRunRecord,\n app_config: AppConfig,\n llm_config: LlmConfig,\n tx: Option>,\n) -> Result {\n let run_dir = run_dir(&app_config, &run.run_id);\n let trace = TraceWriter::new(&run_dir).await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Fetching,\n tx.as_ref(),\n )\n .await?;\n trace_planned_tool_calls(&trace, &run.query_portfolio).await?;\n\n let mut github_config = app_config.clone();\n github_config.enrich = app_config.enrich || run.policy.allow_github_enrich;\n let (github_result, arxiv_result) = tokio::join!(\n github_scout::scout_github(&run.query_portfolio, &github_config, 1),\n arxiv_scout::scout_arxiv(&run.query_portfolio, &app_config, 1)\n );\n let (github_repos, github_attempts, github_lineage) = github_result?;\n let (arxiv_papers, arxiv_attempts, arxiv_lineage) = arxiv_result?;\n let query_attempts = github_attempts\n .into_iter()\n .chain(arxiv_attempts)\n .collect::>();\n let source_lineage = github_lineage\n .into_iter()\n .chain(arxiv_lineage)\n .collect::>();\n for attempt in &query_attempts {\n trace\n .append(&TraceEvent::ToolCallFinished {\n tool: attempt.source.clone(),\n result_count: attempt.result_count,\n error: attempt.error.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n if github_repos.is_empty() && arxiv_papers.is_empty() {\n let message = \"GitHub 与 arXiv 均未返回可用结果,无法生成 stateful report。\".to_string();\n run.warnings.push(message.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::Workflow(message));\n }\n\n let query = SearchQuery {\n topic: run.topic.clone(),\n github_limit: run.policy.github_budget,\n arxiv_limit: run.policy.arxiv_budget,\n };\n let evidence = evidence::build_evidence_memory(\n &query,\n &app_config,\n github_repos,\n arxiv_papers,\n query_attempts,\n source_lineage,\n )?;\n let ranked_total = evidence.ranked_items.len();\n let group_total = evidence.groups.len();\n let coverage = if run.policy.skip_coverage_critic {\n CoverageReport::pass()\n } else {\n coverage_critic::evaluate_coverage(\n &run.chapters,\n &run.query_portfolio,\n &evidence.memory,\n &run.policy,\n )\n };\n for gap in &coverage.gaps {\n trace\n .append(&TraceEvent::CoverageGapDetected {\n chapter_id: gap.chapter_id.clone(),\n gap_kind: format!(\"{:?}\", gap.gap_kind),\n at: Utc::now(),\n })\n .await?;\n }\n run.evidence_memory = Some(evidence.memory.clone());\n run.coverage_report = Some(coverage.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::EvidenceReady,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::EvidenceReady {\n total: evidence.memory.items.len(),\n })\n .await;\n let _ = tx\n .send(StatefulRunEvent::CoverageReady {\n gaps: coverage.gaps.len(),\n })\n .await;\n }\n trace\n .append(&TraceEvent::QualityWarning {\n message: format!(\n \"EvidenceBuilder ranked {ranked_total} item(s) into {group_total} group(s).\"\n ),\n at: Utc::now(),\n })\n .await?;\n\n let writer_input = serde_json::to_string(&(&run.topic, &run.chapters, &evidence.memory))?;\n let writer_ctx = agent_context(\"writer\", writer_input.clone(), &run.policy, Vec::new());\n if !llm_config_can_call(&llm_config) {\n let message = \"Writer LLM 需要 DeepSeek API key;拒绝生成模板化最终报告。\".to_string();\n run.warnings.push(message.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::InvalidConfig(message));\n }\n let writer_model = llm_config.main_model.clone();\n let writer_budget = TokenBudgetTracker::new();\n writer_budget.before_llm_call(&writer_ctx)?;\n trace_llm_started(&trace, \"writer\", writer_model, &writer_input).await?;\n let draft_result =\n writer::draft_report_with_llm(&run.topic, &run.chapters, &evidence.memory, &llm_config)\n .await;\n let draft = match draft_result {\n Ok(draft) => draft,\n Err(err) => {\n let message = format!(\"Writer LLM 写作失败,拒绝生成模板化最终报告:{err}\");\n run.warnings.push(message.clone());\n trace\n .append(&TraceEvent::QualityWarning {\n message: message.clone(),\n at: Utc::now(),\n })\n .await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::Llm(message));\n }\n };\n let draft_json = serde_json::to_string(&draft)?;\n trace_llm_finished(&trace, \"writer\", &writer_input, &draft_json).await?;\n let audit = if run.policy.require_citation_audit {\n citation_auditor::audit_citations(&draft, &evidence.citations, &evidence.memory)\n } else {\n crate::model::CitationAuditReport::pass()\n };\n let markdown = writer::render_report_markdown(&draft, &evidence.citations);\n let citation_ctx = agent_context(\n \"writer\",\n \"render_report_markdown\".to_string(),\n &run.policy,\n evidence\n .citations\n .citations\n .iter()\n .map(|citation| citation.url.clone())\n .collect(),\n );\n CitationGuard.after_llm_call(&citation_ctx, &markdown)?;\n run.report_draft = Some(draft);\n run.citation_audit = Some(audit.clone());\n run.report_markdown = Some(markdown.clone());\n if !audit.url_whitelist_passed {\n run.warnings\n .push(\"Citation audit failed URL whitelist check.\".to_string());\n }\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::SynthesisReady,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::CitationAuditReady {\n citation_coverage_ratio: audit.citation_coverage_ratio,\n })\n .await;\n }\n\n write_report_file(&app_config, run, &markdown).await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Completed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::Completed {\n run_id: run.run_id.clone(),\n })\n .await;\n }\n Ok(run.clone())\n}\n\npub async fn load_run(app_config: &AppConfig, run_id: &str) -> Result {\n let body = tokio::fs::read_to_string(run_summary_path(app_config, run_id)).await?;\n Ok(serde_json::from_str(&body)?)\n}\n\npub async fn save_run(app_config: &AppConfig, run: &ResearchRunRecord) -> Result<()> {\n tokio::fs::create_dir_all(&app_config.session_dir).await?;\n let body = serde_json::to_string_pretty(run)?;\n tokio::fs::write(run_summary_path(app_config, &run.run_id), body).await?;\n Ok(())\n}\n\npub async fn evidence_for_run(app_config: &AppConfig, run_id: &str) -> Result {\n load_run(app_config, run_id)\n .await?\n .evidence_memory\n .ok_or_else(|| AppError::Workflow(\"run has no EvidenceMemory yet\".to_string()))\n}\n\npub async fn coverage_for_run(app_config: &AppConfig, run_id: &str) -> Result {\n load_run(app_config, run_id)\n .await?\n .coverage_report\n .ok_or_else(|| AppError::Workflow(\"run has no CoverageReport yet\".to_string()))\n}\n\npub async fn revise_plan(\n app_config: &AppConfig,\n run_id: &str,\n chapters: Option>,\n query_portfolio: Option>,\n user_feedback: Option,\n) -> Result {\n let mut run = load_run(app_config, run_id).await?;\n if !matches!(run.state, ResearchRunState::PlanReady) {\n return Err(AppError::Workflow(\n \"only PlanReady runs can revise the Stage 3 plan\".to_string(),\n ));\n }\n if let Some(chapters) = chapters {\n if chapters.is_empty() {\n return Err(AppError::Workflow(\n \"revised chapter plan cannot be empty\".to_string(),\n ));\n }\n run.chapters = chapters;\n }\n if let Some(query_portfolio) = query_portfolio {\n if query_portfolio.is_empty() {\n return Err(AppError::Workflow(\n \"revised query portfolio cannot be empty\".to_string(),\n ));\n }\n run.query_portfolio = query_portfolio;\n }\n if let Some(feedback) = user_feedback.map(|value| value.trim().to_string()) {\n if !feedback.is_empty() {\n run.plan_warnings\n .push(format!(\"用户修订计划备注:{feedback}\"));\n }\n }\n run.updated_at = Utc::now();\n let run_dir = run_dir(app_config, run_id);\n let trace = TraceWriter::new(&run_dir).await?;\n trace\n .append(&TraceEvent::QualityWarning {\n message: \"用户已修订 PlanReady 搜索计划,并创建新的 checkpoint。\".to_string(),\n at: Utc::now(),\n })\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, &run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(app_config, &run).await?;\n Ok(run)\n}\n\npub async fn branch_from_plan_ready(\n app_config: AppConfig,\n run_id: &str,\n checkpoint_id: &str,\n) -> Result {\n let snapshot = load_checkpoint_snapshot_by_id(&app_config, run_id, checkpoint_id).await?;\n if !snapshot.checkpoint.rollback_allowed\n || !matches!(snapshot.checkpoint.state, ResearchRunState::PlanReady)\n {\n return Err(AppError::Workflow(\n \"Stage 3 currently supports rollback branches only from PlanReady checkpoints\"\n .to_string(),\n ));\n }\n let mut branch = snapshot.run;\n branch.run_id = format!(\"run-{}\", Uuid::new_v4());\n branch.created_at = Utc::now();\n branch.updated_at = Utc::now();\n branch.state = ResearchRunState::PlanReady;\n branch.origin_run_id = Some(run_id.to_string());\n branch.origin_checkpoint_id = Some(checkpoint_id.to_string());\n branch.evidence_memory = None;\n branch.coverage_report = None;\n branch.report_draft = None;\n branch.citation_audit = None;\n branch.report_markdown = None;\n branch.output_report = None;\n let branch_dir = run_dir(&app_config, &branch.run_id);\n tokio::fs::create_dir_all(&branch_dir).await?;\n let trace = TraceWriter::new(&branch_dir).await?;\n trace\n .append(&TraceEvent::RollbackBranchCreated {\n origin_run_id: run_id.to_string(),\n new_run_id: branch.run_id.clone(),\n at: Utc::now(),\n })\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&branch_dir, &branch).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(&app_config, &branch).await?;\n Ok(branch)\n}\n\nasync fn load_checkpoint_snapshot_by_id(\n app_config: &AppConfig,\n run_id: &str,\n checkpoint_id: &str,\n) -> Result {\n let run_dir = run_dir(app_config, run_id);\n let checkpoint_dir = run_dir.join(\"checkpoints\");\n let mut entries = tokio::fs::read_dir(checkpoint_dir).await?;\n while let Some(entry) = entries.next_entry().await? {\n let body = tokio::fs::read_to_string(entry.path()).await?;\n let snapshot: CheckpointSnapshot = serde_json::from_str(&body)?;\n if snapshot.checkpoint.checkpoint_id == checkpoint_id {\n return checkpoint::read_checkpoint(&run_dir, &snapshot.checkpoint).await;\n }\n }\n Err(AppError::Workflow(format!(\n \"checkpoint `{checkpoint_id}` was not found for run `{run_id}`\"\n )))\n}\n\npub fn route_followup_for_run(\n question: &str,\n memory: Option<&EvidenceMemory>,\n report_markdown: Option<&str>,\n) -> followup_router::FollowupRoute {\n match (memory, report_markdown) {\n (Some(memory), Some(markdown)) => {\n followup_router::route_followup(question, memory, markdown)\n }\n _ => followup_router::FollowupRoute::IncrementalResearchRequired {\n reason: \"当前 run 尚未完成 evidence/report,无法基于现有证据回答。\".to_string(),\n },\n }\n}\n\nasync fn transition(\n run: &mut ResearchRunRecord,\n trace: &TraceWriter,\n app_config: &AppConfig,\n next: ResearchRunState,\n tx: Option<&mpsc::Sender>,\n) -> Result<()> {\n let from = run.state.clone();\n if !run.transition_to(next.clone()) {\n return Err(invalid_transition(&from, &next));\n }\n trace_transition(trace, from, next.clone()).await?;\n save_run(app_config, run).await?;\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::StateChanged { state: next })\n .await;\n }\n Ok(())\n}\n\nasync fn trace_transition(\n trace: &TraceWriter,\n from: ResearchRunState,\n to: ResearchRunState,\n) -> Result<()> {\n trace\n .append(&TraceEvent::StateTransition {\n from,\n to,\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_llm_started(\n trace: &TraceWriter,\n actor: &str,\n model: String,\n input: &str,\n) -> Result<()> {\n trace\n .append(&TraceEvent::LlmRequestStarted {\n actor: actor.to_string(),\n model,\n input_hash: stable_hash(input),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_llm_finished(\n trace: &TraceWriter,\n actor: &str,\n input: &str,\n output: &str,\n) -> Result<()> {\n let (_input_hash, output_hash, tokens) = decision_hash(input, output);\n trace\n .append(&TraceEvent::LlmRequestFinished {\n actor: actor.to_string(),\n output_hash,\n token_estimate: tokens,\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_agent_decision(\n trace: &TraceWriter,\n actor: &str,\n input: &str,\n output: &str,\n rationale: &str,\n) -> Result<()> {\n let (input_hash, output_hash, _tokens) = decision_hash(input, output);\n trace\n .append(&TraceEvent::AgentDecision {\n actor: actor.to_string(),\n input_hash,\n output_hash,\n rationale: rationale.to_string(),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_checkpoint(trace: &TraceWriter, checkpoint: &Checkpoint) -> Result<()> {\n trace\n .append(&TraceEvent::CheckpointCreated {\n checkpoint_id: checkpoint.checkpoint_id.clone(),\n state: checkpoint.state.clone(),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn send_checkpoint(tx: Option<&mpsc::Sender>, checkpoint: &Checkpoint) {\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::CheckpointCreated {\n checkpoint_id: checkpoint.checkpoint_id.clone(),\n state: checkpoint.state.clone(),\n })\n .await;\n }\n}\n\nasync fn trace_planned_tool_calls(trace: &TraceWriter, portfolio: &[QueryPortfolio]) -> Result<()> {\n for item in portfolio {\n for query in &item.github_queries {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"github\".to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n for query in &item.arxiv_queries {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"arxiv\".to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n }\n Ok(())\n}\n\nfn llm_config_can_call(llm_config: &LlmConfig) -> bool {\n llm_config.enabled\n && llm_config\n .api_key\n .as_deref()\n .map(str::trim)\n .is_some_and(|key| !key.is_empty())\n}\n\nfn fallback_chapter_plan(\n topic: &str,\n policy: &RunPolicy,\n warning: Option<&str>,\n) -> planner::ChapterPlanOutput {\n let default_plan = planner::default_plan(topic, policy.github_budget, policy.arxiv_budget);\n let mut plan = planner::chapter_plan_from_plan_output(&default_plan, policy);\n if let Some(warning) = warning {\n plan.warnings.push(warning.to_string());\n }\n plan\n}\n\nfn agent_context(\n actor: &str,\n input: String,\n policy: &RunPolicy,\n allowed_urls: Vec,\n) -> AgentContext {\n AgentContext {\n actor: actor.to_string(),\n input,\n allowed_urls,\n max_llm_calls: policy.max_llm_calls_per_run,\n }\n}\n\nfn side_model_name(llm_config: &LlmConfig) -> String {\n llm_config\n .side_model\n .clone()\n .unwrap_or_else(|| llm_config.main_model.clone())\n}\n\nfn invalid_transition(from: &ResearchRunState, to: &ResearchRunState) -> AppError {\n AppError::Workflow(format!(\"invalid state transition from {from:?} to {to:?}\"))\n}\n\nfn run_summary_path(app_config: &AppConfig, run_id: &str) -> PathBuf {\n app_config.session_dir.join(format!(\"{run_id}.json\"))\n}\n\nfn run_dir(app_config: &AppConfig, run_id: &str) -> PathBuf {\n app_config.session_dir.join(run_id)\n}\n\nasync fn write_report_file(\n app_config: &AppConfig,\n run: &mut ResearchRunRecord,\n markdown: &str,\n) -> Result<()> {\n let path = resolve_report_path(&app_config.output, &run.topic);\n if let Some(parent) = path.parent() {\n tokio::fs::create_dir_all(parent).await?;\n }\n tokio::fs::write(&path, markdown).await?;\n run.output_report = Some(path.display().to_string());\n Ok(())\n}\n\nfn resolve_report_path(output: &Path, topic: &str) -> PathBuf {\n if output.extension().is_some() {\n return output.to_path_buf();\n }\n output.join(format!(\n \"{}-stage3-{}.md\",\n slugify(topic),\n Utc::now().format(\"%Y%m%d-%H%M%S\")\n ))\n}\n\nfn slugify(topic: &str) -> String {\n let mut slug = String::new();\n let mut last_dash = false;\n for ch in topic.chars().flat_map(char::to_lowercase) {\n if ch.is_ascii_alphanumeric() {\n slug.push(ch);\n last_dash = false;\n } else if !last_dash {\n slug.push('-');\n last_dash = true;\n }\n }\n let slug = slug.trim_matches('-');\n if slug.is_empty() {\n \"report\".to_string()\n } else {\n slug.to_string()\n }\n}\n\npub fn decision_hash(input: &str, output: &str) -> (String, String, usize) {\n (\n stable_hash(input),\n stable_hash(output),\n token_estimate(input) + token_estimate(output),\n )\n}\n\n#[cfg(test)]\nmod tests {\n use std::time::{SystemTime, UNIX_EPOCH};\n\n use super::create_run;\n use crate::config::{AppConfig, LlmConfig};\n use crate::run_policy::RunPolicy;\n use crate::workflow_state::ResearchRunState;\n\n #[tokio::test]\n async fn create_run_falls_back_without_llm_key_and_traces_rule_nodes() {\n let root = temp_root(\"stage3-create-run-fallback\");\n let app_config = AppConfig {\n github_token: None,\n output: root.join(\"reports\"),\n cache_dir: root.join(\"cache\"),\n session_dir: root.join(\"sessions\"),\n tags_file: None,\n use_cache: false,\n cache_ttl_hours: 24,\n timeout_secs: 30,\n enrich: false,\n };\n let llm_config = LlmConfig {\n enabled: true,\n api_key: None,\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n main_model: \"deepseek-v4-pro\".to_string(),\n side_model: Some(\"deepseek-v4-flash\".to_string()),\n max_tokens: 4096,\n timeout_secs: 30,\n };\n\n let run = create_run(\n \"Rust agent framework\".to_string(),\n app_config.clone(),\n llm_config,\n RunPolicy::default(),\n )\n .await\n .expect(\"create_run should degrade to a deterministic plan\");\n\n assert_eq!(run.state, ResearchRunState::PlanReady);\n assert!(run\n .plan_warnings\n .iter()\n .any(|warning| warning.contains(\"API key\")));\n assert!(run\n .warnings\n .iter()\n .any(|warning| warning.contains(\"0 real LLM request attempt\")));\n\n let trace_path = app_config.session_dir.join(&run.run_id).join(\"trace.jsonl\");\n let trace = std::fs::read_to_string(trace_path).expect(\"trace should be readable\");\n assert!(trace.contains(\"\\\"event\\\":\\\"agent_decision\\\"\"));\n assert!(trace.contains(\"\\\"actor\\\":\\\"scoper\\\"\"));\n assert!(trace.contains(\"\\\"actor\\\":\\\"plan_critic\\\"\"));\n assert!(!trace.contains(\"\\\"event\\\":\\\"llm_request_started\\\"\"));\n }\n\n fn temp_root(name: &str) -> std::path::PathBuf {\n let unique = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n std::env::temp_dir().join(format!(\n \"litscout-rs-{name}-{}-{unique}\",\n std::process::id()\n ))\n }\n}\n"}, "1d352f50bc41fcab4e021216bca591d876481974": {"t": 1785750667.6862836, "n": 27615, "h": "d952740a326690ef", "c": "use std::path::{Path, PathBuf};\n\nuse chrono::Utc;\nuse tokio::sync::mpsc;\nuse uuid::Uuid;\n\nuse crate::agent::{\n arxiv_scout, citation_auditor, coverage_critic, evidence, followup_router, github_scout,\n middleware::{\n AgentContext, AgentMiddleware, CitationGuard, JsonSchemaGuard, TokenBudgetTracker,\n },\n plan_critic, planner, scoper, writer,\n};\nuse crate::checkpoint::{self, Checkpoint, CheckpointSnapshot};\nuse crate::config::{AppConfig, LlmConfig};\nuse crate::error::{AppError, Result};\nuse crate::model::{\n ChapterNode, CoverageReport, EvidenceMemory, QueryAttempt, QueryPortfolio, SearchQuery,\n};\nuse crate::run_policy::RunPolicy;\nuse crate::trace::{stable_hash, token_estimate, TraceEvent, TraceWriter};\nuse crate::workflow_state::{ResearchRunRecord, ResearchRunState};\n\n#[derive(Debug, Clone, serde::Serialize)]\n#[serde(tag = \"event\", content = \"data\", rename_all = \"snake_case\")]\npub enum StatefulRunEvent {\n StateChanged {\n state: ResearchRunState,\n },\n CheckpointCreated {\n checkpoint_id: String,\n state: ResearchRunState,\n },\n EvidenceReady {\n total: usize,\n },\n CoverageReady {\n gaps: usize,\n },\n CitationAuditReady {\n citation_coverage_ratio: f64,\n },\n Completed {\n run_id: String,\n },\n Failed {\n error: String,\n },\n}\n\npub async fn create_run(\n topic: String,\n app_config: AppConfig,\n llm_config: LlmConfig,\n policy: RunPolicy,\n) -> Result {\n let run_id = format!(\"run-{}\", Uuid::new_v4());\n let run_dir = run_dir(&app_config, &run_id);\n tokio::fs::create_dir_all(&run_dir).await?;\n let trace = TraceWriter::new(&run_dir).await?;\n let mut run = ResearchRunRecord::new(run_id, topic.clone(), policy.bounded());\n save_run(&app_config, &run).await?;\n let token_budget = TokenBudgetTracker::new();\n let json_guard = JsonSchemaGuard;\n\n let scoper_input = format!(\"topic={topic}; policy={:?}\", run.policy);\n let scoper_ctx = agent_context(\"scoper\", scoper_input.clone(), &run.policy, Vec::new());\n let brief = scoper::generate_research_brief(&topic, &run.policy, &llm_config).await?;\n let brief_json = serde_json::to_string(&brief)?;\n json_guard.after_llm_call(&scoper_ctx, &brief_json)?;\n trace_agent_decision(\n &trace,\n \"scoper\",\n &scoper_input,\n &brief_json,\n \"deterministic ResearchBrief scoping\",\n )\n .await?;\n\n let planner_input = serde_json::to_string(&brief)?;\n let planner_ctx = agent_context(\"planner\", planner_input.clone(), &run.policy, Vec::new());\n let plan = if llm_config_can_call(&llm_config) {\n token_budget.before_llm_call(&planner_ctx)?;\n trace_llm_started(\n &trace,\n \"planner\",\n side_model_name(&llm_config),\n &planner_input,\n )\n .await?;\n match planner::generate_chapter_plan(&brief, &run.policy, &llm_config).await {\n Ok(plan) => {\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n token_budget.after_llm_call(&planner_ctx, &plan_json)?;\n trace_llm_finished(&trace, \"planner\", &planner_input, &plan_json).await?;\n plan\n }\n Err(err) => {\n let reason = err.to_string();\n trace\n .append(&TraceEvent::QualityWarning {\n message: format!(\"Planner LLM 调用失败,已降级为确定性默认计划:{reason}\"),\n at: Utc::now(),\n })\n .await?;\n let plan = fallback_chapter_plan(&brief.topic, &run.policy, Some(&reason));\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n trace_agent_decision(\n &trace,\n \"planner_fallback\",\n &planner_input,\n &plan_json,\n \"planner LLM failed; deterministic default plan\",\n )\n .await?;\n plan\n }\n }\n } else {\n let reason = llm_config\n .enabled\n .then_some(\"LLM 已启用但缺少 API key,已使用确定性默认计划。\");\n let plan = fallback_chapter_plan(&brief.topic, &run.policy, reason);\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n trace_agent_decision(\n &trace,\n \"planner\",\n &planner_input,\n &plan_json,\n \"deterministic default plan\",\n )\n .await?;\n plan\n };\n\n let critic_input = serde_json::to_string(&(&brief, &plan.chapters, &plan.query_portfolio))?;\n let critic_ctx = agent_context(\"plan_critic\", critic_input.clone(), &run.policy, Vec::new());\n let critique =\n plan_critic::critique_plan(&brief, &plan.chapters, &plan.query_portfolio, &run.policy);\n let critique_json = serde_json::to_string(&critique)?;\n json_guard.after_llm_call(&critic_ctx, &critique_json)?;\n trace_agent_decision(\n &trace,\n \"plan_critic\",\n &critic_input,\n &critique_json,\n \"deterministic plan consistency checks\",\n )\n .await?;\n let from = run.state.clone();\n if !run.transition_to(ResearchRunState::PlanReady) {\n return Err(invalid_transition(&from, &ResearchRunState::PlanReady));\n }\n run.brief = Some(brief);\n run.chapters = plan.chapters;\n run.query_portfolio = plan.query_portfolio;\n run.plan_warnings = plan\n .warnings\n .into_iter()\n .chain(critique.warnings)\n .chain(critique.suggestions)\n .collect();\n run.warnings.push(format!(\n \"Stage 3 agent planning used {} real LLM request attempt(s); deterministic nodes are traced as AgentDecision events.\",\n token_budget.calls()\n ));\n trace_transition(&trace, from, ResearchRunState::PlanReady).await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, &run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(&app_config, &run).await?;\n Ok(run)\n}\n\npub async fn continue_run(\n run_id: &str,\n app_config: AppConfig,\n llm_config: LlmConfig,\n tx: Option>,\n) -> Result {\n let mut run = load_run(&app_config, run_id).await?;\n match run.state {\n ResearchRunState::PlanReady => {\n continue_from_plan_ready(&mut run, app_config, llm_config, tx).await\n }\n ResearchRunState::Completed => Ok(run),\n _ => Err(AppError::Workflow(format!(\n \"run `{run_id}` is in state {:?} and cannot continue from this endpoint\",\n run.state\n ))),\n }\n}\n\nasync fn continue_from_plan_ready(\n run: &mut ResearchRunRecord,\n app_config: AppConfig,\n _llm_config: LlmConfig,\n tx: Option>,\n) -> Result {\n let run_dir = run_dir(&app_config, &run.run_id);\n let trace = TraceWriter::new(&run_dir).await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Fetching,\n tx.as_ref(),\n )\n .await?;\n trace_planned_tool_calls(&trace, &run.query_portfolio).await?;\n\n let (github_result, arxiv_result) = tokio::join!(\n github_scout::scout_github(&run.query_portfolio, &app_config, 1),\n arxiv_scout::scout_arxiv(&run.query_portfolio, &app_config, 1)\n );\n let (github_repos, github_attempts) = github_result?;\n let (arxiv_papers, arxiv_attempts) = arxiv_result?;\n let query_attempts = github_attempts\n .into_iter()\n .chain(arxiv_attempts)\n .collect::>();\n for attempt in &query_attempts {\n trace\n .append(&TraceEvent::ToolCallFinished {\n tool: attempt.source.clone(),\n result_count: attempt.result_count,\n error: attempt.error.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n if github_repos.is_empty() && arxiv_papers.is_empty() {\n let message = \"GitHub 与 arXiv 均未返回可用结果,无法生成 stateful report。\".to_string();\n run.warnings.push(message.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::Workflow(message));\n }\n\n let query = SearchQuery {\n topic: run.topic.clone(),\n github_limit: run.policy.github_budget,\n arxiv_limit: run.policy.arxiv_budget,\n };\n let evidence = evidence::build_evidence_memory(\n &query,\n &app_config,\n github_repos,\n arxiv_papers,\n query_attempts,\n )?;\n let ranked_total = evidence.ranked_items.len();\n let group_total = evidence.groups.len();\n let coverage = if run.policy.skip_coverage_critic {\n CoverageReport::pass()\n } else {\n coverage_critic::evaluate_coverage(\n &run.chapters,\n &run.query_portfolio,\n &evidence.memory,\n &run.policy,\n )\n };\n for gap in &coverage.gaps {\n trace\n .append(&TraceEvent::CoverageGapDetected {\n chapter_id: gap.chapter_id.clone(),\n gap_kind: format!(\"{:?}\", gap.gap_kind),\n at: Utc::now(),\n })\n .await?;\n }\n run.evidence_memory = Some(evidence.memory.clone());\n run.coverage_report = Some(coverage.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::EvidenceReady,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::EvidenceReady {\n total: evidence.memory.items.len(),\n })\n .await;\n let _ = tx\n .send(StatefulRunEvent::CoverageReady {\n gaps: coverage.gaps.len(),\n })\n .await;\n }\n trace\n .append(&TraceEvent::QualityWarning {\n message: format!(\n \"EvidenceBuilder ranked {ranked_total} item(s) into {group_total} group(s).\"\n ),\n at: Utc::now(),\n })\n .await?;\n\n let draft = writer::draft_report(&run.topic, &run.chapters, &evidence.memory);\n let audit = if run.policy.require_citation_audit {\n citation_auditor::audit_citations(&draft, &evidence.citations, &evidence.memory)\n } else {\n crate::model::CitationAuditReport::pass()\n };\n let markdown = writer::render_report_markdown(&draft, &evidence.citations);\n let citation_ctx = agent_context(\n \"writer\",\n \"render_report_markdown\".to_string(),\n &run.policy,\n evidence\n .citations\n .citations\n .iter()\n .map(|citation| citation.url.clone())\n .collect(),\n );\n CitationGuard.after_llm_call(&citation_ctx, &markdown)?;\n run.report_draft = Some(draft);\n run.citation_audit = Some(audit.clone());\n run.report_markdown = Some(markdown.clone());\n if !audit.url_whitelist_passed {\n run.warnings\n .push(\"Citation audit failed URL whitelist check.\".to_string());\n }\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::SynthesisReady,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::CitationAuditReady {\n citation_coverage_ratio: audit.citation_coverage_ratio,\n })\n .await;\n }\n\n write_report_file(&app_config, run, &markdown).await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Completed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::Completed {\n run_id: run.run_id.clone(),\n })\n .await;\n }\n Ok(run.clone())\n}\n\npub async fn load_run(app_config: &AppConfig, run_id: &str) -> Result {\n let body = tokio::fs::read_to_string(run_summary_path(app_config, run_id)).await?;\n Ok(serde_json::from_str(&body)?)\n}\n\npub async fn save_run(app_config: &AppConfig, run: &ResearchRunRecord) -> Result<()> {\n tokio::fs::create_dir_all(&app_config.session_dir).await?;\n let body = serde_json::to_string_pretty(run)?;\n tokio::fs::write(run_summary_path(app_config, &run.run_id), body).await?;\n Ok(())\n}\n\npub async fn evidence_for_run(app_config: &AppConfig, run_id: &str) -> Result {\n load_run(app_config, run_id)\n .await?\n .evidence_memory\n .ok_or_else(|| AppError::Workflow(\"run has no EvidenceMemory yet\".to_string()))\n}\n\npub async fn coverage_for_run(app_config: &AppConfig, run_id: &str) -> Result {\n load_run(app_config, run_id)\n .await?\n .coverage_report\n .ok_or_else(|| AppError::Workflow(\"run has no CoverageReport yet\".to_string()))\n}\n\npub async fn revise_plan(\n app_config: &AppConfig,\n run_id: &str,\n chapters: Option>,\n query_portfolio: Option>,\n user_feedback: Option,\n) -> Result {\n let mut run = load_run(app_config, run_id).await?;\n if !matches!(run.state, ResearchRunState::PlanReady) {\n return Err(AppError::Workflow(\n \"only PlanReady runs can revise the Stage 3 plan\".to_string(),\n ));\n }\n if let Some(chapters) = chapters {\n if chapters.is_empty() {\n return Err(AppError::Workflow(\n \"revised chapter plan cannot be empty\".to_string(),\n ));\n }\n run.chapters = chapters;\n }\n if let Some(query_portfolio) = query_portfolio {\n if query_portfolio.is_empty() {\n return Err(AppError::Workflow(\n \"revised query portfolio cannot be empty\".to_string(),\n ));\n }\n run.query_portfolio = query_portfolio;\n }\n if let Some(feedback) = user_feedback.map(|value| value.trim().to_string()) {\n if !feedback.is_empty() {\n run.plan_warnings\n .push(format!(\"用户修订计划备注:{feedback}\"));\n }\n }\n run.updated_at = Utc::now();\n let run_dir = run_dir(app_config, run_id);\n let trace = TraceWriter::new(&run_dir).await?;\n trace\n .append(&TraceEvent::QualityWarning {\n message: \"用户已修订 PlanReady 搜索计划,并创建新的 checkpoint。\".to_string(),\n at: Utc::now(),\n })\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, &run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(app_config, &run).await?;\n Ok(run)\n}\n\npub async fn branch_from_plan_ready(\n app_config: AppConfig,\n run_id: &str,\n checkpoint_id: &str,\n) -> Result {\n let snapshot = load_checkpoint_snapshot_by_id(&app_config, run_id, checkpoint_id).await?;\n if !snapshot.checkpoint.rollback_allowed\n || !matches!(snapshot.checkpoint.state, ResearchRunState::PlanReady)\n {\n return Err(AppError::Workflow(\n \"Stage 3 currently supports rollback branches only from PlanReady checkpoints\"\n .to_string(),\n ));\n }\n let mut branch = snapshot.run;\n branch.run_id = format!(\"run-{}\", Uuid::new_v4());\n branch.created_at = Utc::now();\n branch.updated_at = Utc::now();\n branch.state = ResearchRunState::PlanReady;\n branch.origin_run_id = Some(run_id.to_string());\n branch.origin_checkpoint_id = Some(checkpoint_id.to_string());\n branch.evidence_memory = None;\n branch.coverage_report = None;\n branch.report_draft = None;\n branch.citation_audit = None;\n branch.report_markdown = None;\n branch.output_report = None;\n let branch_dir = run_dir(&app_config, &branch.run_id);\n tokio::fs::create_dir_all(&branch_dir).await?;\n let trace = TraceWriter::new(&branch_dir).await?;\n trace\n .append(&TraceEvent::RollbackBranchCreated {\n origin_run_id: run_id.to_string(),\n new_run_id: branch.run_id.clone(),\n at: Utc::now(),\n })\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&branch_dir, &branch).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(&app_config, &branch).await?;\n Ok(branch)\n}\n\nasync fn load_checkpoint_snapshot_by_id(\n app_config: &AppConfig,\n run_id: &str,\n checkpoint_id: &str,\n) -> Result {\n let run_dir = run_dir(app_config, run_id);\n let checkpoint_dir = run_dir.join(\"checkpoints\");\n let mut entries = tokio::fs::read_dir(checkpoint_dir).await?;\n while let Some(entry) = entries.next_entry().await? {\n let body = tokio::fs::read_to_string(entry.path()).await?;\n let snapshot: CheckpointSnapshot = serde_json::from_str(&body)?;\n if snapshot.checkpoint.checkpoint_id == checkpoint_id {\n return checkpoint::read_checkpoint(&run_dir, &snapshot.checkpoint).await;\n }\n }\n Err(AppError::Workflow(format!(\n \"checkpoint `{checkpoint_id}` was not found for run `{run_id}`\"\n )))\n}\n\npub fn route_followup_for_run(\n question: &str,\n memory: Option<&EvidenceMemory>,\n report_markdown: Option<&str>,\n) -> followup_router::FollowupRoute {\n match (memory, report_markdown) {\n (Some(memory), Some(markdown)) => {\n followup_router::route_followup(question, memory, markdown)\n }\n _ => followup_router::FollowupRoute::IncrementalResearchRequired {\n reason: \"当前 run 尚未完成 evidence/report,无法基于现有证据回答。\".to_string(),\n },\n }\n}\n\nasync fn transition(\n run: &mut ResearchRunRecord,\n trace: &TraceWriter,\n app_config: &AppConfig,\n next: ResearchRunState,\n tx: Option<&mpsc::Sender>,\n) -> Result<()> {\n let from = run.state.clone();\n if !run.transition_to(next.clone()) {\n return Err(invalid_transition(&from, &next));\n }\n trace_transition(trace, from, next.clone()).await?;\n save_run(app_config, run).await?;\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::StateChanged { state: next })\n .await;\n }\n Ok(())\n}\n\nasync fn trace_transition(\n trace: &TraceWriter,\n from: ResearchRunState,\n to: ResearchRunState,\n) -> Result<()> {\n trace\n .append(&TraceEvent::StateTransition {\n from,\n to,\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_llm_started(\n trace: &TraceWriter,\n actor: &str,\n model: String,\n input: &str,\n) -> Result<()> {\n trace\n .append(&TraceEvent::LlmRequestStarted {\n actor: actor.to_string(),\n model,\n input_hash: stable_hash(input),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_llm_finished(\n trace: &TraceWriter,\n actor: &str,\n input: &str,\n output: &str,\n) -> Result<()> {\n let (_input_hash, output_hash, tokens) = decision_hash(input, output);\n trace\n .append(&TraceEvent::LlmRequestFinished {\n actor: actor.to_string(),\n output_hash,\n token_estimate: tokens,\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_agent_decision(\n trace: &TraceWriter,\n actor: &str,\n input: &str,\n output: &str,\n rationale: &str,\n) -> Result<()> {\n let (input_hash, output_hash, _tokens) = decision_hash(input, output);\n trace\n .append(&TraceEvent::AgentDecision {\n actor: actor.to_string(),\n input_hash,\n output_hash,\n rationale: rationale.to_string(),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_checkpoint(trace: &TraceWriter, checkpoint: &Checkpoint) -> Result<()> {\n trace\n .append(&TraceEvent::CheckpointCreated {\n checkpoint_id: checkpoint.checkpoint_id.clone(),\n state: checkpoint.state.clone(),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn send_checkpoint(tx: Option<&mpsc::Sender>, checkpoint: &Checkpoint) {\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::CheckpointCreated {\n checkpoint_id: checkpoint.checkpoint_id.clone(),\n state: checkpoint.state.clone(),\n })\n .await;\n }\n}\n\nasync fn trace_planned_tool_calls(trace: &TraceWriter, portfolio: &[QueryPortfolio]) -> Result<()> {\n for item in portfolio {\n for query in &item.github_queries {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"github\".to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n for query in &item.arxiv_queries {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"arxiv\".to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n }\n Ok(())\n}\n\nfn llm_config_can_call(llm_config: &LlmConfig) -> bool {\n llm_config.enabled\n && llm_config\n .api_key\n .as_deref()\n .map(str::trim)\n .is_some_and(|key| !key.is_empty())\n}\n\nfn fallback_chapter_plan(\n topic: &str,\n policy: &RunPolicy,\n warning: Option<&str>,\n) -> planner::ChapterPlanOutput {\n let default_plan = planner::default_plan(topic, policy.github_budget, policy.arxiv_budget);\n let mut plan = planner::chapter_plan_from_plan_output(&default_plan, policy);\n if let Some(warning) = warning {\n plan.warnings.push(warning.to_string());\n }\n plan\n}\n\nfn agent_context(\n actor: &str,\n input: String,\n policy: &RunPolicy,\n allowed_urls: Vec,\n) -> AgentContext {\n AgentContext {\n actor: actor.to_string(),\n input,\n allowed_urls,\n max_llm_calls: policy.max_llm_calls_per_run,\n }\n}\n\nfn side_model_name(llm_config: &LlmConfig) -> String {\n llm_config\n .side_model\n .clone()\n .unwrap_or_else(|| llm_config.main_model.clone())\n}\n\nfn invalid_transition(from: &ResearchRunState, to: &ResearchRunState) -> AppError {\n AppError::Workflow(format!(\"invalid state transition from {from:?} to {to:?}\"))\n}\n\nfn run_summary_path(app_config: &AppConfig, run_id: &str) -> PathBuf {\n app_config.session_dir.join(format!(\"{run_id}.json\"))\n}\n\nfn run_dir(app_config: &AppConfig, run_id: &str) -> PathBuf {\n app_config.session_dir.join(run_id)\n}\n\nasync fn write_report_file(\n app_config: &AppConfig,\n run: &mut ResearchRunRecord,\n markdown: &str,\n) -> Result<()> {\n let path = resolve_report_path(&app_config.output, &run.topic);\n if let Some(parent) = path.parent() {\n tokio::fs::create_dir_all(parent).await?;\n }\n tokio::fs::write(&path, markdown).await?;\n run.output_report = Some(path.display().to_string());\n Ok(())\n}\n\nfn resolve_report_path(output: &Path, topic: &str) -> PathBuf {\n if output.extension().is_some() {\n return output.to_path_buf();\n }\n output.join(format!(\n \"{}-stage3-{}.md\",\n slugify(topic),\n Utc::now().format(\"%Y%m%d-%H%M%S\")\n ))\n}\n\nfn slugify(topic: &str) -> String {\n let mut slug = String::new();\n let mut last_dash = false;\n for ch in topic.chars().flat_map(char::to_lowercase) {\n if ch.is_ascii_alphanumeric() {\n slug.push(ch);\n last_dash = false;\n } else if !last_dash {\n slug.push('-');\n last_dash = true;\n }\n }\n let slug = slug.trim_matches('-');\n if slug.is_empty() {\n \"report\".to_string()\n } else {\n slug.to_string()\n }\n}\n\npub fn decision_hash(input: &str, output: &str) -> (String, String, usize) {\n (\n stable_hash(input),\n stable_hash(output),\n token_estimate(input) + token_estimate(output),\n )\n}\n\n#[cfg(test)]\nmod tests {\n use std::time::{SystemTime, UNIX_EPOCH};\n\n use super::create_run;\n use crate::config::{AppConfig, LlmConfig};\n use crate::run_policy::RunPolicy;\n use crate::workflow_state::ResearchRunState;\n\n #[tokio::test]\n async fn create_run_falls_back_without_llm_key_and_traces_rule_nodes() {\n let root = temp_root(\"stage3-create-run-fallback\");\n let app_config = AppConfig {\n github_token: None,\n output: root.join(\"reports\"),\n cache_dir: root.join(\"cache\"),\n session_dir: root.join(\"sessions\"),\n tags_file: None,\n use_cache: false,\n cache_ttl_hours: 24,\n timeout_secs: 30,\n enrich: false,\n };\n let llm_config = LlmConfig {\n enabled: true,\n api_key: None,\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n main_model: \"deepseek-v4-pro\".to_string(),\n side_model: Some(\"deepseek-v4-flash\".to_string()),\n max_tokens: 4096,\n timeout_secs: 30,\n };\n\n let run = create_run(\n \"Rust agent framework\".to_string(),\n app_config.clone(),\n llm_config,\n RunPolicy::default(),\n )\n .await\n .expect(\"create_run should degrade to a deterministic plan\");\n\n assert_eq!(run.state, ResearchRunState::PlanReady);\n assert!(run\n .plan_warnings\n .iter()\n .any(|warning| warning.contains(\"API key\")));\n assert!(run\n .warnings\n .iter()\n .any(|warning| warning.contains(\"0 real LLM request attempt\")));\n\n let trace_path = app_config.session_dir.join(&run.run_id).join(\"trace.jsonl\");\n let trace = std::fs::read_to_string(trace_path).expect(\"trace should be readable\");\n assert!(trace.contains(\"\\\"event\\\":\\\"agent_decision\\\"\"));\n assert!(trace.contains(\"\\\"actor\\\":\\\"scoper\\\"\"));\n assert!(trace.contains(\"\\\"actor\\\":\\\"plan_critic\\\"\"));\n assert!(!trace.contains(\"\\\"event\\\":\\\"llm_request_started\\\"\"));\n }\n\n fn temp_root(name: &str) -> std::path::PathBuf {\n let unique = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n std::env::temp_dir().join(format!(\n \"litscout-rs-{name}-{}-{unique}\",\n std::process::id()\n ))\n }\n}\n"}, "7c0c9f55db1e84d4039b524e6a5c6712e65c973e": {"t": 1785750668.8744934, "n": 22717, "h": "1e011587edacd5e6", "c": "use std::path::{Path, PathBuf};\n\nuse chrono::Utc;\nuse tokio::sync::mpsc;\nuse uuid::Uuid;\n\nuse crate::agent::{\n arxiv_scout, citation_auditor, coverage_critic, evidence, followup_router, github_scout,\n middleware::{\n AgentContext, AgentMiddleware, CitationGuard, JsonSchemaGuard, TokenBudgetTracker,\n },\n plan_critic, planner, scoper, writer,\n};\nuse crate::checkpoint::{self, Checkpoint, CheckpointSnapshot};\nuse crate::config::{AppConfig, LlmConfig};\nuse crate::error::{AppError, Result};\nuse crate::model::{\n ChapterNode, CoverageReport, EvidenceMemory, QueryAttempt, QueryPortfolio, SearchQuery,\n};\nuse crate::run_policy::RunPolicy;\nuse crate::trace::{stable_hash, token_estimate, TraceEvent, TraceWriter};\nuse crate::workflow_state::{ResearchRunRecord, ResearchRunState};\n\n#[derive(Debug, Clone, serde::Serialize)]\n#[serde(tag = \"event\", content = \"data\", rename_all = \"snake_case\")]\npub enum StatefulRunEvent {\n StateChanged {\n state: ResearchRunState,\n },\n CheckpointCreated {\n checkpoint_id: String,\n state: ResearchRunState,\n },\n EvidenceReady {\n total: usize,\n },\n CoverageReady {\n gaps: usize,\n },\n CitationAuditReady {\n citation_coverage_ratio: f64,\n },\n Completed {\n run_id: String,\n },\n Failed {\n error: String,\n },\n}\n\npub async fn create_run(\n topic: String,\n app_config: AppConfig,\n llm_config: LlmConfig,\n policy: RunPolicy,\n) -> Result {\n let run_id = format!(\"run-{}\", Uuid::new_v4());\n let run_dir = run_dir(&app_config, &run_id);\n tokio::fs::create_dir_all(&run_dir).await?;\n let trace = TraceWriter::new(&run_dir).await?;\n let mut run = ResearchRunRecord::new(run_id, topic.clone(), policy.bounded());\n save_run(&app_config, &run).await?;\n let token_budget = TokenBudgetTracker::new();\n let json_guard = JsonSchemaGuard;\n\n let scoper_input = format!(\"topic={topic}; policy={:?}\", run.policy);\n let scoper_ctx = agent_context(\"scoper\", scoper_input.clone(), &run.policy, Vec::new());\n token_budget.before_llm_call(&scoper_ctx)?;\n trace_llm_started(\n &trace,\n \"scoper\",\n side_model_name(&llm_config),\n &scoper_input,\n )\n .await?;\n let brief = scoper::generate_research_brief(&topic, &run.policy, &llm_config).await?;\n let brief_json = serde_json::to_string(&brief)?;\n json_guard.after_llm_call(&scoper_ctx, &brief_json)?;\n token_budget.after_llm_call(&scoper_ctx, &brief_json)?;\n trace_llm_finished(&trace, \"scoper\", &scoper_input, &brief_json).await?;\n\n let planner_input = serde_json::to_string(&brief)?;\n let planner_ctx = agent_context(\"planner\", planner_input.clone(), &run.policy, Vec::new());\n token_budget.before_llm_call(&planner_ctx)?;\n trace_llm_started(\n &trace,\n \"planner\",\n side_model_name(&llm_config),\n &planner_input,\n )\n .await?;\n let plan = planner::generate_chapter_plan(&brief, &run.policy, &llm_config).await?;\n let plan_json = serde_json::to_string(&plan)?;\n json_guard.after_llm_call(&planner_ctx, &plan_json)?;\n token_budget.after_llm_call(&planner_ctx, &plan_json)?;\n trace_llm_finished(&trace, \"planner\", &planner_input, &plan_json).await?;\n\n let critic_input = serde_json::to_string(&(&brief, &plan.chapters, &plan.query_portfolio))?;\n let critic_ctx = agent_context(\"plan_critic\", critic_input.clone(), &run.policy, Vec::new());\n token_budget.before_llm_call(&critic_ctx)?;\n trace_llm_started(\n &trace,\n \"plan_critic\",\n side_model_name(&llm_config),\n &critic_input,\n )\n .await?;\n let critique =\n plan_critic::critique_plan(&brief, &plan.chapters, &plan.query_portfolio, &run.policy);\n let critique_json = serde_json::to_string(&critique)?;\n json_guard.after_llm_call(&critic_ctx, &critique_json)?;\n token_budget.after_llm_call(&critic_ctx, &critique_json)?;\n trace_llm_finished(&trace, \"plan_critic\", &critic_input, &critique_json).await?;\n let from = run.state.clone();\n if !run.transition_to(ResearchRunState::PlanReady) {\n return Err(invalid_transition(&from, &ResearchRunState::PlanReady));\n }\n run.brief = Some(brief);\n run.chapters = plan.chapters;\n run.query_portfolio = plan.query_portfolio;\n run.plan_warnings = plan\n .warnings\n .into_iter()\n .chain(critique.warnings)\n .chain(critique.suggestions)\n .collect();\n run.warnings.push(format!(\n \"Stage 3 agent planning used {} bounded LLM node call(s).\",\n token_budget.calls()\n ));\n trace_transition(&trace, from, ResearchRunState::PlanReady).await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, &run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(&app_config, &run).await?;\n Ok(run)\n}\n\npub async fn continue_run(\n run_id: &str,\n app_config: AppConfig,\n llm_config: LlmConfig,\n tx: Option>,\n) -> Result {\n let mut run = load_run(&app_config, run_id).await?;\n match run.state {\n ResearchRunState::PlanReady => {\n continue_from_plan_ready(&mut run, app_config, llm_config, tx).await\n }\n ResearchRunState::Completed => Ok(run),\n _ => Err(AppError::Workflow(format!(\n \"run `{run_id}` is in state {:?} and cannot continue from this endpoint\",\n run.state\n ))),\n }\n}\n\nasync fn continue_from_plan_ready(\n run: &mut ResearchRunRecord,\n app_config: AppConfig,\n _llm_config: LlmConfig,\n tx: Option>,\n) -> Result {\n let run_dir = run_dir(&app_config, &run.run_id);\n let trace = TraceWriter::new(&run_dir).await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Fetching,\n tx.as_ref(),\n )\n .await?;\n trace_planned_tool_calls(&trace, &run.query_portfolio).await?;\n\n let (github_result, arxiv_result) = tokio::join!(\n github_scout::scout_github(&run.query_portfolio, &app_config, 1),\n arxiv_scout::scout_arxiv(&run.query_portfolio, &app_config, 1)\n );\n let (github_repos, github_attempts) = github_result?;\n let (arxiv_papers, arxiv_attempts) = arxiv_result?;\n let query_attempts = github_attempts\n .into_iter()\n .chain(arxiv_attempts.into_iter())\n .collect::>();\n for attempt in &query_attempts {\n trace\n .append(&TraceEvent::ToolCallFinished {\n tool: attempt.source.clone(),\n result_count: attempt.result_count,\n error: attempt.error.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n if github_repos.is_empty() && arxiv_papers.is_empty() {\n let message = \"GitHub 与 arXiv 均未返回可用结果,无法生成 stateful report。\".to_string();\n run.warnings.push(message.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Failed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::Failed {\n error: message.clone(),\n })\n .await;\n }\n return Err(AppError::Workflow(message));\n }\n\n let query = SearchQuery {\n topic: run.topic.clone(),\n github_limit: run.policy.github_budget,\n arxiv_limit: run.policy.arxiv_budget,\n };\n let evidence = evidence::build_evidence_memory(\n &query,\n &app_config,\n github_repos,\n arxiv_papers,\n query_attempts,\n )?;\n let ranked_total = evidence.ranked_items.len();\n let group_total = evidence.groups.len();\n let coverage = if run.policy.skip_coverage_critic {\n CoverageReport::pass()\n } else {\n coverage_critic::evaluate_coverage(\n &run.chapters,\n &run.query_portfolio,\n &evidence.memory,\n &run.policy,\n )\n };\n for gap in &coverage.gaps {\n trace\n .append(&TraceEvent::CoverageGapDetected {\n chapter_id: gap.chapter_id.clone(),\n gap_kind: format!(\"{:?}\", gap.gap_kind),\n at: Utc::now(),\n })\n .await?;\n }\n run.evidence_memory = Some(evidence.memory.clone());\n run.coverage_report = Some(coverage.clone());\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::EvidenceReady,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::EvidenceReady {\n total: evidence.memory.items.len(),\n })\n .await;\n let _ = tx\n .send(StatefulRunEvent::CoverageReady {\n gaps: coverage.gaps.len(),\n })\n .await;\n }\n trace\n .append(&TraceEvent::QualityWarning {\n message: format!(\n \"EvidenceBuilder ranked {ranked_total} item(s) into {group_total} group(s).\"\n ),\n at: Utc::now(),\n })\n .await?;\n\n let draft = writer::draft_report(&run.topic, &run.chapters, &evidence.memory);\n let audit = if run.policy.require_citation_audit {\n citation_auditor::audit_citations(&draft, &evidence.citations, &evidence.memory)\n } else {\n crate::model::CitationAuditReport::pass()\n };\n let markdown = writer::render_report_markdown(&draft, &evidence.citations);\n let citation_ctx = agent_context(\n \"writer\",\n \"render_report_markdown\".to_string(),\n &run.policy,\n evidence\n .citations\n .citations\n .iter()\n .map(|citation| citation.url.clone())\n .collect(),\n );\n CitationGuard.after_llm_call(&citation_ctx, &markdown)?;\n run.report_draft = Some(draft);\n run.citation_audit = Some(audit.clone());\n run.report_markdown = Some(markdown.clone());\n if !audit.url_whitelist_passed {\n run.warnings\n .push(\"Citation audit failed URL whitelist check.\".to_string());\n }\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::SynthesisReady,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx.as_ref() {\n let _ = tx\n .send(StatefulRunEvent::CitationAuditReady {\n citation_coverage_ratio: audit.citation_coverage_ratio,\n })\n .await;\n }\n\n write_report_file(&app_config, run, &markdown).await?;\n transition(\n run,\n &trace,\n &app_config,\n ResearchRunState::Completed,\n tx.as_ref(),\n )\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n send_checkpoint(tx.as_ref(), &checkpoint).await;\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::Completed {\n run_id: run.run_id.clone(),\n })\n .await;\n }\n Ok(run.clone())\n}\n\npub async fn load_run(app_config: &AppConfig, run_id: &str) -> Result {\n let body = tokio::fs::read_to_string(run_summary_path(app_config, run_id)).await?;\n Ok(serde_json::from_str(&body)?)\n}\n\npub async fn save_run(app_config: &AppConfig, run: &ResearchRunRecord) -> Result<()> {\n tokio::fs::create_dir_all(&app_config.session_dir).await?;\n let body = serde_json::to_string_pretty(run)?;\n tokio::fs::write(run_summary_path(app_config, &run.run_id), body).await?;\n Ok(())\n}\n\npub async fn evidence_for_run(app_config: &AppConfig, run_id: &str) -> Result {\n load_run(app_config, run_id)\n .await?\n .evidence_memory\n .ok_or_else(|| AppError::Workflow(\"run has no EvidenceMemory yet\".to_string()))\n}\n\npub async fn coverage_for_run(app_config: &AppConfig, run_id: &str) -> Result {\n load_run(app_config, run_id)\n .await?\n .coverage_report\n .ok_or_else(|| AppError::Workflow(\"run has no CoverageReport yet\".to_string()))\n}\n\npub async fn revise_plan(\n app_config: &AppConfig,\n run_id: &str,\n chapters: Option>,\n query_portfolio: Option>,\n user_feedback: Option,\n) -> Result {\n let mut run = load_run(app_config, run_id).await?;\n if !matches!(run.state, ResearchRunState::PlanReady) {\n return Err(AppError::Workflow(\n \"only PlanReady runs can revise the Stage 3 plan\".to_string(),\n ));\n }\n if let Some(chapters) = chapters {\n if chapters.is_empty() {\n return Err(AppError::Workflow(\n \"revised chapter plan cannot be empty\".to_string(),\n ));\n }\n run.chapters = chapters;\n }\n if let Some(query_portfolio) = query_portfolio {\n if query_portfolio.is_empty() {\n return Err(AppError::Workflow(\n \"revised query portfolio cannot be empty\".to_string(),\n ));\n }\n run.query_portfolio = query_portfolio;\n }\n if let Some(feedback) = user_feedback.map(|value| value.trim().to_string()) {\n if !feedback.is_empty() {\n run.plan_warnings\n .push(format!(\"用户修订计划备注:{feedback}\"));\n }\n }\n run.updated_at = Utc::now();\n let run_dir = run_dir(app_config, run_id);\n let trace = TraceWriter::new(&run_dir).await?;\n trace\n .append(&TraceEvent::QualityWarning {\n message: \"用户已修订 PlanReady 搜索计划,并创建新的 checkpoint。\".to_string(),\n at: Utc::now(),\n })\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&run_dir, &run).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(app_config, &run).await?;\n Ok(run)\n}\n\npub async fn branch_from_plan_ready(\n app_config: AppConfig,\n run_id: &str,\n checkpoint_id: &str,\n) -> Result {\n let snapshot = load_checkpoint_snapshot_by_id(&app_config, run_id, checkpoint_id).await?;\n if !snapshot.checkpoint.rollback_allowed\n || !matches!(snapshot.checkpoint.state, ResearchRunState::PlanReady)\n {\n return Err(AppError::Workflow(\n \"Stage 3 currently supports rollback branches only from PlanReady checkpoints\"\n .to_string(),\n ));\n }\n let mut branch = snapshot.run;\n branch.run_id = format!(\"run-{}\", Uuid::new_v4());\n branch.created_at = Utc::now();\n branch.updated_at = Utc::now();\n branch.state = ResearchRunState::PlanReady;\n branch.origin_run_id = Some(run_id.to_string());\n branch.origin_checkpoint_id = Some(checkpoint_id.to_string());\n branch.evidence_memory = None;\n branch.coverage_report = None;\n branch.report_draft = None;\n branch.citation_audit = None;\n branch.report_markdown = None;\n branch.output_report = None;\n let branch_dir = run_dir(&app_config, &branch.run_id);\n tokio::fs::create_dir_all(&branch_dir).await?;\n let trace = TraceWriter::new(&branch_dir).await?;\n trace\n .append(&TraceEvent::RollbackBranchCreated {\n origin_run_id: run_id.to_string(),\n new_run_id: branch.run_id.clone(),\n at: Utc::now(),\n })\n .await?;\n let checkpoint = checkpoint::write_checkpoint(&branch_dir, &branch).await?;\n trace_checkpoint(&trace, &checkpoint).await?;\n save_run(&app_config, &branch).await?;\n Ok(branch)\n}\n\nasync fn load_checkpoint_snapshot_by_id(\n app_config: &AppConfig,\n run_id: &str,\n checkpoint_id: &str,\n) -> Result {\n let run_dir = run_dir(app_config, run_id);\n let checkpoint_dir = run_dir.join(\"checkpoints\");\n let mut entries = tokio::fs::read_dir(checkpoint_dir).await?;\n while let Some(entry) = entries.next_entry().await? {\n let body = tokio::fs::read_to_string(entry.path()).await?;\n let snapshot: CheckpointSnapshot = serde_json::from_str(&body)?;\n if snapshot.checkpoint.checkpoint_id == checkpoint_id {\n return checkpoint::read_checkpoint(&run_dir, &snapshot.checkpoint).await;\n }\n }\n Err(AppError::Workflow(format!(\n \"checkpoint `{checkpoint_id}` was not found for run `{run_id}`\"\n )))\n}\n\npub fn route_followup_for_run(\n question: &str,\n memory: Option<&EvidenceMemory>,\n report_markdown: Option<&str>,\n) -> followup_router::FollowupRoute {\n match (memory, report_markdown) {\n (Some(memory), Some(markdown)) => {\n followup_router::route_followup(question, memory, markdown)\n }\n _ => followup_router::FollowupRoute::IncrementalResearchRequired {\n reason: \"当前 run 尚未完成 evidence/report,无法基于现有证据回答。\".to_string(),\n },\n }\n}\n\nasync fn transition(\n run: &mut ResearchRunRecord,\n trace: &TraceWriter,\n app_config: &AppConfig,\n next: ResearchRunState,\n tx: Option<&mpsc::Sender>,\n) -> Result<()> {\n let from = run.state.clone();\n if !run.transition_to(next.clone()) {\n return Err(invalid_transition(&from, &next));\n }\n trace_transition(trace, from, next.clone()).await?;\n save_run(app_config, run).await?;\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::StateChanged { state: next })\n .await;\n }\n Ok(())\n}\n\nasync fn trace_transition(\n trace: &TraceWriter,\n from: ResearchRunState,\n to: ResearchRunState,\n) -> Result<()> {\n trace\n .append(&TraceEvent::StateTransition {\n from,\n to,\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_llm_started(\n trace: &TraceWriter,\n actor: &str,\n model: String,\n input: &str,\n) -> Result<()> {\n trace\n .append(&TraceEvent::LlmRequestStarted {\n actor: actor.to_string(),\n model,\n input_hash: stable_hash(input),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_llm_finished(\n trace: &TraceWriter,\n actor: &str,\n input: &str,\n output: &str,\n) -> Result<()> {\n let (_input_hash, output_hash, tokens) = decision_hash(input, output);\n trace\n .append(&TraceEvent::LlmRequestFinished {\n actor: actor.to_string(),\n output_hash,\n token_estimate: tokens,\n at: Utc::now(),\n })\n .await\n}\n\nasync fn trace_checkpoint(trace: &TraceWriter, checkpoint: &Checkpoint) -> Result<()> {\n trace\n .append(&TraceEvent::CheckpointCreated {\n checkpoint_id: checkpoint.checkpoint_id.clone(),\n state: checkpoint.state.clone(),\n at: Utc::now(),\n })\n .await\n}\n\nasync fn send_checkpoint(tx: Option<&mpsc::Sender>, checkpoint: &Checkpoint) {\n if let Some(tx) = tx {\n let _ = tx\n .send(StatefulRunEvent::CheckpointCreated {\n checkpoint_id: checkpoint.checkpoint_id.clone(),\n state: checkpoint.state.clone(),\n })\n .await;\n }\n}\n\nasync fn trace_planned_tool_calls(trace: &TraceWriter, portfolio: &[QueryPortfolio]) -> Result<()> {\n for item in portfolio {\n for query in &item.github_queries {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"github\".to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n for query in &item.arxiv_queries {\n trace\n .append(&TraceEvent::ToolCallStarted {\n tool: \"arxiv\".to_string(),\n query: query.clone(),\n at: Utc::now(),\n })\n .await?;\n }\n }\n Ok(())\n}\n\nfn agent_context(\n actor: &str,\n input: String,\n policy: &RunPolicy,\n allowed_urls: Vec,\n) -> AgentContext {\n AgentContext {\n actor: actor.to_string(),\n input,\n allowed_urls,\n max_llm_calls: policy.max_llm_calls_per_run,\n }\n}\n\nfn side_model_name(llm_config: &LlmConfig) -> String {\n llm_config\n .side_model\n .clone()\n .unwrap_or_else(|| llm_config.main_model.clone())\n}\n\nfn invalid_transition(from: &ResearchRunState, to: &ResearchRunState) -> AppError {\n AppError::Workflow(format!(\"invalid state transition from {from:?} to {to:?}\"))\n}\n\nfn run_summary_path(app_config: &AppConfig, run_id: &str) -> PathBuf {\n app_config.session_dir.join(format!(\"{run_id}.json\"))\n}\n\nfn run_dir(app_config: &AppConfig, run_id: &str) -> PathBuf {\n app_config.session_dir.join(run_id)\n}\n\nasync fn write_report_file(\n app_config: &AppConfig,\n run: &mut ResearchRunRecord,\n markdown: &str,\n) -> Result<()> {\n let path = resolve_report_path(&app_config.output, &run.topic);\n if let Some(parent) = path.parent() {\n tokio::fs::create_dir_all(parent).await?;\n }\n tokio::fs::write(&path, markdown).await?;\n run.output_report = Some(path.display().to_string());\n Ok(())\n}\n\nfn resolve_report_path(output: &Path, topic: &str) -> PathBuf {\n if output.extension().is_some() {\n return output.to_path_buf();\n }\n output.join(format!(\n \"{}-stage3-{}.md\",\n slugify(topic),\n Utc::now().format(\"%Y%m%d-%H%M%S\")\n ))\n}\n\nfn slugify(topic: &str) -> String {\n let mut slug = String::new();\n let mut last_dash = false;\n for ch in topic.chars().flat_map(char::to_lowercase) {\n if ch.is_ascii_alphanumeric() {\n slug.push(ch);\n last_dash = false;\n } else if !last_dash {\n slug.push('-');\n last_dash = true;\n }\n }\n let slug = slug.trim_matches('-');\n if slug.is_empty() {\n \"report\".to_string()\n } else {\n slug.to_string()\n }\n}\n\npub fn decision_hash(input: &str, output: &str) -> (String, String, usize) {\n (\n stable_hash(input),\n stable_hash(output),\n token_estimate(input) + token_estimate(output),\n )\n}\n"}}, "README.zh-CN.md": {"df730e4a09eb4f1c5830a9f3deacc3ae94c1aa19": {"t": 1785747287.9926121, "n": 7525, "h": "9d217ce1685c1a7c", "c": "# LitScout-RS\n\n

\n English | 简体中文\n

\n\n

\n \"LitScout-RS\n

\n\n

\n 用 Rust 构建的证据约束型研究侦察 Agent。\n

\n\n

\n \"Rust\"\n \"Status\"\n \"LLM\"\n \"Sources\"\n \"License\"\n

\n\nLitScout-RS 是一个基于 Rust 的研究侦察 Agent,可以把一个研究主题转化为可审查、带引用的调研报告,并继续进入论文阅读笔记与单篇追问流程。\n\n项目面向课程调研、早期文献综述、开源技术选型和研究型开发。它不把自己设计成无限制浏览器 Agent,而是从受控来源收集证据,记录每条证据的查询来源,筛掉噪声较高的学术候选,审计引用边界,并允许用户从生成报告继续进入论文级阅读笔记和问答。\n\n## 项目亮点\n\n- **完整研究流程,而不只是搜索列表**:生成研究简介、章节计划、查询组合、证据池、覆盖度报告、引用审计和最终 Markdown 报告。\n- **受控多源收集**:默认使用 GitHub 和 arXiv,可显式启用 Semantic Scholar、DBLP、OpenAlex、Crossref 组成的 Stage A 学术扩展源。\n- **证据优先的报告生成**:写作模块只能引用登记在 `CitationLedger` 中的 URL,来源 lineage 和 query attempt 均可检查。\n- **准确率优先的扩源策略**:学术候选需要经过 canonical merge、ranking、classification 和 `EvidenceQualityGate`,才能进入报告证据池。\n- **有状态的 Agent 控制环**:状态迁移、trace 事件、checkpoint 和 PlanReady 分支让每次运行都可复盘,而不是只看到一个黑盒结果。\n- **内置论文阅读库**:证据池中的 arXiv 论文可以加入阅读库,继续做全文或近全文抓取、结构化笔记和单篇论文追问。\n- **Rust 后端和类型化边界**:API 响应、session、checkpoint、证据模型和 LLM 输出都用 Rust 类型与 serde 兼容 JSON 表达。\n\n## 系统工作流\n\n

\n \"LitScout-RS\n

\n\n```text\nUser research topic\n -> Scoper creates ResearchBrief\n -> Planner creates ChapterPlan and QueryPortfolio\n -> PlanCritic reviews plan quality\n -> User approves or revises the plan\n -> GitHubScout / ArxivScout collect default evidence\n -> Optional AcademicScout collects Semantic Scholar / DBLP / OpenAlex / Crossref\n -> EvidenceBuilder performs canonical merge, ranking, classification, and EvidenceQualityGate\n -> EvidenceMemory and CitationLedger are built with query lineage\n -> CoverageCritic checks chapter-level evidence gaps\n -> Writer generates a cited Markdown report\n -> CitationAuditor checks URL whitelist, citation coverage, and source diversity\n -> Reading Library supports arXiv paper notes and single-paper Q&A\n```\n\nLLM 不会被允许自由浏览网页或编造来源。它只能接收 LitScout-RS 已收集并登记的证据,最终报告中的引用也会被 `CitationLedger` 约束和检查。\n\n## 架构概览\n\n```text\nsrc/\n main.rs # 程序入口和 Web server 启动\n cli.rs # 命令行参数\n config.rs # AppConfig 和环境变量处理\n model.rs # SourceItem、EvidenceMemory、CitationLedger、run model\n sources/ # GitHub、arXiv、Semantic Scholar、DBLP、OpenAlex、Crossref\n agent/ # scoper、planner、scout、evidence builder、writer、auditor\n reading/ # arXiv 阅读库、正文抓取、笔记、单篇论文问答\n server/ # axum 路由和 SSE endpoint\n ranking.rs # 排序信号\n dedup.rs # canonical work merge\n classify.rs # 证据分类\n checkpoint.rs # 不持久化密钥的 checkpoint\n trace.rs # jsonl run trace\nweb/src/\n components/ # React 工作台视图\n api/ # 类型化前端 API client\n```\n\n## 环境要求\n\n- Rust stable toolchain 和 Cargo\n- Node.js 与 npm,用于 Web 工作台\n- DeepSeek 兼容的 chat completion endpoint\n- 可选 GitHub token,用于提高 GitHub API rate limit\n\n## 配置\n\nLitScout-RS 从 CLI 参数和环境变量读取运行配置。参考配置写在 `config.example.toml` 中。\n\n```bash\nexport DEEPSEEK_API_KEY=...\nexport DEEPSEEK_BASE_URL=https://api.deepseek.com\nexport DEEPSEEK_MODEL=deepseek-v4-pro\nexport DEEPSEEK_SIDE_MODEL=deepseek-v4-flash\nexport DEEPSEEK_MAX_TOKENS=4096\nexport DEEPSEEK_TIMEOUT_SECS=30\n\n# 可选,建议用于提高 GitHub API rate limit。\nexport GITHUB_TOKEN=...\n\n# 可选学术扩展源。\nexport SEMANTIC_SCHOLAR_API_KEY=...\nexport OPENALEX_API_KEY=...\nexport CROSSREF_MAILTO=you@example.com\n```\n\n不要提交真实 API key。checkpoint 和 trace 的设计目标是不持久化密钥,但环境变量和本地 shell history 仍需要正常保护。\n\n## 快速开始:Web 工作台\n\n构建前端并启动 Rust server:\n\n```bash\ncd web\nnpm install\nnpm run build\ncd ..\n\ncargo run -- --serve --port 3000\n```\n\n打开:\n\n```text\nhttp://127.0.0.1:3000\n```\n\n推荐的 Web 使用流程:\n\n1. 配置 DeepSeek 和可选 GitHub token。\n2. 输入中文或英文主题创建调研任务。\n3. 在正式收集前审查生成的计划。\n4. 批准计划,观察运行进度、事件和 checkpoint。\n5. 检查证据、query lineage、覆盖度和引用审计。\n6. 阅读生成的 Markdown 报告。\n7. 将有价值的 arXiv 论文加入阅读库,继续生成笔记和单篇论文问答。\n\n学术扩展源需要显式启用。只有当你希望 Semantic Scholar、DBLP、OpenAlex 和 Crossref 参与证据池时,才在工作台 policy 中打开对应能力。\n\n## CLI 使用\n\n当前主流程需要 LLM mode:\n\n```bash\ncargo run -- \"rust agent framework\" --llm\ncargo run -- \"llm tool calling\" --llm --github-limit 10 --arxiv-limit 10\ncargo run -- \"llm agent benchmark\" --llm --academic-extra --academic-limit 10\n```\n\n需要时也可以通过命令行传入 DeepSeek 配置:\n\n```bash\ncargo run -- \"rust agent framework\" --llm \\\n --deepseek-api-key \"$DEEPSEEK_API_KEY\" \\\n --deepseek-base-url https://api.deepseek.com \\\n --deepseek-model deepseek-v4-pro \\\n --deepseek-side-model deepseek-v4-flash \\\n --llm-timeout 45 \\\n --llm-max-tokens 4096\n```\n\n报告会写入 `reports/-.md`。session summary、trace 和 checkpoint 会写入 `sessions/`,其中不会保存 API key。\n\n## Web 工作台能力\n\n- 证据收集前的计划审查和修订。\n- 基于 SSE 的运行进度展示,覆盖 fetching、evidence building、synthesis 和 audit 阶段。\n- 证据池展示 source link、query attempt、lineage 和 selection summary。\n- 覆盖度矩阵展示章节级 `QueryGap` 与 `SourceGap` 诊断。\n- 引用审计检查 URL 白名单、引用覆盖度和来源多样性。\n- checkpoint 列表和 PlanReady 分支创建。\n- Markdown 报告预览。\n- 阅读库支持 arXiv 论文入库、正文抓取诊断、结构化笔记和单篇论文流式问答。\n\n## API 概览\n\n核心 endpoints:\n\n- `GET /api/health`\n- `POST /api/plan`\n- `POST /api/plan/revise`\n- `POST /api/run`\n- `POST /api/run/stream`\n- `POST /api/report/translate`\n\n有状态运行 endpoints:\n\n- `POST /api/runs`\n- `GET /api/runs/:run_id`\n- `GET /api/runs/:run_id/events`\n- `POST /api/runs/:run_id/approve-plan`\n- `POST /api/runs/:run_id/continue`\n- `POST /api/runs/:run_id/revise-plan`\n- `GET /api/runs/:run_id/evidence`\n- `GET /api/runs/:run_id/coverage`\n- `GET /api/runs/:run_id/citation-audit`\n- `GET /api/runs/:run_id/checkpoints`\n- `POST /api/runs/:run_id/branch-from-checkpoint`\n\n阅读库 endpoints:\n\n- `GET /api/library`\n- `POST /api/library/items`\n- `GET /api/library/items/:paper_key`\n- `DELETE /api/library/items/:paper_key`\n- `POST /api/library/items/:paper_key/generate-note`\n- `POST /api/library/items/:paper_key/chat/stream`\n\n报告级追问 endpoints 已移除。追问现在属于阅读库,语境限定在单篇论文内部。\n\n## 开发检查\n\nRust:\n\n```bash\ncargo fmt\ncargo check\ncargo test\n```\n\n前端:\n\n```bash\ncd web\nnpm run build\n```\n\nStage 3 fixture bench:\n\n```bash\nnode scripts/stage3_eval.mjs\n```\n\n这个 mini bench 用 fixture/mock data 验证控制环结构,不能替代 GitHub、arXiv、学术扩展源和 LLM 的真实集成检查。\n\n## 当前边界\n\n- 默认来源是 GitHub 和 arXiv。\n- Stage A 学术扩展源需要通过 `--academic-extra` 或 Web run policy 显式启用。\n- 开放 Web 搜索、浏览器自动化、无限制 ReAct、微信公众号抓取和通用新闻抓取都不在当前范围内。\n- Academic index 和 bibliography 候选必须通过 `EvidenceQualityGate`,才能进入 `EvidenceMemory` 和 `CitationLedger`。\n- 阅读库当前聚焦 arXiv 论文。它可以通过 Jina Reader 和本地 PDF extraction 抓取正文,但不是通用 PDF/文档分析系统。\n- `CoverageCritic` 负责报告缺口和建议,不会启动开放式自主爬取循环。\n- 分支能力目前支持从 PlanReady checkpoint 创建新 run。\n- 论文问答使用 SSE 接口。当前后端可能先缓冲模型输出,再发送 Markdown chunk;后续可以补 token-native streaming。\n\n## 仓库维护建议\n\n推荐的 GitHub project settings:\n\n- 添加简短仓库描述,比如:`Rust research scouting agent with evidence-gated multi-source search, cited reports, and arXiv reading notes.`\n- 添加 topics,例如 `rust`、`llm`、`research-agent`、`literature-review`、`arxiv`、`github-api`、`evidence`、`citation-audit`。\n- 如果仓库准备公开发布或复用,请添加 license file。\n- 不要提交 `.env`、API key、生成的 sessions、本地 cache 和私有 reports。\n- 如果希望 README 在 GitHub 上展示截图或架构图,建议把图片放在可追踪的 `docs/assets/` 目录下。\n\n## License / 许可证\n"}, "42bc98a2548dae3e116a8e54ae1fb9bc03b4ffd2": {"t": 1785750660.999696, "n": 7582, "h": "91139754b5cf1f10", "c": "# LitScout-RS\n\n

\n English | 简体中文\n

\n\n

\n \"LitScout-RS\n

\n\n

\n 用 Rust 构建的证据约束型研究侦察 Agent。\n

\n\n

\n \"Rust\"\n \"Status\"\n \"LLM\"\n \"Sources\"\n \"License\"\n

\n\nLitScout-RS 是一个基于 Rust 的研究侦察 Agent,可以把一个研究主题转化为可审查、带引用的调研报告,并继续进入论文阅读笔记与单篇追问流程。\n\n项目面向课程调研、早期文献综述、开源技术选型和研究型开发。它不把自己设计成无限制浏览器 Agent,而是从受控来源收集证据,记录每条证据的查询来源,筛掉噪声较高的学术候选,审计引用边界,并允许用户从生成报告继续进入论文级阅读笔记和问答。\n\n## 项目亮点\n\n- **完整研究流程,而不只是搜索列表**:生成研究简介、章节计划、查询组合、证据池、覆盖度报告、引用审计和最终 Markdown 报告。\n- **受控多源收集**:默认使用 GitHub 和 arXiv,可显式启用 Semantic Scholar、DBLP、OpenAlex、Crossref 组成的 Stage A 学术扩展源。\n- **证据优先的报告生成**:写作模块只能引用登记在 `CitationLedger` 中的 URL,来源 lineage 和 query attempt 均可检查。\n- **准确率优先的扩源策略**:学术候选需要经过 canonical merge、ranking、classification 和 `EvidenceQualityGate`,才能进入报告证据池。\n- **有状态的 Agent 控制环**:状态迁移、trace 事件、checkpoint 和 PlanReady 分支让每次运行都可复盘,而不是只看到一个黑盒结果。\n- **内置论文阅读库**:证据池中的 arXiv 论文可以加入阅读库,继续做全文或近全文抓取、结构化笔记和单篇论文追问。\n- **Rust 后端和类型化边界**:API 响应、session、checkpoint、证据模型和 LLM 输出都用 Rust 类型与 serde 兼容 JSON 表达。\n\n## 系统工作流\n\n

\n \"LitScout-RS\n

\n\n```text\nUser research topic\n -> Scoper creates ResearchBrief\n -> Planner creates ChapterPlan and QueryPortfolio\n -> PlanCritic reviews plan quality\n -> User approves or revises the plan\n -> GitHubScout / ArxivScout collect default evidence\n -> Optional AcademicScout collects Semantic Scholar / DBLP / OpenAlex / Crossref\n -> EvidenceBuilder performs canonical merge, ranking, classification, and EvidenceQualityGate\n -> EvidenceMemory and CitationLedger are built with query lineage\n -> CoverageCritic checks chapter-level evidence gaps\n -> Writer generates a cited Markdown report\n -> CitationAuditor checks URL whitelist, citation coverage, and source diversity\n -> Reading Library supports arXiv paper notes and single-paper Q&A\n```\n\nLLM 不会被允许自由浏览网页或编造来源。它只能接收 LitScout-RS 已收集并登记的证据,最终报告中的引用也会被 `CitationLedger` 约束和检查。\n\n## 架构概览\n\n```text\nsrc/\n main.rs # 程序入口和 Web server 启动\n cli.rs # 命令行参数\n config.rs # AppConfig 和环境变量处理\n model.rs # SourceItem、EvidenceMemory、CitationLedger、run model\n sources/ # GitHub、arXiv、Semantic Scholar、DBLP、OpenAlex、Crossref\n agent/ # scoper、planner、scout、evidence builder、writer、auditor\n reading/ # arXiv 阅读库、正文抓取、笔记、单篇论文问答\n server/ # axum 路由和 SSE endpoint\n ranking.rs # 排序信号\n dedup.rs # canonical work merge\n classify.rs # 证据分类\n checkpoint.rs # 不持久化密钥的 checkpoint\n trace.rs # jsonl run trace\nweb/src/\n components/ # React 工作台视图\n api/ # 类型化前端 API client\n```\n\n## 环境要求\n\n- Rust stable toolchain 和 Cargo\n- Node.js 与 npm,用于 Web 工作台\n- DeepSeek 兼容的 chat completion endpoint\n- 可选 GitHub token,用于提高 GitHub API rate limit\n\n## 配置\n\nLitScout-RS 从 CLI 参数和环境变量读取运行配置。参考配置写在 `config.example.toml` 中。\n\n```bash\nexport DEEPSEEK_API_KEY=...\nexport DEEPSEEK_BASE_URL=https://api.deepseek.com\nexport DEEPSEEK_MODEL=deepseek-v4-pro\nexport DEEPSEEK_SIDE_MODEL=deepseek-v4-flash\nexport DEEPSEEK_MAX_TOKENS=4096\nexport DEEPSEEK_TIMEOUT_SECS=30\n\n# 可选,建议用于提高 GitHub API rate limit。\nexport GITHUB_TOKEN=...\n\n# 可选学术扩展源。\nexport SEMANTIC_SCHOLAR_API_KEY=...\nexport OPENALEX_API_KEY=...\nexport CROSSREF_MAILTO=you@example.com\n```\n\n不要提交真实 API key。checkpoint 和 trace 的设计目标是不持久化密钥,但环境变量和本地 shell history 仍需要正常保护。\n\n## 快速开始:Web 工作台\n\n构建前端并启动 Rust server:\n\n```bash\ncd web\nnpm install\nnpm run build\ncd ..\n\ncargo run -- --serve --port 3000\n```\n\n打开:\n\n```text\nhttp://127.0.0.1:3000\n```\n\n推荐的 Web 使用流程:\n\n1. 配置 DeepSeek 和可选 GitHub token。\n2. 输入中文或英文主题创建调研任务。\n3. 在正式收集前审查生成的计划。\n4. 批准计划,观察运行进度、事件和 checkpoint。\n5. 检查证据、query lineage、覆盖度和引用审计。\n6. 阅读生成的 Markdown 报告。\n7. 将有价值的 arXiv 论文加入阅读库,继续生成笔记和单篇论文问答。\n\n学术扩展源需要显式启用。只有当你希望 Semantic Scholar、DBLP、OpenAlex 和 Crossref 参与证据池时,才在工作台 policy 中打开对应能力。\n\n## CLI 使用\n\n当前主流程需要 LLM mode:\n\n```bash\ncargo run -- \"rust agent framework\" --llm\ncargo run -- \"llm tool calling\" --llm --github-limit 10 --arxiv-limit 10\ncargo run -- \"llm agent benchmark\" --llm --academic-extra --academic-limit 10\n```\n\n需要时也可以通过命令行传入 DeepSeek 配置:\n\n```bash\ncargo run -- \"rust agent framework\" --llm \\\n --deepseek-api-key \"$DEEPSEEK_API_KEY\" \\\n --deepseek-base-url https://api.deepseek.com \\\n --deepseek-model deepseek-v4-pro \\\n --deepseek-side-model deepseek-v4-flash \\\n --llm-timeout 45 \\\n --llm-max-tokens 4096\n```\n\n报告会写入 `reports/-.md`。session summary、trace 和 checkpoint 会写入 `sessions/`,其中不会保存 API key。\n\n## Web 工作台能力\n\n- 证据收集前的计划审查和修订。\n- 基于 SSE 的运行进度展示,覆盖 fetching、evidence building、synthesis 和 audit 阶段。\n- 证据池展示 source link、query attempt、lineage 和 selection summary。\n- 覆盖度矩阵展示章节级 `QueryGap` 与 `SourceGap` 诊断。\n- 引用审计检查 URL 白名单、引用覆盖度和来源多样性。\n- checkpoint 列表和 PlanReady 分支创建。\n- Markdown 报告预览。\n- 阅读库支持 arXiv 论文入库、正文抓取诊断、结构化笔记和单篇论文流式问答。\n\n## API 概览\n\n核心 endpoints:\n\n- `GET /api/health`\n- `POST /api/plan`\n- `POST /api/plan/revise`\n- `POST /api/run`\n- `POST /api/run/stream`\n- `POST /api/report/translate`\n\n有状态运行 endpoints:\n\n- `POST /api/runs`\n- `GET /api/runs/:run_id`\n- `GET /api/runs/:run_id/events`\n- `POST /api/runs/:run_id/approve-plan`\n- `POST /api/runs/:run_id/continue`\n- `POST /api/runs/:run_id/revise-plan`\n- `GET /api/runs/:run_id/evidence`\n- `GET /api/runs/:run_id/coverage`\n- `GET /api/runs/:run_id/citation-audit`\n- `GET /api/runs/:run_id/checkpoints`\n- `POST /api/runs/:run_id/branch-from-checkpoint`\n\n阅读库 endpoints:\n\n- `GET /api/library`\n- `POST /api/library/items`\n- `GET /api/library/items/:paper_key`\n- `DELETE /api/library/items/:paper_key`\n- `POST /api/library/items/:paper_key/generate-note`\n- `POST /api/library/items/:paper_key/chat/stream`\n\n报告级追问 endpoints 已移除。追问现在属于阅读库,语境限定在单篇论文内部。\n\n## 开发检查\n\nRust:\n\n```bash\ncargo fmt\ncargo check\ncargo test\n```\n\n前端:\n\n```bash\ncd web\nnpm run build\n```\n\nStage 3 fixture bench:\n\n```bash\nnode scripts/stage3_eval.mjs\n```\n\n这个 mini bench 用 fixture/mock data 验证控制环结构,不能替代 GitHub、arXiv、学术扩展源和 LLM 的真实集成检查。\n\n## 当前边界\n\n- 默认来源是 GitHub 和 arXiv。\n- Stage A 学术扩展源需要通过 `--academic-extra` 或 Web run policy 显式启用。\n- 开放 Web 搜索、浏览器自动化、无限制 ReAct、微信公众号抓取和通用新闻抓取都不在当前范围内。\n- Academic index 和 bibliography 候选必须通过 `EvidenceQualityGate`,才能进入 `EvidenceMemory` 和 `CitationLedger`。\n- 阅读库当前聚焦 arXiv 论文。它可以通过 Jina Reader 和本地 PDF extraction 抓取正文,但不是通用 PDF/文档分析系统。\n- `CoverageCritic` 负责报告缺口和建议,不会启动开放式自主爬取循环。\n- 分支能力目前支持从 PlanReady checkpoint 创建新 run。\n- 论文问答使用 SSE 接口。当前后端可能先缓冲模型输出,再发送 Markdown chunk;后续可以补 token-native streaming。\n\n## 仓库维护建议\n\n推荐的 GitHub project settings:\n\n- 添加简短仓库描述,比如:`Rust research scouting agent with evidence-gated multi-source search, cited reports, and arXiv reading notes.`\n- 添加 topics,例如 `rust`、`llm`、`research-agent`、`literature-review`、`arxiv`、`github-api`、`evidence`、`citation-audit`。\n- 如果仓库准备公开发布或复用,请添加 license file。\n- 不要提交 `.env`、API key、生成的 sessions、本地 cache 和私有 reports。\n- 如果希望 README 在 GitHub 上展示截图或架构图,建议把图片放在可追踪的 `docs/assets/` 目录下。\n\n## License / 许可证\n\n当前仓库没有包含 license file。如果项目准备发布或复用,建议在 release 前添加明确许可证。\n"}}}}