Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
672 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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<AppState>) -> Json<HealthResponse> {\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<AppState>,\n Json(req): Json<CreateStatefulRunRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<StatefulRunStreamEvent>(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<AppState>,\n Path(run_id): Path<String>,\n req: Option<Json<ContinueStatefulRunRequest>>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<StatefulRunStreamEvent>(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<AppState>,\n Path(run_id): Path<String>,\n Json(req): Json<ReviseStatefulPlanRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<EvidenceResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CoverageResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CitationAuditResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CheckpointListResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n Json(req): Json<BranchRunRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n) -> Result<Json<ReadingLibraryResponse>> {\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<AppState>,\n Json(req): Json<AddReadingLibraryItemRequest>,\n) -> Result<Json<ReadingLibraryItemResponse>> {\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<AppState>,\n Path(paper_key): Path<String>,\n) -> Result<Json<ReadingLibraryItemResponse>> {\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<AppState>,\n Path(paper_key): Path<String>,\n) -> Result<Json<ReadingLibraryResponse>> {\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<AppState>,\n Path(paper_key): Path<String>,\n Json(req): Json<GenerateReadingNoteRequest>,\n) -> Result<Json<ReadingLibraryItemResponse>> {\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<dyn std::future::Future<Output = Result<()>> + 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<AppState>,\n Path(paper_key): Path<String>,\n Json(req): Json<PaperChatRequest>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<ChatStreamEvent>(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::<String>(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<AppState>,\n Json(req): Json<ReportTranslateRequest>,\n) -> Result<Json<ReportTranslateResponse>> {\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<AppState>) -> Json<HealthResponse> {\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<AppState>,\n Json(req): Json<CreateStatefulRunRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<StatefulRunStreamEvent>(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<AppState>,\n Path(run_id): Path<String>,\n req: Option<Json<ContinueStatefulRunRequest>>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<StatefulRunStreamEvent>(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<AppState>,\n Path(run_id): Path<String>,\n Json(req): Json<ReviseStatefulPlanRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<EvidenceResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CoverageResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CitationAuditResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CheckpointListResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n Json(req): Json<BranchRunRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n) -> Result<Json<ReadingLibraryResponse>> {\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<AppState>,\n Json(req): Json<AddReadingLibraryItemRequest>,\n) -> Result<Json<ReadingLibraryItemResponse>> {\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<AppState>,\n Path(paper_key): Path<String>,\n) -> Result<Json<ReadingLibraryItemResponse>> {\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<AppState>,\n Path(paper_key): Path<String>,\n) -> Result<Json<ReadingLibraryResponse>> {\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<AppState>,\n Path(paper_key): Path<String>,\n Json(req): Json<GenerateReadingNoteRequest>,\n) -> Result<Json<ReadingLibraryItemResponse>> {\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<dyn std::future::Future<Output = Result<()>> + 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<AppState>,\n Path(paper_key): Path<String>,\n Json(req): Json<PaperChatRequest>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<ChatStreamEvent>(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::<String>(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<AppState>,\n Json(req): Json<ReportTranslateRequest>,\n) -> Result<Json<ReportTranslateResponse>> {\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<AppState>) -> Json<HealthResponse> {\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<AppState>,\n Json(req): Json<CreateStatefulRunRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<StatefulRunStreamEvent>(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<AppState>,\n Path(run_id): Path<String>,\n req: Option<Json<ContinueStatefulRunRequest>>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<StatefulRunStreamEvent>(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<AppState>,\n Path(run_id): Path<String>,\n Json(req): Json<ReviseStatefulPlanRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<EvidenceResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CoverageResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CitationAuditResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CheckpointListResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n Json(req): Json<BranchRunRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Json(req): Json<ReportChatRequest>,\n) -> Result<Json<ReportChatResponse>> {\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<AppState>,\n Json(req): Json<ReportChatRequest>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<ChatStreamEvent>(32);\n\n tokio::spawn(async move {\n let (delta_tx, mut delta_rx) = mpsc::channel::<String>(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<AppState>,\n) -> Result<Json<ReadingLibraryResponse>> {\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<AppState>,\n Json(req): Json<AddReadingLibraryItemRequest>,\n) -> Result<Json<ReadingLibraryItemResponse>> {\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<AppState>,\n Path(paper_key): Path<String>,\n) -> Result<Json<ReadingLibraryItemResponse>> {\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<AppState>,\n Path(paper_key): Path<String>,\n) -> Result<Json<ReadingLibraryResponse>> {\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<AppState>,\n Path(paper_key): Path<String>,\n Json(req): Json<GenerateReadingNoteRequest>,\n) -> Result<Json<ReadingLibraryItemResponse>> {\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<dyn std::future::Future<Output = Result<()>> + 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<AppState>,\n Path(paper_key): Path<String>,\n Json(req): Json<PaperChatRequest>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<ChatStreamEvent>(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::<String>(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<AppState>,\n Json(req): Json<ReportTranslateRequest>,\n) -> Result<Json<ReportTranslateResponse>> {\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<AppState>) -> Json<HealthResponse> {\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<AppState>,\n Json(req): Json<CreateStatefulRunRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<StatefulRunStreamEvent>(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<AppState>,\n Path(run_id): Path<String>,\n req: Option<Json<ContinueStatefulRunRequest>>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<StatefulRunStreamEvent>(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<AppState>,\n Path(run_id): Path<String>,\n Json(req): Json<ReviseStatefulPlanRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<EvidenceResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CoverageResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CitationAuditResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CheckpointListResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n Json(req): Json<BranchRunRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Json(req): Json<ReportChatRequest>,\n) -> Result<Json<ReportChatResponse>> {\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<AppState>,\n Json(req): Json<ReportChatRequest>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<ChatStreamEvent>(32);\n\n tokio::spawn(async move {\n let (delta_tx, mut delta_rx) = mpsc::channel::<String>(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<AppState>,\n) -> Result<Json<ReadingLibraryResponse>> {\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<AppState>,\n Json(req): Json<AddReadingLibraryItemRequest>,\n) -> Result<Json<ReadingLibraryItemResponse>> {\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<AppState>,\n Path(paper_key): Path<String>,\n) -> Result<Json<ReadingLibraryItemResponse>> {\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<AppState>,\n Path(paper_key): Path<String>,\n) -> Result<Json<ReadingLibraryResponse>> {\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<AppState>,\n Path(paper_key): Path<String>,\n Json(req): Json<GenerateReadingNoteRequest>,\n) -> Result<Json<ReadingLibraryItemResponse>> {\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<dyn std::future::Future<Output = Result<()>> + 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<AppState>,\n Path(paper_key): Path<String>,\n Json(req): Json<PaperChatRequest>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<ChatStreamEvent>(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::<String>(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<AppState>,\n Json(req): Json<ReportTranslateRequest>,\n) -> Result<Json<ReportTranslateResponse>> {\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<AppState>) -> Json<HealthResponse> {\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<AppState>,\n Json(req): Json<CreateStatefulRunRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<StatefulRunStreamEvent>(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<AppState>,\n Path(run_id): Path<String>,\n req: Option<Json<ContinueStatefulRunRequest>>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<StatefulRunStreamEvent>(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<AppState>,\n Path(run_id): Path<String>,\n Json(req): Json<ReviseStatefulPlanRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<EvidenceResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CoverageResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CitationAuditResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CheckpointListResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n Json(req): Json<BranchRunRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n Json(req): Json<StatefulFollowupRequest>,\n) -> Result<Json<StatefulFollowupResponse>> {\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<AppState>,\n Json(req): Json<ReportChatRequest>,\n) -> Result<Json<ReportChatResponse>> {\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<AppState>,\n Json(req): Json<ReportChatRequest>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<ChatStreamEvent>(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<AppState>,\n Json(req): Json<ReportTranslateRequest>,\n) -> Result<Json<ReportTranslateResponse>> {\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<String> {\n let mut chunks = answer\n .split_inclusive('\\n')\n .map(str::to_string)\n .filter(|chunk| !chunk.is_empty())\n .collect::<Vec<_>>();\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<AppState>) -> Json<HealthResponse> {\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<AppState>,\n Json(req): Json<PlanRequest>,\n) -> Result<Json<PlanResponse>> {\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<AppState>,\n Json(req): Json<PlanReviseRequest>,\n) -> Result<Json<PlanResponse>> {\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 &current_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<AppState>,\n Json(req): Json<RunRequest>,\n) -> Result<Json<RunResponse>> {\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<AppState>,\n Json(req): Json<RunRequest>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<RunStreamEvent>(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<RunStreamEvent>,\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<AppState>,\n Json(req): Json<CreateStatefulRunRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<StatefulRunStreamEvent>(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<AppState>,\n Path(run_id): Path<String>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<StatefulRunStreamEvent>(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<AppState>,\n Path(run_id): Path<String>,\n Json(req): Json<ReviseStatefulPlanRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<EvidenceResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CoverageResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CitationAuditResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CheckpointListResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n Json(req): Json<BranchRunRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n Json(req): Json<StatefulFollowupRequest>,\n) -> Result<Json<StatefulFollowupResponse>> {\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<AppState>,\n Json(req): Json<ReportChatRequest>,\n) -> Result<Json<ReportChatResponse>> {\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<AppState>,\n Json(req): Json<ReportChatRequest>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<ChatStreamEvent>(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<AppState>,\n Json(req): Json<ReportTranslateRequest>,\n) -> Result<Json<ReportTranslateResponse>> {\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<String> {\n let mut chunks = answer\n .split_inclusive('\\n')\n .map(str::to_string)\n .filter(|chunk| !chunk.is_empty())\n .collect::<Vec<_>>();\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::<usize>()\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::<usize>()\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<AppState>) -> Json<HealthResponse> {\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<AppState>,\n Json(req): Json<PlanRequest>,\n) -> Result<Json<PlanResponse>> {\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<AppState>,\n Json(req): Json<PlanReviseRequest>,\n) -> Result<Json<PlanResponse>> {\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 &current_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<AppState>,\n Json(req): Json<RunRequest>,\n) -> Result<Json<RunResponse>> {\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<AppState>,\n Json(req): Json<RunRequest>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<RunStreamEvent>(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<RunStreamEvent>,\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<AppState>,\n Json(req): Json<CreateStatefulRunRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<StatefulRunStreamEvent>(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<AppState>,\n Path(run_id): Path<String>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<StatefulRunStreamEvent>(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<AppState>,\n Path(run_id): Path<String>,\n Json(req): Json<ReviseStatefulPlanRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<EvidenceResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CoverageResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CitationAuditResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n) -> Result<Json<CheckpointListResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n Json(req): Json<BranchRunRequest>,\n) -> Result<Json<StatefulRunResponse>> {\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<AppState>,\n Path(run_id): Path<String>,\n Json(req): Json<StatefulFollowupRequest>,\n) -> Result<Json<StatefulFollowupResponse>> {\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<AppState>,\n Json(req): Json<ReportChatRequest>,\n) -> Result<Json<ReportChatResponse>> {\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<AppState>,\n Json(req): Json<ReportChatRequest>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<ChatStreamEvent>(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<AppState>,\n Json(req): Json<ReportTranslateRequest>,\n) -> Result<Json<ReportTranslateResponse>> {\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<String> {\n let mut chunks = answer\n .split_inclusive('\\n')\n .map(str::to_string)\n .filter(|chunk| !chunk.is_empty())\n .collect::<Vec<_>>();\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::<usize>()\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::<usize>()\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<AppState>) -> Json<HealthResponse> {\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<AppState>,\n Json(req): Json<PlanRequest>,\n) -> Result<Json<PlanResponse>> {\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<AppState>,\n Json(req): Json<PlanReviseRequest>,\n) -> Result<Json<PlanResponse>> {\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 &current_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<AppState>,\n Json(req): Json<RunRequest>,\n) -> Result<Json<RunResponse>> {\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<AppState>,\n Json(req): Json<RunRequest>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<RunStreamEvent>(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<RunStreamEvent>,\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<AppState>,\n Json(req): Json<ReportChatRequest>,\n) -> Result<Json<ReportChatResponse>> {\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<AppState>,\n Json(req): Json<ReportChatRequest>,\n) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {\n let (tx, rx) = mpsc::channel::<ChatStreamEvent>(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<AppState>,\n Json(req): Json<ReportTranslateRequest>,\n) -> Result<Json<ReportTranslateResponse>> {\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<String> {\n let mut chunks = answer\n .split_inclusive('\\n')\n .map(str::to_string)\n .filter(|chunk| !chunk.is_empty())\n .collect::<Vec<_>>();\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::<usize>()\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::<usize>()\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<ReportDraft> {\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<ChapterDraft> {\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<EvidenceItem>,\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<ParagraphWithCitations>,\n}\n\nfn batch_chapter_evidence(evidence_items: &[EvidenceItem]) -> Vec<EvidenceBatch> {\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<EvidenceItem> {\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<EvidenceItem>,\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::<String>();\n out.push_str(\"...\");\n out\n}\n\nfn parse_batch_draft_content(content: &str, batch: &EvidenceBatch) -> Result<BatchDraft> {\n let output =\n serde_json::from_str::<BatchDraftOutput>(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<BatchDraft> {\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<ChatCompletionsRequest> {\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<ChatCompletionsRequest> {\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<ChapterDraft> {\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<ChatCompletionsRequest> {\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<ChatCompletionsRequest> {\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<String> {\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<ChatCompletionsRequest> {\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<ChatCompletionsRequest> {\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<String> {\n let output =\n serde_json::from_str::<GlobalSummaryOutput>(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<ChapterDraft> {\n let output =\n serde_json::from_str::<ChapterDraftOutput>(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<ChapterParagraphOutput>,\n evidence_items: &[EvidenceItem],\n max_paragraphs: usize,\n) -> Result<Vec<ParagraphWithCitations>> {\n let allowed_ids = evidence_items\n .iter()\n .map(|item| item.evidence_id.as_str())\n .collect::<BTreeSet<_>>();\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::<Vec<_>>();\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(&paragraph.text_zh);\n if !paragraph.cited_evidence_ids.is_empty() {\n out.push(' ');\n out.push_str(\n &paragraph\n .cited_evidence_ids\n .iter()\n .map(|id| format!(\"`{id}`\"))\n .collect::<Vec<_>>()\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<EvidenceSourceInput>,\n}\n\n#[derive(Debug, Serialize)]\nstruct ChapterSynthesisInput {\n chapter_title: String,\n research_question: String,\n allowed_evidence_ids: Vec<String>,\n batch_drafts: Vec<BatchDraft>,\n}\n\n#[derive(Debug, Serialize)]\nstruct GlobalSummaryInput {\n topic: String,\n chapter_count: usize,\n evidence_count: usize,\n chapters: Vec<GlobalSummaryChapterInput>,\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<String>,\n paragraph_summaries: Vec<String>,\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::<BTreeSet<_>>()\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(&paragraph.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<ChapterParagraphOutput>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct BatchDraftOutput {\n #[serde(default)]\n findings_summary_zh: String,\n #[serde(default)]\n paragraphs: Vec<ChapterParagraphOutput>,\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<String>,\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::<Vec<_>>();\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::<Vec<_>>();\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::<Vec<_>>();\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<Utc> {\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<ReportDraft> {\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<ChapterDraft> {\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<EvidenceItem>,\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<ParagraphWithCitations>,\n}\n\nfn batch_chapter_evidence(evidence_items: &[EvidenceItem]) -> Vec<EvidenceBatch> {\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<EvidenceItem> {\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<EvidenceItem>,\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::<String>();\n out.push_str(\"...\");\n out\n}\n\nfn parse_batch_draft_content(content: &str, batch: &EvidenceBatch) -> Result<BatchDraft> {\n let output =\n serde_json::from_str::<BatchDraftOutput>(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<BatchDraft> {\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<ChatCompletionsRequest> {\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<ChatCompletionsRequest> {\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<ChapterDraft> {\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<ChatCompletionsRequest> {\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<ChatCompletionsRequest> {\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<String> {\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<ChatCompletionsRequest> {\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<ChatCompletionsRequest> {\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<String> {\n let output =\n serde_json::from_str::<GlobalSummaryOutput>(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<ChapterDraft> {\n let output =\n serde_json::from_str::<ChapterDraftOutput>(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<ChapterParagraphOutput>,\n evidence_items: &[EvidenceItem],\n max_paragraphs: usize,\n) -> Result<Vec<ParagraphWithCitations>> {\n let allowed_ids = evidence_items\n .iter()\n .map(|item| item.evidence_id.as_str())\n .collect::<BTreeSet<_>>();\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::<Vec<_>>();\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(&paragraph.text_zh);\n if !paragraph.cited_evidence_ids.is_empty() {\n out.push(' ');\n out.push_str(\n &paragraph\n .cited_evidence_ids\n .iter()\n .map(|id| format!(\"`{id}`\"))\n .collect::<Vec<_>>()\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<EvidenceSourceInput>,\n}\n\n#[derive(Debug, Serialize)]\nstruct ChapterSynthesisInput {\n chapter_title: String,\n research_question: String,\n allowed_evidence_ids: Vec<String>,\n batch_drafts: Vec<BatchDraft>,\n}\n\n#[derive(Debug, Serialize)]\nstruct GlobalSummaryInput {\n topic: String,\n chapter_count: usize,\n evidence_count: usize,\n chapters: Vec<GlobalSummaryChapterInput>,\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<String>,\n paragraph_summaries: Vec<String>,\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::<BTreeSet<_>>()\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(&paragraph.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<ChapterParagraphOutput>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct BatchDraftOutput {\n #[serde(default)]\n findings_summary_zh: String,\n #[serde(default)]\n paragraphs: Vec<ChapterParagraphOutput>,\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<String>,\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::<Vec<_>>();\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::<Vec<_>>();\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::<Vec<_>>();\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<Utc> {\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<ReportDraft> {\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<ChapterDraft> {\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<EvidenceItem>,\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<ParagraphWithCitations>,\n}\n\nfn batch_chapter_evidence(evidence_items: &[EvidenceItem]) -> Vec<EvidenceBatch> {\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<EvidenceItem> {\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<EvidenceItem>,\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::<String>();\n out.push_str(\"...\");\n out\n}\n\nfn parse_batch_draft_content(content: &str, batch: &EvidenceBatch) -> Result<BatchDraft> {\n let output =\n serde_json::from_str::<BatchDraftOutput>(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<BatchDraft> {\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<ChatCompletionsRequest> {\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<ChatCompletionsRequest> {\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<ChapterDraft> {\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<ChatCompletionsRequest> {\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<ChatCompletionsRequest> {\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<String> {\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<ChatCompletionsRequest> {\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<ChatCompletionsRequest> {\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<String> {\n let output =\n serde_json::from_str::<GlobalSummaryOutput>(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<ChapterDraft> {\n let output =\n serde_json::from_str::<ChapterDraftOutput>(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<ChapterParagraphOutput>,\n evidence_items: &[EvidenceItem],\n max_paragraphs: usize,\n) -> Result<Vec<ParagraphWithCitations>> {\n let allowed_ids = evidence_items\n .iter()\n .map(|item| item.evidence_id.as_str())\n .collect::<BTreeSet<_>>();\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::<Vec<_>>();\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(&paragraph.text_zh);\n if !paragraph.cited_evidence_ids.is_empty() {\n out.push(' ');\n out.push_str(\n &paragraph\n .cited_evidence_ids\n .iter()\n .map(|id| format!(\"`{id}`\"))\n .collect::<Vec<_>>()\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<EvidenceSourceInput>,\n}\n\n#[derive(Debug, Serialize)]\nstruct ChapterSynthesisInput {\n chapter_title: String,\n research_question: String,\n allowed_evidence_ids: Vec<String>,\n batch_drafts: Vec<BatchDraft>,\n}\n\n#[derive(Debug, Serialize)]\nstruct GlobalSummaryInput {\n topic: String,\n chapter_count: usize,\n evidence_count: usize,\n chapters: Vec<GlobalSummaryChapterInput>,\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<String>,\n paragraph_summaries: Vec<String>,\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::<BTreeSet<_>>()\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(&paragraph.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<ChapterParagraphOutput>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct BatchDraftOutput {\n #[serde(default)]\n findings_summary_zh: String,\n #[serde(default)]\n paragraphs: Vec<ChapterParagraphOutput>,\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<String>,\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::<Vec<_>>();\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::<Vec<_>>();\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::<Vec<_>>();\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<Utc> {\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<ReportDraft> {\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<ChapterDraft> {\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<EvidenceItem>,\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<ParagraphWithCitations>,\n}\n\nfn batch_chapter_evidence(evidence_items: &[EvidenceItem]) -> Vec<EvidenceBatch> {\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::<String>();\n out.push_str(\"...\");\n out\n}\n\nfn parse_batch_draft_content(content: &str, batch: &EvidenceBatch) -> Result<BatchDraft> {\n let output =\n serde_json::from_str::<BatchDraftOutput>(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<BatchDraft> {\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<ChatCompletionsRequest> {\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<ChatCompletionsRequest> {\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<ChapterDraft> {\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<ChatCompletionsRequest> {\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<ChatCompletionsRequest> {\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<String> {\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<ChatCompletionsRequest> {\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<ChatCompletionsRequest> {\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<String> {\n let output =\n serde_json::from_str::<GlobalSummaryOutput>(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<ChapterDraft> {\n let output =\n serde_json::from_str::<ChapterDraftOutput>(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<ChapterParagraphOutput>,\n evidence_items: &[EvidenceItem],\n max_paragraphs: usize,\n) -> Result<Vec<ParagraphWithCitations>> {\n let allowed_ids = evidence_items\n .iter()\n .map(|item| item.evidence_id.as_str())\n .collect::<BTreeSet<_>>();\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::<Vec<_>>();\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(&paragraph.text_zh);\n if !paragraph.cited_evidence_ids.is_empty() {\n out.push(' ');\n out.push_str(\n &paragraph\n .cited_evidence_ids\n .iter()\n .map(|id| format!(\"`{id}`\"))\n .collect::<Vec<_>>()\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<EvidenceSourceInput>,\n}\n\n#[derive(Debug, Serialize)]\nstruct ChapterSynthesisInput {\n chapter_title: String,\n research_question: String,\n allowed_evidence_ids: Vec<String>,\n batch_drafts: Vec<BatchDraft>,\n}\n\n#[derive(Debug, Serialize)]\nstruct GlobalSummaryInput {\n topic: String,\n chapter_count: usize,\n evidence_count: usize,\n chapters: Vec<GlobalSummaryChapterInput>,\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<String>,\n paragraph_summaries: Vec<String>,\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::<BTreeSet<_>>()\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(&paragraph.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<ChapterParagraphOutput>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct BatchDraftOutput {\n #[serde(default)]\n findings_summary_zh: String,\n #[serde(default)]\n paragraphs: Vec<ChapterParagraphOutput>,\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<String>,\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::<Vec<_>>();\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::<Vec<_>>();\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<Utc> {\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::<Vec<_>>();\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(&paragraph.text_zh);\n if !paragraph.cited_evidence_ids.is_empty() {\n out.push(' ');\n out.push_str(\n &paragraph\n .cited_evidence_ids\n .iter()\n .map(|id| format!(\"`{id}`\"))\n .collect::<Vec<_>>()\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::<Vec<_>>();\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(&paragraph.text_zh);\n if !paragraph.cited_evidence_ids.is_empty() {\n out.push_str(\" \");\n out.push_str(\n &paragraph\n .cited_evidence_ids\n .iter()\n .map(|id| format!(\"`{id}`\"))\n .collect::<Vec<_>>()\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<ResearchRunRecord> {\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<mpsc::Sender<StatefulRunEvent>>,\n) -> Result<ResearchRunRecord> {\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<mpsc::Sender<StatefulRunEvent>>,\n) -> Result<ResearchRunRecord> {\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::<Vec<QueryAttempt>>();\n let source_lineage = github_lineage\n .into_iter()\n .chain(arxiv_lineage)\n .chain(academic_lineage)\n .collect::<Vec<_>>();\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::<Vec<_>>()\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<ResearchRunRecord> {\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<EvidenceMemory> {\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<CoverageReport> {\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<Vec<ChapterNode>>,\n query_portfolio: Option<Vec<QueryPortfolio>>,\n user_feedback: Option<String>,\n) -> Result<ResearchRunRecord> {\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<ResearchRunRecord> {\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<CheckpointSnapshot> {\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<StatefulRunEvent>>,\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<StatefulRunEvent>>, 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<String> {\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<String>,\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<ResearchRunRecord> {\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<mpsc::Sender<StatefulRunEvent>>,\n) -> Result<ResearchRunRecord> {\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<mpsc::Sender<StatefulRunEvent>>,\n) -> Result<ResearchRunRecord> {\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::<Vec<QueryAttempt>>();\n let source_lineage = github_lineage\n .into_iter()\n .chain(arxiv_lineage)\n .chain(academic_lineage)\n .collect::<Vec<_>>();\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::<Vec<_>>()\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<ResearchRunRecord> {\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<EvidenceMemory> {\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<CoverageReport> {\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<Vec<ChapterNode>>,\n query_portfolio: Option<Vec<QueryPortfolio>>,\n user_feedback: Option<String>,\n) -> Result<ResearchRunRecord> {\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<ResearchRunRecord> {\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<CheckpointSnapshot> {\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<StatefulRunEvent>>,\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<StatefulRunEvent>>, 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<String> {\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<String>,\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<ResearchRunRecord> {\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<mpsc::Sender<StatefulRunEvent>>,\n) -> Result<ResearchRunRecord> {\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<mpsc::Sender<StatefulRunEvent>>,\n) -> Result<ResearchRunRecord> {\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::<Vec<QueryAttempt>>();\n let source_lineage = github_lineage\n .into_iter()\n .chain(arxiv_lineage)\n .chain(academic_lineage)\n .collect::<Vec<_>>();\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<ResearchRunRecord> {\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<EvidenceMemory> {\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<CoverageReport> {\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<Vec<ChapterNode>>,\n query_portfolio: Option<Vec<QueryPortfolio>>,\n user_feedback: Option<String>,\n) -> Result<ResearchRunRecord> {\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<ResearchRunRecord> {\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<CheckpointSnapshot> {\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<StatefulRunEvent>>,\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<StatefulRunEvent>>, 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<String> {\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<String>,\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<ResearchRunRecord> {\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<mpsc::Sender<StatefulRunEvent>>,\n) -> Result<ResearchRunRecord> {\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<mpsc::Sender<StatefulRunEvent>>,\n) -> Result<ResearchRunRecord> {\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::<Vec<QueryAttempt>>();\n let source_lineage = github_lineage\n .into_iter()\n .chain(arxiv_lineage)\n .chain(academic_lineage)\n .collect::<Vec<_>>();\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<ResearchRunRecord> {\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<EvidenceMemory> {\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<CoverageReport> {\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<Vec<ChapterNode>>,\n query_portfolio: Option<Vec<QueryPortfolio>>,\n user_feedback: Option<String>,\n) -> Result<ResearchRunRecord> {\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<ResearchRunRecord> {\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<CheckpointSnapshot> {\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<StatefulRunEvent>>,\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<StatefulRunEvent>>, 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<String> {\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<String>,\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<ResearchRunRecord> {\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<mpsc::Sender<StatefulRunEvent>>,\n) -> Result<ResearchRunRecord> {\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<mpsc::Sender<StatefulRunEvent>>,\n) -> Result<ResearchRunRecord> {\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::<Vec<QueryAttempt>>();\n let source_lineage = github_lineage\n .into_iter()\n .chain(arxiv_lineage)\n .collect::<Vec<_>>();\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<ResearchRunRecord> {\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<EvidenceMemory> {\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<CoverageReport> {\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<Vec<ChapterNode>>,\n query_portfolio: Option<Vec<QueryPortfolio>>,\n user_feedback: Option<String>,\n) -> Result<ResearchRunRecord> {\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<ResearchRunRecord> {\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<CheckpointSnapshot> {\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<StatefulRunEvent>>,\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<StatefulRunEvent>>, 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<String>,\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<ResearchRunRecord> {\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<mpsc::Sender<StatefulRunEvent>>,\n) -> Result<ResearchRunRecord> {\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<mpsc::Sender<StatefulRunEvent>>,\n) -> Result<ResearchRunRecord> {\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::<Vec<QueryAttempt>>();\n let source_lineage = github_lineage\n .into_iter()\n .chain(arxiv_lineage)\n .collect::<Vec<_>>();\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<ResearchRunRecord> {\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<EvidenceMemory> {\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<CoverageReport> {\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<Vec<ChapterNode>>,\n query_portfolio: Option<Vec<QueryPortfolio>>,\n user_feedback: Option<String>,\n) -> Result<ResearchRunRecord> {\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<ResearchRunRecord> {\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<CheckpointSnapshot> {\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<StatefulRunEvent>>,\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<StatefulRunEvent>>, 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<String>,\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<ResearchRunRecord> {\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<mpsc::Sender<StatefulRunEvent>>,\n) -> Result<ResearchRunRecord> {\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<mpsc::Sender<StatefulRunEvent>>,\n) -> Result<ResearchRunRecord> {\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::<Vec<QueryAttempt>>();\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<ResearchRunRecord> {\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<EvidenceMemory> {\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<CoverageReport> {\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<Vec<ChapterNode>>,\n query_portfolio: Option<Vec<QueryPortfolio>>,\n user_feedback: Option<String>,\n) -> Result<ResearchRunRecord> {\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<ResearchRunRecord> {\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<CheckpointSnapshot> {\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<StatefulRunEvent>>,\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<StatefulRunEvent>>, 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<String>,\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<ResearchRunRecord> {\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<mpsc::Sender<StatefulRunEvent>>,\n) -> Result<ResearchRunRecord> {\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<mpsc::Sender<StatefulRunEvent>>,\n) -> Result<ResearchRunRecord> {\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::<Vec<QueryAttempt>>();\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<ResearchRunRecord> {\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<EvidenceMemory> {\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<CoverageReport> {\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<Vec<ChapterNode>>,\n query_portfolio: Option<Vec<QueryPortfolio>>,\n user_feedback: Option<String>,\n) -> Result<ResearchRunRecord> {\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<ResearchRunRecord> {\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<CheckpointSnapshot> {\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<StatefulRunEvent>>,\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<StatefulRunEvent>>, 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<String>,\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<p align=\"center\">\n <a href=\"README.md\">English</a> | 简体中文\n</p>\n\n<p align=\"center\">\n <img src=\"icon.png\" alt=\"LitScout-RS logo\" width=\"132\" />\n</p>\n\n<p align=\"center\">\n <strong>用 Rust 构建的证据约束型研究侦察 Agent。</strong>\n</p>\n\n<p align=\"center\">\n <a href=\"https://www.rust-lang.org/\"><img alt=\"Rust\" src=\"https://img.shields.io/badge/Rust-2021-b7410e?logo=rust&logoColor=white\"></a>\n <img alt=\"Status\" src=\"https://img.shields.io/badge/status-active-brightgreen\">\n <img alt=\"LLM\" src=\"https://img.shields.io/badge/LLM-DeepSeek-4b6bfb\">\n <img alt=\"Sources\" src=\"https://img.shields.io/badge/sources-GitHub%20%7C%20arXiv%20%7C%20Academic%20Indexes-0f766e\">\n <img alt=\"License\" src=\"https://img.shields.io/badge/license-not%20set-lightgrey\">\n</p>\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<p align=\"center\">\n <img src=\"workflow.png\" alt=\"LitScout-RS workflow\" width=\"860\" />\n</p>\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/<topic>-<timestamp>.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<p align=\"center\">\n <a href=\"README.md\">English</a> | 简体中文\n</p>\n\n<p align=\"center\">\n <img src=\"icon.png\" alt=\"LitScout-RS logo\" width=\"132\" />\n</p>\n\n<p align=\"center\">\n <strong>用 Rust 构建的证据约束型研究侦察 Agent。</strong>\n</p>\n\n<p align=\"center\">\n <a href=\"https://www.rust-lang.org/\"><img alt=\"Rust\" src=\"https://img.shields.io/badge/Rust-2021-b7410e?logo=rust&logoColor=white\"></a>\n <img alt=\"Status\" src=\"https://img.shields.io/badge/status-active-brightgreen\">\n <img alt=\"LLM\" src=\"https://img.shields.io/badge/LLM-DeepSeek-4b6bfb\">\n <img alt=\"Sources\" src=\"https://img.shields.io/badge/sources-GitHub%20%7C%20arXiv%20%7C%20Academic%20Indexes-0f766e\">\n <img alt=\"License\" src=\"https://img.shields.io/badge/license-not%20set-lightgrey\">\n</p>\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<p align=\"center\">\n <img src=\"workflow.png\" alt=\"LitScout-RS workflow\" width=\"860\" />\n</p>\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/<topic>-<timestamp>.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"}}, "src/agent/planner.rs": {"df730e4a09eb4f1c5830a9f3deacc3ae94c1aa19": {"t": 1786068337.9169388, "n": 9831, "h": "a47ef55c1564492d", "c": "use serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\nuse crate::config::LlmConfig;\nuse crate::error::{AppError, Result};\nuse crate::llm::deepseek::{\n ChatCompletionsRequest, ChatMessage, DeepSeekClient, DeepSeekConfig, ResponseFormat,\n};\nuse crate::model::{ChapterNode, QueryPortfolio, ResearchBrief};\nuse crate::run_policy::RunPolicy;\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\npub struct ChapterPlanOutput {\n pub chapters: Vec<ChapterNode>,\n pub query_portfolio: Vec<QueryPortfolio>,\n pub warnings: Vec<String>,\n}\n\npub async fn generate_chapter_plan(\n brief: &ResearchBrief,\n policy: &RunPolicy,\n llm_config: &LlmConfig,\n) -> Result<ChapterPlanOutput> {\n let plan = generate_chinese_plan(\n &brief.topic,\n policy.github_budget,\n policy.arxiv_budget,\n llm_config,\n )\n .await?;\n Ok(chapter_plan_from_plan_output(&plan, policy))\n}\n\npub fn chapter_plan_from_plan_output(plan: &PlanOutput, policy: &RunPolicy) -> ChapterPlanOutput {\n let aspects = plan\n .aspects\n .iter()\n .take(policy.max_aspects_per_round.max(1))\n .collect::<Vec<_>>();\n let mut warnings = plan.warnings.clone();\n if aspects.is_empty() {\n warnings.push(\"搜索计划为空,使用默认章节。\".to_string());\n }\n\n let fallback_aspect = AspectOutput {\n name_zh: \"默认研究方向\".to_string(),\n rationale_zh: \"围绕原始主题进行 GitHub 与 arXiv 检索。\".to_string(),\n github_query: plan.original_topic.clone(),\n arxiv_query: plan.original_topic.clone(),\n github_limit: policy.github_budget,\n arxiv_limit: policy.arxiv_budget,\n };\n let effective_aspects = if aspects.is_empty() {\n vec![&fallback_aspect]\n } else {\n aspects\n };\n\n let chapters = effective_aspects\n .iter()\n .enumerate()\n .map(|(index, aspect)| ChapterNode {\n id: format!(\"ch-{}\", index + 1),\n parent_id: None,\n title_zh: aspect.name_zh.clone(),\n research_question: aspect.rationale_zh.clone(),\n required_evidence_kinds: vec![\"github\".to_string(), \"arxiv\".to_string()],\n evidence_quota: (aspect.github_limit + aspect.arxiv_limit).max(1),\n sort_order: index + 1,\n })\n .collect::<Vec<_>>();\n\n let query_portfolio = effective_aspects\n .iter()\n .enumerate()\n .map(|(index, aspect)| QueryPortfolio {\n chapter_id: format!(\"ch-{}\", index + 1),\n github_queries: vec![aspect.github_query.clone()],\n arxiv_queries: vec![aspect.arxiv_query.clone()],\n rationale: aspect.rationale_zh.clone(),\n budget: (aspect.github_limit + aspect.arxiv_limit).max(1),\n })\n .collect();\n\n ChapterPlanOutput {\n chapters,\n query_portfolio,\n warnings,\n }\n}\n\npub async fn generate_chinese_plan(\n topic: &str,\n github_limit: usize,\n arxiv_limit: usize,\n llm_config: &LlmConfig,\n) -> Result<PlanOutput> {\n if !llm_config.enabled {\n return Ok(default_plan(topic, github_limit, arxiv_limit));\n }\n\n let config = DeepSeekConfig::from_llm_config(llm_config).ok_or_else(|| {\n AppError::InvalidConfig(\n \"`--llm` requires a DeepSeek API key. Set DEEPSEEK_API_KEY or pass --deepseek-api-key.\"\n .to_string(),\n )\n })?;\n let client = DeepSeekClient::new(config)?;\n let request = build_plan_request(\n client.side_model(),\n format!(\n \"请为以下技术主题生成搜索计划。\\n主题:{topic}\\nGitHub limit: {github_limit}\\narXiv limit: {arxiv_limit}\"\n ),\n );\n let response = client.chat_completions_with_retry(request).await?;\n let content = response.first_content()?;\n parse_plan_json(content, topic, github_limit, arxiv_limit)\n}\n\npub fn default_plan(topic: &str, github_limit: usize, arxiv_limit: usize) -> PlanOutput {\n PlanOutput {\n plan_id: Uuid::new_v4().to_string(),\n original_topic: topic.to_string(),\n aspects: vec![AspectOutput {\n name_zh: \"默认搜索\".to_string(),\n rationale_zh: \"未启用 LLM,使用原始主题作为搜索词。\".to_string(),\n github_query: topic.to_string(),\n arxiv_query: topic.to_string(),\n github_limit,\n arxiv_limit,\n }],\n llm_generated: false,\n warnings: vec![\n \"LLM 未启用,使用默认搜索计划;如需中文智能规划,请以 --llm 启动并配置 DeepSeek API key。\"\n .to_string(),\n ],\n }\n}\n\nfn build_plan_request(model: String, content: String) -> ChatCompletionsRequest {\n ChatCompletionsRequest {\n model,\n messages: vec![\n ChatMessage {\n role: \"system\".to_string(),\n content: SEARCH_PLAN_SYSTEM_PROMPT.to_string(),\n },\n ChatMessage {\n role: \"user\".to_string(),\n content,\n },\n ],\n temperature: Some(0.2),\n max_tokens: Some(1024),\n stream: Some(false),\n response_format: Some(ResponseFormat {\n r#type: \"json_object\".to_string(),\n }),\n }\n}\n\npub(crate) fn parse_plan_json(\n content: &str,\n topic: &str,\n github_limit: usize,\n arxiv_limit: usize,\n) -> Result<PlanOutput> {\n let json = crate::llm::deepseek::strip_json_fence(content);\n let llm_response: LlmPlanResponse = serde_json::from_str(json)\n .map_err(|err| AppError::Llm(format!(\"章节计划 JSON 解析失败: {err}\")))?;\n\n let raw_aspects = llm_response\n .aspects\n .into_iter()\n .take(3)\n .filter(|aspect| {\n !aspect.github_query.trim().is_empty() && !aspect.arxiv_query.trim().is_empty()\n })\n .collect::<Vec<_>>();\n\n if raw_aspects.is_empty() {\n return Err(AppError::Llm(\"章节计划未包含可用搜索方向\".to_string()));\n }\n\n let github_per_aspect = split_limit(github_limit, raw_aspects.len());\n let arxiv_per_aspect = split_limit(arxiv_limit, raw_aspects.len());\n let aspects = raw_aspects\n .into_iter()\n .map(|aspect| AspectOutput {\n name_zh: non_empty_or(aspect.name_zh, \"研究方向\"),\n rationale_zh: non_empty_or(aspect.rationale_zh, \"基于主题关键词的搜索方向。\"),\n github_query: aspect.github_query.trim().to_string(),\n arxiv_query: aspect.arxiv_query.trim().to_string(),\n github_limit: github_per_aspect,\n arxiv_limit: arxiv_per_aspect,\n })\n .collect();\n\n Ok(PlanOutput {\n plan_id: Uuid::new_v4().to_string(),\n original_topic: topic.to_string(),\n aspects,\n llm_generated: true,\n warnings: Vec::new(),\n })\n}\n\nfn split_limit(limit: usize, parts: usize) -> usize {\n if parts == 0 {\n return limit.max(1);\n }\n limit.div_ceil(parts).max(1)\n}\n\nfn non_empty_or(value: String, default: &str) -> String {\n let trimmed = value.trim();\n if trimmed.is_empty() {\n default.to_string()\n } else {\n trimmed.to_string()\n }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\npub struct PlanOutput {\n pub plan_id: String,\n pub original_topic: String,\n pub aspects: Vec<AspectOutput>,\n pub llm_generated: bool,\n pub warnings: Vec<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\npub struct AspectOutput {\n pub name_zh: String,\n pub rationale_zh: String,\n pub github_query: String,\n pub arxiv_query: String,\n pub github_limit: usize,\n pub arxiv_limit: usize,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlmPlanResponse {\n aspects: Vec<LlmAspect>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlmAspect {\n name_zh: String,\n rationale_zh: String,\n github_query: String,\n arxiv_query: String,\n}\n\nconst SEARCH_PLAN_SYSTEM_PROMPT: &str = \"\\\n你是 LitScout-RS 的研究侦察规划器。用户用中文描述技术主题,你生成受控搜索计划。\n\n规则:\n1. 生成 1-3 个搜索角度。\n2. name_zh 和 rationale_zh 必须使用中文。\n3. github_query 和 arxiv_query 必须使用英文技术术语或中英混合术语,以保证 GitHub/arXiv 检索效果。\n4. 不要使用任意网页、浏览器、PDF 或外部搜索源。\n5. 不要使用 AND/OR/NOT 等复杂布尔表达式,使用自然关键词即可。\n6. 只返回 JSON,不要任何额外文本。\n\n返回格式:\n{\n \\\"aspects\\\": [\n {\n \\\"name_zh\\\": \\\"核心框架方向\\\",\n \\\"rationale_zh\\\": \\\"优先定位可直接复用的实现框架。\\\",\n \\\"github_query\\\": \\\"llm agent framework tool calling\\\",\n \\\"arxiv_query\\\": \\\"large language model tool calling agent\\\"\n }\n ]\n}\";\n\n#[cfg(test)]\nmod tests {\n use super::{default_plan, parse_plan_json};\n\n #[test]\n fn default_plan_is_valid_without_llm() {\n let plan = default_plan(\"Rust Agent 框架\", 8, 6);\n\n assert!(!plan.llm_generated);\n assert_eq!(plan.original_topic, \"Rust Agent 框架\");\n assert_eq!(plan.aspects.len(), 1);\n assert_eq!(plan.aspects[0].github_limit, 8);\n assert_eq!(plan.aspects[0].arxiv_limit, 6);\n assert!(!plan.plan_id.is_empty());\n }\n\n #[test]\n fn parse_chinese_plan_json_from_fixture() {\n let content = r#\"{\n \"aspects\": [\n {\n \"name_zh\": \"核心框架方向\",\n \"rationale_zh\": \"寻找可复用 Rust agent 框架。\",\n \"github_query\": \"rust agent framework tool calling\",\n \"arxiv_query\": \"large language model agent framework\"\n },\n {\n \"name_zh\": \"评测基准方向\",\n \"rationale_zh\": \"补充 agent benchmark 相关论文和项目。\",\n \"github_query\": \"agent benchmark rust\",\n \"arxiv_query\": \"agent benchmark tool use\"\n }\n ]\n }\"#;\n\n let plan = parse_plan_json(content, \"Rust Agent 框架\", 9, 7).expect(\"plan should parse\");\n\n assert!(plan.llm_generated);\n assert_eq!(plan.aspects.len(), 2);\n assert_eq!(plan.aspects[0].name_zh, \"核心框架方向\");\n assert_eq!(plan.aspects[0].github_limit, 5);\n assert_eq!(plan.aspects[0].arxiv_limit, 4);\n assert_eq!(plan.aspects[1].arxiv_query, \"agent benchmark tool use\");\n }\n}\n"}}}}