Compare commits

..

No commits in common. "main" and "micro-arch-refactoring" have entirely different histories.

493 changed files with 9200 additions and 75970 deletions

View File

@ -1,51 +0,0 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2024"
rust-version = "1.63"
name = "thread_local"
version = "1.1.9"
authors = ["Amanieu d'Antras <amanieu@gmail.com>"]
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Per-object thread-local storage"
documentation = "https://docs.rs/thread_local/"
readme = "README.md"
keywords = [
"thread_local",
"concurrent",
"thread",
]
license = "MIT OR Apache-2.0"
repository = "https://github.com/Amanieu/thread_local-rs"
[features]
nightly = []
[lib]
name = "thread_local"
path = "src/lib.rs"
[[bench]]
name = "thread_local"
path = "benches/thread_local.rs"
harness = false
[dependencies.cfg-if]
version = "1.0.0"
[dev-dependencies.criterion]
version = "0.5.1"

View File

@ -1,39 +0,0 @@
# VCS
.git
.gitignore
# Editor/IDE
.vscode
.idea
.DS_Store
# Node/Next.js
frontend/node_modules
frontend/.next
**/node_modules
# Rust build artifacts
target
**/target
# Python/build caches
__pycache__
*.pyc
# Large reference/resources not needed in images
# ref/ is usually ignored, but we need service_kit_mirror for build context
# We use exclusion pattern (!) to allow specific subdirectories
ref/*
!ref/service_kit_mirror
archive/
docs/
# Logs/temp
*.log
tmp/
temp/
.cache
# Docker compose override (optional)
docker-compose.override.yml

2
.gitignore vendored
View File

@ -17,7 +17,7 @@ services/**/node_modules/
# Build artifacts
dist/
build/
ref/
# Binaries
portwardenc-amd64

View File

@ -1,6 +0,0 @@
# Ignore Rust source changes to prevent Tilt from rebuilding/restarting containers.
# We rely on cargo-watch inside the container for hot reload (via volume mounts).
**/*.rs
**/Cargo.toml
**/Cargo.lock

View File

@ -1,34 +0,0 @@
[workspace]
resolver = "2"
members = [
"services/alphavantage-provider-service",
"services/api-gateway",
"services/common-contracts",
"services/data-persistence-service",
"services/finnhub-provider-service",
"services/mock-provider-service",
"services/report-generator-service",
"services/tushare-provider-service",
"services/workflow-orchestrator-service",
"services/yfinance-provider-service",
"crates/workflow-context",
"tests/end-to-end",
]
[workspace.package]
edition = "2024"
version = "0.1.0"
authors = ["Lv, Qi <lvsoft@gmail.com>"]
license = "MIT"
repository = "https://github.com/lvsoft/Fundamental_Analysis"
homepage = "https://github.com/lvsoft/Fundamental_Analysis"
readme = "README.md"
[workspace.dependencies]
rmcp = "0.9.1"
rmcp-macros = "0.9.1"
[patch.crates-io]
service_kit = { path = "ref/service_kit_mirror/service_kit/service_kit" }
service-kit-macros = { path = "ref/service_kit_mirror/service_kit/service_kit/service-kit-macros" }

View File

@ -1,23 +0,0 @@
# 加载生产环境配置
docker_compose('docker-compose.prod.yml')
# 定义服务列表
# 这些服务涉及到代码编译Release 模式)或构建(前端),过程较慢
# 我们将它们设置为手动触发模式,避免开发过程中意外修改文件导致自动触发漫长的重构建
services = [
'data-persistence-service',
'api-gateway',
'mock-provider-service',
'alphavantage-provider-service',
'tushare-provider-service',
'finnhub-provider-service',
'yfinance-provider-service',
'report-generator-service',
'workflow-orchestrator-service',
'frontend'
]
# 遍历设置触发模式为手动 (Manual)
for name in services:
dc_resource(name, trigger_mode=TRIGGER_MODE_MANUAL)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,23 +0,0 @@
[package]
name = "workflow-context"
version = "0.1.0"
edition = "2024"
[lib]
name = "workflow_context"
path = "src/lib.rs"
[dependencies]
git2 = { version = "0.18", features = ["vendored-openssl"] }
sha2 = "0.10"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
thiserror = "1.0"
hex = "0.4"
walkdir = "2.3"
regex = "1.10"
globset = "0.4"
[dev-dependencies]
tempfile = "3.8"

View File

@ -1,320 +0,0 @@
use anyhow::{Result, anyhow, Context};
use std::io::Read;
use std::sync::Arc;
use regex::Regex;
use crate::types::{DocNode, DocNodeKind, EntryKind};
use crate::traits::{ContextStore, Transaction};
pub trait DocManager {
/// Reload state based on the latest Commit
fn reload(&mut self, commit_hash: &str) -> Result<()>;
/// Get the current document tree outline
fn get_outline(&self) -> Result<DocNode>;
/// Read node content
fn read_content(&self, path: &str) -> Result<String>;
/// Write content (Upsert)
fn write_content(&mut self, path: &str, content: &str) -> Result<()>;
/// Insert subsection (Implies Promotion)
fn insert_subsection(&mut self, parent_path: &str, name: &str, content: &str) -> Result<()>;
/// Demote Composite to Leaf (Aggregation)
fn demote(&mut self, path: &str) -> Result<()>;
/// Commit changes
fn save(&mut self, message: &str) -> Result<String>;
}
pub struct DocOS<S: ContextStore> {
store: Arc<S>,
req_id: String,
commit_hash: String,
transaction: Option<Box<dyn Transaction>>,
}
impl<S: ContextStore> DocOS<S> {
pub fn new(store: Arc<S>, req_id: &str, commit_hash: &str) -> Self {
Self {
store,
req_id: req_id.to_string(),
commit_hash: commit_hash.to_string(),
transaction: None,
}
}
fn ensure_transaction(&mut self) -> Result<&mut Box<dyn Transaction>> {
if self.transaction.is_none() {
let tx = self.store.begin_transaction(&self.req_id, &self.commit_hash)?;
self.transaction = Some(tx);
}
Ok(self.transaction.as_mut().unwrap())
}
fn is_leaf(&self, path: &str) -> Result<bool> {
match self.store.read_file(&self.req_id, &self.commit_hash, path) {
Ok(_) => Ok(true),
Err(_) => Ok(false),
}
}
fn is_composite(&self, path: &str) -> Result<bool> {
match self.store.list_dir(&self.req_id, &self.commit_hash, path) {
Ok(_) => Ok(true),
Err(_) => Ok(false),
}
}
/// Parse Markdown headers to find subsections
fn parse_markdown_headers(&self, content: &str) -> Vec<DocNode> {
let re = Regex::new(r"(?m)^(#{1,6})\s+(.+)").unwrap();
let mut sections = Vec::new();
for cap in re.captures_iter(content) {
let _level = cap[1].len();
let name = cap[2].trim().to_string();
// Simplified logic: All headers are children of the file node
// In a real rich outline, we would build a tree based on level.
// For this MVP, we treat found sections as direct children in the outline view.
sections.push(DocNode {
name: name.clone(),
path: "".to_string(), // Virtual path, no direct file address
kind: DocNodeKind::Section,
children: vec![],
});
}
sections
}
fn build_node(&self, name: String, path: String, kind: DocNodeKind) -> Result<DocNode> {
let mut node = DocNode {
name,
path: path.clone(),
kind: kind.clone(),
children: vec![],
};
match kind {
DocNodeKind::Composite => {
let entries = self.store.list_dir(&self.req_id, &self.commit_hash, &path)?;
// 1. Process index.md first if exists (content of this composite node)
let mut index_content = String::new();
if let Ok(mut reader) = self.store.read_file(&self.req_id, &self.commit_hash, &format!("{}/index.md", path)) {
reader.read_to_string(&mut index_content).unwrap_or_default();
let sections = self.parse_markdown_headers(&index_content);
node.children.extend(sections);
}
// 2. Process children files/dirs
let mut children_nodes = Vec::new();
for entry in entries {
if entry.name == "index.md" || entry.name == "_meta.json" || entry.name.starts_with(".") {
continue;
}
let child_path = if path == "/" {
entry.name.clone()
} else {
format!("{}/{}", path, entry.name)
};
let child_kind = match entry.kind {
EntryKind::Dir => DocNodeKind::Composite,
EntryKind::File => DocNodeKind::Leaf,
};
let child_node = self.build_node(entry.name, child_path, child_kind)?;
children_nodes.push(child_node);
}
// Sort children by name (simple default)
children_nodes.sort_by(|a, b| a.name.cmp(&b.name));
node.children.extend(children_nodes);
}
DocNodeKind::Leaf => {
// Parse content for sections
if let Ok(mut reader) = self.store.read_file(&self.req_id, &self.commit_hash, &path) {
let mut content = String::new();
reader.read_to_string(&mut content).unwrap_or_default();
let sections = self.parse_markdown_headers(&content);
node.children.extend(sections);
}
}
DocNodeKind::Section => {
// Sections don't have children in this simplified view
}
}
Ok(node)
}
}
impl<S: ContextStore> DocManager for DocOS<S> {
fn reload(&mut self, commit_hash: &str) -> Result<()> {
self.commit_hash = commit_hash.to_string();
self.transaction = None;
Ok(())
}
fn get_outline(&self) -> Result<DocNode> {
self.build_node("Root".to_string(), "/".to_string(), DocNodeKind::Composite)
}
fn read_content(&self, path: &str) -> Result<String> {
let target_path = if path == "/" {
"index.md".to_string()
} else if self.is_composite(path)? {
format!("{}/index.md", path)
} else {
path.to_string()
};
let mut reader = self.store.read_file(&self.req_id, &self.commit_hash, &target_path)
.context("Failed to read content")?;
let mut content = String::new();
reader.read_to_string(&mut content)?;
Ok(content)
}
fn write_content(&mut self, path: &str, content: &str) -> Result<()> {
let is_comp = self.is_composite(path)?;
let target_path = if is_comp {
format!("{}/index.md", path)
} else {
path.to_string()
};
let tx = self.ensure_transaction()?;
tx.write(&target_path, content.as_bytes())?;
Ok(())
}
fn insert_subsection(&mut self, parent_path: &str, name: &str, content: &str) -> Result<()> {
let is_leaf = self.is_leaf(parent_path)?;
let is_composite = self.is_composite(parent_path)?;
if !is_leaf && !is_composite && parent_path != "/" {
return Err(anyhow!("Parent path '{}' does not exist", parent_path));
}
if is_leaf {
// Promote: Leaf -> Composite
let old_content = self.read_content(parent_path)?;
let tx = self.ensure_transaction()?;
tx.remove(parent_path)?;
let index_path = format!("{}/index.md", parent_path);
tx.write(&index_path, old_content.as_bytes())?;
let child_path = format!("{}/{}", parent_path, name);
tx.write(&child_path, content.as_bytes())?;
} else {
let child_path = if parent_path == "/" {
name.to_string()
} else {
format!("{}/{}", parent_path, name)
};
let tx = self.ensure_transaction()?;
tx.write(&child_path, content.as_bytes())?;
}
Ok(())
}
fn demote(&mut self, path: &str) -> Result<()> {
if !self.is_composite(path)? {
return Err(anyhow!("Path '{}' is not a composite node (directory)", path));
}
if path == "/" {
return Err(anyhow!("Cannot demote root"));
}
// 1. Read index.md (Main content)
let mut main_content = String::new();
if let Ok(content) = self.read_content(path) {
main_content = content;
}
// Reading directory entries
let entries = self.store.list_dir(&self.req_id, &self.commit_hash, path)?;
// Sort entries to have deterministic order
let mut sorted_entries = entries;
sorted_entries.sort_by(|a, b| a.name.cmp(&b.name));
let mut combined_content = main_content;
// Iterate for content reading (Borrowing self immutably)
for entry in &sorted_entries {
if entry.name == "index.md" || entry.name == "_meta.json" || entry.name.starts_with(".") {
continue;
}
let child_rel_path = format!("{}/{}", path, entry.name);
let child_content = self.read_content(&child_rel_path)?;
combined_content.push_str(&format!("\n\n# {}\n\n", entry.name));
combined_content.push_str(&child_content);
}
// Get list of items to remove before starting transaction (to avoid double borrow)
// We need a recursive list of paths to remove from git index.
let paths_to_remove = self.collect_recursive_paths(path)?;
let tx = self.ensure_transaction()?;
// 3. Remove everything recursively
for p in paths_to_remove {
tx.remove(&p)?;
}
// Also remove the directory path itself (conceptually, or handled by git index cleanup)
// In our simplified VGCS, remove(dir) is not enough if not empty.
// But we just cleaned up recursively.
// 4. Write new file
tx.write(path, combined_content.as_bytes())?;
Ok(())
}
fn save(&mut self, message: &str) -> Result<String> {
if let Some(tx) = self.transaction.take() {
let new_oid = tx.commit(message, "DocOS User")?;
self.commit_hash = new_oid.clone();
Ok(new_oid)
} else {
Ok(self.commit_hash.clone())
}
}
}
impl<S: ContextStore> DocOS<S> {
// Helper: Collect paths recursively (reading from store, immutable self)
fn collect_recursive_paths(&self, path: &str) -> Result<Vec<String>> {
let mut paths = Vec::new();
let entries = self.store.list_dir(&self.req_id, &self.commit_hash, path);
if let Ok(entries) = entries {
for entry in entries {
let child_path = format!("{}/{}", path, entry.name);
match entry.kind {
EntryKind::File => {
paths.push(child_path);
},
EntryKind::Dir => {
// Add children of dir first
let mut sub_paths = self.collect_recursive_paths(&child_path)?;
paths.append(&mut sub_paths);
// No need to remove dir itself in git, but we might track it?
}
}
}
}
Ok(paths)
}
}

View File

@ -1,11 +0,0 @@
pub mod types;
pub mod traits;
pub mod vgcs;
pub mod docos;
pub mod worker_runtime;
pub use types::*;
pub use traits::*;
pub use vgcs::Vgcs;
pub use docos::{DocOS, DocManager};
pub use worker_runtime::{WorkerContext, ContextShell, OutputFormat, FindOptions, NodeMetadata, GrepMatch, FileStats};

View File

@ -1,39 +0,0 @@
use anyhow::Result;
use std::io::Read;
use crate::types::{DirEntry, FileChange};
pub trait ContextStore {
/// Initialize a new repository for the request
fn init_repo(&self, req_id: &str) -> Result<()>;
/// Read file content. Transparently handles BlobRef redirection.
fn read_file(&self, req_id: &str, commit_hash: &str, path: &str) -> Result<Box<dyn Read + Send>>;
/// List directory contents
fn list_dir(&self, req_id: &str, commit_hash: &str, path: &str) -> Result<Vec<DirEntry>>;
/// Get changes between two commits
fn diff(&self, req_id: &str, from_commit: &str, to_commit: &str) -> Result<Vec<FileChange>>;
/// Three-way merge (In-Memory), returns new Tree OID
fn merge_trees(&self, req_id: &str, base: &str, ours: &str, theirs: &str) -> Result<String>;
/// Smart merge two commits, automatically finding the best common ancestor.
/// Returns the OID of the new merge commit.
fn merge_commits(&self, req_id: &str, our_commit: &str, their_commit: &str) -> Result<String>;
/// Start a write transaction
fn begin_transaction(&self, req_id: &str, base_commit: &str) -> Result<Box<dyn Transaction>>;
}
pub trait Transaction {
/// Write file content
fn write(&mut self, path: &str, content: &[u8]) -> Result<()>;
/// Remove file
fn remove(&mut self, path: &str) -> Result<()>;
/// Commit changes
fn commit(self: Box<Self>, message: &str, author: &str) -> Result<String>;
}

View File

@ -1,50 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EntryKind {
File,
Dir,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirEntry {
pub name: String,
pub kind: EntryKind,
pub object_id: String,
// New metadata fields
pub size: Option<u64>,
pub line_count: Option<usize>,
pub word_count: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FileChange {
Added(String),
Modified(String),
Deleted(String),
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BlobRef {
#[serde(rename = "$vgcs_ref")]
pub vgcs_ref: String, // "v1"
pub sha256: String,
pub size: u64,
pub mime_type: String,
pub original_name: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum DocNodeKind {
Leaf, // Pure content node (file)
Composite, // Composite node (dir with index.md)
Section, // Virtual node (Markdown Header inside a file)
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DocNode {
pub name: String,
pub path: String, // Logical path e.g., "Analysis/Revenue"
pub kind: DocNodeKind,
pub children: Vec<DocNode>, // Only for Composite or Section-bearing Leaf
}

View File

@ -1,361 +0,0 @@
use std::path::{Path, PathBuf};
use std::fs::{self, File};
use std::io::{Cursor, Read, Write};
use anyhow::{Context, Result, anyhow};
use git2::{Repository, Oid, ObjectType, Signature, Index, IndexEntry, IndexTime};
use sha2::{Sha256, Digest};
use crate::traits::{ContextStore, Transaction};
use crate::types::{DirEntry, EntryKind, FileChange, BlobRef};
pub struct Vgcs {
root_path: PathBuf,
}
impl Vgcs {
pub fn new<P: AsRef<Path>>(path: P) -> Self {
Self {
root_path: path.as_ref().to_path_buf(),
}
}
fn get_repo_path(&self, req_id: &str) -> PathBuf {
self.root_path.join("repos").join(format!("{}.git", req_id))
}
fn get_blob_store_root(&self, req_id: &str) -> PathBuf {
self.root_path.join("blobs").join(req_id)
}
fn get_blob_path(&self, req_id: &str, sha256: &str) -> PathBuf {
self.get_blob_store_root(req_id)
.join(&sha256[0..2])
.join(sha256)
}
}
impl ContextStore for Vgcs {
fn init_repo(&self, req_id: &str) -> Result<()> {
let repo_path = self.get_repo_path(req_id);
if !repo_path.exists() {
fs::create_dir_all(&repo_path).context("Failed to create repo dir")?;
Repository::init_bare(&repo_path).context("Failed to init bare repo")?;
}
Ok(())
}
fn read_file(&self, req_id: &str, commit_hash: &str, path: &str) -> Result<Box<dyn Read + Send>> {
let repo_path = self.get_repo_path(req_id);
let repo = Repository::open(&repo_path).context("Failed to open repo")?;
let oid = Oid::from_str(commit_hash).context("Invalid commit hash")?;
let commit = repo.find_commit(oid).context("Commit not found")?;
let tree = commit.tree().context("Tree not found")?;
let entry = tree.get_path(Path::new(path)).context("File not found in tree")?;
let object = entry.to_object(&repo).context("Object not found")?;
if let Some(blob) = object.as_blob() {
let content = blob.content();
// Try parsing as BlobRef
if let Ok(blob_ref) = serde_json::from_slice::<BlobRef>(content) {
if blob_ref.vgcs_ref == "v1" {
let blob_path = self.get_blob_path(req_id, &blob_ref.sha256);
let file = File::open(blob_path).context("Failed to open blob file from store")?;
return Ok(Box::new(file));
}
}
// Return raw content
return Ok(Box::new(Cursor::new(content.to_vec())));
}
Err(anyhow!("Path is not a file"))
}
fn list_dir(&self, req_id: &str, commit_hash: &str, path: &str) -> Result<Vec<DirEntry>> {
let repo_path = self.get_repo_path(req_id);
let repo = Repository::open(&repo_path).context("Failed to open repo")?;
let oid = Oid::from_str(commit_hash).context("Invalid commit hash")?;
let commit = repo.find_commit(oid).context("Commit not found")?;
let root_tree = commit.tree().context("Tree not found")?;
let tree = if path.is_empty() || path == "/" || path == "." {
root_tree
} else {
let entry = root_tree.get_path(Path::new(path)).context("Path not found")?;
let object = entry.to_object(&repo).context("Object not found")?;
object.into_tree().map_err(|_| anyhow!("Path is not a directory"))?
};
let mut entries = Vec::new();
for entry in tree.iter() {
let name = entry.name().unwrap_or("").to_string();
let kind = match entry.kind() {
Some(ObjectType::Tree) => EntryKind::Dir,
_ => EntryKind::File,
};
let object_id = entry.id().to_string();
// Metadata extraction (Expensive but necessary for the prompt)
let mut size = None;
let mut line_count = None;
let mut word_count = None;
if kind == EntryKind::File {
if let Ok(object) = entry.to_object(&repo) {
if let Some(blob) = object.as_blob() {
let content = blob.content();
size = Some(content.len() as u64);
// Check for binary content or just use heuristic
if !content.contains(&0) {
let s = String::from_utf8_lossy(content);
line_count = Some(s.lines().count());
word_count = Some(s.split_whitespace().count());
}
}
}
}
entries.push(DirEntry { name, kind, object_id, size, line_count, word_count });
}
Ok(entries)
}
fn diff(&self, req_id: &str, from_commit: &str, to_commit: &str) -> Result<Vec<FileChange>> {
let repo_path = self.get_repo_path(req_id);
let repo = Repository::open(&repo_path).context("Failed to open repo")?;
let from_oid = Oid::from_str(from_commit).context("Invalid from_commit")?;
let to_oid = Oid::from_str(to_commit).context("Invalid to_commit")?;
let from_tree = repo.find_commit(from_oid)?.tree()?;
let to_tree = repo.find_commit(to_oid)?.tree()?;
let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)?;
let mut changes = Vec::new();
diff.foreach(&mut |delta, _| {
let path = delta.new_file().path().or(delta.old_file().path()).unwrap();
let path_str = path.to_string_lossy().to_string();
match delta.status() {
git2::Delta::Added => changes.push(FileChange::Added(path_str)),
git2::Delta::Deleted => changes.push(FileChange::Deleted(path_str)),
git2::Delta::Modified => changes.push(FileChange::Modified(path_str)),
_ => {}
}
true
}, None, None, None)?;
Ok(changes)
}
fn merge_trees(&self, req_id: &str, base: &str, ours: &str, theirs: &str) -> Result<String> {
let repo_path = self.get_repo_path(req_id);
let repo = Repository::open(&repo_path).context("Failed to open repo")?;
let base_tree = repo.find_commit(Oid::from_str(base)?)?.tree()?;
let our_tree = repo.find_commit(Oid::from_str(ours)?)?.tree()?;
let their_tree = repo.find_commit(Oid::from_str(theirs)?)?.tree()?;
let mut index = repo.merge_trees(&base_tree, &our_tree, &their_tree, None)?;
if index.has_conflicts() {
return Err(anyhow!("Merge conflict detected"));
}
let oid = index.write_tree_to(&repo)?;
Ok(oid.to_string())
}
fn merge_commits(&self, req_id: &str, our_commit: &str, their_commit: &str) -> Result<String> {
let repo_path = self.get_repo_path(req_id);
let repo = Repository::open(&repo_path).context("Failed to open repo")?;
let our_oid = Oid::from_str(our_commit).context("Invalid our_commit")?;
let their_oid = Oid::from_str(their_commit).context("Invalid their_commit")?;
let base_oid = repo.merge_base(our_oid, their_oid).context("Failed to find merge base")?;
let base_commit = repo.find_commit(base_oid)?;
let our_commit_obj = repo.find_commit(our_oid)?;
let their_commit_obj = repo.find_commit(their_oid)?;
// If base equals one of the commits, it's a fast-forward
if base_oid == our_oid {
return Ok(their_commit.to_string());
}
if base_oid == their_oid {
return Ok(our_commit.to_string());
}
let base_tree = base_commit.tree()?;
let our_tree = our_commit_obj.tree()?;
let their_tree = their_commit_obj.tree()?;
let mut index = repo.merge_trees(&base_tree, &our_tree, &their_tree, None)?;
if index.has_conflicts() {
return Err(anyhow!("Merge conflict detected between {} and {}", our_commit, their_commit));
}
let tree_oid = index.write_tree_to(&repo)?;
let tree = repo.find_tree(tree_oid)?;
let sig = Signature::now("vgcs-merge", "system")?;
let merge_commit_oid = repo.commit(
None, // Detached
&sig,
&sig,
&format!("Merge commit {} into {}", their_commit, our_commit),
&tree,
&[&our_commit_obj, &their_commit_obj],
)?;
Ok(merge_commit_oid.to_string())
}
fn begin_transaction(&self, req_id: &str, base_commit: &str) -> Result<Box<dyn Transaction>> {
let repo_path = self.get_repo_path(req_id);
let repo = Repository::open(&repo_path).context("Failed to open repo")?;
let mut index = Index::new()?;
let mut base_commit_oid = None;
if !base_commit.is_empty() {
let base_oid = Oid::from_str(base_commit).context("Invalid base_commit")?;
if !base_oid.is_zero() {
// Scope the borrow of repo
{
let commit = repo.find_commit(base_oid).context("Base commit not found")?;
let tree = commit.tree()?;
index.read_tree(&tree)?;
}
base_commit_oid = Some(base_oid);
}
}
Ok(Box::new(VgcsTransaction {
repo,
req_id: req_id.to_string(),
root_path: self.root_path.clone(),
base_commit: base_commit_oid,
index,
}))
}
}
pub struct VgcsTransaction {
repo: Repository,
req_id: String,
root_path: PathBuf,
base_commit: Option<Oid>,
index: Index,
}
impl Transaction for VgcsTransaction {
fn write(&mut self, path: &str, content: &[u8]) -> Result<()> {
let final_content = if content.len() > 1024 * 1024 { // 1MB
// Calculate SHA256
let mut hasher = Sha256::new();
hasher.update(content);
let result = hasher.finalize();
let sha256 = hex::encode(result);
// Write to Blob Store
let blob_path = self.root_path
.join("blobs")
.join(&self.req_id)
.join(&sha256[0..2])
.join(&sha256);
if !blob_path.exists() {
if let Some(parent) = blob_path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = File::create(&blob_path)?;
file.write_all(content)?;
}
// Create BlobRef JSON
let blob_ref = BlobRef {
vgcs_ref: "v1".to_string(),
sha256: sha256,
size: content.len() as u64,
mime_type: "application/octet-stream".to_string(), // Simplified
original_name: Path::new(path).file_name().unwrap_or_default().to_string_lossy().to_string(),
};
serde_json::to_vec(&blob_ref)?
} else {
content.to_vec()
};
// Write to ODB manually
let oid = self.repo.blob(&final_content)?;
let mut entry = create_index_entry(path, 0o100644);
entry.id = oid;
entry.file_size = final_content.len() as u32;
self.index.add(&entry).context("Failed to add entry to index")?;
Ok(())
}
fn remove(&mut self, path: &str) -> Result<()> {
self.index.remove_path(Path::new(path))?;
Ok(())
}
fn commit(mut self: Box<Self>, message: &str, author: &str) -> Result<String> {
let tree_oid = self.index.write_tree_to(&self.repo)?;
let tree = self.repo.find_tree(tree_oid)?;
let sig = Signature::now(author, "vgcs@system")?;
let commit_oid = if let Some(base_oid) = self.base_commit {
let parent_commit = self.repo.find_commit(base_oid)?;
self.repo.commit(
None, // Detached commit
&sig,
&sig,
message,
&tree,
&[&parent_commit],
)?
} else {
self.repo.commit(
None, // Detached commit
&sig,
&sig,
message,
&tree,
&[],
)?
};
Ok(commit_oid.to_string())
}
}
fn create_index_entry(path: &str, mode: u32) -> IndexEntry {
IndexEntry {
ctime: IndexTime::new(0, 0),
mtime: IndexTime::new(0, 0),
dev: 0,
ino: 0,
mode,
uid: 0,
gid: 0,
file_size: 0,
id: Oid::zero(),
flags: 0,
flags_extended: 0,
path: path.as_bytes().to_vec(),
}
}

View File

@ -1,378 +0,0 @@
use std::path::Path;
use std::sync::Arc;
use std::env;
use anyhow::{Result, Context, anyhow};
use serde::{Serialize, Deserialize};
use serde::de::DeserializeOwned;
use globset::Glob;
use regex::Regex;
use crate::{DocOS, DocManager, Vgcs, DocNodeKind};
// --- Data Structures ---
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Text,
Json,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct NodeMetadata {
pub path: String,
pub kind: String, // "File" or "Dir"
pub size: u64,
// pub modified: bool, // TODO: Implement diff check against base
}
#[derive(Debug, Default, Clone)]
pub struct FindOptions {
pub recursive: bool,
pub max_depth: Option<usize>,
pub type_filter: Option<String>, // "File" or "Dir"
pub min_size: Option<u64>,
pub max_size: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GrepMatch {
pub path: String,
pub line_number: usize,
pub content: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FileStats {
pub path: String,
pub lines: usize,
pub bytes: usize,
}
// --- Trait Definition ---
pub trait ContextShell {
fn tree(&self, path: &str, depth: Option<usize>, format: OutputFormat) -> Result<String>;
fn find(&self, name_pattern: &str, options: FindOptions) -> Result<Vec<NodeMetadata>>;
fn grep(&self, pattern: &str, paths: Option<Vec<String>>) -> Result<Vec<GrepMatch>>;
fn cat(&self, paths: &[String]) -> Result<String>;
fn wc(&self, paths: &[String]) -> Result<Vec<FileStats>>;
fn patch(&mut self, path: &str, original: &str, replacement: &str) -> Result<()>;
}
// --- WorkerContext Implementation ---
pub struct WorkerContext {
doc: DocOS<Vgcs>,
}
impl WorkerContext {
pub fn from_env() -> Result<Self> {
let req_id = env::var("WORKFLOW_REQ_ID").context("Missing WORKFLOW_REQ_ID")?;
let commit = env::var("WORKFLOW_BASE_COMMIT").context("Missing WORKFLOW_BASE_COMMIT")?;
let data_path = env::var("WORKFLOW_DATA_PATH").context("Missing WORKFLOW_DATA_PATH")?;
let vgcs = Vgcs::new(&data_path);
let doc = DocOS::new(Arc::new(vgcs), &req_id, &commit);
Ok(Self { doc })
}
pub fn new(data_path: &str, req_id: &str, commit: &str) -> Self {
let vgcs = Vgcs::new(data_path);
let doc = DocOS::new(Arc::new(vgcs), req_id, commit);
Self { doc }
}
pub fn read_json<T: DeserializeOwned>(&self, path: impl AsRef<Path>) -> Result<T> {
let path_str = path.as_ref().to_string_lossy();
let content = self.doc.read_content(&path_str)?;
let data = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse JSON from {}", path_str))?;
Ok(data)
}
pub fn read_text(&self, path: impl AsRef<Path>) -> Result<String> {
let path_str = path.as_ref().to_string_lossy();
self.doc.read_content(&path_str)
}
pub fn write_file(&mut self, path: impl AsRef<Path>, content: &str) -> Result<()> {
let path_str = path.as_ref().to_string_lossy();
self.doc.write_content(&path_str, content)
}
pub fn attach_subsection(&mut self, parent: impl AsRef<Path>, name: &str, content: &str) -> Result<()> {
let parent_str = parent.as_ref().to_string_lossy();
self.doc.insert_subsection(&parent_str, name, content)
}
pub fn commit(&mut self, message: &str) -> Result<String> {
self.doc.save(message)
}
pub fn get_tool_definitions() -> serde_json::Value {
serde_json::json!([
{
"type": "function",
"function": {
"name": "tree",
"description": "List directory structure to understand the file layout.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Root path to list (default: root)" },
"depth": { "type": "integer", "description": "Recursion depth" },
"format": { "type": "string", "enum": ["Text", "Json"], "default": "Text" }
}
}
}
},
{
"type": "function",
"function": {
"name": "find",
"description": "Find files by name pattern (glob). Fast metadata search.",
"parameters": {
"type": "object",
"required": ["pattern"],
"properties": {
"pattern": { "type": "string", "description": "Glob pattern (e.g. **/*.rs)" },
"recursive": { "type": "boolean", "default": true }
}
}
}
},
{
"type": "function",
"function": {
"name": "grep",
"description": "Search for content within files using regex.",
"parameters": {
"type": "object",
"required": ["pattern"],
"properties": {
"pattern": { "type": "string", "description": "Regex pattern" },
"paths": { "type": "array", "items": { "type": "string" }, "description": "Limit search to these paths" }
}
}
}
},
{
"type": "function",
"function": {
"name": "cat",
"description": "Read and assemble content of multiple files.",
"parameters": {
"type": "object",
"required": ["paths"],
"properties": {
"paths": { "type": "array", "items": { "type": "string" } }
}
}
}
},
{
"type": "function",
"function": {
"name": "patch",
"description": "Replace a specific text block in a file. Use this for small corrections.",
"parameters": {
"type": "object",
"required": ["path", "original", "replacement"],
"properties": {
"path": { "type": "string" },
"original": { "type": "string", "description": "Exact text to look for. Must be unique in file." },
"replacement": { "type": "string", "description": "New text to insert." }
}
}
}
}
])
}
}
impl ContextShell for WorkerContext {
fn tree(&self, path: &str, depth: Option<usize>, format: OutputFormat) -> Result<String> {
let root_node = self.doc.get_outline()?;
let target_node = if path == "/" || path == "." {
Some(&root_node)
} else {
fn find_node<'a>(node: &'a crate::DocNode, path: &str) -> Option<&'a crate::DocNode> {
if node.path == path {
return Some(node);
}
for child in &node.children {
if let Some(found) = find_node(child, path) {
return Some(found);
}
}
None
}
find_node(&root_node, path)
};
let node = target_node.ok_or_else(|| anyhow!("Path not found: {}", path))?;
match format {
OutputFormat::Json => {
Ok(serde_json::to_string_pretty(node)?)
},
OutputFormat::Text => {
let mut output = String::new();
fn print_tree(node: &crate::DocNode, prefix: &str, is_last: bool, depth: usize, max_depth: Option<usize>, output: &mut String) {
if let Some(max) = max_depth {
if depth > max { return; }
}
let name = if node.path == "/" { "." } else { &node.name };
if depth > 0 {
let connector = if is_last { "└── " } else { "├── " };
output.push_str(&format!("{}{}{}\n", prefix, connector, name));
} else {
output.push_str(&format!("{}\n", name));
}
let child_prefix = if depth > 0 {
if is_last { format!("{} ", prefix) } else { format!("{}", prefix) }
} else {
"".to_string()
};
for (i, child) in node.children.iter().enumerate() {
print_tree(child, &child_prefix, i == node.children.len() - 1, depth + 1, max_depth, output);
}
}
print_tree(node, "", true, 0, depth, &mut output);
Ok(output)
}
}
}
fn find(&self, name_pattern: &str, options: FindOptions) -> Result<Vec<NodeMetadata>> {
let root = self.doc.get_outline()?;
let mut results = Vec::new();
let glob = Glob::new(name_pattern)?.compile_matcher();
fn traverse(node: &crate::DocNode, glob: &globset::GlobMatcher, opts: &FindOptions, depth: usize, results: &mut Vec<NodeMetadata>) {
if let Some(max) = opts.max_depth {
if depth > max { return; }
}
let match_name = glob.is_match(&node.name) || glob.is_match(&node.path);
let kind_str = match node.kind {
DocNodeKind::Composite => "Dir",
DocNodeKind::Leaf => "File",
DocNodeKind::Section => "Section",
};
let type_match = match &opts.type_filter {
Some(t) => t.eq_ignore_ascii_case(kind_str),
None => true,
};
if depth > 0 && match_name && type_match {
results.push(NodeMetadata {
path: node.path.clone(),
kind: kind_str.to_string(),
size: 0,
});
}
if opts.recursive || depth == 0 {
for child in &node.children {
traverse(child, glob, opts, depth + 1, results);
}
}
}
traverse(&root, &glob, &options, 0, &mut results);
Ok(results)
}
fn grep(&self, pattern: &str, paths: Option<Vec<String>>) -> Result<Vec<GrepMatch>> {
let re = Regex::new(pattern).context("Invalid regex pattern")?;
let target_paths = match paths {
Some(p) => p,
None => {
let all_nodes = self.find("**/*", FindOptions {
recursive: true,
type_filter: Some("File".to_string()),
..Default::default()
})?;
all_nodes.into_iter().map(|n| n.path).collect()
}
};
let mut matches = Vec::new();
for path in target_paths {
if let Ok(content) = self.read_text(&path) {
for (i, line) in content.lines().enumerate() {
if re.is_match(line) {
matches.push(GrepMatch {
path: path.clone(),
line_number: i + 1,
content: line.trim().to_string(),
});
}
}
}
}
Ok(matches)
}
fn cat(&self, paths: &[String]) -> Result<String> {
let mut output = String::new();
for path in paths {
match self.read_text(path) {
Ok(content) => {
output.push_str(&format!("<file path=\"{}\">\n", path));
output.push_str(&content);
if !content.ends_with('\n') {
output.push('\n');
}
output.push_str("</file>\n\n");
},
Err(e) => {
output.push_str(&format!("<!-- Failed to read {}: {} -->\n", path, e));
}
}
}
Ok(output)
}
fn wc(&self, paths: &[String]) -> Result<Vec<FileStats>> {
let mut stats = Vec::new();
for path in paths {
if let Ok(content) = self.read_text(path) {
stats.push(FileStats {
path: path.clone(),
lines: content.lines().count(),
bytes: content.len(),
});
}
}
Ok(stats)
}
fn patch(&mut self, path: &str, original: &str, replacement: &str) -> Result<()> {
let content = self.read_text(path)?;
let matches: Vec<_> = content.match_indices(original).collect();
match matches.len() {
0 => return Err(anyhow!("Original text not found in {}", path)),
1 => {
let new_content = content.replace(original, replacement);
self.write_file(path, &new_content)?;
Ok(())
},
_ => return Err(anyhow!("Ambiguous match: original text found {} times", matches.len())),
}
}
}

View File

@ -1,141 +0,0 @@
use workflow_context::{ContextStore, Vgcs, DocOS, DocManager, DocNodeKind};
use tempfile::TempDir;
use std::sync::Arc;
const ZERO_OID: &str = "0000000000000000000000000000000000000000";
#[test]
fn test_docos_basic() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let store = Arc::new(Vgcs::new(temp_dir.path()));
let req_id = "req-docos-1";
store.init_repo(req_id)?;
// 1. Init DocOS with empty repo
let mut docos = DocOS::new(store.clone(), req_id, ZERO_OID);
// 2. Create a file (Leaf)
docos.write_content("Introduction", "Intro Content")?;
let _commit_1 = docos.save("Add Intro")?;
// 3. Verify outline
let outline = docos.get_outline()?;
// Root -> [Introduction (Leaf)]
assert_eq!(outline.children.len(), 1);
let intro_node = &outline.children[0];
assert_eq!(intro_node.name, "Introduction");
assert_eq!(intro_node.kind, DocNodeKind::Leaf);
// 4. Read content
let content = docos.read_content("Introduction")?;
assert_eq!(content, "Intro Content");
Ok(())
}
#[test]
fn test_docos_fission() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let store = Arc::new(Vgcs::new(temp_dir.path()));
let req_id = "req-docos-2";
store.init_repo(req_id)?;
let mut docos = DocOS::new(store.clone(), req_id, ZERO_OID);
// 1. Start with a Leaf: "Analysis"
docos.write_content("Analysis", "General Analysis")?;
let commit_1 = docos.save("Init Analysis")?;
// 2. Insert subsection "Revenue" into "Analysis"
// This should promote "Analysis" to Composite
docos.reload(&commit_1)?;
docos.insert_subsection("Analysis", "Revenue", "Revenue Data")?;
let commit_2 = docos.save("Split Analysis")?;
// 3. Verify Structure
docos.reload(&commit_2)?;
let outline = docos.get_outline()?;
// Root -> [Analysis (Composite)]
assert_eq!(outline.children.len(), 1);
let analysis_node = &outline.children[0];
assert_eq!(analysis_node.name, "Analysis");
assert_eq!(analysis_node.kind, DocNodeKind::Composite);
// Analysis -> [Revenue (Leaf)] (index.md is hidden in outline)
assert_eq!(analysis_node.children.len(), 1);
let revenue_node = &analysis_node.children[0];
assert_eq!(revenue_node.name, "Revenue");
assert_eq!(revenue_node.kind, DocNodeKind::Leaf);
// 4. Verify Content
// Reading "Analysis" should now read "Analysis/index.md" which contains "General Analysis"
let analysis_content = docos.read_content("Analysis")?;
assert_eq!(analysis_content, "General Analysis");
let revenue_content = docos.read_content("Analysis/Revenue")?;
assert_eq!(revenue_content, "Revenue Data");
Ok(())
}
#[test]
fn test_docos_fusion_and_outline() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let store = Arc::new(Vgcs::new(temp_dir.path()));
let req_id = "req-docos-3";
store.init_repo(req_id)?;
let mut docos = DocOS::new(store.clone(), req_id, ZERO_OID);
// 1. Create a composite structure (Pre-fissioned state)
// Root -> [Chapter1 (Composite)] -> [SectionA (Leaf), SectionB (Leaf)]
docos.write_content("Chapter1/index.md", "Chapter 1 Intro")?;
docos.write_content("Chapter1/SectionA", "Content A")?;
docos.write_content("Chapter1/SectionB", "Content B")?;
let commit_1 = docos.save("Setup Structure")?;
docos.reload(&commit_1)?;
// Verify Initial Outline
let outline_1 = docos.get_outline()?;
let ch1 = &outline_1.children[0];
assert_eq!(ch1.kind, DocNodeKind::Composite);
assert_eq!(ch1.children.len(), 2); // SectionA, SectionB
// 2. Demote (Fusion)
docos.demote("Chapter1")?;
let commit_2 = docos.save("Demote Chapter 1")?;
// 3. Verify Fusion Result
docos.reload(&commit_2)?;
let outline_2 = docos.get_outline()?;
// Now Chapter1 should be a Leaf
let ch1_fused = &outline_2.children[0];
assert_eq!(ch1_fused.name, "Chapter1");
assert_eq!(ch1_fused.kind, DocNodeKind::Leaf);
// But wait! Because of our Outline Enhancement (Markdown Headers),
// we expect the Fused file to have children (Sections) derived from headers!
// The demote logic appends children with "# Name".
// So "SectionA" became "# SectionA".
// Let's inspect the children of the Fused node
// We expect 2 children: "SectionA" and "SectionB" (as Sections)
assert_eq!(ch1_fused.children.len(), 2);
assert_eq!(ch1_fused.children[0].name, "SectionA");
assert_eq!(ch1_fused.children[0].kind, DocNodeKind::Section);
assert_eq!(ch1_fused.children[1].name, "SectionB");
// 4. Verify Content of Fused File
let content = docos.read_content("Chapter1")?;
// Should contain Intro + # SectionA ... + # SectionB ...
assert!(content.contains("Chapter 1 Intro"));
assert!(content.contains("# SectionA"));
assert!(content.contains("Content A"));
assert!(content.contains("# SectionB"));
Ok(())
}

View File

@ -1,171 +0,0 @@
use workflow_context::{ContextStore, Vgcs};
use std::io::Read;
use tempfile::TempDir;
use std::sync::Arc;
use std::thread;
const ZERO_OID: &str = "0000000000000000000000000000000000000000";
#[test]
fn test_basic_workflow() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let store = Vgcs::new(temp_dir.path());
let req_id = "req-001";
// 1. Init
store.init_repo(req_id)?;
// 2. Write Transaction (Initial Commit)
let mut tx = store.begin_transaction(req_id, ZERO_OID)?;
tx.write("test.txt", b"Hello World")?;
let commit_hash_1 = tx.commit("Initial commit", "Test User")?;
// 3. Read
let mut reader = store.read_file(req_id, &commit_hash_1, "test.txt")?;
let mut content = String::new();
reader.read_to_string(&mut content)?;
assert_eq!(content, "Hello World");
// 4. Modify file
let mut tx = store.begin_transaction(req_id, &commit_hash_1)?;
tx.write("test.txt", b"Hello World Modified")?;
tx.write("new.txt", b"New File")?;
let commit_hash_2 = tx.commit("Second commit", "Test User")?;
// 5. Verify Diff
let changes = store.diff(req_id, &commit_hash_1, &commit_hash_2)?;
// Should have 1 Modified (test.txt) and 1 Added (new.txt)
assert_eq!(changes.len(), 2);
// 6. List Dir
let entries = store.list_dir(req_id, &commit_hash_2, "")?;
assert_eq!(entries.len(), 2); // test.txt, new.txt
Ok(())
}
#[test]
fn test_large_file_support() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let store = Vgcs::new(temp_dir.path());
let req_id = "req-large";
store.init_repo(req_id)?;
// Create 2MB data
let large_data = vec![b'a'; 2 * 1024 * 1024];
let mut tx = store.begin_transaction(req_id, ZERO_OID)?;
tx.write("large.bin", &large_data)?;
let commit_hash = tx.commit("Add large file", "Tester")?;
// Read back
let mut reader = store.read_file(req_id, &commit_hash, "large.bin")?;
let mut read_data = Vec::new();
reader.read_to_end(&mut read_data)?;
assert_eq!(read_data.len(), large_data.len());
// Checking first and last bytes to be reasonably sure without comparing 2MB in assertion message on failure
assert_eq!(read_data[0], b'a');
assert_eq!(read_data[read_data.len()-1], b'a');
assert_eq!(read_data, large_data);
// Check internal blob store
// We don't calculate SHA256 here to verify path exactly, but we check if blobs dir has content
let blobs_dir = temp_dir.path().join("blobs").join(req_id);
assert!(blobs_dir.exists());
// Should have subdirectories for SHA prefix
let entries = std::fs::read_dir(blobs_dir)?.collect::<Vec<_>>();
assert!(!entries.is_empty());
Ok(())
}
#[test]
fn test_parallel_branching_and_merge() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
// Clone temp_path for threads
let temp_path = temp_dir.path().to_path_buf();
let store = Arc::new(Vgcs::new(&temp_path));
let req_id = "req-parallel";
store.init_repo(req_id)?;
// Initial commit
let base_commit = {
let mut tx = store.begin_transaction(req_id, ZERO_OID)?;
tx.write("base.txt", b"Base Content")?;
tx.commit("Base Commit", "System")?
};
// Fork 1: Modify base.txt
let store1 = store.clone();
let base1 = base_commit.clone();
let handle1 = thread::spawn(move || -> anyhow::Result<String> {
let mut tx = store1.begin_transaction(req_id, &base1)?;
tx.write("base.txt", b"Base Content Modified by 1")?;
tx.write("file1.txt", b"File 1 Content")?;
Ok(tx.commit("Fork 1 Commit", "User 1")?)
});
// Fork 2: Add file2.txt (No conflict)
let store2 = store.clone();
let base2 = base_commit.clone();
let handle2 = thread::spawn(move || -> anyhow::Result<String> {
let mut tx = store2.begin_transaction(req_id, &base2)?;
tx.write("file2.txt", b"File 2 Content")?;
Ok(tx.commit("Fork 2 Commit", "User 2")?)
});
let commit1 = handle1.join().unwrap()?;
let commit2 = handle2.join().unwrap()?;
// Merge Fork 2 into Fork 1 (Memory Merge)
// This merge should succeed as they touch different files/areas (mostly)
// But wait, Fork 1 modified base.txt, Fork 2 kept it as is.
// Git merge should take Fork 1's change and include Fork 2's new file.
// We need to commit the merge result to verify it
let merge_tree_oid = store.merge_trees(req_id, &base_commit, &commit1, &commit2)?;
// Manually create a commit from the merge tree to verify content (optional but good)
// In real system, Orchestrator would do this.
// For test, we can just verify the tree contains what we expect or use a helper.
// Or we can just trust merge_trees returns an OID on success.
assert!(!merge_tree_oid.is_empty());
Ok(())
}
#[test]
fn test_merge_conflict() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let store = Vgcs::new(temp_dir.path());
let req_id = "req-conflict";
store.init_repo(req_id)?;
// Base
let mut tx = store.begin_transaction(req_id, ZERO_OID)?;
tx.write("conflict.txt", b"Base Version")?;
let base_commit = tx.commit("Base", "System")?;
// Branch A: Edit conflict.txt
let mut tx_a = store.begin_transaction(req_id, &base_commit)?;
tx_a.write("conflict.txt", b"Version A")?;
let commit_a = tx_a.commit("Commit A", "User A")?;
// Branch B: Edit conflict.txt differently
let mut tx_b = store.begin_transaction(req_id, &base_commit)?;
tx_b.write("conflict.txt", b"Version B")?;
let commit_b = tx_b.commit("Commit B", "User B")?;
// Try Merge
let result = store.merge_trees(req_id, &base_commit, &commit_a, &commit_b);
// Should fail with conflict
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Merge conflict"));
Ok(())
}

View File

@ -1,142 +0,0 @@
use workflow_context::{WorkerContext, ContextShell, OutputFormat, FindOptions, Vgcs, ContextStore};
use tempfile::TempDir;
const ZERO_OID: &str = "0000000000000000000000000000000000000000";
fn setup_env() -> (TempDir, String, String) {
let temp_dir = TempDir::new().unwrap();
let data_path = temp_dir.path().to_str().unwrap().to_string();
let req_id = "req-shell-test".to_string();
// Init Repo
let vgcs = Vgcs::new(&data_path);
vgcs.init_repo(&req_id).unwrap();
(temp_dir, data_path, req_id)
}
#[test]
fn test_shell_comprehensive() -> anyhow::Result<()> {
let (_tmp, data_path, req_id) = setup_env();
// 1. Setup Initial Context
let mut ctx = WorkerContext::new(&data_path, &req_id, ZERO_OID);
ctx.write_file("README.md", "Project Root\n\nIntroduction here.")?;
ctx.write_file("src/main.rs", "fn main() {\n println!(\"Hello\");\n println!(\"Hello\");\n}")?; // Double Hello for ambiguity test
ctx.write_file("src/util.rs", "pub fn util() -> i32 { 42 }")?;
ctx.write_file("data/config.json", "{\n \"key\": \"value\",\n \"retries\": 3\n}")?;
ctx.write_file("文档/说明.txt", "这是一个中文文件。")?; // Unicode Path & Content
let commit_1 = ctx.commit("Init")?;
let mut ctx = WorkerContext::new(&data_path, &req_id, &commit_1);
// --- Find Tests ---
println!("Testing Find...");
// Test: Recursive vs Non-recursive
// Note: Includes directories (src, data, 文档) + files (5) = 8
let all_nodes = ctx.find("**/*", FindOptions { recursive: true, ..Default::default() })?;
assert_eq!(all_nodes.len(), 8);
// Test: Only Files
let only_files = ctx.find("**/*", FindOptions {
recursive: true,
type_filter: Some("File".to_string()),
..Default::default()
})?;
assert_eq!(only_files.len(), 5);
// Test: Non-recursive (Top level)
let root_nodes = ctx.find("*", FindOptions { recursive: false, ..Default::default() })?;
// Expect README.md, src(dir), data(dir), 文档(dir)
assert!(root_nodes.iter().any(|f| f.path == "README.md"));
assert!(root_nodes.iter().any(|f| f.path == "src"));
// Test: Type Filter (Dir)
let dirs = ctx.find("**/*", FindOptions {
recursive: true,
type_filter: Some("Dir".to_string()),
..Default::default()
})?;
assert!(dirs.iter().any(|d| d.path == "src"));
assert!(dirs.iter().any(|d| d.path == "data"));
assert!(dirs.iter().any(|d| d.path == "文档"));
assert!(!dirs.iter().any(|d| d.path == "README.md"));
// --- Grep Tests ---
println!("Testing Grep...");
// Test: Regex Match
let matches = ctx.grep(r"fn \w+\(\)", None)?;
assert_eq!(matches.len(), 2); // main() and util()
// Test: Unicode Content
let zh_matches = ctx.grep("中文", None)?;
assert_eq!(zh_matches.len(), 1);
assert_eq!(zh_matches[0].path, "文档/说明.txt");
// Test: Invalid Regex
let bad_regex = ctx.grep("(", None);
assert!(bad_regex.is_err());
// --- Patch Tests ---
println!("Testing Patch...");
// Test: Ambiguous Match (Safety Check)
// src/main.rs has two "println!(\"Hello\");"
let res = ctx.patch("src/main.rs", "println!(\"Hello\");", "println!(\"World\");");
assert!(res.is_err(), "Should fail on ambiguous match");
let err_msg = res.unwrap_err().to_string();
assert!(err_msg.contains("Ambiguous match"), "Error message mismatch: {}", err_msg);
// Test: Unique Match
// Patch "Introduction here." to "Intro v2." in README.md
ctx.patch("README.md", "Introduction here.", "Intro v2.")?;
ctx.commit("Patch 1")?; // Must commit to verify via read (if read uses committed state)
// Verify
let readme = ctx.read_text("README.md")?;
assert!(readme.contains("Intro v2."));
// Test: Special Characters (Literal Match)
// Let's try to patch JSON which has braces and quotes
ctx.patch("data/config.json", "\"retries\": 3", "\"retries\": 5")?;
ctx.commit("Patch 2")?;
let config = ctx.read_text("data/config.json")?;
assert!(config.contains("\"retries\": 5"));
// Test: Cross-line Patch
// Replace the whole function body in util.rs
let old_block = "pub fn util() -> i32 { 42 }";
let new_block = "pub fn util() -> i32 {\n return 100;\n}";
ctx.patch("src/util.rs", old_block, new_block)?;
ctx.commit("Patch 3")?;
let util = ctx.read_text("src/util.rs")?;
assert!(util.contains("return 100;"));
// Test: Patch non-existent file
let res = ctx.patch("ghost.txt", "foo", "bar");
assert!(res.is_err());
Ok(())
}
#[test]
fn test_tool_schema_validity() {
let defs = WorkerContext::get_tool_definitions();
assert!(defs.is_array());
let arr = defs.as_array().unwrap();
// Verify critical fields exist for OpenAI
for tool in arr {
let obj = tool.as_object().unwrap();
assert_eq!(obj["type"], "function");
let func = obj["function"].as_object().unwrap();
assert!(func.contains_key("name"));
assert!(func.contains_key("description"));
assert!(func.contains_key("parameters"));
}
}

View File

@ -1,339 +0,0 @@
#!/bin/bash
# 遇到错误立即退出
set -e
# 配置变量
REGISTRY="harbor.3prism.ai"
PROJECT="fundamental_analysis"
VERSION="latest"
NAMESPACE="$REGISTRY/$PROJECT"
# 颜色输出
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${GREEN}=== 开始构建并推送镜像到 $NAMESPACE ===${NC}"
# 定义服务列表
# 格式: "服务名:Dockerfile路径"
# 注意:所有的后端服务现在都使用通用的 docker/Dockerfile.backend.prod
SERVICES=(
"data-persistence-service:docker/Dockerfile.backend.prod"
"api-gateway:docker/Dockerfile.backend.prod"
"alphavantage-provider-service:docker/Dockerfile.backend.prod"
"tushare-provider-service:docker/Dockerfile.backend.prod"
"finnhub-provider-service:docker/Dockerfile.backend.prod"
"yfinance-provider-service:docker/Dockerfile.backend.prod"
"report-generator-service:docker/Dockerfile.backend.prod"
"workflow-orchestrator-service:docker/Dockerfile.backend.prod"
"mock-provider-service:docker/Dockerfile.backend.prod"
"frontend:docker/Dockerfile.frontend.prod"
)
# 总大小计数器
TOTAL_SIZE=0
for entry in "${SERVICES[@]}"; do
KEY="${entry%%:*}"
DOCKERFILE="${entry#*:}"
IMAGE_NAME="$NAMESPACE/$KEY:$VERSION"
echo -e "\n${YELLOW}>>> 正在构建 $KEY ...${NC}"
echo "使用 Dockerfile: $DOCKERFILE"
# 构建镜像
if [ "$KEY" == "frontend" ]; then
# 前端不需要 SERVICE_NAME build-arg
docker build -t "$IMAGE_NAME" -f "$DOCKERFILE" .
elif [ "$KEY" == "data-persistence-service" ]; then
# 特殊处理 data-persistence-service 的二进制名称差异
docker build -t "$IMAGE_NAME" --build-arg SERVICE_NAME="data-persistence-service-server" -f "$DOCKERFILE" .
else
# 后端服务需要传递 SERVICE_NAME
docker build -t "$IMAGE_NAME" --build-arg SERVICE_NAME="$KEY" -f "$DOCKERFILE" .
fi
# 获取镜像大小 (MB)
SIZE_BYTES=$(docker inspect "$IMAGE_NAME" --format='{{.Size}}')
SIZE_MB=$(echo "scale=2; $SIZE_BYTES / 1024 / 1024" | bc)
echo -e "${GREEN}$KEY 构建完成. 大小: ${SIZE_MB} MB${NC}"
# 累加大小
TOTAL_SIZE=$(echo "$TOTAL_SIZE + $SIZE_BYTES" | bc)
echo -e "${YELLOW}>>> 正在推送 $KEY 到 Harbor ...${NC}"
docker push "$IMAGE_NAME"
done
TOTAL_SIZE_MB=$(echo "scale=2; $TOTAL_SIZE / 1024 / 1024" | bc)
echo -e "\n${GREEN}=== 所有镜像处理完成 ===${NC}"
echo -e "${GREEN}总大小: ${TOTAL_SIZE_MB} MB${NC}"
# 生成服务器使用的 docker-compose.server.yml
echo -e "\n${YELLOW}>>> 正在生成服务器部署文件 docker-compose.server.yml ...${NC}"
# 基于 docker-compose.prod.yml 生成,但是替换 build 为 image
# 这里我们直接手动定义,因为解析 yaml 替换比较复杂,且我们清楚结构
cat > docker-compose.server.yml <<EOF
services:
postgres-db:
image: timescale/timescaledb:2.15.2-pg16
container_name: fundamental-postgres
command: -c shared_preload_libraries=timescaledb
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: fundamental
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d fundamental"]
interval: 5s
timeout: 5s
retries: 10
networks:
- app-network
restart: always
nats:
image: nats:2.9
container_name: fundamental-nats
volumes:
- nats_data:/data
networks:
- app-network
restart: always
data-persistence-service:
image: $NAMESPACE/data-persistence-service:$VERSION
container_name: data-persistence-service
environment:
HOST: 0.0.0.0
PORT: 3000
DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental
RUST_LOG: info
RUST_BACKTRACE: "1"
SKIP_MIGRATIONS_ON_MISMATCH: "1"
depends_on:
postgres-db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:3000/health >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
restart: always
api-gateway:
image: $NAMESPACE/api-gateway:$VERSION
container_name: api-gateway
environment:
SERVER_PORT: 4000
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
REPORT_GENERATOR_SERVICE_URL: http://report-generator-service:8004
RUST_LOG: info,axum=info
RUST_BACKTRACE: "1"
depends_on:
nats:
condition: service_started
data-persistence-service:
condition: service_healthy
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:4000/health >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 5
restart: always
mock-provider-service:
image: $NAMESPACE/mock-provider-service:$VERSION
container_name: mock-provider-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8006
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: mock-provider-service
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
restart: always
alphavantage-provider-service:
image: $NAMESPACE/alphavantage-provider-service:$VERSION
container_name: alphavantage-provider-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8000
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: alphavantage-provider-service
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
restart: always
tushare-provider-service:
image: $NAMESPACE/tushare-provider-service:$VERSION
container_name: tushare-provider-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8001
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
TUSHARE_API_URL: http://api.waditu.com
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: tushare-provider-service
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
restart: always
finnhub-provider-service:
image: $NAMESPACE/finnhub-provider-service:$VERSION
container_name: finnhub-provider-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8002
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
FINNHUB_API_URL: https://finnhub.io/api/v1
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: finnhub-provider-service
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
restart: always
yfinance-provider-service:
image: $NAMESPACE/yfinance-provider-service:$VERSION
container_name: yfinance-provider-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8003
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: yfinance-provider-service
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
dns:
- 8.8.8.8
- 8.8.4.4
restart: always
report-generator-service:
image: $NAMESPACE/report-generator-service:$VERSION
container_name: report-generator-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8004
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
GOTENBERG_URL: http://gotenberg:3000
WORKFLOW_DATA_PATH: /mnt/workflow_data
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
- gotenberg
networks:
- app-network
restart: always
workflow-orchestrator-service:
image: $NAMESPACE/workflow-orchestrator-service:$VERSION
container_name: workflow-orchestrator-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8005
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
WORKFLOW_DATA_PATH: /mnt/workflow_data
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
restart: always
gotenberg:
image: gotenberg/gotenberg:8
container_name: gotenberg
networks:
- app-network
restart: always
frontend:
image: $NAMESPACE/frontend:$VERSION
container_name: fundamental-frontend
ports:
- "8080:80" # Map host 8080 to container 80 (Nginx)
depends_on:
api-gateway:
condition: service_healthy
networks:
- app-network
restart: always
volumes:
workflow_data:
pgdata:
nats_data:
networks:
app-network:
EOF
echo -e "${GREEN}生成完成: docker-compose.server.yml${NC}"
echo -e "请将此文件复制到远程服务器,并执行: docker-compose -f docker-compose.server.yml up -d"

View File

@ -1,69 +0,0 @@
services:
api-gateway:
ports:
- "4000:4000"
workflow-orchestrator-service:
ports:
- "8005:8005" # Expose for debugging if needed
volumes:
- workflow_data:/mnt/workflow_data
environment:
- WORKFLOW_DATA_PATH=/mnt/workflow_data
alphavantage-provider-service:
volumes:
- workflow_data:/mnt/workflow_data
environment:
- WORKFLOW_DATA_PATH=/mnt/workflow_data
tushare-provider-service:
volumes:
- workflow_data:/mnt/workflow_data
environment:
- WORKFLOW_DATA_PATH=/mnt/workflow_data
finnhub-provider-service:
volumes:
- workflow_data:/mnt/workflow_data
environment:
- WORKFLOW_DATA_PATH=/mnt/workflow_data
yfinance-provider-service:
volumes:
- workflow_data:/mnt/workflow_data
environment:
- WORKFLOW_DATA_PATH=/mnt/workflow_data
report-generator-service:
volumes:
- workflow_data:/mnt/workflow_data
environment:
- WORKFLOW_DATA_PATH=/mnt/workflow_data
mock-provider-service:
build:
context: .
dockerfile: services/mock-provider-service/Dockerfile
container_name: mock-provider-service
environment:
SERVER_PORT: 8006
NATS_ADDR: nats://nats:4222
API_GATEWAY_URL: http://api-gateway:4000
SERVICE_HOST: mock-provider-service
WORKFLOW_DATA_PATH: /mnt/workflow_data
RUST_LOG: info
volumes:
- workflow_data:/mnt/workflow_data
depends_on:
- nats
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8006/health >/dev/null || exit 1"]
interval: 5s
timeout: 5s
retries: 12
volumes:
workflow_data:

View File

@ -1,292 +0,0 @@
services:
postgres-db:
image: timescale/timescaledb:2.15.2-pg16
container_name: fundamental-postgres
command: -c shared_preload_libraries=timescaledb
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: fundamental
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d fundamental"]
interval: 5s
timeout: 5s
retries: 10
networks:
- app-network
restart: always
nats:
image: nats:2.9
container_name: fundamental-nats
volumes:
- nats_data:/data
networks:
- app-network
restart: always
data-persistence-service:
build:
context: .
dockerfile: docker/Dockerfile.backend.prod
args:
SERVICE_NAME: data-persistence-service-server
container_name: data-persistence-service
# Note: The binary name in Dockerfile is generic 'app' or we can override entrypoint.
# The Dockerfile entrypoint is /usr/local/bin/app.
environment:
HOST: 0.0.0.0
PORT: 3000
DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental
RUST_LOG: info
RUST_BACKTRACE: "1"
SKIP_MIGRATIONS_ON_MISMATCH: "1"
depends_on:
postgres-db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:3000/health >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
restart: always
api-gateway:
build:
context: .
dockerfile: docker/Dockerfile.backend.prod
args:
SERVICE_NAME: api-gateway
container_name: api-gateway
environment:
SERVER_PORT: 4000
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
REPORT_GENERATOR_SERVICE_URL: http://report-generator-service:8004
RUST_LOG: info,axum=info
RUST_BACKTRACE: "1"
depends_on:
nats:
condition: service_started
data-persistence-service:
condition: service_healthy
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:4000/health >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 5
restart: always
mock-provider-service:
build:
context: .
dockerfile: docker/Dockerfile.backend.prod
args:
SERVICE_NAME: mock-provider-service
container_name: mock-provider-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8006
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: mock-provider-service
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
restart: always
alphavantage-provider-service:
build:
context: .
dockerfile: docker/Dockerfile.backend.prod
args:
SERVICE_NAME: alphavantage-provider-service
container_name: alphavantage-provider-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8000
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: alphavantage-provider-service
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
restart: always
tushare-provider-service:
build:
context: .
dockerfile: docker/Dockerfile.backend.prod
args:
SERVICE_NAME: tushare-provider-service
container_name: tushare-provider-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8001
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
TUSHARE_API_URL: http://api.waditu.com
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: tushare-provider-service
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
restart: always
finnhub-provider-service:
build:
context: .
dockerfile: docker/Dockerfile.backend.prod
args:
SERVICE_NAME: finnhub-provider-service
container_name: finnhub-provider-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8002
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
FINNHUB_API_URL: https://finnhub.io/api/v1
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: finnhub-provider-service
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
restart: always
yfinance-provider-service:
build:
context: .
dockerfile: docker/Dockerfile.backend.prod
args:
SERVICE_NAME: yfinance-provider-service
container_name: yfinance-provider-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8003
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: yfinance-provider-service
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
dns:
- 8.8.8.8
- 8.8.4.4
restart: always
report-generator-service:
build:
context: .
dockerfile: docker/Dockerfile.backend.prod
args:
SERVICE_NAME: report-generator-service
container_name: report-generator-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8004
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
GOTENBERG_URL: http://gotenberg:3000
WORKFLOW_DATA_PATH: /mnt/workflow_data
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
- gotenberg
networks:
- app-network
restart: always
gotenberg:
image: gotenberg/gotenberg:8
container_name: gotenberg
networks:
- app-network
restart: always
workflow-orchestrator-service:
build:
context: .
dockerfile: docker/Dockerfile.backend.prod
args:
SERVICE_NAME: workflow-orchestrator-service
container_name: workflow-orchestrator-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8005
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
WORKFLOW_DATA_PATH: /mnt/workflow_data
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
restart: always
frontend:
build:
context: .
dockerfile: docker/Dockerfile.frontend.prod
container_name: fundamental-frontend
ports:
- "8080:80" # Map host 8080 to container 80 (Nginx)
depends_on:
api-gateway:
condition: service_healthy
networks:
- app-network
restart: always
volumes:
workflow_data:
pgdata:
nats_data:
networks:
app-network:

View File

@ -1,230 +0,0 @@
services:
postgres-db:
image: timescale/timescaledb:2.15.2-pg16
container_name: fundamental-postgres
command: -c shared_preload_libraries=timescaledb
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: fundamental
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d fundamental"]
interval: 5s
timeout: 5s
retries: 10
networks:
- app-network
restart: always
nats:
image: nats:2.9
container_name: fundamental-nats
volumes:
- nats_data:/data
networks:
- app-network
restart: always
data-persistence-service:
image: harbor.3prism.ai/fundamental_analysis/data-persistence-service:latest
container_name: data-persistence-service
environment:
HOST: 0.0.0.0
PORT: 3000
DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental
RUST_LOG: info
RUST_BACKTRACE: "1"
SKIP_MIGRATIONS_ON_MISMATCH: "1"
depends_on:
postgres-db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:3000/health >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
restart: always
api-gateway:
image: harbor.3prism.ai/fundamental_analysis/api-gateway:latest
container_name: api-gateway
environment:
SERVER_PORT: 4000
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
REPORT_GENERATOR_SERVICE_URL: http://report-generator-service:8004
RUST_LOG: info,axum=info
RUST_BACKTRACE: "1"
depends_on:
nats:
condition: service_started
data-persistence-service:
condition: service_healthy
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:4000/health >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 5
restart: always
alphavantage-provider-service:
image: harbor.3prism.ai/fundamental_analysis/alphavantage-provider-service:latest
container_name: alphavantage-provider-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8000
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: alphavantage-provider-service
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
restart: always
tushare-provider-service:
image: harbor.3prism.ai/fundamental_analysis/tushare-provider-service:latest
container_name: tushare-provider-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8001
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
TUSHARE_API_URL: http://api.waditu.com
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: tushare-provider-service
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
restart: always
finnhub-provider-service:
image: harbor.3prism.ai/fundamental_analysis/finnhub-provider-service:latest
container_name: finnhub-provider-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8002
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
FINNHUB_API_URL: https://finnhub.io/api/v1
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: finnhub-provider-service
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
restart: always
yfinance-provider-service:
image: harbor.3prism.ai/fundamental_analysis/yfinance-provider-service:latest
container_name: yfinance-provider-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8003
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: yfinance-provider-service
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
dns:
- 8.8.8.8
- 8.8.4.4
restart: always
report-generator-service:
image: harbor.3prism.ai/fundamental_analysis/report-generator-service:latest
container_name: report-generator-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8004
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
GOTENBERG_URL: http://gotenberg:3000
WORKFLOW_DATA_PATH: /mnt/workflow_data
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
- gotenberg
networks:
- app-network
restart: always
workflow-orchestrator-service:
image: harbor.3prism.ai/fundamental_analysis/workflow-orchestrator-service:latest
container_name: workflow-orchestrator-service
volumes:
- workflow_data:/mnt/workflow_data
environment:
SERVER_PORT: 8005
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
WORKFLOW_DATA_PATH: /mnt/workflow_data
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
restart: always
gotenberg:
image: gotenberg/gotenberg:8
container_name: gotenberg
networks:
- app-network
restart: always
frontend:
image: harbor.3prism.ai/fundamental_analysis/frontend:latest
container_name: fundamental-frontend
ports:
- "28080:80" # Map host 28080 to container 80 (Nginx)
depends_on:
api-gateway:
condition: service_healthy
networks:
- app-network
restart: always
volumes:
workflow_data:
pgdata:
nats_data:
networks:
app-network:

View File

@ -1,51 +0,0 @@
services:
postgres-test:
image: timescale/timescaledb:2.15.2-pg16
container_name: fundamental-postgres-test
command: -c shared_preload_libraries=timescaledb
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: fundamental_test
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d fundamental_test"]
interval: 5s
timeout: 5s
retries: 10
networks:
- test-network
nats-test:
image: nats:2.9
container_name: fundamental-nats-test
ports:
- "4223:4222"
networks:
- test-network
data-persistence-test:
build:
context: .
dockerfile: services/data-persistence-service/Dockerfile
container_name: data-persistence-service-test
environment:
HOST: 0.0.0.0
PORT: 3000
# Connect to postgres-test using internal docker network alias
DATABASE_URL: postgresql://postgres:postgres@postgres-test:5432/fundamental_test
RUST_LOG: info
RUST_BACKTRACE: "1"
ports:
- "3005:3000"
depends_on:
postgres-test:
condition: service_healthy
networks:
- test-network
networks:
test-network:

View File

@ -1,3 +1,5 @@
version: "3.9"
services:
postgres-db:
image: timescale/timescaledb:2.15.2-pg16
@ -14,46 +16,52 @@ services:
interval: 5s
timeout: 5s
retries: 10
networks:
- app-network
ports:
- "5434:5432"
nats:
image: nats:2.9
volumes:
- nats_data:/data
networks:
- app-network
- "15432:5432"
data-persistence-service:
build:
context: .
dockerfile: docker/Dockerfile.dev
context: ./services/data-persistence-service
dockerfile: Dockerfile
container_name: data-persistence-service
working_dir: /app/services/data-persistence-service
command: ["cargo", "watch", "-x", "run --bin data-persistence-service-server"]
environment:
HOST: 0.0.0.0
PORT: 3000
# Rust service connects to the internal DB service name
DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental
RUST_LOG: info
RUST_BACKTRACE: "1"
ports:
- "13000:3000"
depends_on:
postgres-db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:3000/health >/dev/null || exit 1"]
interval: 5s
timeout: 5s
retries: 10
volumes:
- ./:/app
- cargo-target:/app/target
- cargo-cache:/usr/local/cargo
networks:
- app-network
# If you prefer live-reload or local code mount, consider switching to a dev Dockerfile.
# volumes:
# - ./:/workspace
backend:
build:
context: .
dockerfile: backend/Dockerfile
container_name: fundamental-backend
working_dir: /workspace/backend
command: uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
environment:
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
# Config service base URL
CONFIG_SERVICE_BASE_URL: http://config-service:7000/api/v1
# Data persistence service base URL
DATA_PERSISTENCE_BASE_URL: http://data-persistence-service:3000/api/v1
volumes:
# 挂载整个项目,确保后端代码中对项目根目录的相对路径(如 config/)仍然有效
- ./:/workspace
ports:
- "18000:8000"
depends_on:
config-service:
condition: service_started
data-persistence-service:
condition: service_started
frontend:
build:
@ -61,15 +69,12 @@ services:
dockerfile: frontend/Dockerfile
container_name: fundamental-frontend
working_dir: /workspace/frontend
command: ["/workspace/frontend/scripts/docker-dev-entrypoint.sh"]
command: npm run dev
environment:
# Vite Proxy Target
VITE_API_TARGET: http://api-gateway:4000
# 让 Next 的 API 路由代理到新的 api-gateway
NEXT_PUBLIC_BACKEND_URL: http://api-gateway:4000/v1
# SSR 内部访问自身 API 的内部地址,避免使用 x-forwarded-host 导致访问宿主机端口
FRONTEND_INTERNAL_URL: http://fundamental-frontend:3001
BACKEND_INTERNAL_URL: http://api-gateway:4000/v1
# 让 Next 的 API 路由代理到后端容器
NEXT_PUBLIC_BACKEND_URL: http://backend:8000/api
# Prisma 直连数据库(与后端共用同一库)
DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental?schema=public
NODE_ENV: development
NEXT_TELEMETRY_DISABLED: "1"
volumes:
@ -79,306 +84,26 @@ services:
ports:
- "13001:3001"
depends_on:
api-gateway:
condition: service_healthy
networks:
- app-network
- backend
- postgres-db
- config-service
api-gateway:
config-service:
build:
context: .
dockerfile: docker/Dockerfile.dev
container_name: api-gateway
restart: unless-stopped
working_dir: /app/services/api-gateway
command: ["cargo", "watch", "-x", "run --bin api-gateway"]
dockerfile: services/config-service/Dockerfile
container_name: fundamental-config-service
working_dir: /workspace/services/config-service
command: uvicorn app.main:app --host 0.0.0.0 --port 7000
environment:
PROJECT_ROOT: /workspace
volumes:
- ./:/workspace
ports:
- "4000:4000"
environment:
SERVER_PORT: 4000
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
REPORT_GENERATOR_SERVICE_URL: http://report-generator-service:8004
RUST_LOG: info,axum=info
RUST_BACKTRACE: "1"
depends_on:
nats:
condition: service_started
data-persistence-service:
condition: service_healthy
alphavantage-provider-service:
condition: service_started
mock-provider-service:
condition: service_started
tushare-provider-service:
condition: service_started
finnhub-provider-service:
condition: service_started
yfinance-provider-service:
condition: service_started
report-generator-service:
condition: service_started
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:4000/health >/dev/null || exit 1"]
interval: 5s
timeout: 5s
retries: 12
volumes:
- ./:/app
- cargo-target:/app/target
- cargo-cache:/usr/local/cargo
mock-provider-service:
build:
context: .
dockerfile: docker/Dockerfile.dev
container_name: mock-provider-service
working_dir: /app/services/mock-provider-service
command: ["cargo", "watch", "-x", "run"]
volumes:
- workflow_data:/mnt/workflow_data
- ./:/app
- cargo-target:/app/target
- cargo-cache:/usr/local/cargo
environment:
SERVER_PORT: 8006
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: mock-provider-service
RUST_LOG: info,axum=info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8006/health >/dev/null || exit 1"]
interval: 5s
timeout: 5s
retries: 12
alphavantage-provider-service:
build:
context: .
dockerfile: docker/Dockerfile.dev
container_name: alphavantage-provider-service
working_dir: /app/services/alphavantage-provider-service
command: ["cargo", "watch", "-x", "run"]
volumes:
- workflow_data:/mnt/workflow_data
- ./:/app
- cargo-target:/app/target
- cargo-cache:/usr/local/cargo
environment:
SERVER_PORT: 8000
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: alphavantage-provider-service
RUST_LOG: info,axum=info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8000/health >/dev/null || exit 1"]
interval: 5s
timeout: 5s
retries: 12
tushare-provider-service:
build:
context: .
dockerfile: docker/Dockerfile.dev
container_name: tushare-provider-service
working_dir: /app/services/tushare-provider-service
command: ["cargo", "watch", "-x", "run"]
volumes:
- workflow_data:/mnt/workflow_data
- ./:/app
- cargo-target:/app/target
- cargo-cache:/usr/local/cargo
environment:
SERVER_PORT: 8001
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
TUSHARE_API_URL: http://api.waditu.com
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: tushare-provider-service
RUST_LOG: info,axum=info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8001/health >/dev/null || exit 1"]
interval: 5s
timeout: 5s
retries: 12
finnhub-provider-service:
build:
context: .
dockerfile: docker/Dockerfile.dev
container_name: finnhub-provider-service
working_dir: /app/services/finnhub-provider-service
command: ["cargo", "watch", "-x", "run"]
volumes:
- workflow_data:/mnt/workflow_data
- ./:/app
- cargo-target:/app/target
- cargo-cache:/usr/local/cargo
environment:
SERVER_PORT: 8002
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
FINNHUB_API_URL: https://finnhub.io/api/v1
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: finnhub-provider-service
RUST_LOG: info,axum=info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8002/health >/dev/null || exit 1"]
interval: 5s
timeout: 5s
retries: 12
yfinance-provider-service:
build:
context: .
dockerfile: docker/Dockerfile.dev
container_name: yfinance-provider-service
working_dir: /app/services/yfinance-provider-service
command: ["cargo", "watch", "-x", "run"]
volumes:
- workflow_data:/mnt/workflow_data
- ./:/app
- cargo-target:/app/target
- cargo-cache:/usr/local/cargo
environment:
SERVER_PORT: 8003
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
API_GATEWAY_URL: http://api-gateway:4000
WORKFLOW_DATA_PATH: /mnt/workflow_data
SERVICE_HOST: yfinance-provider-service
RUST_LOG: info,axum=info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
dns:
- 8.8.8.8
- 8.8.4.4
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8003/health >/dev/null || exit 1"]
interval: 5s
timeout: 5s
retries: 12
report-generator-service:
build:
context: .
dockerfile: docker/Dockerfile.dev
container_name: report-generator-service
working_dir: /app/services/report-generator-service
command: ["cargo", "watch", "-x", "run --bin report-generator-service"]
volumes:
- workflow_data:/mnt/workflow_data
- ./:/app
- cargo-target:/app/target
- cargo-cache:/usr/local/cargo
environment:
SERVER_PORT: 8004
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
GOTENBERG_URL: http://gotenberg:3000
WORKFLOW_DATA_PATH: /mnt/workflow_data
RUST_LOG: info,axum=info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
- gotenberg
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8004/health >/dev/null || exit 1"]
interval: 5s
timeout: 5s
retries: 12
gotenberg:
image: gotenberg/gotenberg:8
container_name: gotenberg
ports:
- "3000:3000"
networks:
- app-network
workflow-orchestrator-service:
build:
context: .
dockerfile: docker/Dockerfile.dev
container_name: workflow-orchestrator-service
working_dir: /app/services/workflow-orchestrator-service
command: ["cargo", "watch", "-x", "run --bin workflow-orchestrator-service"]
volumes:
- workflow_data:/mnt/workflow_data
- ./:/app
- cargo-target:/app/target
- cargo-cache:/usr/local/cargo
environment:
SERVER_PORT: 8005
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
WORKFLOW_DATA_PATH: /mnt/workflow_data
RUST_LOG: info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8005/health >/dev/null || exit 1"]
interval: 5s
timeout: 5s
retries: 12
# =================================================================
# Python Services (Legacy - to be replaced)
# =================================================================
- "17000:7000"
volumes:
workflow_data:
pgdata:
frontend_node_modules:
nats_data:
cargo-target:
driver: local
cargo-cache:
driver: local
networks:
app-network:

View File

@ -1,67 +0,0 @@
# 1. Build Stage
FROM rust:1.90-bookworm as builder
ARG SERVICE_NAME
WORKDIR /usr/src/app
# Copy the entire workspace
COPY . .
# Build the specific service in release mode
ENV SQLX_OFFLINE=true
RUN cargo build --release --bin ${SERVICE_NAME}
# Prepare runtime assets directory
RUN mkdir -p /app/assets
# Conditionally copy potential asset folders if they exist for the service
# We use a shell loop or explicit checks. Docker COPY doesn't support conditionals well.
# So we do it in the builder stage using shell.
# 1. Migrations (e.g., data-persistence-service)
RUN if [ -d "services/${SERVICE_NAME}/migrations" ]; then \
mkdir -p /app/assets/migrations && \
cp -r services/${SERVICE_NAME}/migrations/* /app/assets/migrations/; \
fi
# 2. Templates (e.g., report-generator-service)
RUN if [ -d "services/${SERVICE_NAME}/templates" ]; then \
mkdir -p /app/assets/templates && \
cp -r services/${SERVICE_NAME}/templates/* /app/assets/templates/; \
fi
# 2.1 Cookies (e.g., report-generator-service)
RUN if [ -f "services/${SERVICE_NAME}/cookies.txt" ]; then \
cp services/${SERVICE_NAME}/cookies.txt /app/assets/cookies.txt; \
fi
# 3. Config folder (root level, needed by some services like data-persistence)
# We copy it to a specific location.
RUN cp -r config /app/config
# 4. Service Kit Mirror (needed by data-persistence-service build usually, but maybe runtime?)
# It was needed for build. Runtime usually doesn't need it unless it compiles code at runtime.
# 2. Runtime Stage
FROM debian:bookworm-slim
ARG SERVICE_NAME
ENV TZ=Asia/Shanghai
# Install dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libssl3 \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy binary
COPY --from=builder /usr/src/app/target/release/${SERVICE_NAME} /usr/local/bin/app
# Copy prepared assets
COPY --from=builder /app/assets /app/
COPY --from=builder /app/config /app/config
# Set the binary as the entrypoint
ENTRYPOINT ["/usr/local/bin/app"]

View File

@ -1,13 +0,0 @@
FROM rust:1.90-bookworm
WORKDIR /usr/src/app
# Copy the entire workspace
COPY . .
# Set SQLX offline mode to avoid needing a running DB during build
ENV SQLX_OFFLINE=true
# Build the entire workspace in release mode
# This compiles all crates in the workspace at once
RUN cargo build --release --workspace

View File

@ -1,13 +0,0 @@
FROM rust:1.90-bookworm
# Install cargo-watch for hot reload
RUN cargo install cargo-watch
WORKDIR /app
# Create target and cache directories to ensure permissions
RUN mkdir -p /app/target && mkdir -p /usr/local/cargo
# Default command
CMD ["cargo", "watch", "-x", "run"]

View File

@ -1,25 +0,0 @@
FROM debian:bookworm-slim
ENV TZ=Asia/Shanghai
# Install minimal runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libssl3 \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# The build context is expected to be prepared by the deployment script
# It should contain:
# - app (the binary)
# - config/ (if needed)
# - assets/ (if needed)
COPY . .
# Ensure the binary is executable
RUN chmod +x /app/app
ENTRYPOINT ["/app/app"]

View File

@ -1,24 +0,0 @@
# 1. Build Stage
FROM node:20-slim AS builder
WORKDIR /app
# Environment variables for build time
# ENV NODE_ENV=production <- REMOVED: This causes npm ci to skip devDependencies (tsc, vite)
# These must match the Nginx proxy paths
ENV VITE_API_TARGET=/api
ENV NEXT_PUBLIC_BACKEND_URL=/api/v1
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
# 2. Runtime Stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY docker/nginx.prod.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -1,36 +0,0 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to the backend
# Matches /api/v1/..., /api/context/..., etc.
location /api/ {
proxy_pass http://api-gateway:4000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy specific endpoints that are at root level in api-gateway
location /health {
proxy_pass http://api-gateway:4000/health;
proxy_set_header Host $host;
}
location /tasks/ {
proxy_pass http://api-gateway:4000/tasks/;
proxy_set_header Host $host;
}
}

View File

@ -1,63 +0,0 @@
# Fundamental Analysis Platform 用户指南 (v2.0 - Vite Refactor)
日期: 2025-11-22
版本: 2.0
## 1. 简介
Fundamental Analysis Platform 是一个基于 AI Agent 的深度基本面投研平台,旨在通过自动化工作流聚合多源金融数据,并利用 LLM大语言模型生成专业的财务分析报告。
v2.0 版本采用了全新的 Vite + React SPA 架构,提供了更流畅的交互体验和实时的分析状态可视化。
## 2. 核心功能
### 2.1 仪表盘 (Dashboard)
平台首页,提供简洁的分析入口。
* **股票代码**: 支持输入 A股 (如 `600519.SS`)、美股 (如 `AAPL`) 或港股代码。
* **市场选择**: 下拉选择 CN (中国)、US (美国) 或 HK (香港)。
* **开始分析**: 点击“生成分析报告”按钮即可启动分析流程。
### 2.2 分析报告页 (Report View)
核心工作区,分为左侧状态栏和右侧详情区。
#### 左侧:工作流状态
* **可视化 DAG**: 展示当前的分析任务依赖图。
* **节点颜色**: 灰色(等待)、蓝色(运行中)、绿色(完成)、红色(失败)。
* **动态连线**: 当任务运行时,连接线会有流光动画指示数据流向。
* **实时日志**: 滚动展示所有后台任务的执行日志,支持实时查看数据抓取和分析进度。
#### 右侧:详情面板
* **Analysis Report**: 展示由 AI 生成的最终分析报告。支持 Markdown 格式(标题、表格、加粗、引用),并带有打字机生成特效。
* **Fundamental Data**: (开发中) 展示抓取到的原始财务数据表格。
* **Stock Chart**: (开发中) 展示股价走势图。
### 2.3 系统配置 (Config)
集中管理平台的所有外部连接和参数。
* **AI Provider**:
* 管理 LLM 供应商 (OpenAI, Anthropic, Local Ollama 等)。
* 配置 API Key 和 Base URL。
* 刷新并选择可用的模型 (GPT-4o, Claude-3.5 等)。
* **数据源配置**:
* 启用/禁用金融数据源 (Tushare, Finnhub, AlphaVantage)。
* 输入对应的 API Token。
* 支持连接测试。
* **分析模板**:
* 查看当前的分析流程模板(如 "Quick Scan")。
* 查看每个模块使用的 Prompt 模板及模型配置。
* **系统状态**:
* 监控微服务集群 (API Gateway, Orchestrator 等) 的健康状态。
## 3. 快速开始
1. 进入 **配置页** -> **AI Provider**,添加您的 OpenAI API Key。
2. 进入 **配置页** -> **数据源配置**,启用 Tushare 并输入 Token。
3. 回到 **首页**,输入 `600519.SS`,选择 `CN` 市场。
4. 点击 **生成分析报告**,观察工作流运行及报告生成。
## 4. 常见问题
* **Q: 报告生成卡住怎么办?**
* A: 检查左侧“实时日志”,查看是否有 API 连接超时或配额耗尽的错误。
* **Q: 如何添加本地模型?**
* A: 在 AI Provider 页添加新的 ProviderBase URL 填入 `http://localhost:11434/v1` (Ollama 默认地址)。

View File

@ -1,159 +0,0 @@
---
status: 'Active'
created: '2025-11-16'
last_updated: '2025-11-16'
owner: '@lv'
---
# 系统架构设计总览
## 1. 引言
### 1.1. 文档目的
本文档旨在为“基本面选股系统”的事件驱动微服务架构,提供一份统一的、作为“单一事实源”的核心技术蓝图。它整合并取代了多个历史设计文档,旨在清晰、准确地描述当前系统的核心架构理念、服务职责、关键设计以及数据模型。
### 1.2. 核心架构理念
本系统采用纯 Rust 构建的现代化微服务架构其核心理念根植于“Rustic”风格的健壮性与确定性遵循以下原则
- **服务独立化**: 每个外部数据源、每个核心业务能力都被封装成独立的、可独立部署和运行的微服务。
- **事件驱动**: 引入消息总线Message Bus作为服务间通信的主干实现服务的高度解耦和异步协作。
- **数据中心化**: 所有微服务将标准化的数据写入一个由 `data-persistence-service` 独占管理的中央数据库,实现“数据写入即共享”。
- **契约先行**: 所有服务间的通信与数据模型,均通过 `common-contracts` 共享库进行强类型约束,确保系统的一致性与稳定性。
## 2. 架构图与服务职责
### 2.1. 目标架构图
```
+-------------+ +------------------+ +---------------------------+
| | HTTP | | | |
| 前端 |----->| API 网关 |----->| 消息总线 (NATS) |
| (Next.js) | | (Rust) | | |
| | | | | |
+-------------+ +-------+----------+ +-------------+-------------+
| |
(读操作) | | (发布/订阅 命令与事件)
| |
+-----------------v------------------+ +------v------+ +----------------+
| | | 数据提供商A | | 数据提供商B |
| 数据持久化服务 (Rust) |<---->| (Tushare) | | (Finnhub) |
| | | 服务 (Rust) | | 服务 (Rust) |
+-----------------+------------------+ +-------------+ +----------------+
|
v
+-----------------------------------------------------+
| |
| PostgreSQL 数据库 |
| |
+-----------------------------------------------------+
```
### 2.2. 服务职责划分
- **API 网关 (api-gateway)**:
- 面向前端的唯一入口 (BFF)。
- 负责用户请求、认证鉴权。
- 将前端的查询请求,转化为对**数据持久化服务**的数据读取调用。
- 将前端的操作请求如“生成新报告”转化为命令Command并发布到**消息总线**。
- **数据提供商服务 (`*_provider-service`)**:
- 一组独立的微服务,每个服务对应一个外部数据 API。
- 订阅消息总线上的相关命令(如 `FetchFinancialsRequest`)。
- 独立调用外部 API对返回数据进行清洗、标准化。
- 调用**数据持久化服务**的接口,将标准化后的数据写入数据库。
- **数据持久化服务 (data-persistence-service)**:
- 数据库的**唯一守门人**,是整个系统中唯一有权直接与数据库交互的服务。
- 为所有其他内部微服务提供稳定、统一的数据库读写 HTTP 接口。
- **消息总线 (Message Bus)**:
- 整个系统的神经中枢,负责所有服务间的异步通信。当前选用 **NATS** 作为具体实现。
## 3. `SystemModule` 核心规范
为确保所有微服务行为一致、可观测、易于管理,我们定义了一套名为 `SystemModule` 的设计规范。它并非真实的 Rust Trait而是一个所有服务都必须遵守的**行为契约**。
**每个微服务都必须:**
1. **容器化**: 提供一个 `Dockerfile` 用于部署。
2. **配置驱动**: 从环境变量或配置服务中读取配置,缺少必要配置必须启动失败。
3. **消息契约**: 严格按照 `common-contracts` 中定义的契约进行消息的订阅和发布。
4. **暴露标准接口**: 实现一个内置的 HTTP 服务器,并暴露**两个强制性的 API 端点**
- `GET /health`: 返回服务的健康状态。
- `GET /tasks`: 返回服务当前正在处理的所有任务列表及其进度。
## 4. 关键服务设计:数据持久化服务
- **核心定位**: 整个微服务架构中**唯一的数据持久化层**。
- **职责边界**: 严格限定在管理跨多个业务领域共享的**核心数据实体**上如公司信息、财务数据、市场数据、AI分析结果
- **API 端点摘要**:
| Method | Endpoint | 描述 |
| :--- | :--- | :--- |
| `PUT` | `/api/v1/companies` | 创建或更新公司基本信息 |
| `GET` | `/api/v1/companies/{symbol}` | 获取公司基本信息 |
| `POST` | `/api/v1/market-data/financials/batch` | 批量写入时间序列财务指标 |
| `GET` | `/api/v1/market-data/financials/{symbol}` | 查询财务指标 |
| `POST` | `/api/v1/analysis-results` | 保存一条新的 AI 分析结果 |
| `GET` | `/api/v1/analysis-results` | 查询分析结果列表 |
## 5. 数据库 Schema 设计
### 5.1. 设计哲学
采用**“为不同形态的数据建立专属的、高度优化的持久化方案”**的核心哲学,统一使用 **PostgreSQL** 及其扩展生态。
- **时间序列数据**: 明确采用 **TimescaleDB** 扩展,通过 Hypertables 机制保障高性能的写入与查询。
- **其他数据**: 使用标准的关系表进行存储。
### 5.2. 核心表结构
#### `time_series_financials` (财务指标表 - TimescaleDB)
```sql
CREATE TABLE time_series_financials (
symbol VARCHAR(32) NOT NULL,
metric_name VARCHAR(64) NOT NULL,
period_date DATE NOT NULL,
value NUMERIC NOT NULL,
source VARCHAR(64),
PRIMARY KEY (symbol, metric_name, period_date)
);
SELECT create_hypertable('time_series_financials', 'period_date');
```
#### `daily_market_data` (每日市场数据表 - TimescaleDB)
```sql
CREATE TABLE daily_market_data (
symbol VARCHAR(32) NOT NULL,
trade_date DATE NOT NULL,
open_price NUMERIC,
close_price NUMERIC,
volume BIGINT,
total_mv NUMERIC,
PRIMARY KEY (symbol, trade_date)
);
SELECT create_hypertable('daily_market_data', 'trade_date');
```
#### `analysis_results` (AI分析结果表)
```sql
CREATE TABLE analysis_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
symbol VARCHAR(32) NOT NULL,
module_id VARCHAR(64) NOT NULL,
generated_at TIMESTAMTz NOT NULL DEFAULT NOW(),
content TEXT NOT NULL,
meta_data JSONB
);
```
#### `company_profiles` (公司基本信息表)
```sql
CREATE TABLE company_profiles (
symbol VARCHAR(32) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
industry VARCHAR(255),
list_date DATE
);
```

View File

@ -1,112 +0,0 @@
# 设计文档:统一历史记录与上下文管理重构
## 1. 目标
实现一个统一且一致的历史管理系统,达成以下目标:
1. **原子化历史记录**:一个“历史记录”严格对应**一次 Workflow 执行**(由 `request_id` 标识),彻底解决历史列表重复/碎片化问题。
2. **单一数据源**全局上下文VGCS/Git作为所有文件产物报告、日志、数据的唯一真实存储源。
3. **轻量化索引**:数据库(`session_data` 或新表仅存储结构化的“索引”Snapshot指向 VGCS 中的 Commit 和文件路径。
## 2. 现状分析
- **碎片化**:目前 `analysis_results` 表存储的是单个 Task 的结果。如果一个工作流包含 N 个分析步骤,历史列表中就会出现 N 条记录。
- **数据冗余**结果内容Markdown 等)既作为文件存在 VGCS 中,又作为文本列存在 Postgres 数据库中。
- **历史视图缺失**:缺乏一个能够代表整次执行状态(包含拓扑结构、状态、所有产物引用)的根对象,导致查询历史列表时困难。
## 3. 架构方案
### 3.1. 核心概念:工作流快照 (Workflow Snapshot)
不再将每个 Task 视为独立的历史记录,我们定义 **Workflow Snapshot** 为历史的原子单位。
一个 Snapshot 包含:
- **元数据**`request_id`请求ID, `symbol`(标的), `market`(市场), `template_id`模板ID, `start_time`(开始时间), `end_time`(结束时间), `final_status`(最终状态)。
- **拓扑结构**DAG 结构(节点与边)。
- **执行状态**:针对每个节点记录:
- `status`:状态 (Completed, Failed, Skipped)
- `output_commit`:该节点产生的 VGCS Commit Hash。
- `artifacts`产物映射表Key 为产物名称Value 为 VGCS 文件路径 (例如 `{"report": "analysis/summary.md", "log": "analysis/execution.log"}`)。
### 3.2. 数据存储变更
#### A. `workflow_history` 表 (或重构后的 `session_data`)
我们将引入一张专用表(或规范化 `session_data` 的使用)来存储 **Workflow Manifest**
```sql
CREATE TABLE workflow_history (
request_id UUID PRIMARY KEY,
symbol VARCHAR(20) NOT NULL,
market VARCHAR(10) NOT NULL,
template_id VARCHAR(50),
status VARCHAR(20) NOT NULL, -- 'Completed', 'Failed'
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
-- Snapshot JSON 数据
snapshot_data JSONB NOT NULL
-- {
-- "dag": { ... },
-- "tasks": {
-- "task_id_1": { "status": "Completed", "commit": "abc1234", "paths": { "report": "..." } }
-- }
-- }
);
```
*注:为了减少迁移摩擦,我们可以继续使用 `session_data` 表,并指定 `data_type = 'workflow_snapshot'`,但建立专用表更有利于查询和维护。*
#### B. VGCS (Git 上下文) 的使用规范
- **输入**:初始 Commit 包含 `request.json`
- **过程**:每个 Task (Worker) 检出基础 Commit执行工作写入文件报告、日志并创建 **New Commit**
- **合并**Orchestrator 负责追踪这些 Commit 的 DAG 关系。
- **终态**Orchestrator 创建最终的“Merge Commit”可选或仅引用各叶子节点的 Commit并在 `workflow_history` 中记录。
### 3.3. 组件职责划分
#### 1. Worker 服务 (Report Gen, Providers)
- **输入**:接收 `base_commit`, `task_id`, `output_path_config`
- **动作**
- 初始化 `WorkerContext` (VGCS)。
- 将 `report.md` 写入 `output_path`
- 将 `_execution.md` (日志) 写入 `log_path`
- **Commit**:提交更改,生成 Commit Hash。
- **输出**:返回 `new_commit_hash``artifact_paths` (Map<Name, Path>) 给 Orchestrator。
- **禁止**Worker 不再直接向数据库的 `analysis_results` 表写入数据。
#### 2. Workflow Orchestrator (编排器)
- **协调**:从 `TaskCompleted` 事件中收集 `new_commit_hash``artifact_paths`
- **状态追踪**:更新内存中的 DAG 状态。
- **完成处理**
- 当所有任务结束后,生成 **Workflow Snapshot**
- 调用 `persistence-service` 将 Snapshot 保存至 `workflow_history`
- 发送 `WorkflowCompleted` 事件。
#### 3. Data Persistence Service (持久化服务)
- **新接口**`GET /api/v1/history`
- 返回 `workflow_history` 列表(摘要信息)。
- **新接口**`GET /api/v1/history/{request_id}`
- 返回完整的 Snapshot详情信息
- **旧接口处理**:废弃 `GET /api/v1/analysis-results` 或将其重定向为查询 `workflow_history`
#### 4. Frontend (前端)
- **历史页**:调用 `/api/v1/history`。每个 `request_id` 只展示一行。
- **报告页**
- 获取特定的历史详情。
- 使用 `artifact_paths` + `commit_hash` 通过 VGCS API (或代理)以此获取文件内容。
## 4. 实施计划
1. **Schema 定义**:定义 `WorkflowSnapshot` 结构体及 SQL 迁移脚本 (`workflow_history`)。
2. **Orchestrator 改造**
- 修改 `handle_task_completed` 以聚合 `artifact_paths`
- 实现 `finalize_workflow` 逻辑,用于构建并保存 Snapshot。
3. **Worker 改造**
- 确保 `report-generator``TaskResult` 中返回结构化的 `artifact_paths`
- 移除 `report-generator` 中对 `create_analysis_result` 的数据库调用。
4. **Persistence Service 改造**
- 实现 `workflow_history` 的 CRUD 操作。
5. **Frontend 改造**
- 更新 API 调用以适配新的历史记录接口。
## 5. 核心收益
- **单一事实来源**:文件存 Git元数据存 DB杜绝数据不同步。
- **历史记录原子性**:一次运行 = 一条记录。
- **可追溯性**:每个产物都精确关联到一个 Git Commit。

View File

@ -1,154 +0,0 @@
---
status: 'Pending'
created: '2025-11-16'
owner: '@lv'
---
# 任务重构LLM Provider架构 (V2 - 数据库中心化)
## 1. 任务目标
为解决当前系统大语言模型LLM配置的僵化问题本次任务旨在重构LLM的配置和调用工作流。我们将实现一个以数据库为中心的、支持多供应商的、结构化的配置体系。该体系将允许每个分析模块都能按需选择其所需的LLM供应商和具体模型同时保证整个系统的类型安全和数据一致性。
## 2. 新架构设计:配置即数据
我们将废弃所有基于本地文件的配置方案 (`analysis-config.json`, `llm-providers.json`),并将所有配置信息作为结构化数据存入数据库。
### 2.1. 核心原则Schema-in-Code
- **不新增数据表**: 我们将利用现有的 `system_config` 表及其 `JSONB` 字段来存储所有配置无需修改数据库Schema。
- **强类型约束**: 所有配置的JSON结构其“单一事实源”都将是在 **`common-contracts`** crate中定义的Rust Structs。所有服务都必须依赖这些共享的Structs来序列化和反序列化配置数据从而在应用层面实现强类型约束。
### 2.2. `common-contracts`中的数据结构定义
将在`common-contracts`中创建一个新模块(例如 `config_models.rs`),定义如下结构:
```rust
// In: common-contracts/src/config_models.rs
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
// 单个启用的模型
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LlmModel {
pub model_id: String, // e.g., "gpt-4o"
pub name: Option<String>, // 别名用于UI显示
pub is_active: bool, // 是否在UI中可选
}
// 单个LLM供应商的完整配置
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LlmProvider {
pub name: String, // "OpenAI 官方"
pub api_base_url: String,
pub api_key: String, // 直接明文存储
pub models: Vec<LlmModel>, // 该供应商下我们启用的模型列表
}
// 整个LLM Provider注册中心的数据结构
pub type LlmProvidersConfig = HashMap<String, LlmProvider>; // Key: provider_id, e.g., "openai_official"
// 单个分析模块的配置
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AnalysisModuleConfig {
pub name: String, // "看涨分析"
pub provider_id: String, // 引用 LlmProvidersConfig 的 Key
pub model_id: String, // 引用 LlmModel 中的 model_id
pub prompt_template: String,
pub dependencies: Vec<String>,
}
// 整个分析模块配置集合的数据结构
pub type AnalysisModulesConfig = HashMap<String, AnalysisModuleConfig>; // Key: module_id, e.g., "bull_case"
```
### 2.3. `system_config` 表中的数据存储
我们将使用两个`config_key`来存储这些结构序列化后的JSON
1. **Key: `"llm_providers"`**: 其`config_value`是一个序列化后的`LlmProvidersConfig`。
2. **Key: `"analysis_modules"`**: 其`config_value`是一个序列化后的`AnalysisModulesConfig`。
## 3. 实施步骤
### 步骤 1: 更新 `common-contracts` (地基)
1. 在`common-contracts/src/`下创建`config_models.rs`文件。
2. 将上述所有Rust Structs定义添加到该文件中并确保它们在`lib.rs`中被正确导出。
### 步骤 2: 重构 `data-persistence-service` (配置守门人)
1. **移除 `config-service-rs`**: 该服务的功能将被`data-persistence-service`完全吸收和取代,可以准备将其从`docker-compose.yml`中移除。
2. **实现新的CRUD API**:
- `GET /api/v1/configs/llm_providers`: 读取并返回`system_config`中key为`llm_providers`的JSON文档。
- `PUT /api/v1/configs/llm_providers`: 接收一个`LlmProvidersConfig`的JSON payload**使用`common-contracts`中的Structs进行反序列化验证**,验证通过后,将其存入数据库。
- `GET /api/v1/configs/analysis_modules`: 读取并返回key为`analysis_modules`的JSON文档。
- `PUT /api/v1/configs/analysis_modules`: 接收一个`AnalysisModulesConfig`的JSON payload进行验证后存入数据库。
### 步骤 3: 重构 `frontend` (管理UI)
1. **创建LLM Provider管理页面**:
- 提供一个表单,用于新增/编辑`LlmProvider`(对应`llm_providers`JSON中的一个顶级条目
- 在每个Provider下提供一个子表单来管理其`models`列表(增、删、改、切换`is_active`状态)。
- 实现“自动发现模型”功能,调用`api-gateway`的模型发现端点,让用户可以从中选择模型加入列表。
2. **更新分析模块配置页面**:
- 为每个分析模块提供两个级联下拉框:
1. 第一个下拉框选择`Provider` (数据来自`GET /api/v1/configs/llm_providers`)。
2. 第二个下拉框根据第一个的选择动态加载该Provider下所有`is_active: true`的`Model`。
- 更新保存逻辑,以调用`PUT /api/v1/configs/analysis_modules`。
### 步骤 4: 更新 `api-gateway`
1. **移除对`config-service-rs`的代理**
2. **代理新的配置API**: 将所有`/api/v1/configs/*`的请求正确地代理到`data-persistence-service`。
3. **实现模型发现端点**:
- 创建`GET /api/v1/discover-models/{provider_id}`。
- 该端点会先调用`data-persistence-service`获取指定provider的`api_base_url`和`api_key`。
- 然后使用这些信息向LLM供应商的官方`/models`接口发起请求,并将结果返回给前端。
### 步骤 5: 重构 `report-generator-service` (最终消费者)
1. **移除旧配置**:
- 修改`docker-compose.yml`,移除所有旧的`LLM_*`环境变量。
2. **重构工作流**:
- 当收到任务时(例如`bull_case`),它将:
a. 并行调用`data-persistence-service`的`GET /api/v1/configs/llm_providers`和`GET /api/v1/configs/analysis_modules`接口,获取完整的配置。
b. **使用`common-contracts`中的Structs反序列化**这两个JSON响应得到类型安全的`LlmProvidersConfig`和`AnalysisModulesConfig`对象。
c. 通过`analysis_config["bull_case"]`找到`provider_id`和`model_id`。
d. 通过`providers_config[provider_id]`找到对应的`api_base_url`和`api_key`。
e. 动态创建`LlmClient`实例,并执行任务。
## 4. 验收标准
- ✅ `common-contracts` crate中包含了所有新定义的配置Structs。
- ✅ `data-persistence-service`提供了稳定、类型安全的API来管理存储在`system_config`表中的配置。
- ✅ `config-service-rs`服务已安全移除。
- ✅ 前端提供了一个功能完善的UI用于管理LLM Providers、Models并能将它们正确地指派给各个分析模块。
- ✅ `report-generator-service`能够正确地、动态地使用数据库中的配置为不同的分析模块调用不同的LLM Provider和模型。
## 6. 任务实施清单 (TODO List)
### 阶段一:定义数据契约 (`common-contracts`)
- [x] 在 `src` 目录下创建 `config_models.rs` 文件。
- [x] 在 `config_models.rs` 中定义 `LlmModel`, `LlmProvider`, `LlmProvidersConfig`, `AnalysisModuleConfig`, `AnalysisModulesConfig` 等所有Structs。
- [x] 在 `lib.rs` 中正确导出 `config_models` 模块,使其对其他服务可见。
### 阶段二:实现配置的持久化与服务 (`data-persistence-service`)
- [x] **[API]** 实现 `GET /api/v1/configs/llm_providers` 端点。
- [x] **[API]** 实现 `PUT /api/v1/configs/llm_providers` 端点,并确保使用 `common-contracts` 中的Structs进行反序列化验证。
- [x] **[API]** 实现 `GET /api/v1/configs/analysis_modules` 端点。
- [x] **[API]** 实现 `PUT /api/v1/configs/analysis_modules` 端点,并进行相应的验证。
- [x] **[系统]** 从 `docker-compose.yml` 中安全移除 `config-service-rs` 服务,因其功能已被本服务吸收。
### 阶段三更新API网关与前端 (`api-gateway` & `frontend`)
- [x] **[api-gateway]** 更新路由配置,将所有 `/api/v1/configs/*` 的请求代理到 `data-persistence-service`
- [x] **[api-gateway]** 实现 `GET /api/v1/discover-models/{provider_id}` 模型发现代理端点。
- [x] **[frontend]** 创建全新的“LLM Provider管理”页面UI骨架。
- [x] **[frontend]** 实现调用新配置API对LLM Providers和Models进行增、删、改、查的完整逻辑。
- [x] **[frontend]** 在Provider管理页面上实现“自动发现模型”的功能按钮及其后续的UI交互。
- [x] **[frontend]** 重构“分析模块配置”页面使用级联下拉框来选择Provider和Model。
### 阶段四:重构报告生成服务 (`report-generator-service`)
- [x] **[配置]** 从 `docker-compose.yml` 中移除所有旧的、全局的 `LLM_*` 环境变量。
- [x] **[核心逻辑]** 重构服务的工作流,实现从 `data-persistence-service` 动态获取`LlmProvidersConfig`和`AnalysisModulesConfig`。
- [x] **[核心逻辑]** 实现动态创建 `LlmClient` 实例的逻辑使其能够根据任务需求使用不同的Provider配置。

View File

@ -1,542 +0,0 @@
# 设计文档: 面向Rust的事件驱动数据微服务架构
## 1. 引言
### 1.1. 文档目的
本文档旨在为“基本面选股系统”设计一个**完全基于Rust的、事件驱动的、去中心化的微服务架构**。此设计将作为彻底替换现有Python组件、并构建下一代数据处理生态系统的核心技术蓝图。
新的架构目标是:
1. **服务独立化**将每个外部数据源Tushare, Finnhub等封装成独立的、可独立部署和运行的微服务。
2. **事件驱动**引入消息总线Message Bus作为服务间通信的主干实现服务的高度解耦和异步协作。
3. **数据中心化**:所有微服务将标准化的数据写入一个由`data-persistence-service`独占管理的中央数据库,实现“数据写入即共享”。
4. **纯Rust生态**从前端网关到最末端的数据提供商整个后端生态系统将100%使用Rust构建确保端到端的类型安全、高性能和健壮性。
### 1.2. 核心架构理念
- **独立单元 (Independent Units)**: 每个服务都是一个完整的、自包含的应用程序,拥有自己的配置、逻辑和生命周期。
- **异步协作 (Asynchronous Collaboration)**: 服务之间通过发布/订阅消息进行通信而非紧耦合的直接API调用。
- **单一事实源 (Single Source of Truth)**: 数据库是所有结构化数据的唯一事实源。服务通过向数据库写入数据来“广播”其工作成果。
## 2. 目标架构 (Target Architecture)
### 2.1. 架构图
```
+-------------+ +------------------+ +---------------------------+
| | HTTP | | | |
| Frontend |----->| API Gateway |----->| Message Bus |
| (Next.js) | | (Rust) | | (e.g., RabbitMQ, NATS) |
| | | | | |
+-------------+ +-------+----------+ +-------------+-------------+
| |
(Read operations) | | (Pub/Sub Commands & Events)
| |
+-----------------v------------------+ +------v------+ +----------------+ +----------------+
| | | Tushare | | Finnhub | | iFind |
| Data Persistence Service (Rust) |<---->| Provider | | Provider | | Provider |
| | | Service | | Service | | Service |
+-----------------+------------------+ | (Rust) | | (Rust) | | (Rust) |
| +-------------+ +----------------+ +----------------+
v
+-----------------------------------------------------+
| |
| PostgreSQL Database |
| |
+-----------------------------------------------------+
```
### 2.2. 服务职责划分
- **API Gateway (Rust)**:
- 面向前端的唯一入口 (BFF - Backend for Frontend)。
- 负责处理用户请求、认证鉴权。
- 将前端的查询请求转化为对`Data Persistence Service`的数据读取调用。
- 将前端的操作请求如“生成新报告”转化为命令Command并发布到**Message Bus**。
- **`*_provider-service` (Rust)**:
- **一组**独立的微服务每个服务对应一个外部数据API如`tushare-provider-service`)。
- 订阅Message Bus上的相关命令如`FetchFinancialsRequest`)。
- 独立调用外部API对返回数据进行清洗、标准化。
- 调用`Data Persistence Service`的接口,将标准化后的数据写入数据库。
- 操作完成后可以向Message Bus发布事件Event如`FinancialsDataReady`。
- **Data Persistence Service (Rust)**:
- **(职责不变)** 数据库的唯一守门人。
- 为所有其他内部微服务提供稳定、统一的数据库读写gRPC/HTTP接口。
- **Message Bus (e.g., RabbitMQ, NATS)**:
- 整个系统的神经中枢,负责所有服务间的异步通信。
- 传递命令(“做什么”)和事件(“发生了什么”)。
## 3. 核心抽象与数据契约
### 3.1. `DataProvider` Trait (内部实现蓝图)
此Trait依然是构建**每个独立Provider微服务内部逻辑**的核心蓝图。它定义了一个Provider应该具备的核心能力。
```rust
// This trait defines the internal logic blueprint for each provider microservice.
#[async_trait]
pub trait DataProvider: Send + Sync {
// ... (trait definition remains the same as previous version) ...
fn get_id(&self) -> &'static str;
async fn get_company_profile(&self, symbol: &str) -> Result<CompanyProfile, DataProviderError>;
async fn get_historical_financials(&self, symbol: &str, years: &[u16]) -> Result<Vec<FinancialStatement>, DataProviderError>;
// ... etc ...
}
```
### 3.2. 标准化数据模型 (共享的数据契约)
这些模型是服务间共享的“通用语言”,也是存入数据库的最终形态,其重要性在新架构下更高。
```rust
// These structs are the shared "Data Contracts" across all services.
// Their definitions remain the same as the previous version.
#[derive(Debug, Clone, Serialize, Deserialize)] // Add Serialize/Deserialize for messaging
pub struct CompanyProfile { ... }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FinancialStatement { ... }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketDataPoint { ... }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RealtimeQuote { ... }
```
### 3.3. 消息/事件定义 (Message/Event Contracts)
这是新架构的核心定义了在Message Bus上传递的消息格式。
```rust
use uuid::Uuid;
use serde::{Serialize, Deserialize};
// --- Commands (Instructions to do something) ---
#[derive(Serialize, Deserialize)]
pub struct FetchCompanyDataCommand {
pub request_id: Uuid,
pub symbol: String,
pub market: String, // To help providers route to the correct API endpoint
}
// --- Events (Notifications that something has happened) ---
#[derive(Serialize, Deserialize)]
pub struct CompanyProfilePersistedEvent {
pub request_id: Uuid,
pub symbol: String,
// We don't need to carry the full data, as it's now in the database.
// Interested services can query it.
}
#[derive(Serialize, Deserialize)]
pub struct FinancialsPersistedEvent {
pub request_id: Uuid,
pub symbol: String,
pub years_updated: Vec<u16>,
}
```
## 4. 数据工作流示例 (Example Data Workflow)
1. **请求发起**: 用户在前端请求`AAPL`的分析报告。请求到达`API Gateway`。
2. **命令发布**: `API Gateway`生成一个唯一的`request_id`然后向Message Bus发布一个`FetchCompanyDataCommand`命令。
3. **命令消费**: `tushare-provider`、`finnhub-provider`等所有订阅了此命令的服务都会收到消息。
4. **独立执行**:
- `finnhub-provider`根据`market`和`symbol`调用Finnhub API获取公司简介、财务、行情数据。
- 数据获取成功后,它将数据转换为标准化的`CompanyProfile`, `Vec<FinancialStatement>`等模型。
- 它调用`Data Persistence Service`的接口,将这些标准化的数据写入数据库。
- 写入成功后它向Message Bus发布`CompanyProfilePersistedEvent`和`FinancialsPersistedEvent`等事件。
- `tushare-provider`收到命令后,可能因为市场不匹配而直接忽略该消息。
5. **下游响应**: 一个潜在的`report-generator-service`(图中未画出,属于业务层)可以订阅`...PersistedEvent`。当它收到了生成一份完整报告所需的所有数据事件后便开始从数据库中拉取这些数据进行AI分析并将最终报告存回数据库。
6. **前端轮询/通知**: `API Gateway`可以通过WebSocket或长轮询等方式将最终报告的完成状态通知给前端。
## 5. 实施路线图 (Roadmap) - 更新于 2025-11-15
**基于对项目现状的调研,本路线图已更新,明确标识了已完成的工作和接下来的行动计划。**
---
### **✔ 阶段-1容器化与初步服务拆分 (已完成)**
- [x] **核心服务已容器化**: `data-persistence-service` 已完全开发、容器化并通过Docker Compose与数据库和现有Python后端集成。
- [x] **数据库已初始化**: Rust服务的 `migrations` 目录证实了数据库表结构已通过 `sqlx-cli` 创建和管理。
- [x] **Python后端部分重构**: Python `backend` 服务已经作为客户端通过HTTP API调用`data-persistence-service`来读写数据。
- [x] **配置服务已拆分**: `config-service` 作为一个独立的Python微服务也已存在并运行。
- [x] **开发环境已建立**: 整个系统可以通过`Docker Compose`和`Tilt`一键启动。
---
### **阶段〇:奠定新架构的基石 (Laying the New Foundation)**
- [x] **1. 部署消息总线**: 在`docker-compose.yml`中添加一个消息总线服务 (`NATS`)。这是实现事件驱动架构的**先决条件**。
- [x] **2. 创建共享契约库 (`common-contracts`)**: 在`services/`下创建一个新的Rust `common-contracts` crate。
- 将`data-persistence-service/src/dtos.rs` 和 `models.rs`中的核心数据结构(如`CompanyProfile`, `FinancialStatement`等)迁移至此。
- 添加`architecture_module_specification.md`中定义的消息契约 (`FetchCompanyDataCommand`等) 和可观测性结构 (`HealthStatus`, `TaskProgress`)。
- [x] **3. 升级 `data-persistence-service`**:
- 使其依赖新的`common-contracts` crate替换掉本地的数据模型定义。
- 为其实现`SystemModule`规范,即添加`/health`和`/tasks`端点。
---
### **阶段一:开发 `alphavantage-provider-service` (精确实现蓝图)**
**目标**: 创建并实现 `alphavantage-provider-service`,使其成为我们新架构下的第一个功能完备、可独立运行的数据提供商微服务。
- [x] **1. 项目初始化与依赖配置**
- [x] **任务**: 基于我们的微服务模板创建新的Rust项目 `services/alphavantage-provider-service`
- [x] **任务**: 在其`Cargo.toml`中添加核心依赖。
```toml
# Cargo.toml
[dependencies]
# ... other dependencies like axum, tokio, etc.
common-contracts = { path = "../common-contracts" }
# Generic MCP Client
rmcp = "0.8.5"
# Message Queue (NATS)
async-nats = "0.33"
```
- [x] **验收标准**: 项目可以成功编译 (`cargo check`)。
- [x] **2. 实现 `SystemModule` 规范**
- [x] **任务**: 在`main.rs`中启动一个Axum HTTP服务器。
- [x] **任务**: 实现强制的`/health`端点,返回当前服务的健康状态。
- [x] **任务**: 实现强制的`/tasks`端点。此端点需要从一个线程安全的内存存储(例如 `Arc<DashMap<Uuid, TaskProgress>>`)中读取并返回所有正在进行的任务。
- [x] **验收标准**: 启动服务后,可以通过`curl`或浏览器访问`http://localhost:port/health`和`http://localhost:port/tasks`并得到正确的JSON响应。
- [x] **3. 实现核心业务逻辑:事件驱动的数据处理**
- [x] **任务**: 实现连接到Message Bus并订阅`FetchCompanyDataCommand`命令的逻辑。
- [x] **任务**: 当收到`FetchCompanyDataCommand`命令时,执行以下异步工作流:
1. 在任务存储中创建并插入一个新的`TaskProgress`记录。
2. 从配置中读取`ALPHAVANTAGE_API_KEY`并构建MCP端点URL。
3. 初始化通用的`rmcp`客户端: `let client = rmcp::mcp::Client::new(mcp_endpoint_url);`
4. 使用`tokio::try_join!`**并行**执行多个数据获取任务。**注意:函数名是字符串,返回的是`serde_json::Value`。**
```rust
// 伪代码示例
let symbol = &command.symbol;
let overview_task = client.query("OVERVIEW", &[("symbol", symbol)]);
let income_task = client.query("INCOME_STATEMENT", &[("symbol", symbol)]);
// ... 其他任务
match tokio::try_join!(overview_task, income_task, /*...*/) {
Ok((overview_json, income_json, /*...*/)) => {
// overview_json and income_json are of type serde_json::Value
// ... 进入步骤 4
},
Err(e) => { /* ... */ }
}
```
5. 在`try_join!`前后,精确地更新内存中`TaskProgress`的状态。
- [x] **验收标准**: 在Message Bus中发布命令后服务的日志能正确打印出从Alpha Vantage获取到的原始JSON数据。
- [x] **4. 实现数据转换与持久化 (强类型映射)**
- [x] **任务**: **(关键变更)** 实现 `TryFrom<serde_json::Value>` Trait完成从动态JSON到我们`common-contracts`模型的**带错误处理的**转换。
```rust
// alphavantage-provider-service/src/mapping.rs
use serde_json::Value;
use common_contracts::models as our;
impl TryFrom<Value> for our::CompanyProfile {
type Error = anyhow::Error; // Or a more specific parsing error
fn try_from(v: Value) -> Result<Self, Self::Error> {
Ok(our::CompanyProfile {
symbol: v["Symbol"].as_str().ok_or_else(|| anyhow!("Missing Symbol"))?.to_string(),
name: v["Name"].as_str().ok_or_else(|| anyhow!("Missing Name"))?.to_string(),
// ... 其他字段的安全解析和转换
})
}
}
```
- [x] **任务**: 创建一个类型化的HTTP客户端 (Data Persistence Client),用于与`data-persistence-service`通信。
- [x] **任务**: 在所有数据转换成功后,调用上述客户端进行持久化。
- [x] **验收标准**: 数据库中查询到的数据,结构完全符合`common-contracts`定义。
- [x] **5. 实现事件发布与任务完成**
- [x] **任务**: 在数据成功持久化到数据库后向Message Bus发布相应的数据就绪事件如`CompanyProfilePersistedEvent`和`FinancialsPersistedEvent`。
- [x] **任务**: 在所有流程执行完毕(无论成功或失败)后,从内存存储中移除对应的`TaskProgress`对象或将其标记为“已完成”并设置TTL
- [x] **验收标准**: 能够在Message Bus中监听到本服务发布的事件。`/tasks`接口不再显示已完成的任务。
---
### **阶段二重构API网关与请求流程 (精确实现蓝图)**
**目标**: 创建一个纯Rust的`api-gateway`服务,它将作为前端的唯一入口(BFF),负责发起数据获取任务、查询持久化数据以及追踪分布式任务进度。
- [x] **1. 项目初始化与 `SystemModule` 规范实现**
- [x] **任务**: 基于我们的微服务模板创建新的Rust项目 `services/api-gateway`
- [x] **任务**: 在其`Cargo.toml`中添加核心依赖: `axum`, `tokio`, `common-contracts`, `async-nats`, `reqwest`, `tracing`, `config`
- [x] **任务**: 实现强制的`/health`端点。
- [x] **任务**: 实现强制的`/tasks`端点。由于网关本身是无状态的、不执行长任务,此端点当前可以简单地返回一个空数组`[]`。
- [x] **验收标准**: `api-gateway`服务可以独立编译和运行,并且`/health`接口按预期工作。
- [x] **2. 实现数据触发流程 (发布命令)**
- [x] **任务**: 在`api-gateway`中创建一个新的HTTP端点 `POST /v1/data-requests`它应接收一个JSON体例如: `{"symbol": "AAPL", "market": "US"}`
- [x] **任务**: 为此端点实现处理逻辑:
1. 生成一个全局唯一的 `request_id` (UUID)。
2. 创建一个`common_contracts::messages::FetchCompanyDataCommand`消息,填入请求参数和`request_id`。
3. 连接到Message Bus并将此命令发布到`data_fetch_commands`队列。
4. 向前端立即返回 `202 Accepted` 状态码,响应体中包含 `{ "request_id": "..." }`,以便前端后续追踪。
- [x] **验收标准**: 通过工具如Postman调用此端点后能够在NATS的管理界面看到相应的消息被发布同时`alphavantage-provider-service`的日志显示它已接收并开始处理该命令。
- [x] **3. 实现数据查询流程 (读取持久化数据)**
- [x] **任务**: 在`api-gateway`中创建一个类型化的HTTP客户端 (Persistence Client),用于与`data-persistence-service`通信。
- [x] **任务**: 实现 `GET /v1/companies/{symbol}/profile` 端点。该端点接收股票代码通过Persistence Client调用`data-persistence-service`的相应接口,并将查询到的`CompanyProfile`数据返回给前端。
- [x] **任务**: (可选) 根据需要,实现查询财务报表、行情数据等其他数据类型的端点。
- [x] **验收标准**: 在`alphavantage-provider-service`成功写入数据后,通过浏览器或`curl`调用这些新端点可以查询到预期的JSON数据。
- [x] **4. 实现分布式任务进度追踪**
- [x] **任务**: 在`api-gateway`的配置中,增加一个`provider_services`字段,用于列出所有数据提供商服务的地址,例如: `["http://alphavantage-provider-service:8000"]`
- [x] **任务**: 实现 `GET /v1/tasks/{request_id}` 端点。
- [x] **任务**: 该端点的处理逻辑需要:
1. 读取配置中的`provider_services`列表。
2. 使用`tokio::join!`或`futures::future::join_all`**并行地**向所有provider服务的`/tasks`端点发起HTTP GET请求。
3. 聚合所有服务的返回结果(一个`Vec<Vec<TaskProgress>>`并从中线性搜索与URL路径中`request_id`匹配的`TaskProgress`对象。
4. 如果找到匹配的任务将其作为JSON返回。如果遍历完所有结果都未找到则返回`404 Not Found`。
- [x] **验收标准**: 当`alphavantage-provider-service`正在处理一个任务时,通过`api-gateway`的这个新端点(并传入正确的`request_id`),能够实时查询到该任务的进度详情。
---
### **阶段三:逐步迁移与替换 (精确实现蓝图)**
**目标**: 将前端应用无缝对接到新的Rust `api-gateway`并随着数据提供商的逐步完善最终彻底移除旧的Python `backend`服务,完成整个系统的架构升级。
- [x] **1. 将 `api-gateway` 集成到开发环境**
- [x] **任务**: 在根目录的 `docker-compose.yml` 文件中,为我们新创建的 `api-gateway` 服务添加入口定义。
```yaml
# docker-compose.yml
services:
# ... other services
api-gateway:
build:
context: ./services/api-gateway
dockerfile: Dockerfile
container_name: api-gateway
environment:
# 注入所有必要的配置
SERVER_PORT: 4000
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
# 注意: provider_services需要包含所有provider的内部地址
PROVIDER_SERVICES: '["http://alphavantage-provider-service:8000"]'
ports:
- "14000:4000"
depends_on:
- nats
- data-persistence-service
- alphavantage-provider-service
```
- [x] **任务**: (如果尚未完成) 在`docker-compose.yml`中添加`nats`服务。
- [x] **验收标准**: 运行 `docker-compose up` (或 `tilt up`) 后,`api-gateway` 服务能够成功启动并连接到消息总线。
- [x] **2. 迁移前端应用的API调用逻辑**
- [x] **任务**: 修改前端项目的环境变量将API请求的目标从旧`backend`指向新`api-gateway`。
```
# frontend/.env.local (or in docker-compose.yml)
NEXT_PUBLIC_BACKEND_URL=http://api-gateway:4000/v1
```
- [x] **任务**: 重构前端的数据获取Hooks例如 `useApi.ts`)。
- **旧逻辑**: 发起一个长轮询GET请求等待完整数据返回。
- **新逻辑**:
1. **触发**: 发起一个 `POST` 请求到 `/data-requests`,并从响应中获取 `request_id`
2. **轮询**: 使用 `useSWR``react-query` 的轮询功能每隔2-3秒调用一次 `GET /tasks/{request_id}` 端点来获取任务进度。
3. **展示**: 根据任务进度更新UI例如显示加载条和状态信息
4. **完成**: 当任务状态变为 "completed" (或类似状态),或 `GET /tasks/{request_id}` 返回 `404` 时,停止轮询,并调用 `GET /companies/{symbol}/profile` 等数据查询端点来获取最终数据并渲染。
- [x] **验收标准**: 在前端页面输入股票代码并点击“生成报告”后能够触发新的异步工作流并在UI上看到实时进度的更新最终成功展示由 `alphavantage-provider-service` 获取的数据。
---
### **阶段四:数据提供商生态系统扩展 (Data Provider Ecosystem Expansion)**
**目标**: 将现有Python `backend`中的核心`data_providers`逐一重写为独立的Rust微服务丰富我们的数据维度。
- [ ] **0. (前置任务) 完成所有Provider的适配性分析**
- [ ] **任务**: 在开始大规模编码前,完成 **附录A** 中所有待迁移数据提供商的适配性分析,确保`common-contracts`模型的完备性并明确每个Provider的实现关键点。
- [x] **1. 实现 `tushare-provider-service` (中国市场核心)**
- [x] **任务**: 基于 `alphavantage-provider-service` 模板,创建并实现服务的基本框架。
- [x] **任务**: 完成 Tushare 8个核心API的并行数据获取、聚合与报告期筛选逻辑。
- [x] **任务**: 在 `mapping.rs`精确复刻Python版本 `_calculate_derived_metrics` 方法中近20个派生财务指标的计算逻辑。
- [x] **任务**: 在 `docker-compose.yml`中添加此服务,并将其地址加入到`api-gateway`的`PROVIDER_SERVICES`环境变量中。
- [x] **验收标准**: 收到`market: "CN"`的`FetchCompanyDataCommand`命令时该服务能被触发并成功将与Python版本逻辑一致的A股数据**包含所有派生指标**)写入数据库。
- [x] **2. (可选) 迁移其他数据提供商**
- [x] **任务**: 基于各自的适配性分析,创建并实现`finnhub-provider-service`。
- [x] **任务**: 基于各自的适配性分析,创建并实现`yfinance-provider-service`。
- [ ] **任务**: 基于各自的适配性分析,创建并实现`ifind-provider-service`。
---
### **阶段五:业务逻辑迁移与最终替换 (Business Logic Migration & Final Replacement)**
**目标**: 将Python `backend`中剩余的AI分析和配置管理逻辑迁移到Rust生态并最终彻底下线Python服务。
- [x] **1. 创建 `report-generator-service`**
- [x] **任务**: 创建一个新的Rust微服务`report-generator-service`。
- [x] **任务**: 实现对Message Bus事件如`FinancialsPersistedEvent`)的订阅。
- [x] **任务**: 将原Python `backend`中的`analysis_client.py`和`company_profile_client.py`的逻辑迁移至此服务。
- [x] **验收标准**: 当所有数据提供商完成数据写入后此服务能被自动触发并成功生成AI分析报告。
- [x] **2. (可选) 创建 `config-service-rs`**
- [x] **任务**: 用Rust重写现有的Python `config-service`
- [x] **验收标准**: 所有Rust微服务都能从新的配置服务中获取配置并正常启动。
- [x] **3. 光荣退役下线所有Python服务**
- [x] **前提条件**: 所有数据获取和AI分析功能均已由新的Rust微服务完全承载。
- [x] **任务**: 在 `docker-compose.yml` 中,删除 `backend``config-service` 的服务定义。
- [x] **任务**: 将`backend/`和`services/config-service/`目录移动至`archive/python/`进行归档保留。
- [x] **验收标准**: 整个系统在没有任何Python组件的情况下依然能够完整、正常地运行所有核心功能。架构升级正式完成。
---
## 附录A: 数据提供商适配性分析
本附录用于详细记录每个待迁移的数据提供商API与我们`common-contracts`标准模型之间的适配性。
### A.1 Tushare 适配性分析
**核心结论**: 适配**完全可行**,但**计算逻辑复杂**。`common-contracts`无需调整。迁移工作的核心是精确复刻Python版本中近400行的财务数据聚合与派生指标计算逻辑。
**1. 数据模型适配概览**
| `common-contracts` 模型 | 适配可行性 | 关键实现要点 |
| :--- | :--- | :--- |
| **`CompanyProfile`** | ✅ **高** | 使用 `stock_basic``stock_company` 接口。 |
| **`DailyMarketData`** | ✅ **高** | 关联 `daily``daily_basic` 接口。 |
| **`RealtimeQuote`** | ⚠️ **中** | Tushare无直接对应接口可使用最新日线数据作为“准实时”替代。 |
| **`FinancialStatement`** | ✅ **高,但复杂** | **(核心难点)** 需聚合 `balancesheet`, `income`, `cashflow`, `fina_indicator` 等8个API并复刻近20个派生指标的计算。 |
**2. 关键迁移逻辑**
- **多表聚合**: Rust版本需实现并行调用多个Tushare API并以`end_date`为主键将结果聚合。
- **报告期筛选**: 需复刻“今年的最新报告 + 往年所有年报”的筛选逻辑。
- **派生指标计算**: 必须用Rust精确实现`_calculate_derived_metrics`方法中的所有计算公式。
---
### A.2 Finnhub 适配性分析
**核心结论**: 适配**可行**。Finnhub作为美股和全球市场的主要数据源数据较为规范但同样涉及**多API聚合**和**少量派生计算**。`common-contracts`无需调整。
**1. 数据模型适配概览**
| `common-contracts` 模型 | 适配可行性 | 关键实现要点 |
| :--- | :--- | :--- |
| **`CompanyProfile`** | ✅ **高** | 使用 `/stock/profile2` 接口。 |
| **`DailyMarketData`** | ✅ **高** | 使用 `/stock/candle` 接口获取OHLCV使用 `/stock/metric` 获取PE/PB等指标。 |
| **`RealtimeQuote`** | ✅ **高** | 使用 `/quote` 接口。 |
| **`FinancialStatement`** | ✅ **高,但需聚合** | 需聚合 `/stock/financials-reported` (按`ic`, `bs`, `cf`查询)返回的三张报表,并进行少量派生计算。 |
**2. 关键迁移逻辑**
- **多API聚合**: `FinancialStatement`的构建需要组合`/stock/financials-reported`接口的三次调用结果。`DailyMarketData`的构建也需要组合`/stock/candle`和`/stock/metric`。
- **派生指标计算**: Python代码 (`finnhub.py`) 中包含了自由现金流 (`__free_cash_flow`) 和其他一些比率的计算这些需要在Rust中复刻。
- **字段名映射**: Finnhub返回的字段名如`netIncome`)需要被映射到我们标准模型的字段名(如`net_income`)。
---
### A.3 YFinance 适配性分析
**核心结论**: 适配**可行**,主要作为**行情数据**的补充或备用源。`yfinance`库的封装使得数据获取相对简单。
**1. 数据模型适配概览**
| `common-contracts` 模型 | 适配可行性 | 关键实现要点 |
| :--- | :--- | :--- |
| **`CompanyProfile`** | ✅ **中** | `ticker.info` 字典提供了大部分信息但字段不如Finnhub或Tushare规范。 |
| **`DailyMarketData`** | ✅ **高** | `ticker.history()` 方法是主要数据来源可直接提供OHLCV。 |
| **`RealtimeQuote`** | ⚠️ **低** | `yfinance`本身不是为实时流式数据设计的,获取的数据有延迟。 |
| **`FinancialStatement`** | ✅ **中** | `ticker.financials`, `ticker.balance_sheet`, `ticker.cashflow` 提供了数据,但需要手动将多年度的数据列转换为按年份的记录行。 |
**2. 关键迁移逻辑**
- **数据结构转换**: `yfinance`返回的DataFrame需要被转换为我们期望的`Vec<Record>`结构。特别是财务报表,需要将列式(多年份)数据转换为行式(单年份)记录。
- **库的替代**: Rust中没有`yfinance`库。我们需要找到一个替代的Rust库 (如 `yahoo_finance_api`)或者直接模拟其HTTP请求来获取数据。这将是迁移此模块的主要工作。
---
- [ ] **2. (可选) 迁移其他数据提供商**
- [x] **任务**: 基于各自的适配性分析,创建并实现`finnhub-provider-service`。
- [x] **任务**: 基于各自的适配性分析,创建并实现`yfinance-provider-service`。
- [ ] **任务**: **(已暂停/待独立规划)** 实现`ifind-provider-service`。
---
### A.4 iFind 适配性分析 - **更新于 2025-11-16**
**核心结论**: **当前阶段纯Rust迁移复杂度极高任务已暂停**。iFind的Python接口 (`iFinDPy.py`) 是一个基于 `ctypes` 的薄封装它直接调用了底层的C/C++动态链接库 (`.so` 文件)。这意味着没有任何可见的网络协议或HTTP请求可供我们在Rust中直接模拟。
**1. 迁移路径评估**
基于对 `ref/ifind` 库文件的调研,我们确认了迁移此模块面临两个选择:
1. **HTTP API方案 (首选,待调研)**:
- **描述**: 您提到iFind存在一个HTTP API版本。这是最符合我们纯Rust、去中心化架构的理想路径。
- **工作量评估**: **中等**。如果该HTTP API文档齐全且功能满足需求那么开发此服务的工作量将与 `finnhub-provider-service` 类似。
- **规划**: 此路径应作为一个**独立的、后续的调研与开发任务**。当前置于暂停状态。
2. **FFI方案 (备选,不推荐)**:
- **描述**: 在Rust服务中通过FFI`pyo3``rust-cpython` crate嵌入Python解释器直接调用 `iFinDPy` 库。
- **工作量评估**: **高**。虽然可以复用逻辑但这会引入技术栈污染破坏我们纯Rust的目标并显著增加部署和维护的复杂度需要在容器中管理Python环境和iFind的二进制依赖。这与我们“rustic”的确定性原则相悖。
**2. 最终决定**
- **暂停实现**: `ifind-provider-service` 的开发工作已**正式暂停**。
- **更新路线图**: 在主路线图中,此任务已被标记为“已暂停/待独立规划”。
- **未来方向**: 当项目进入下一阶段时,我们将启动一个独立的任务来**专门调研其HTTP API**,并基于调研结果决定最终的实现策略。
---
## 附录B: 业务逻辑模块迁移分析
本附录用于分析`backend/app/services/`中包含的核心业务逻辑并为将其迁移至Rust服务制定策略。
### B.1 `analysis_client.py` & `company_profile_client.py`
- **核心功能**: 这两个模块是AI分析的核心负责与大语言模型如GeminiAPI进行交互。
- `analysis_client.py`: 提供一个**通用**的分析框架,可以根据不同的`prompt_template`执行任意类型的分析。它还包含一个`SafeFormatter`来安全地填充模板。
- `company_profile_client.py`: 是一个**特化**的版本包含了用于生成公司简介的、具体的、硬编码的长篇Prompt。
- **迁移策略**:
1. **统一并重写为 `report-generator-service`**: 这两个模块的功能应被合并并迁移到一个全新的Rust微服务——`report-generator-service`中。
2. **订阅事件**: 该服务将订阅Message Bus上的数据就绪事件如`FinancialsPersistedEvent`而不是被HTTP直接调用。
3. **Prompt管理**: 硬编码在`company_profile_client.py`中的Prompt以及`analysis_client.py`所依赖的、从`analysis-config.json`加载的模板,都应该由`report-generator-service`统一管理。在初期可以从配置文件加载未来可以由Rust版的`config-service-rs`提供。
4. **复刻`SafeFormatter`**: Python版本中用于安全填充模板的`SafeFormatter`逻辑需要在Rust中被等价复刻以确保在上下文不完整时系统的健壮性。
5. **AI客户端**: 使用`reqwest`或其他HTTP客户端库在Rust中重新实现与大模型API的交互逻辑。
- **结论**: 迁移**完全可行**。核心工作是将Python中的Prompt管理和API调用逻辑用Rust的异步方式重写。这将使AI分析任务成为一个独立的、可扩展的、事件驱动的后台服务。
---
### B.2 `config_manager.py`
- **核心功能**: 作为Python `backend`内部的一个组件,它负责从`config-service`拉取配置,并与本地`config.json`文件进行合并。它还包含了测试各种配置有效性的逻辑如测试数据库连接、Tushare Token等
- **迁移策略**:
- **功能分散化**: `ConfigManager`本身不会作为一个独立的Rust服务存在它的功能将被**分散**到每个需要它的微服务中。
- **配置拉取**: 每个Rust微服务`api-gateway`, `tushare-provider`等)在启动时,都将负责**独立地**从环境变量或未来的`config-service-rs`中获取自己的配置。我们为每个服务编写的`config.rs`模块已经实现了这一点。
- **配置测试逻辑**: 测试配置的逻辑(如`_test_database`, `_test_tushare`)非常有用,但不属于运行时功能。这些逻辑可以被迁移到:
1. **独立的CLI工具**: 创建一个Rust CLI工具专门用于测试和验证整个系统的配置。
2. **服务的`/health`端点**: 在每个服务的`/health`检查中可以包含对其依赖服务数据库、外部API连通性的检查从而在运行时提供健康状况反馈。
- **结论**: `ConfigManager`的功能将被**“肢解”并吸收**到新的Rust微服务生态中而不是直接迁移。
---
### B.3 `data_persistence_client.py`
- **核心功能**: 这是一个HTTP客户端用于让Python `backend`与其他微服务(`data-persistence-service`)通信。
- **迁移策略**:
- **模式复用**: 这个模块本身就是我们新架构模式的一个成功范例。
- **Rust等价实现**: 我们在`alphavantage-provider-service`中创建的`persistence.rs`客户端,以及即将在`api-gateway`和`report-generator-service`中创建的类似客户端,正是`data_persistence_client.py`的Rust等价物。
- **最终废弃**: 当Python `backend`最终被下线时,这个客户端模块也将随之被废弃。
- **结论**: 该模块**无需迁移**其设计思想已被我们的Rust服务所采纳和实现。

View File

@ -1,245 +0,0 @@
---
status: "Active"
date: "2025-11-17"
author: "AI 助手"
---
# 设计文档:可配置的分析模板与编排器
## 1. 概述与目标
### 1.1. 问题陈述
我们当前基于 Rust 的后端缺少执行智能、多步骤财务分析所需的核心业务逻辑。`report-generator-service` 作为此逻辑的载体,其内部实现尚不完整。更重要的是,当前的系统设计缺少一个清晰的、可扩展的方式来管理和复用成套的分析流程,并且在配置初始化方面存在对本地文件的依赖,这不符合我们健壮的系统设计原则。
### 1.2. 目标
本任务旨在我们的 Rust 微服务架构中,设计并实现一个以**分析模板集Analysis Template Sets**为核心的、健壮的、可配置的**分析模块编排器**。该系统将允许我们创建、管理和执行多套独立的、包含复杂依赖关系的分析工作流。
为达成此目标,需要完成以下任务:
1. **引入分析模板集**:在系统顶层设计中引入“分析模板集”的概念,每个模板集包含一套独立的分析模块及其配置。
2. **实现前端模板化管理**:在前端配置中心实现对“分析模板集”的完整 CRUD 管理,并允许在每个模板集内部对分析模块进行 CRUD 管理。
3. **构建健壮的后端编排器**:在 `report-generator-service` 中实现一个能够执行指定分析模板集的后端编排器,该编排器需基于拓扑排序来处理模块间的依赖关系。
4. **实现无文件依赖的数据初始化**通过在服务二进制文件中嵌入默认配置的方式实现系统首次启动时的数据播种Seeding彻底移除对本地配置文件的依赖。
## 2. 新数据模型 (`common-contracts`)
为了支持“分析模板集”的概念,我们需要定义新的数据结构。
```rust
// common-contracts/src/config_models.rs
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
// 整个系统的分析模板配置,作为顶级对象存储在数据库中
// Key: 模板ID (e.g., "standard_fundamentals")
pub type AnalysisTemplateSets = HashMap<String, AnalysisTemplateSet>;
// 单个分析模板集,代表一套完整的分析流程
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AnalysisTemplateSet {
pub name: String, // 人类可读的模板名称, e.g., "标准基本面分析"
// 该模板集包含的所有分析模块
// Key: 模块ID (e.g., "fundamental_analysis")
pub modules: HashMap<String, AnalysisModuleConfig>,
}
// 单个分析模块的配置 (与之前定义保持一致)
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AnalysisModuleConfig {
pub name: String,
pub provider_id: String,
pub model_id: String,
pub prompt_template: String,
// 依赖关系列表其中的字符串必须是同一个模板集内其他模块的ID
pub dependencies: Vec<String>,
}
```
## 3. 系统架构与数据流
### 3.1. 高层数据流
1. **配置流程**:
* **用户** 在 **前端** 与配置页面交互,创建或修改一个“分析模板集”。
* **前端** 向 **API 网关** 发送 `PUT /api/v1/configs/analysis_template_sets` 请求。
* **API 网关** 将请求代理至 **数据持久化服务**,由其将序列化后的 `AnalysisTemplateSets` 对象完整保存到数据库中。
2. **执行流程**:
* **用户** 在 **前端** 选择一个**分析模板集**,然后为特定的股票代码触发分析。
* **前端** 向 **API 网关** 发送 `POST /api/v1/analysis-requests/{symbol}` 请求,请求体中包含所选的 `template_id`
* **API 网关** 验证请求,并向 **NATS 消息总线** 发布一条包含 `symbol`, `template_id``request_id``GenerateReportCommand` 消息。
* **报告生成服务** 订阅该消息,并根据 `template_id` 启动指定的编排工作流。
## 4. 前端实施计划 (`/config` 页面)
前端配置页面需要重构为两级结构:
1. **第一级:模板集管理**
* 显示一个包含所有“分析模板集”的列表。
* 提供“创建新模板集”、“重命名”、“删除模板集”的功能。
* 用户选择一个模板集后,进入第二级管理界面。
2. **第二级:分析模块管理 (在选定的模板集内)**
* **主界面**: 进入模板集后,主界面将以列表形式展示该模板集内所有的分析模块。每个模块将以一个独立的“卡片”形式呈现。
* **创建 (Create)**:
* 在模块列表的顶部或底部,将设置一个“新增分析模块”按钮。
* 点击后,将展开一个表单,要求用户输入新模块的**模块ID**(唯一的、机器可读的英文标识符)和**模块名称**(人类可读的显示名称)。
* **读取 (Read)**:
* 每个模块卡片默认会显示其**模块名称**和**模块ID**。
* 卡片可以被展开,以显示其详细配置。
* **更新 (Update)**:
* 在展开的模块卡片内,所有配置项均可编辑:
* **LLM Provider**: 一个下拉菜单选项为系统中所有已配置的LLM供应商。
* **Model**: 一个级联下拉菜单根据所选的Provider动态加载其可用模型。
* **提示词模板**: 一个多行文本输入框用于编辑模块的核心Prompt。
* **依赖关系**: 一个复选框列表,该列表**仅显示当前模板集内除本模块外的所有其他模块**,用于勾选依赖项。
* **删除 (Delete)**:
* 每个模块卡片的右上角将设置一个“删除”按钮。
* 点击后,会弹出一个确认对话框,防止用户误操作。
## 6. 数据库与数据结构设计
为了支撑上述功能,我们需要在 `data-persistence-service` 中明确两个核心的数据存储模型:一个用于存储**配置**,一个用于存储**结果**。
### 6.1. 配置存储:`system_config` 表
我们将利用现有的 `system_config` 表来存储整个分析模板集的配置。
- **用途**: 作为所有分析模板集的“单一事实来源”。
- **存储方式**:
- 表中的一条记录。
- `config_key` (主键): `analysis_template_sets`
- `config_value` (类型: `JSONB`): 存储序列化后的 `AnalysisTemplateSets` (即 `HashMap<String, AnalysisTemplateSet>`) 对象。
- **对应数据结构 (`common-contracts`)**: 我们在第2节中定义的 `AnalysisTemplateSets` 类型是此记录的直接映射。
### 6.2. 结果存储:`analysis_results` 表 (新)
为了存储每次分析工作流执行后,各个模块生成的具体内容,我们需要一张新表。
- **表名**: `analysis_results`
- **用途**: 持久化存储每一次分析运行的产出,便于历史追溯和未来查询。
- **SQL Schema**:
```sql
CREATE TABLE analysis_results (
id BIGSERIAL PRIMARY KEY,
request_id UUID NOT NULL, -- 关联单次完整分析请求的ID
symbol VARCHAR(32) NOT NULL, -- 关联的股票代码
template_id VARCHAR(64) NOT NULL, -- 使用的分析模板集ID
module_id VARCHAR(64) NOT NULL, -- 产出此结果的模块ID
content TEXT NOT NULL, -- LLM生成的分析内容
meta_data JSONB, -- 存储额外元数据 (e.g., model_name, tokens, elapsed_ms)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- 建立索引以优化查询
INDEX idx_analysis_results_request_id (request_id),
INDEX idx_analysis_results_symbol_template (symbol, template_id)
);
```
- **对应数据结构 (`common-contracts`)**:
```rust
// common-contracts/src/dtos.rs
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct NewAnalysisResult {
pub request_id: Uuid,
pub symbol: String,
pub template_id: String,
pub module_id: String,
pub content: String,
pub meta_data: serde_json::Value,
}
```
## 5. 后端实施计划
### 5.1. `data-persistence-service`
- **数据初始化 (无文件依赖)**: 实现一次性的、基于硬编码的启动逻辑。
1. 在 `data-persistence-service` 的代码中,将 `config/analysis-config.json` 的内容硬编码为一个 Rust 字符串常量。
2. 在服务启动时,检查 `system_config` 表中是否存在键为 `analysis_template_sets` 的记录。
3. 如果**不存在**,则:
a. 解析硬编码的字符串,构建一个默认的 `AnalysisTemplateSet` (例如ID为 `default`, 名称为 “默认分析模板”)。
b. 将这个默认模板集包装进一个 `AnalysisTemplateSets` 的 HashMap 中。
c. 将序列化后的 `AnalysisTemplateSets` 对象写入数据库。
4. 此机制确保系统在首次部署时,无需任何外部文件即可拥有一套功能完备的默认分析模板。
- **新职责**: 实现对 `analysis_results` 表的CRUD操作API。
### 5.2. `api-gateway`
- **端点更新**: `POST /api/v1/analysis-requests/{symbol}`
- **逻辑变更**:
* 该端点现在需要从请求体中解析出 `template_id`
* 它构建的 `GenerateReportCommand` 消息中,必须包含 `template_id` 字段。
### 5.3. `report-generator-service` (核心任务)
`worker.rs` 中的编排逻辑需要进行如下调整和实现:
1. **消息消费者**: 订阅的 `GenerateReportCommand` 消息现在会包含 `template_id`
2. **编排逻辑 (`run_report_generation_workflow`)**:
* **获取配置**: 从 `data-persistence-service` 获取完整的 `AnalysisTemplateSets` 对象。
* **选择模板**: 根据传入的 `template_id`,从 `AnalysisTemplateSets` 中选择出本次需要执行的 `AnalysisTemplateSet`。如果找不到,则记录错误并终止。
* **构建依赖图**: 使用所选模板集中的 `modules` 来构建有向图。强烈推荐使用 `petgraph` crate。
* **拓扑排序**: 对该图执行拓扑排序,**必须包含循环检测**。
* **顺序执行**: 遍历排序后的模块列表后续的上下文注入、LLM调用和结果持久化逻辑与之前设计一致但操作范围仅限于当前模板集内的模块。
3. **补全缺失逻辑**:
* **实现结果持久化**: 调用 `data-persistence-service` 提供的API将每个模块生成的 `NewAnalysisResult` 存入 `analysis_results` 表。
## 6. 未来工作
### 6.1. 演进至 "Deep Research" 模块
此设计为未来的 "Deep Research" 模块演进奠定了坚实的基础。当该模块准备就绪时,我们可以创建一个新的“分析模板集”,其中的某些模块(如 `news_analysis`)将不再直接调用 LLM而是调用 Deep Research 服务。Deep Research 服务将执行复杂的数据挖掘,并将高度精炼的结果返回给编排器,再由编排器注入到后续的 LLM 调用中,从而实现“数据驱动”的分析范式。
### 6.2. 引入工具调用框架 (Tool Calling Framework)
为了以一种更通用和可扩展的方式向提示词模板中注入多样化的上下文数据,我们规划引入“工具调用”框架。
- **概念**: “工具”是指一段独立的、用于获取特定类型数据的程序(例如,获取财务数据、获取实时股价、获取最新新闻等)。
- **配置**: 在前端的模块配置界面,除了依赖关系外,我们还将为每个模块提供一个“可用工具”的复选框列表。用户可以为模块勾选需要调用的一个或多个工具。
- **执行**:
1. 在 `report-generator-service` 的编排器执行一个模块前,它会先检查该模块配置中启用了哪些“工具”。
2. 编排器将按顺序执行这些工具。
3. 每个工具的输出例如格式化为Markdown的财务数据表格将被注入到一个统一的上下文字段中。
- **首个工具**: 我们设想的第一个工具就是 **`财务数据注入工具`**。它将负责获取并格式化财务报表,其实现逻辑与本文档旧版本中描述的“核心逻辑细化”部分一致。
通过此框架,我们可以将数据注入的逻辑与编排器的核心逻辑解耦,使其更易于维护和扩展。**此项为远期规划,不在本轮实施范围之内。**
## 8. 实施清单 (Step-by-Step To-do List)
以下是为完成本项目所需的、按顺序排列的开发任务清单。
### 阶段一:数据模型与持久化层准备
- [x] **任务 1.1**: 在 `common-contracts` crate 中,创建或更新 `src/config_models.rs`,定义 `AnalysisTemplateSets`, `AnalysisTemplateSet`, `AnalysisModuleConfig` 等新的数据结构。
- [x] **任务 1.2**: 在 `common-contracts` crate 中,创建或更新 `src/dtos.rs`,定义用于写入分析结果的 `NewAnalysisResult` 数据传输对象 (DTO)。
- [x] **任务 1.3**: 在 `data-persistence-service` 中,创建新的数据库迁移文件 (`migrations/`),用于新增 `analysis_results` 表,其 schema 遵循本文档第6.2节的定义。
- [x] **任务 1.4**: 在 `data-persistence-service` 中,实现 `analysis_results` 表的 CRUD API (至少需要 `create` 方法)。
- [x] **任务 1.5**: 在 `data-persistence-service`实现数据播种Seeding逻辑在服务启动时将硬编码的默认分析模板集写入数据库如果尚不存在
### 阶段二:后端核心逻辑实现 (`report-generator-service`)
- [x] **任务 2.1**: 为 `report-generator-service` 添加 `petgraph` crate 作为依赖,用于构建和处理依赖图。
- [x] **任务 2.2**: 重构 `worker.rs` 中的 `run_report_generation_workflow` 函数,使其能够接收包含 `template_id` 的消息。
- [x] **任务 2.3**: 在 `worker.rs` 中,**实现完整的拓扑排序算法**,用以替代当前简陋的循环实现。此算法必须包含循环依赖检测。
- [x] **任务 2.4**: 更新编排器逻辑,使其能够根据 `template_id` 从获取到的 `AnalysisTemplateSets` 中选择正确的工作流进行处理。
- [x] **任务 2.5**: 实现调用 `data-persistence-service` 的逻辑,将每个模块成功生成的 `NewAnalysisResult` 持久化到 `analysis_results` 表中。
### 阶段三:服务集成与端到端打通
- [x] **任务 3.1**: 在 `api-gateway` 中,新增 `POST /api/v1/analysis-requests/{symbol}` 端点。
- [x] **任务 3.2**: 在 `api-gateway` 的新端点中,实现接收前端请求(包含 `template_id`),并向 NATS 发布 `GenerateReportCommand` 消息的逻辑。
- [x] **任务 3.3**: 在 `report-generator-service` 中,更新其 NATS 消费者,使其能够正确订阅和解析新的 `GenerateReportCommand` 消息。
- [x] **任务 3.4**: 进行端到端集成测试,确保从前端触发的请求能够正确地启动 `report-generator-service` 并执行完整的分析流程此时可不关心前端UI
### 阶段四:前端 UI 实现
- [x] **任务 4.1**: 重构 `frontend/src/app/config/page.tsx` 页面,实现两级管理结构:先管理“分析模板集”。
- [x] **任务 4.2**: 实现“分析模板集”的创建、重命名和删除功能并调用对应的后端API。
- [x] **任务 4.3**: 实现模板集内部的“分析模块”管理界面,包括模块的创建、更新(所有字段)和删除功能。
- [x] **任务 4.4**: 确保在分析请求发起的页面(例如主查询页面),用户可以选择使用哪个“分析模板集”来执行分析。
- [x] **任务 4.5**: 更新前端调用 `api-gateway` 的逻辑,在分析请求的 body 中附带上用户选择的 `template_id`

View File

@ -1,98 +0,0 @@
# 任务文档配置管理重构——统一API凭证管理
- **状态**: Active
- **创建日期**: 2025-11-17
- **负责人**: @AI-Assistant
- **审查人**: @lv
---
## 1. 背景与目标
### 1.1. 当前问题
当前系统对外部服务(如 Tushare, FinnhubAPI Token 的管理方式存在两个主要问题:
1. **配置方式分裂**:
- **敏感凭证 (API Tokens)**: 通过启动时的**环境变量**注入。这种方式虽然安全,但缺乏灵活性,每次修改都需要重新部署或重启服务。
- **业务逻辑配置 (AI模型选择等)**: 通过**数据库**统一管理并支持UI动态调整。
- 这种分裂的管理模式增加了系统的运维复杂性,与我们追求的“单一事实源”架构理念不符。
2. **服务韧性不足**:
- 依赖环境变量的服务采取“快速失败” (Fail-Fast) 策略。如果启动时未提供有效的 API Token服务会立即崩溃退出。
- 这种模式虽然能尽早暴露问题,但在一个动态的、持续运行的系统中显得过于“僵硬”。我们期望的行为是:服务在缺少非核心配置时,应能进入一个“降级”状态,待配置就绪后再自动恢复工作,而不是直接停止运行。
### 1.2. 改造目标
本次重构旨在将所有外部服务的 API Token 配置,从环境变量迁移到数据库中,实现与业务逻辑配置的统一管理。具体目标如下:
- **统一配置源**: 将 `system_config` 数据库表作为所有可变配置包括API Tokens的唯一事实源。
- **提升易用性**: 允许用户通过前端UI界面集中管理和更新所有数据源的 API Token。
- **增强服务韧性**: 改造数据提供商服务,使其在缺少 API Token 时不会崩溃,而是进入“降级模式”,并能在 Token 被提供后自动恢复正常工作。
- **简化部署**: 移除对多个环境变量的依赖,使服务的部署和运维过程更加简洁。
---
## 2. 实施方案
本次改造将遵循“后端 -> 服务 -> 前端”的顺序分层实施,确保每一步都有坚实的基础。
### 2.1. 数据模型与持久化层
我们将通过复用 `system_config` 表中现有的 `(config_key, config_value)` 存储模式,来扩展配置管理的能力,使其能够安全地存储和检索数据源的配置。
1. **定义数据结构**: 在 `common-contracts` 共享库中,定义一个清晰的、用于描述数据源配置的 `DataSourceConfig` 结构体。它将包含 `provider_id`, `api_token`, `api_url` 等字段。
2. **复用现有表结构**: 我们将向 `system_config` 表中插入一条新的记录,其 `config_key` 固定为 `"data_sources"`,并将所有数据源的配置集合(一个 `HashMap<String, DataSourceConfig>`)序列化后存入该记录的 `config_value` 字段中。
3. **扩展API**: 在 `data-persistence-service` 中增加新的 HTTP API 端点用于对数据源配置进行增、删、改、查CRUD操作。例如
- `GET /api/v1/configs/data-sources`: 获取所有数据源的配置列表。
- `PUT /api/v1/configs/data-sources`: 创建或更新所有数据源的配置。
### 2.2. 微服务改造:引入“降级与恢复”模式
这是本次重构的核心。所有依赖外部 API Token 的数据提供商服务 (`finnhub`, `tushare`, `alphavantage`) 都将进行如下改造:
1. **移除启动时检查**: 删除 `config.rs` 中检查环境变量并导致程序崩溃的逻辑。
2. **引入内部状态机**: 每个服务内部将维护一个状态(例如 `State<ServiceOperationalStatus>`),包含 `Active``Degraded(reason: String)` 两种状态。
3. **动态配置加载**: 服务将不再从环境变量读取 Token而是在内部启动一个**后台任务**(轮询器),该任务会:
- 在服务启动时,以及之后每隔一段时间(例如 60 秒),调用 `data-persistence-service` 的新 API 来获取自己的配置。
- 如果成功获取到有效的 Token则更新服务内部的 API 客户端,并将服务状态设置为 `Active`。此时,服务正常订阅和处理来自 NATS 的消息。
- 如果未能获取 Token或 Token 为空),则将服务状态设置为 `Degraded`,并附上原因(如 "API Token not configured")。在这种状态下,服务**不会**订阅 NATS 消息队列,避免接收无法处理的任务。
4. **更新健康检查**: 服务的 `/health` 端点将反映其内部状态。当处于 `Degraded` 状态时,健康检查接口应返回相应的状态码和信息,以便监控系统能够清晰地了解服务当前是否可用。
### 2.3. 前端UI实现
为了让用户能够方便地管理这些配置,我们将在前端进行如下调整:
1. **创建新UI组件**: 在 `/config` 页面,新增一个名为“数据源配置”的管理面板。
2. **功能实现**: 该面板将提供一个表单或列表,允许用户:
- 查看当前所有数据源Tushare, Finnhub 等)的配置状态。
- 为每个数据源输入或更新其 API Token。
- 保存更改。点击保存后,前端将调用 `data-persistence-service` 的新 API将更新后的配置持久化到数据库中。
---
## 3. 详细任务清单
### 第一阶段:后端基础
- [x] ~~**任务 BE-1**: 在 `common-contracts` 中定义 `DataSourceConfig``DataSourceProvider` 等共享数据结构。~~
- [x] ~~**任务 BE-3**: 在 `data-persistence-service` 中实现对数据源配置的 CRUD 业务逻辑。~~
- [x] ~~**任务 BE-4**: 在 `data-persistence-service` 中暴露 `GET /api/v1/configs/data-sources``PUT /api/v1/configs/data-sources` 这两个 API 端点。~~
### 第二阶段:微服务改造
- [x] ~~**任务 SVC-1**: **(Finnhub)** 重构 `finnhub-provider-service`~~
- [x] ~~移除 `config.rs` 中的 `FINNHUB_API_KEY` 环境变量加载逻辑。~~
- [x] ~~实现内部状态机 (`Active`/`Degraded`) 和动态配置轮询器。~~
- [x] ~~修改 `/health` 端点以反映内部状态。~~
- [x] ~~调整 NATS 消息订阅逻辑,只在 `Active` 状态下进行订阅。~~
- [x] ~~**任务 SVC-2**: **(Tushare)** 以 `finnhub-provider-service` 为模板,对 `tushare-provider-service` 进行相同的重构。~~
- [x] ~~**任务 SVC-3**: **(Alphavantage)** 以 `finnhub-provider-service` 为模板,对 `alphavantage-provider-service` 进行相同的重构。~~
- [x] ~~**任务 SVC-4**: **(审查)** 审查 `report-generator-service` 的 LLM 配置加载逻辑,确保其与新的动态配置模式在设计理念上保持一致。~~
### 第三阶段:前端实现
- [x] **任务 FE-1**: 在 `/config` 页面设计并实现“数据源配置”UI 组件。
- [x] **任务 FE-2**: 实现 `useApi.ts` 中用于获取和更新数据源配置的 hooks。
- [x] **任务 FE-3**: 将 UI 组件与 API hooks 连接,完成前端的完整功能。
- [x] **任务 FE-4**: 调整 `/llm-config` 页面使其在UI/UX风格上与新的“数据源配置”面板保持一致性。

View File

@ -1,62 +0,0 @@
# [待处理] 实现 AlphaVantage 服务连接测试功能
**日期**: 2025-11-18
**状态**: 待处理 (Pending)
**负责人**: AI Assistant
## 1. 需求背景
目前系统配置中心的数据源配置页面中Tushare 和 Finnhub 模块均提供了“测试”按钮,用于验证用户填写的 API Key 和 URL 是否有效。然而AlphaVantage 模块缺少此功能。由于 AlphaVantage 的数据是通过 MCP (Meta-protocol Computation Platform) 协议间接调用的,其连接健康状态的检查尤为重要。
本任务旨在为 AlphaVantage 模块添加一个功能完善的“测试”按钮,以提升系统的健壮性和用户体验。
## 2. 技术方案与执行细节
该功能的实现需要贯穿前端、API网关和后端的 AlphaVantage 服务,形成一个完整的调用链路。
### 2.1. 前端 (Frontend)
* **文件**: `/frontend/src/app/config/page.tsx`
* **任务**:
1. **新增UI元素**: 在 AlphaVantage 配置卡片中,仿照其他服务,添加一个“测试 AlphaVantage”按钮。
2. **创建事件处理器**:
* 实现 `handleTestAlphaVantage` 函数。
* 该函数将从组件的本地状态 (`localDataSources`) 中读取 `alphavantage``api_key``api_url` (此 URL 为 MCP Endpoint)。
* 调用通用的 `handleTest('alphavantage', { ...config })` 函数,将请求发送至 Next.js 后端 API 路由。
### 2.2. Next.js API 路由
* **文件**: `/frontend/src/app/api/configs/test/route.ts` (推测)
* **任务**:
1. **新增处理分支**: 在 `POST` 请求处理逻辑中,为 `type: 'alphavantage'` 增加一个新的 `case`
2. **请求转发**: 该分支将把收到的测试请求(包含配置信息)原样转发到后端的 API 网关。
### 2.3. API 网关 (API Gateway)
* **文件**: `/services/api-gateway/src/api.rs` (或相关路由模块)
* **任务**:
1. **更新路由规则**: 修改处理配置测试的路由逻辑。
2. **分发请求**: 当识别到请求类型为 `alphavantage` 时,将该请求精准地转发到 `alphavantage-provider-service` 的新测试接口。
### 2.4. AlphaVantage 服务 (alphavantage-provider-service)
这是实现测试逻辑的核心。
* **文件**: `/services/alphavantage-provider-service/src/api.rs` (或新建的模块)
* **任务**:
1. **创建新接口**: 在服务中创建一个新的 HTTP `POST /test` 接口,用于接收来自网关的测试请求。
2. **实现核心测试逻辑**:
* 接口从请求体中解析出 `api_url``api_key`
* **动态 MCP 客户端**: 使用传入的 `api_url` 动态地、临时地创建一个 MCP 客户端实例。这确保了测试的是用户当前输入的配置,而不是服务启动时加载的旧配置。
* **调用 `list_capability`**: 利用此临时客户端,调用 MCP 服务标准工具集中的 `list_capability` 工具。`api_key` 将作为认证凭证传递给此调用。
* **响应处理**:
* **成功**: 如果 `list_capability` 调用成功返回,意味着 MCP Endpoint 可达、服务正常、API Key 有效。此时,接口返回 `{"success": true, "message": "MCP connection successful."}`
* **失败**: 如果调用过程中出现任何错误(网络问题、认证失败、超时等),接口将捕获异常并返回 `{"success": false, "message": "MCP connection failed: [具体错误信息]"}`
## 3. 预期成果
* 用户可以在配置中心页面点击按钮来测试 AlphaVantage 的连接配置。
* 系统能够通过调用 MCP 的 `list_capability` 接口,实时验证配置的有效性。
* 前端能够清晰地展示测试成功或失败的结果,为用户提供明确的反馈。

View File

@ -1,130 +0,0 @@
# 分析模板集成设计文档
## 1. 概述
系统正在从单一、固定的分析配置架构向多模板架构迁移。目前,后端已支持 `AnalysisTemplateSets` 并能执行特定的模板。然而前端在渲染报告标签页Tabs和触发分析时仍然依赖于过时的 `AnalysisModulesConfig`(单一配置集)。
本文档概述了将“分析模板”完全集成到用户工作流中所需的变更,具体包括:
1. **触发分析**:在启动新的分析任务时选择特定的模板。
2. **报告展示**:根据分析所使用的模板,动态渲染标签页和内容。
## 2. 当前状态 vs. 目标状态
| 功能特性 | 当前状态 | 目标状态 |
| :--- | :--- | :--- |
| **配置管理** | `useAnalysisConfig` (过时,单一模块列表) | `useAnalysisTemplateSets` (多套具名模板) |
| **触发分析** | `trigger(symbol, market)` (无模板选择) | `trigger(symbol, market, templateId)` |
| **报告标签页** | 硬编码遍历过时的 `analysis_modules` keys | 根据报告使用的**特定模板**动态生成标签页 |
| **模块名称** | 从全局默认配置获取 | 从所使用的特定模板配置中获取 |
## 3. 详细设计
### 3.1. 后端变更
#### 3.1.1. API Gateway (`api-gateway`)
* **Endpoint**: `POST /api/data-requests`
* **变更**: 更新请求 DTO (Data Transfer Object) 以接收可选参数 `template_id: String`
* **逻辑**: 将此 `template_id` 通过 `GenerateReportCommand` 向下传递给 `report-generator-service`
#### 3.1.2. 数据持久化 / 报告数据
* **需求**: 前端需要知道生成某份报告时具体使用了*哪个*模板,以便正确渲染标签页(包括标题和顺序)。
* **变更**: 确保 `GET /api/financials/...``GET /api/reports/...` 的响应数据中,在 metadata 中包含 `template_id`
* **实现**: 前端 `route.ts` 聚合层通过查询 `analysis-results` 获取最新的 `template_id` 并注入到 `meta` 中。
### 3.2. 前端变更
#### 3.2.1. API Hooks (`useApi.ts`)
* **`useDataRequest`**: 更新 `trigger` 函数签名:
```typescript
trigger(symbol: string, market: string, templateId?: string)
```
* **`useAnalysisTemplateSets`**: 确保此 hook 可用(目前代码中已存在)。
#### 3.2.2. 触发 UI (报告页侧边栏 / 查询页)
* **组件**: 在“触发分析”按钮旁增加一个 `TemplateSelector` (选择框/下拉菜单)。
* **数据源**: `useAnalysisTemplateSets`
* **默认值**: 自动选中第一个可用的模板,或者标记为 "default" 的模板。
#### 3.2.3. 报告页面 (`frontend/src/app/report/[symbol]/page.tsx`)
这是最复杂的变更部分。我们需要重构标签页Tabs的生成逻辑。
1. **移除旧逻辑**:
* 移除对 `useAnalysisConfig` (全局默认配置) 的依赖。
* 弃用/移除 `runAnalysesSequentially` (旧的前端编排流程)。
2. **识别模板**:
* 从获取到的财务/报告数据中读取 `template_id` (例如 `financials.meta.template_id` 或类似位置)。
* **Strict Mode**: 如果缺失 `template_id`,则视为严重数据错误,前端直接报错停止渲染,**绝不进行默认值回退或自动推断**。
3. **动态标签页**:
* 使用 `useAnalysisTemplateSets` 获取 `templateSets`
* 从 `templateSets[currentTemplateId].modules` 中推导出 `activeModules` 列表。
* 遍历 `activeModules` 来生成 `TabsTrigger``TabsContent`
* **显示名称**: 使用 `moduleConfig.name`
* **排序**: 严格遵循模板中定义的顺序(或依赖顺序)。
### 3.3. 数据流
1. **用户**选择 "标准分析模板 V2" (Standard Analysis V2) 并点击 "运行"。
2. **前端**调用 `POST /api/data-requests`,载荷为 `{ ..., template_id: "standard_v2" }`
3. **后端**使用 "standard_v2" 中定义的模块生成报告。
4. **前端**轮询任务进度。
5. **前端**获取完成的数据。数据包含元数据 `meta: { template_id: "standard_v2" }`
6. **前端**查询 "standard_v2" 的配置详情。
7. **前端**渲染标签页:如 "公司简介"、"财务健康"(均来自 V2 配置)。
## 4. 实施步骤
1. **后端更新**:
* 验证 `api-gateway` 是否正确传递 `template_id`
* 验证报告 API 是否在 metadata 中返回 `template_id`
2. **前端 - 触发**:
* 更新 `useDataRequest` hook。
* 在 `ReportPage` 中添加 `TemplateSelector` 组件。
3. **前端 - 展示**:
* 重构 `ReportPage` 以使用 `templateSets`
* 根据报告中的 `template_id` 动态计算 `analysisTypes`
## 5. 待办事项列表 (To-Do List)
### Phase 1: 后端与接口 (Backend & API)
- [x] **1.1 更新请求 DTO (api-gateway)**
- 目标: `api-gateway``DataRequest` 结构体
- 动作: 增加 `template_id` 字段 (Option<String>)
- 验证: `curl` 请求带 `template_id` 能被解析
- [x] **1.2 传递 Command (api-gateway -> report-service)**
- 目标: `GenerateReportCommand` 消息
- 动作: 确保 `template_id` 被正确透传到消息队列或服务调用中
- [x] **1.3 验证报告元数据 (data-persistence)**
- 目标: `GET /api/financials/...` 接口
- 动作: 检查返回的 JSON 中 `meta` 字段是否包含 `template_id`
- 备注: 已通过 frontend `route.ts` 聚合实现
### Phase 2: 前端逻辑 (Frontend Logic)
- [x] **2.1 更新 API Hook**
- 文件: `frontend/src/hooks/useApi.ts`
- 动作: 修改 `useDataRequest``trigger` 方法签名,支持 `templateId` 参数
- [x] **2.2 移除旧版依赖**
- 文件: `frontend/src/app/report/[symbol]/page.tsx`
- 动作: 移除 `useAnalysisConfig` 及相关旧版逻辑 (`runAnalysesSequentially`)
### Phase 3: 前端界面 (Frontend UI)
- [x] **3.1 实现模板选择器**
- 文件: `frontend/src/app/report/[symbol]/page.tsx` (侧边栏)
- 动作: 添加 `<Select>` 组件,数据源为 `useAnalysisTemplateSets`
- 逻辑: 默认选中第一个模板,点击"触发分析"时传递选中的 ID
- [x] **3.2 动态渲染标签页**
- 文件: `frontend/src/app/report/[symbol]/page.tsx` (主区域)
- 动作:
1. 从 `financials.meta.template_id` 获取当前报告的模板 ID
2. 若 ID 缺失直接抛出错误 (Strict Mode)
3. 根据 ID 从 `templateSets` 获取模块列表
4. 遍历模块列表渲染 `<TabsTrigger>``<TabsContent>`
5. 内容从 `useAnalysisResults` hook 获取
### Phase 4: 验证与清理 (Verification)
- [ ] **4.1 端到端测试**
- 动作: 创建新模板 -> 选择该模板触发分析 -> 验证报告页只显示该模板定义的模块
- [x] **4.2 代码清理**
- 动作: 删除未使用的旧版配置 Hook 和类型定义

View File

@ -1,90 +0,0 @@
# 分析流程优化与数据缓存机制修复
## 1. 问题背景 (Problem Statement)
根据系统日志分析与代码排查,当前系统存在以下关键问题:
1. **数据源重复请求 (Missing Cache Logic)**:
* `yfinance-provider-service` (及其他数据服务) 在接收到任务指令时,未检查本地数据库是否存在有效数据,而是直接向外部 API 发起请求。
* 这导致每次用户点击,都会触发耗时约 1.5s 的外部抓取,既慢又浪费资源。
2. **任务依赖与执行时序错乱 (Race Condition)**:
* `api-gateway` 在接收到请求时,**同时**触发了数据抓取 (`DATA_FETCH_QUEUE`) 和分析报告生成 (`ANALYSIS_COMMANDS_QUEUE`)。
* 导致 `report-generator-service` 在数据还没抓回来时就启动了,读到空数据(或旧数据)后瞬间完成,导致 Token 消耗为 0报告内容为空。
3. **前端无法展示数据 (Frontend Data Visibility)**:
* **根本原因**: API Gateway 路由缺失与路径映射错误。
* 前端 BFF (`frontend/src/app/api/financials/...`) 试图请求 `${BACKEND_BASE}/market-data/financial-statements/...`
* 然而,`api-gateway` **并未暴露** 此路由(仅暴露了 `/v1/companies/{symbol}/profile`)。
* 因此,前端获取财务数据的请求全部 404 失败,导致界面始终显示 "暂无可展示的数据",即使用户多次运行也无效。
## 2. 目标 (Goals)
1. **实现"读写穿透"缓存策略**: 数据服务在抓取前必须先检查本地数据库数据的时效性。
2. **构建事件驱动的依赖工作流**: 分析服务必须严格等待数据服务完成后触发(通过 NATS 事件链)。
3. **修复数据访问层**: 确保 API Gateway 正确暴露并转发财务数据接口,使前端可见。
4. **定义数据时效性标准**: 针对不同类型数据实施差异化的缓存过期策略。
## 3. 详细技术方案 (Technical Plan)
### 3.1. 数据时效性与缓存策略 (Data Freshness Policy)
针对基本面分析场景,不同数据的更新频率和时效性要求如下:
| 数据类型 | 内容示例 | 更新频率 | 建议 TTL (缓存有效期) | 更新策略 |
| :--- | :--- | :--- | :--- | :--- |
| **公司概况 (Profile)** | 名称、行业、简介、高管 | 极低 (年/不定期) | **30 天** | **Stale-While-Revalidate (SWR)**<br>过期后先返回旧数据,后台异步更新。 |
| **财务报表 (Financials)** | 营收、利润、资产负债 (季/年) | 季度 (4/8/10月) | **24 小时** | **Cache-Aside**<br>每次请求先查库。若 `updated_at > 24h`,则强制 Fetch否则直接返回库中数据。<br>*注: 对于同一天内的重复请求将直接命中缓存0延迟。* |
| **市场数据 (Market Data)** | PE/PB/市值/价格 | 实时/日频 | **1 小时** | 基本面分析不需要秒级价格。取最近一小时内的快照即可。若需实时价格,使用专用实时接口。 |
### 3.2. 数据服务层 (Providers)
* **涉及服务**: `yfinance-provider-service`, `alphavantage-provider-service`, `finnhub-provider-service`.
* **逻辑变更**:
1. 订阅 `FetchCommand`
2. **Step 1 (Check DB)**: 调用 `PersistenceClient` 获取目标 Symbol 数据的 `updated_at`
3. **Step 2 (Decision)**:
* 若 `now - updated_at < TTL`: **Hit Cache**. Log "Cache Hit", 跳过外部请求,直接进入 Step 4。
* 若数据不存在 或 `now - updated_at > TTL`: **Miss Cache**. Log "Cache Miss", 执行外部 API 抓取。
4. **Step 3 (Upsert)**: 将抓取的数据存入 DB (Update `updated_at` = now)。
5. **Step 4 (Publish Event)**: 发布 `CompanyDataPersistedEvent` (包含 symbol, data_types: ["profile", "financials"])。
### 3.3. 工作流编排 (Workflow Orchestration)
* **API Gateway**:
* 移除 `POST /data-requests` 中自动触发 Analysis 的逻辑。
* 只发布 `FetchCompanyDataCommand`
* **Report Generator**:
* **不再监听** `StartAnalysisCommand` (作为触发源)。
* 改为监听 `CompanyDataPersistedEvent`
* 收到事件后,检查事件中的 `request_id` 是否关联了待处理的分析任务(或者简单的:收到数据更新就检查是否有待跑的分析模板)。
* *临时方案*: 为了简化,可以在 API Gateway 发送 Fetch 命令时,在 payload 里带上 `trigger_analysis: true``template_id`。Data Provider 在发出的 `PersistedEvent` 里透传这些字段。Report Generator 看到 `trigger_analysis: true` 才执行。
### 3.4. API 修复 (Fixing Visibility)
* **Backend (API Gateway)**:
* 在 `create_v1_router` 中新增路由:
* `GET /v1/market-data/financial-statements/{symbol}` -> 转发至 Data Persistence Service。
* `GET /v1/market-data/quotes/{symbol}` -> 转发至 Data Persistence Service (可选)。
* **Frontend (Next.js API Route)**:
* 修改 `frontend/src/app/api/financials/[...slug]/route.ts`
* 将请求路径从 `${BACKEND_BASE}/market-data/...` 修正为 `${BACKEND_BASE}/v1/market-data/...` (匹配 Gateway 新路由)。
* 或者直接修正为 Data Persistence Service 的正确路径 (但最佳实践是走 Gateway)。
## 4. 执行计划 (Action Items)
### Phase 1: API & Frontend 可见性修复 (立即执行)
1. [x] **API Gateway**: 添加 `/v1/market-data/financial-statements/{symbol}` 路由。
2. [x] **Frontend**: 修正 `route.ts` 中的后端请求路径。(通过修正 Gateway 路由适配前端)
3. [ ] **验证**: 打开页面,应能看到(哪怕是旧的)财务图表数据,不再显示 404/无数据。
### Phase 2: 缓存与时效性逻辑 (核心)
4. [x] **Data Providers**: 在 `worker.rs` 中实现 TTL 检查逻辑 (Profile: 30d, Financials: 24h)。(YFinance 已实现,其他 Provider 已适配事件)
5. [x] **Persistence Service**: 确保 `get_company_profile``get_financials` 返回 `updated_at` 字段(如果还没有的话)。
### Phase 3: 事件驱动工作流 (解决 Race Condition)
6. [x] **Contracts**: 定义新事件 `CompanyDataPersistedEvent` (含 `trigger_analysis` 标记)。
7. [x] **API Gateway**: 停止直接发送 Analysis 命令,将其参数打包进 Fetch 命令。
8. [x] **Data Providers**: 完成任务后发布 `PersistedEvent`
9. [x] **Report Generator**: 监听 `PersistedEvent` 触发分析。
## 5. 待确认
* 是否需要为每个数据源单独设置 TTL(暂定统一策略)
* 前端是否需要显示数据的"上次更新时间"(建议加上,增强用户信任)

View File

@ -1,193 +0,0 @@
# 前端报告页面重构设计文档 (Frontend Refactoring Design Doc)
**日期**: 2025-11-19
**状态**: 待评审 (Draft)
**目标**: 重构 `app/report/[symbol]` 页面,消除历史技术债务,严格对齐 V2 后端微服务架构。
## 1. 核心原则
1. **单一数据源 (SSOT)**: 前端不再维护任务进度、依赖关系或倒计时。所有状态严格来自后端 API (`/api/tasks/{id}`, `/api/analysis-results`).
2. **无隐式逻辑 (No Implicit Logic)**: 严格按照用户选择的 Template ID 渲染,后端未返回的数据即视为不存在,不进行客户端推断或 Fallback。
3. **真·流式传输 (True Streaming)**: 废弃数据库轮询方案。采用 **Server-Sent Events (SSE)** 技术。
* 后端在内存中维护 `tokio::sync::broadcast` 通道。
* LLM 生成的 Token 实时推送到通道,直达前端。
* 数据库只负责存储**最终完成**的分析结果 (Persistence),不参与流式传输过程。
## 2. 页面布局设计
页面采用“固定框架 + 动态内容”的布局模式。
```text
+-----------------------------------------------------------------------+
| [Header Area] |
| Symbol: AAPL | Market: US | Price: $230.5 (Snapshot) | [Status Badge]|
| Control: [ Template Select Dropdown [v] ] [ Trigger Analysis Button ]|
+-----------------------------------------------------------------------+
| |
| [ Tab Navigation Bar ] |
| +-----------+ +--------------+ +------------+ +------------+ +-----+ |
| | 股价图表 | | 基本面数据 | | 分析模块A | | 分析模块B | | ... | |
| +-----------+ +--------------+ +------------+ +------------+ +-----+ |
| | |
+-----------------------------------------------------------------------+
| [ Main Content Area ] |
| |
| (Content changes based on selected Tab) |
| |
| SCENARIO 1: Stock Chart Tab |
| +-------------------------------------------------+ |
| | [ PLACEHOLDER: TradingView / K-Line Chart ] | |
| | (Future: Connect to Time-Series DB) | |
| +-------------------------------------------------+ |
| |
| SCENARIO 2: Fundamental Data Tab |
| +-------------------------------------------------+ |
| | Status: Waiting for Providers (2/3)... | |
| | --------------------------------------------- | |
| | [Tushare]: OK (JSON/Table Dump) | |
| | [Finnhub]: OK (JSON/Table Dump) | |
| | [AlphaV ]: Pending... | |
| +-------------------------------------------------+ |
| |
| SCENARIO 3: Analysis Module Tab (e.g., Valuation) |
| +-------------------------------------------------+ |
| | [Markdown Renderer] | |
| | ## Valuation Analysis | |
| | Based on the PE ratio of 30... | |
| | (Streaming Cursor) _ | |
| +-------------------------------------------------+ |
| |
+-----------------------------------------------------------------------+
| [ Execution Details Footer / Tab ] |
| Total Time: 12s | Tokens: 4050 | Cost: $0.02 |
+-----------------------------------------------------------------------+
```
## 3. 数据流与状态机
### 3.1 固定 Tab 定义
无论选择何种模板,以下 Tab 始终存在Fixed Tabs
1. **股价图表 (Stock Chart)**
* **数据源**: 独立的实时行情 API / 时间序列数据库。
* **当前实现**: 占位符 (Placeholder)。
2. **基本面数据 (Fundamental Data)**
* **定义**: 所有已启用的 Data Providers 返回的原始数据聚合。
* **状态逻辑**:
* 此 Tab 代表“数据准备阶段”。
* 必须等待后端 `FetchCompanyDataCommand` 对应的 Task 状态为 Completed/Partial/Failed。
* UI 展示所有 Provider 的回执。只有当所有 Provider 都有定论(成功或失败),此阶段才算结束。
* **作为后续分析的“门控”**: 此阶段未完成前,后续分析 Tab 处于“等待中”状态。
3. **执行详情 (Execution Details)**
* **定义**: 工作流的元数据汇总。
* **内容**: 耗时统计、Token 消耗、API 调用清单。
### 3.2 动态 Tab 定义 (Analysis Modules)
* **来源**: 根据当前选中的 `Template ID` 从后端获取 `AnalysisTemplateConfig`
* **生成逻辑**:
* Template 中定义了 Modules: `[Module A, Module B, Module C]`.
* 前端直接映射为 Tab A, Tab B, Tab C。
* **渲染**:
* **Loading**: 后端 `AnalysisResult` 状态为 `processing`
* **Streaming**: 通过 SSE (`/api/analysis-results/stream`) 接收增量内容。
* **Done**: 后端流结束,或直接从 DB 读取完整内容。
### 3.3 状态机 (useReportEngine Hook)
我们将废弃旧的 Hook实现一个纯粹的 `useReportEngine`
```typescript
interface ReportState {
// 1. 配置上下文
symbol: string;
templateId: string;
templateConfig: AnalysisTemplateSet | null; // 用于生成动态 Tab
// 2. 阶段状态
fetchStatus: 'idle' | 'fetching' | 'complete' | 'error'; // 基本面数据阶段
analysisStatus: 'idle' | 'running' | 'complete'; // 分析阶段
// 3. 数据持有
fundamentalData: any[]; // 来自各个 Provider 的原始数据
analysisResults: Record<string, AnalysisResultDto>; // Key: ModuleID
// 4. 进度
executionMeta: {
startTime: number;
elapsed: number;
tokens: number;
}
}
```
## 4. 交互流程
1. **初始化**:
* 用户进入页面 -> 加载 `api/configs/analysis_template_sets` -> 填充下拉框。
* 如果 URL 或历史数据中有 `template_id`,自动选中。
2. **触发 (Trigger)**:
* 用户点击“开始分析”。
* 前端 POST `/api/data-requests` (payload: `{ symbol, template_id }`)。
* **前端重置所有动态 Tab 内容为空**。
* 进入 `fetchStatus: fetching`
3. **阶段一:基本面数据获取**:
* 前端轮询 `/api/tasks/{request_id}`
* **基本面 Tab** 高亮/显示 Spinner。
* 展示各个 Provider 的子任务进度。
* 当 Task 状态 = Completed -> 进入阶段二。
4. **阶段二:流式分析 (SSE)**:
* 前端建立 EventSource 连接 `/api/analysis-results/stream?request_id={id}`
* **智能切换 Tab**: (可选) 当某个 Module 开始生成 (收到 SSE 事件 `module_start`) 时UI 可以自动切换到该 Tab。
* **渲染**: 收到 `content` 事件,追加到对应 Module 的内容中。
* **持久化**: 只有当 SSE 收到 `DONE` 事件时,后端才保证数据已落库。
5. **完成**:
* SSE 连接关闭。
* 状态转为 `complete`
## 5. 架构设计 (Architecture Design)
为了实现真流式传输,后端架构调整如下:
1. **内存状态管理 (In-Memory State)**:
* `AppState` 中增加 `stream_manager: StreamManager`
* `StreamManager` 维护 `HashMap<RequestId, BroadcastSender<StreamEvent>>`
* 这消除了对数据库的中间状态写入压力。
2. **Worker 职责**:
* Worker 执行 LLM 请求。
* 收到 Token -> 写入 `BroadcastSender` (Fire and forget)。
* 同时将 Token 累积在内存 Buffer 中。
* 生成结束 -> 将完整 Buffer 写入数据库 (PostgreSQL) -> 广播 `ModuleDone` 事件。
3. **API 职责**:
* `GET /stream`:
* 检查内存中是否有对应的 `BroadcastSender`?
* **有**: 建立 SSE 连接,订阅并转发事件。
* **无**: 检查数据库是否已完成?
* **已完成**: 一次性返回完整内容 (模拟 SSE 或直接返回 JSON)。
* **未开始/不存在**: 返回 404 或等待。
## 6. 迁移计划 (Action Items)
### 6.1 清理与归档 (Cleanup)
- [x] 创建 `frontend/archive/v1_report` 目录。
- [x] 移动 `app/report/[symbol]/components` 下的旧组件(`ExecutionDetails.tsx`, `TaskStatus.tsx`, `ReportHeader.tsx`, `AnalysisContent.tsx`)到 archive。
- [x] 移动 `app/report/[symbol]/hooks` 下的 `useAnalysisRunner.ts``useReportData.ts` 到 archive。
### 6.2 核心构建 (Core Scaffolding)
- [x] 创建 `hooks/useReportEngine.ts`: 实现上述状态机,严格对接后端 API。
- [x] 创建 `components/ReportLayout.tsx`: 实现新的布局框架Header + Tabs + Content
- [x] 创建 `components/RawDataViewer.tsx`: 用于展示基本面原始数据JSON View
- [x] 创建 `components/AnalysisViewer.tsx`: 用于展示分析结果Markdown Streaming
### 6.3 页面集成 (Integration)
- [x] 重写 `app/report/[symbol]/page.tsx`: 引入 `useReportEngine` 和新组件。
- [ ] 验证全流程Trigger -> Task Fetching -> Analysis Streaming -> Finish。
### 6.4 后端重构 (Backend Refactoring) - NEW
- [x] **State Upgrade**: 更新 `AppState` 引入 `tokio::sync::broadcast` 用于流式广播。
- [x] **Worker Update**: 修改 `run_report_generation_workflow`,不再生成完才写库,也不中间写库,而是**中间发广播,最后写库**。
- [x] **API Update**: 新增 `GET /api/analysis-results/stream` (SSE Endpoint),对接广播通道。
- [x] **Frontend Update**: 修改 `useReportEngine.ts`,将轮询 `analysis-results` 改为 `EventSource` 连接。

View File

@ -1,148 +0,0 @@
# 供应商隔离的数据新鲜度与缓存设计方案
## 1. 背景 (Background)
当前系统使用 `company_profiles` 表中的全局 `updated_at` 时间戳来判断某个股票的数据是否“新鲜”(例如:过去 24 小时内更新过)。
**现有问题:**
这种方法在多供应商Multi-Provider环境中会导致严重的竞态条件Race Condition
1. **Tushare**A股数据源通常响应较快获取数据并更新了 `company_profiles` 表的 `updated_at`
2. `updated_at` 时间戳被更新为 `NOW()`
3. **YFinance****AlphaVantage**(全球数据源)稍后启动任务。
4. 它们检查 `company_profiles` 表,发现 `updated_at` 非常新,因此错误地认为**自己的**数据也是最新的。
5. 结果YFinance/AlphaVantage 跳过执行,导致这些特定字段的数据为空或陈旧。
## 2. 目标 (Objective)
实现一个**供应商隔离的缓存机制**允许每个数据供应商Tushare, YFinance, AlphaVantage, Finnhub能够
1. 独立追踪其最后一次成功更新数据的时间。
2. 仅根据**自己的**数据新鲜度来决定是否执行任务。
3. 避免干扰其他供应商的执行逻辑。
## 3. 设计原则 (Design Principles)
1. **不新增数据表**:利用数据库现有的文档-关系混合特性Document-Relational。具体来说使用 `company_profiles` 表中的 `additional_info` (JSONB) 字段。
2. **服务层抽象**:解析和管理这些元数据的复杂性应封装在 `Data Persistence Service` 内部,向各 Provider Service 暴露简洁的 API。
3. **并发安全**:确保不同供应商的并发更新不会覆盖彼此的元数据状态。
## 4. 数据结构设计 (Data Structure Design)
我们将利用现有的 `company_profiles.additional_info` 字段(类型:`JSONB`)来存储一个供应商状态字典。
### `additional_info` JSON Schema 设计
```json
{
"provider_status": {
"tushare": {
"last_updated": "2025-11-19T10:00:00Z",
"data_version": "v1",
"status": "success"
},
"yfinance": {
"last_updated": "2025-11-18T09:30:00Z",
"status": "success"
},
"alphavantage": {
"last_updated": "2025-11-15T14:00:00Z",
"status": "partial_success" // 例如:触发了速率限制
}
},
"other_metadata": "..." // 保留其他现有元数据
}
```
## 5. 实施计划 (Implementation Plan)
### 5.1. 数据持久化服务更新 (Data Persistence Service)
我们需要扩展 `PersistenceClient` 及其底层 API以支持细粒度的元数据更新。
**新增/更新 API 端点:**
1. **`PUT /companies/{symbol}/providers/{provider_id}/status`** (新增)
* **目的**:原子更新特定供应商的状态,无需读取/写入完整的 profile。
* **实现**:使用 Postgres 的 `jsonb_set` 函数,直接更新 JSON 路径 `['provider_status', provider_id]`
* **Payload**:
```json
{
"last_updated": "2025-11-19T12:00:00Z",
"status": "success"
}
```
2. **`GET /companies/{symbol}/providers/{provider_id}/status`** (新增)
* **目的**:辅助接口,用于获取特定供应商的当前缓存状态。
### 5.2. 供应商服务工作流更新 (Provider Service)
每个 Provider Service例如 `yfinance-provider-service`)将修改其 `worker.rs` 中的逻辑:
**现有逻辑(有缺陷):**
```rust
let profile = client.get_company_profile(symbol).await?;
if profile.updated_at > 24h_ago { return; } // 全局检查
```
**新逻辑:**
```rust
// 1. 检查 Provider 专属缓存
let status = client.get_provider_status(symbol, "yfinance").await?;
if let Some(s) = status {
if s.last_updated > 24h_ago {
info!("YFinance 数据较新,跳过执行。");
return;
}
}
// 2. 获取并持久化数据
// ... fetch ...
client.upsert_company_profile(profile).await?; // 更新基本信息
client.batch_insert_financials(financials).await?;
// 3. 更新 Provider 状态
client.update_provider_status(symbol, "yfinance", ProviderStatus {
last_updated: Utc::now(),
status: "success"
}).await?;
```
## 6. 风险管理与迁移 (Risk Management & Migration)
* **竞态条件 (Race Conditions)**:通过在数据库层使用 `jsonb_set` 进行部分更新,我们避免了“读-改-写”的竞态条件,确保 Provider A 的更新不会覆盖 Provider B 同时写入的状态。
* **数据迁移 (Migration)**
* **策略****Lazy Migration (懒迁移)**。
* 现有数据中没有 `provider_status` 字段。代码将优雅地处理 `null` 或缺失键的情况(将其视为“陈旧/从未运行”,触发重新获取)。
* **无需**编写专门的 SQL 迁移脚本去清洗历史数据。旧数据会随着新的抓取任务运行而自动补充上状态信息。
* 如果必须清理,可以直接执行 `UPDATE company_profiles SET additional_info = additional_info - 'provider_status';` 来重置所有缓存状态。
## 7. 实施清单 (Implementation Checklist)
- [x] **Phase 1: Common Contracts & DTOs**
- [x] 在 `services/common-contracts/src/dtos.rs` 中定义 `ProviderStatusDto`.
- [x] **Phase 2: Data Persistence Service API**
- [x] 实现 DB 层逻辑: `get_provider_status` (读取 JSONB).
- [x] 实现 DB 层逻辑: `update_provider_status` (使用 `jsonb_set`).
- [x] 添加 API Handler: `GET /companies/{symbol}/providers/{provider_id}/status`.
- [x] 添加 API Handler: `PUT /companies/{symbol}/providers/{provider_id}/status`.
- [x] 注册路由并测试接口.
- [x] **Phase 3: Client Logic Update**
- [x] 更新各服务中的 `PersistenceClient` (如 `services/yfinance-provider-service/src/persistence.rs` 等),增加 `get_provider_status``update_provider_status` 方法.
- [x] **Phase 4: Provider Services Integration**
- [x] **Tushare Service**: 更新 `worker.rs`,集成新的缓存检查逻辑.
- [x] **YFinance Service**: 更新 `worker.rs`,集成新的缓存检查逻辑.
- [x] **AlphaVantage Service**: 更新 `worker.rs`,集成新的缓存检查逻辑.
- [x] **Finnhub Service**: 更新 `worker.rs`,集成新的缓存检查逻辑.
- [ ] **Phase 5: Verification (验证)**
- [ ] 运行 `scripts/test_data_fetch.py` 验证全流程.
- [ ] 验证不同 Provider 的状态互不干扰.
- [ ] **Phase 6: Caching Logic Abstraction (缓存逻辑抽象 - 智能客户端)**
- [ ] 将 `PersistenceClient` 迁移至 `services/common-contracts/src/persistence_client.rs`(或新建 `service-sdk` 库),消除重复代码。
- [ ] 在共享客户端中实现高层方法 `should_fetch_data(symbol, provider, ttl)`
- [ ] 重构所有 Provider Service 以使用共享的 `PersistenceClient`
- [ ] 验证所有 Provider 的缓存逻辑是否一致且无需手动实现。

View File

@ -1,128 +0,0 @@
# 报告生成优化与 UI 状态反馈改进设计文档
**状态**: Draft
**日期**: 2025-11-19
**涉及模块**: Report Generator Service (Backend), Frontend (UI)
## 1. 背景与问题分析
当前系统的报告生成流程存在两个主要痛点,导致用户体验不佳且生成内容质量低下:
1. **数据注入缺失 (Data Injection Gap)**:
* 后端在执行 Prompt 渲染时,`financial_data` 被硬编码为 `"..."`
* 大模型LLM缺乏上下文输入导致输出“幻觉”内容如自我介绍、复读指令或通用废话。
* 依赖链条虽然在拓扑排序上是正确的,但由于上游(如“基本面分析”)输出无效内容,下游(如“最终结论”)的输入也随之失效。
2. **UI 状态反馈缺失 (UI/UX Gap)**:
* 前端仅有简单的“有数据/无数据”判断。
* 点击“重新生成”时UI 往往显示旧的缓存数据,缺乏“生成中”或“进度更新”的实时反馈。
* 用户无法区分“旧报告”和“正在生成的新报告”。
## 2. 后端优化设计 (Report Generator Service)
### 2.1 数据注入逻辑修复 (Fixing Financial Data Injection)
我们将把当前的“基本面数据获取”视为一个**内置的基础工具Native Tool**。
* **当前逻辑**: 直接透传数据库 Raw Data。
* **改进逻辑**: 在 `worker.rs` 中实现一个数据格式化器,将 `Vec<TimeSeriesFinancialDto>` 转换为 LLM 易读的 Markdown 表格或结构化文本。
**实现细节**:
1. **格式化函数**: 实现 `format_financials_to_markdown(financials: &[TimeSeriesFinancialDto]) -> String`
* 按年份/季度降序排列。
* 提取关键指标营收、净利润、ROE、毛利率等
* 生成 Markdown Table。
2. **注入 Context**:
* 在 `Tera` 模板渲染前,调用上述函数。
* 替换占位符: `context.insert("financial_data", &formatted_data);`
3. **上游依赖注入 (保持不变)**:
* 继续保留现有的 `generated_results` 注入逻辑,确保上游模块(如 `market_analysis`)的输出能正确传递给下游(如 `final_conclusion`)。
### 2.2 执行状态管理 (Execution Status Management)
为了支持前端的“实时状态”,后端需要能够区分“排队中”、“生成中”和“已完成”。
* **现状**: 只有生成完成后才写入 `analysis_results` 表。
* **改进**: 引入任务状态流转。
**方案 A (基于数据库 - 推荐 MVP)**:
利用现有的 `analysis_results` 表或新建 `analysis_tasks` 表。
1. **任务开始时**:
* Worker 开始处理某个 `module_id` 时,立即写入/更新一条记录。
* `status`: `PROCESSING`
* `content`: 空或 "Analysis in progress..."
2. **任务完成时**:
* 更新记录。
* `status`: `COMPLETED`
* `content`: 实际生成的 Markdown。
3. **任务失败时**:
* `status`: `FAILED`
* `content`: 错误信息。
### 2.3 未来扩展性:工具模块 (Future Tool Module)
* 当前设计中,`financial_data` 是硬编码注入的。
* **未来规划**: 在 Prompt 模板配置中,增加 `tools` 字段。
```json
"tools": ["financial_aggregator", "news_search", "calculator"]
```
* Worker 在渲染 Prompt 前,先解析 `tools` 配置,并行执行对应的工具函数(如 Python 数据清洗脚本),获取输出后注入 Context。当前修复的 `financial_data` 本质上就是 `financial_aggregator` 工具的默认实现。
## 3. 前端优化设计 (Frontend)
### 3.1 状态感知与交互
**目标**: 让用户清晰感知到“正在生成”。
1. **重新生成按钮行为**:
* 点击“重新生成”后,**立即**将当前模块的 UI 状态置为 `GENERATING`
* **视觉反馈**:
* 方案一(简单):清空旧内容,显示 Skeleton骨架屏+ 进度条/Spinner。
* 方案二(平滑):保留旧内容,但在上方覆盖一层半透明遮罩,并显示“正在更新分析...”。(推荐方案二,避免内容跳动)。
2. **状态轮询 (Polling)**:
* 由于后端暂未实现 SSE (Server-Sent Events),前端需采用轮询机制。
* 当状态为 `GENERATING` 时,每隔 2-3 秒调用一次 API 检查该 `module_id` 的状态。
* 当后端返回状态变更为 `COMPLETED` 时,停止轮询,刷新显示内容。
### 3.2 组件结构调整
修改 `AnalysisContent.tsx` 组件:
```typescript
interface AnalysisState {
status: 'idle' | 'loading' | 'success' | 'error';
data: string | null; // Markdown content
isStale: boolean; // 标记当前显示的是否为旧缓存
}
```
* **Idle**: 初始状态。
* **Loading**: 点击生成后,显示加载动画。
* **Success**: 获取到新数据。
* **IsStale**: 点击重新生成瞬间,将 `isStale` 设为 true。UI 上可以给旧文本加灰色滤镜,直到新数据到来。
## 4. 实施计划 (Action Plan)
### Phase 1: 后端数据修正 (Backend Core)
- [ ] 修改 `services/report-generator-service/src/worker.rs`
- [ ] 实现 `format_financial_data` 辅助函数。
- [ ] 将格式化后的数据注入 Tera Context。
- [ ] 验证大模型输出不再包含“幻觉”文本。
### Phase 2: 后端状态透出 (Backend API)
- [ ] 确认 `NewAnalysisResult` 或相关 DTO 是否支持状态字段。
- [ ] 在 Worker 开始处理模块时,写入 `PROCESSING` 状态到数据库。
- [ ] 确保 API 查询接口能返回 `status` 字段。
### Phase 3: 前端体验升级 (Frontend UI)
- [ ] 修改 `AnalysisContent.tsx`,增加对 `status` 字段的处理。
- [ ] 实现“重新生成”时的 UI 遮罩或 Loading 状态,不再单纯依赖 `useQuery` 的缓存。
- [ ] 优化 Markdown 渲染区的用户体验。
## 5. 验收标准 (Acceptance Criteria)
1. **内容质量**: 市场分析、基本面分析报告中包含具体的财务数字(如营收、利润),且引用正确,不再出现“请提供数据”的字样。
2. **流程闭环**: 点击“重新生成”UI 显示加载状态 -> 后端处理 -> UI 自动刷新为新内容。
3. **无闪烁**: 页面不会因为轮询而频繁闪烁,状态切换平滑。

View File

@ -1,225 +0,0 @@
# 架构重构设计文档:引入 Workflow Orchestrator
## 1. 背景与目标
当前系统存在 `api-gateway` 职责过载、业务逻辑分散、状态机隐式且脆弱、前后端状态不同步等核心问题。为了彻底解决这些架构痛点,本设计提出引入 **Workflow Orchestrator Service**,作为系统的“大脑”,负责集中管理业务流程、状态流转与事件协调。
### 核心目标
1. **解耦 (Decoupling)**: 将业务协调逻辑从 `api-gateway` 剥离Gateway 回归纯粹的流量入口和连接管理职责。
2. **状态一致性 (Consistency)**: 建立单一事实来源 (Single Source of Truth),所有业务状态由 Orchestrator 统一维护并广播。
3. **细粒度任务编排 (Fine-Grained Orchestration)**: 废除粗粒度的“阶段”概念,转向基于 DAG (有向无环图) 的任务编排。后端只负责执行任务和广播每个任务的状态,前端根据任务状态自由决定呈现逻辑。
## 2. 架构全景图 (Architecture Overview)
### 2.1 服务角色重定义
| 服务 | 现有职责 | **新职责** |
| :--- | :--- | :--- |
| **API Gateway** | 路由, 鉴权, 注册发现, 业务聚合, 流程触发 | 路由, 鉴权, 注册发现, **SSE/WS 代理 (Frontend Proxy)** |
| **Workflow Orchestrator** | *(新服务)* | **DAG 调度**, **任务依赖管理**, **事件广播**, **状态快照** |
| **Data Providers** | 数据抓取, 存库, 发 NATS 消息 | (保持不变) 接收指令 -> 干活 -> 发结果事件 |
| **Report Generator** | 报告生成, 发 NATS 消息 | (保持不变) 接收指令 -> 干活 -> 发进度/结果事件 |
| **Data Processors** | *(新服务类型)* | **数据清洗/转换** (接收上下文 -> 转换 -> 更新上下文) |
### 2.2 数据流向 (Data Flow)
1. **启动**: 前端 -> Gateway (`POST /start`) -> **Orchestrator** (NATS: `StartWorkflow`)
2. **调度**: **Orchestrator** 解析模板构建 DAG -> NATS: 触发无依赖的 Tasks (如 Data Fetching)
3. **反馈**: Executors (Providers/ReportGen/Processors) -> NATS: `TaskCompleted` -> **Orchestrator**
4. **流转**: **Orchestrator** 检查依赖 -> NATS: 触发下一层 Tasks
5. **广播**: **Orchestrator** -> NATS: `WorkflowEvent` (Task Status Updates) -> Gateway -> 前端 (SSE)
## 3. 接口与协议定义 (Contracts & Schemas)
需在 `services/common-contracts` 中进行以下调整:
### 3.1 新增 Commands (NATS Subject: `workflow.commands.*`)
```rust
// Topic: workflow.commands.start
#[derive(Serialize, Deserialize, Debug)]
pub struct StartWorkflowCommand {
pub request_id: Uuid,
pub symbol: CanonicalSymbol,
pub market: String,
pub template_id: String,
}
// 新增:用于手动请求状态对齐 (Reconnect Scenario)
// Topic: workflow.commands.sync_state
#[derive(Serialize, Deserialize, Debug)]
pub struct SyncStateCommand {
pub request_id: Uuid,
}
```
### 3.2 新增 Events (NATS Subject: `events.workflow.{request_id}`)
这是前端唯一需要订阅的流。
```rust
// Topic: events.workflow.{request_id}
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type", content = "payload")]
pub enum WorkflowEvent {
// 1. 流程初始化 (携带完整的任务依赖图)
WorkflowStarted {
timestamp: i64,
// 定义所有任务及其依赖关系,前端可据此绘制流程图或进度条
task_graph: WorkflowDag
},
// 2. 任务状态变更 (核心事件)
TaskStateChanged {
task_id: String, // e.g., "fetch:tushare", "process:clean_financials", "module:swot_analysis"
task_type: TaskType, // DataFetch | DataProcessing | Analysis
status: TaskStatus, // Pending, Scheduled, Running, Completed, Failed, Skipped
message: Option<String>,
timestamp: i64
},
// 3. 任务流式输出 (用于 LLM 打字机效果)
TaskStreamUpdate {
task_id: String,
content_delta: String,
index: u32
},
// 4. 流程整体结束
WorkflowCompleted {
result_summary: serde_json::Value,
end_timestamp: i64
},
WorkflowFailed {
reason: String,
is_fatal: bool,
end_timestamp: i64
},
// 5. 状态快照 (用于重连/丢包恢复)
// 当前端重连或显式发送 SyncStateCommand 时Orchestrator 发送此事件
WorkflowStateSnapshot {
timestamp: i64,
task_graph: WorkflowDag,
tasks_status: HashMap<String, TaskStatus>, // 当前所有任务的最新状态
tasks_output: HashMap<String, Option<String>> // (可选) 已完成任务的关键输出摘要
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct WorkflowDag {
pub nodes: Vec<TaskNode>,
pub edges: Vec<TaskDependency> // from -> to
}
#[derive(Serialize, Deserialize, Debug)]
pub struct TaskNode {
pub id: String,
pub name: String,
pub type: TaskType,
pub initial_status: TaskStatus
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub enum TaskType {
DataFetch, // 创造原始上下文
DataProcessing, // 消耗并转换上下文 (New)
Analysis // 读取上下文生成新内容
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub enum TaskStatus {
Pending, // 等待依赖
Scheduled, // 依赖满足,已下发给 Worker
Running, // Worker 正在执行
Completed, // 执行成功
Failed, // 执行失败
Skipped // 因上游失败或策略原因被跳过
}
```
### 3.3 调整现有 Messages
* **`FetchCompanyDataCommand`**: Publisher 变更为 `Workflow Orchestrator`
* **`GenerateReportCommand`**: Publisher 变更为 `Workflow Orchestrator`
## 4. Workflow Orchestrator 内部设计
### 4.1 DAG 调度器 (DAG Scheduler)
每个 `request_id` 对应一个 DAG 实例。
1. **初始化**: 根据 `TemplateID` 读取配置。
* 创建 Data Fetch Tasks (作为 DAG 的 Root Nodes)。
* 创建 Analysis Module Tasks (根据 `dependencies` 配置连接边)。
2. **依赖检查**:
* 监听 Task 状态变更。
* 当 Task A 变成 `Completed` -> 检查依赖 A 的 Task B。
* 如果 Task B 的所有依赖都 `Completed` -> 触发 Task B。
* 如果 Task A `Failed` -> 将依赖 A 的 Task B 标记为 `Skipped` (除非有容错策略)。
### 4.2 状态对齐机制 (State Alignment / Snapshot)
为了解决前端刷新或网络丢包导致的状态不一致:
1. **主动推送快照 (On Connect)**:
* Gateway 在前端建立 SSE 连接时,向 Orchestrator 发送 `SyncStateCommand`
* Orchestrator 收到命令后,将当前内存中的完整 DAG 状态打包成 `WorkflowStateSnapshot` 事件发送。
2. **前端合并逻辑**:
* 前端收到 Snapshot 后,全量替换本地的任务状态树。
* 如果 Snapshot 显示某任务 `Running`,前端恢复 Loading 动画。
* 如果 Snapshot 显示某任务 `Completed`,前端渲染结果。
### 4.3 容错策略 (Policy)
Orchestrator 需要内置策略来处理非二元结果。
* **Data Fetch Policy**: 并非所有 Data Fetch 必须成功。可以配置 "At least one data source" 策略。如果满足策略Orchestrator 将下游的 Analysis Task 依赖视为满足。
## 5. 实施步骤 (Implementation Checklist)
### Phase 1: Contract & Interface
- [x] **Update common-contracts**:
- [x] Add `StartWorkflowCommand` and `SyncStateCommand`.
- [x] Add `WorkflowEvent` enum (incl. Started, StateChanged, StreamUpdate, Completed, Failed, Snapshot).
- [x] Add `WorkflowDag`, `TaskNode`, `TaskType`, `TaskStatus` structs.
- [x] Update publishers for `FetchCompanyDataCommand` and `GenerateReportCommand`.
- [x] Bump version and publish crate.
### Phase 2: Workflow Orchestrator Service (New)
- [x] **Scaffold Service**:
- [x] Create new Rust service `services/workflow-orchestrator-service`.
- [x] Setup `Dockerfile`, `Cargo.toml`, and `main.rs`.
- [x] Implement NATS connection and multi-topic subscription.
- [x] **Core Logic - State Machine**:
- [x] Implement `WorkflowState` struct (InMemory + Redis/DB persistence optional for MVP).
- [x] Implement `DagScheduler`: Logic to parse template and build dependency graph.
- [x] **Core Logic - Handlers**:
- [x] Handle `StartWorkflowCommand`: Init DAG, fire initial tasks.
- [x] Handle `TaskCompleted` events (from Providers/ReportGen): Update DAG, trigger next tasks.
- [x] Handle `SyncStateCommand`: Serialize current state and emit `WorkflowStateSnapshot`.
- [x] **Policy Engine**:
- [x] Implement "At least one provider" policy for data fetching.
### Phase 3: API Gateway Refactoring
- [x] **Remove Legacy Logic**:
- [x] Delete `aggregator.rs` completely.
- [x] Remove `trigger_data_fetch` aggregation logic.
- [x] Remove `/api/tasks` polling endpoint.
- [x] **Implement Proxy Logic**:
- [x] Add `POST /api/v2/workflow/start` -> Publishes `StartWorkflowCommand`.
- [x] Add `GET /api/v2/workflow/events/{id}` -> Subscribes to NATS, sends `SyncStateCommand` on open, proxies events to SSE.
### Phase 4: Integration & Frontend
- [x] **Docker Compose**: Add `workflow-orchestrator-service` to stack.
- [x] **Frontend Adapter**:
- [x] **Type Definitions**: Define `WorkflowEvent`, `WorkflowDag`, `TaskStatus` in `src/types/workflow.ts`.
- [x] **API Proxy**: Implement Next.js Route Handlers for `POST /workflow/start` and `GET /workflow/events/{id}` (SSE).
- [x] **Core Logic (`useWorkflow`)**:
- [x] Implement SSE connection management with auto-reconnect.
- [x] Handle `WorkflowStarted`, `TaskStreamUpdate`, `WorkflowCompleted`.
- [x] Implement state restoration via `WorkflowStateSnapshot`.
- [x] **UI Components**:
- [x] `WorkflowVisualizer`: Task list and status tracking.
- [x] `TaskOutputViewer`: Markdown-rendered stream output.
- [x] `WorkflowReportLayout`: Integrated analysis page layout.
- [x] **Page Integration**: Refactor `app/report/[symbol]/page.tsx` to use the new workflow engine.
---
*Updated: 2025-11-20 - Added Implementation Checklist*

View File

@ -1,175 +0,0 @@
# 架构修订:基于会话的数据快照与分层存储 (Session-Based Data Snapshotting)
## 1. 核心理念修订 (Core Philosophy Refinement)
基于您的反馈,我们修正了架构的核心逻辑,将数据明确划分为两类,并采取不同的存储策略。
### 1.1 数据分类 (Data Classification)
1. **客观历史数据 (Objective History / Time-Series)**
* **定义**: 股价、成交量、K线图等交易数据。
* **特性**: "出现即历史",不可篡改,全球唯一。
* **存储策略**: **全局共享存储**。不需要按 Session 隔离,不需要存多份。
* **表**: 现有的 `daily_market_data` (TimescaleDB) 保持不变。
2. **观测型数据 (Observational Data / Fundamentals)**
* **定义**: 财务报表、公司简介、以及 Provider 返回的原始非结构化或半结构化信息。
* **特性**: 不同来源Providers说法不一可能随时间修正Restatement分析依赖于“当时”获取的版本。
* **存储策略**: **基于 Session 的快照存储**。每一次 Session 都必须保存一份当时获取的原始数据的完整副本。
* **表**: 新增 `session_raw_data` 表。
### 1.2 解决的问题
* **会话隔离**: 新的 Session 拥有自己独立的一套基础面数据,不受历史 Session 干扰,也不污染未来 Session。
* **历史回溯**: 即使 Provider 变了,查看历史 Report 时,依然能看到当时是基于什么数据得出的结论。
* **数据清洗解耦**: 我们现在只负责“收集并快照”不负责“清洗和聚合”。复杂的清洗逻辑WASM/AI留待后续模块处理。
---
## 2. 数据库架构设计 (Schema Design)
### 2.1 新增:会话原始数据表 (`session_raw_data`)
这是本次架构调整的核心。我们不再试图把财务数据强行塞进一个全局唯一的标准表,而是忠实记录每个 Provider 在该 Session 中返回的内容。
```sql
CREATE TABLE session_raw_data (
id BIGSERIAL PRIMARY KEY,
request_id UUID NOT NULL, -- 关联的 Session ID
symbol VARCHAR(32) NOT NULL,
provider VARCHAR(64) NOT NULL, -- e.g., 'tushare', 'alphavantage'
data_type VARCHAR(32) NOT NULL, -- e.g., 'financial_statements', 'company_profile'
-- 核心:直接存储 Provider 返回的(或稍微标准化的)完整 JSON
data_payload JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
-- 索引:为了快速查询某次 Session 的数据
CONSTRAINT fk_request_id FOREIGN KEY (request_id) REFERENCES requests(id) ON DELETE CASCADE
);
CREATE INDEX idx_session_data_req ON session_raw_data(request_id);
```
### 2.2 新增:供应商缓存表 (`provider_response_cache`)
为了优化性能和节省 API 调用次数,我们在全局层引入缓存。但请注意:**缓存仅作为读取源,不作为 Session 的存储地。**
```sql
CREATE TABLE provider_response_cache (
cache_key VARCHAR(255) PRIMARY KEY, -- e.g., "tushare:AAPL:financials"
data_payload JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
```
### 2.3 保持不变:市场数据表 (`daily_market_data`)
* 继续使用 TimescaleDB 存储 `open`, `high`, `low`, `close`, `volume`
* 所有 Session 共享读取此表。
---
## 3. 数据流转逻辑 (Data Lifecycle)
### Phase 1: Session 启动与数据获取 (Acquisition)
1. **Start**: API Gateway 生成 `request_id`
2. **Fetch & Cache Logic (在 Provider Service 中执行)**:
* Provider 收到任务 (Symbol: AAPL)。
* **Check Cache**: 查询 `provider_response_cache`
* *Hit*: 拿出现成的 JSON。
* *Miss*: 调用外部 API获得 JSON写入 Cache (设置过期时间如 24h)。
3. **Snapshot (关键步骤)**:
* Provider 将拿到的 JSON (无论来自 Cache 还是 API),作为一条**新记录**写入 `session_raw_data`
* 字段: `request_id=UUID`, `provider=tushare`, `data=JSON`
### Phase 2: 展示与分析 (Consumption)
1. **Frontend Raw View (UI)**:
* 前端调用 `GET /api/v1/session/{request_id}/raw-data`
* 后端 `SELECT * FROM session_raw_data WHERE request_id = ...`。
* UI 依然可以使用之前的 Accordion 结构,展示 "Tushare: Financials", "AlphaVantage: Profile"。这就是用户看到的“本次调查的原始底稿”。
2. **Analysis (LLM)**:
* Report Generator 获取 `request_id` 对应的所有 raw data。
* 将这些 Raw Data 作为 Context 喂给 LLM。
* (未来扩展): 在这一步之前,插入一个 "Data Cleaning Agent/Wasm",读取 raw data输出 clean data再喂给 LLM。
### Phase 3: 归档与清理 (Cleanup)
* **Session Deletion**: 当我们需要清理某个历史 Session 时,只需 `DELETE FROM session_raw_data WHERE request_id = ...`
* **副作用**: 零。因为 `daily_market_data` 是共享的(留着也没事),而 Session 独享的 `raw_data` 被彻底删除了。
---
## 4. 实施路线图 (Implementation Roadmap)
1. **Database Migration**:
* 创建 `session_raw_data` 表。
* 创建 `provider_response_cache` 表。
* (清理旧表): 废弃 `time_series_financials` 表(原计划用于存标准化的财务指标,现在确认不需要。我们只存 `session_raw_data` 中的原始基本面数据,财务报表由原始数据动态推导)。
* **保留** `daily_market_data`存储股价、K线等客观时间序列数据保持全局共享
2. **Provider Services**:
* 引入 Cache 检查逻辑。
* 修改输出逻辑:不再尝试 Upsert 全局表,而是 Insert `session_raw_data`
3. **Frontend Refactor**:
* 修改 `RawDataViewer` 的数据源,从读取“最后一次更新”改为读取“当前 Session 的 Raw Data”。
* 这完美解决了“刷新页面看到旧数据”的问题——如果是一个新 Session ID它的 `session_raw_data` 一开始是空的UI 就会显示为空/Loading直到新的 Snapshot 写入。
4. **Future Extensibility (Aggregation)**:
* 当前架构下Frontend 直接展示 Raw Data。
* 未来:新增 `DataProcessorService`。它监听 "Data Fetched" 事件,读取 `session_raw_data`,执行聚合逻辑,将结果写入 `session_clean_data` (假想表),供 UI 显示“完美报表”。
---
## 5. Step-by-Step Task List
### Phase 1: Data Persistence Service & Database (Foundation)
- [x] **Task 1.1**: Create new SQL migration file.
- Define `session_raw_data` table (Columns: `id`, `request_id`, `symbol`, `provider`, `data_type`, `data_payload`, `created_at`).
- Define `provider_response_cache` table (Columns: `cache_key`, `data_payload`, `updated_at`, `expires_at`).
- (Optional) Rename `time_series_financials` to `_deprecated_time_series_financials` to prevent accidental usage.
- [x] **Task 1.2**: Run SQL migration (`sqlx migrate run`).
- [x] **Task 1.3**: Implement `db/session_data.rs` in Data Persistence Service.
- Function: `insert_session_data(pool, request_id, provider, data_type, payload)`.
- Function: `get_session_data(pool, request_id)`.
- [x] **Task 1.4**: Implement `db/provider_cache.rs` in Data Persistence Service.
- Function: `get_cache(pool, key) -> Option<Payload>`.
- Function: `set_cache(pool, key, payload, ttl)`.
- [x] **Task 1.5**: Expose new API endpoints in `api/`.
- `POST /api/v1/session-data` (Internal use by Providers).
- `GET /api/v1/session-data/:request_id` (Used by ReportGen & Frontend).
- `GET/POST /api/v1/provider-cache` (Internal use by Providers).
### Phase 2: Common Contracts & SDK (Glue Code)
- [x] **Task 2.1**: Update `common-contracts`.
- Add DTOs for `SessionData` and `CacheEntry`.
- Update `PersistenceClient` struct to include methods for calling new endpoints (`save_session_data`, `get_cache`, `set_cache`).
### Phase 3: Provider Services (Logic Update)
- [x] **Task 3.1**: Refactor `tushare-provider-service`.
- Update Worker to check Cache first.
- On Cache Miss: Call Tushare API -> Save to Cache.
- **Final Step**: Post data to `POST /api/v1/session-data` (instead of old batch insert).
- Ensure `request_id` is propagated correctly.
- [x] **Task 3.2**: Refactor `alphavantage-provider-service` (same logic).
- [x] **Task 3.3**: Refactor `yfinance-provider-service` (same logic).
- [x] **Task 3.4**: Verify `FinancialsPersistedEvent` is still emitted (or similar event) to trigger Gateway aggregation.
### Phase 4: API Gateway & Report Generator (Consumption)
- [x] **Task 4.1**: Update `api-gateway` routing.
- Proxy `GET /api/v1/session-data/:request_id` for Frontend.
- [x] **Task 4.2**: Update `report-generator-service`.
- In `worker.rs`, change data fetching logic.
- Instead of `get_financials_by_symbol`, call `get_session_data(request_id)`.
- Pass the raw JSON list to the LLM Context Builder.
### Phase 5: Frontend (UI Update)
- [x] **Task 5.1**: Update `useReportEngine.ts`.
- Change polling/fetching logic to request `GET /api/v1/session-data/${requestId}`.
- [x] **Task 5.2**: Update `RawDataViewer.tsx`.
- Adapt to new data structure (List of `{ provider, data_type, payload }`).
- Ensure the UI correctly groups these raw snapshots by Provider.

View File

@ -1,110 +0,0 @@
# 动态服务注册与发现机制设计方案 (Dynamic Service Registration & Discovery Proposal)
## 1. 问题陈述 (Problem Statement)
目前的 **API Gateway** 依赖于静态配置(环境变量中的 `provider_services` 映射表)来获知可用的数据提供商服务 (Data Provider Services)。
* **脆弱性 (Brittleness)**: 增加或迁移 Provider 需要修改 Gateway 配置并重启。
* **缺乏健康感知 (Lack of Health Awareness)**: Gateway 会盲目地尝试连接配置的 URL。如果某个服务挂了但配置还在请求会遭遇超时或连接错误。
* **运维复杂 (Operational Complexity)**: 手动管理 URL 既机械又容易出错。
## 2. 解决方案:动态注册系统 (Dynamic Registration System)
我们将实施**服务注册 (Service Registry)** 模式,由 API Gateway 充当注册中心。
### 2.1. "注册" 生命周期
1. **启动 (Startup)**: 当一个 Provider Service (例如 Tushare) 启动时,它向 API Gateway 发送 `POST /v1/registry/register` 请求。
* 载荷包括:服务 ID、基础 URL、能力标识如 "tushare")。
2. **存活心跳 (Liveness/Heartbeat)**: Provider Service 运行一个后台任务,每隔 **N 秒** (建议 **10秒**) 发送一次 `POST /v1/registry/heartbeat`
* **注意**: 由于我们主要在本地容器网络运行,网络开销极低,我们可以使用较短的心跳周期(如 10秒来实现快速的故障检测。
3. **发现 (Discovery)**: API Gateway 在内存中维护活跃服务列表。
* 如果超过 **2 * N 秒** (如 20秒) 未收到心跳,该服务将被标记为“不健康”或被移除。
4. **关闭 (Shutdown)**: 在优雅退出 (Graceful Shutdown, SIGTERM/SIGINT) 时Provider 发送 `POST /v1/registry/deregister`
### 2.2. 架构变更
#### A. 共享契约 (`common-contracts`)
定义注册所需的数据结构。
```rust
// services/common-contracts/src/registry.rs
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ServiceRegistration {
pub service_id: String, // 唯一ID, 例如 "tushare-provider-1"
pub service_name: String, // 类型, 例如 "tushare"
pub base_url: String, // 例如 "http://10.0.1.5:8000"
pub health_check_url: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Heartbeat {
pub service_id: String,
pub status: ServiceStatus, // Active, Degraded
}
```
#### B. API Gateway (`api-gateway`)
* **新组件**: `ServiceRegistry` (带 TTL 的线程安全 Map)。
* **新接口**:
* `POST /v1/registry/register`: 添加/更新条目。
* `POST /v1/registry/heartbeat`: 刷新 TTL。
* `POST /v1/registry/deregister`: 移除条目。
* **逻辑变更**: `get_task_progress``trigger_data_fetch` 将不再读取静态配置,而是查询动态的 `ServiceRegistry`
#### C. Provider Services (`*-provider-service`)
我们需要一个统一的机制来处理这个生命周期。
建议在 `common-contracts` 中引入一个标准的生命周期处理模块。
**建议的 Trait / 辅助结构体:**
```rust
// services/common-contracts/src/lifecycle.rs (New)
pub struct ServiceRegistrar {
gateway_url: String,
registration: ServiceRegistration,
// ...
}
impl ServiceRegistrar {
/// 注册服务 (重试直到成功)
pub async fn register(&self) -> Result<()>;
/// 启动后台心跳循环 (10s 间隔)
pub async fn start_heartbeat_loop(&self);
/// 注销服务
pub async fn deregister(&self) -> Result<()>;
}
```
## 3. 实施计划 (TODO List)
### Phase 1: 基础建设 (Infrastructure)
* [ ] **Task 1.1 (Contracts)**: 在 `services/common-contracts` 中创建 `registry.rs`,定义 `ServiceRegistration``Heartbeat` 结构体。
* [ ] **Task 1.2 (Library)**: 在 `services/common-contracts` 中实现 `ServiceRegistrar` 逻辑。
* 包含重试机制的 `register`
* 包含 `tokio::time::interval` (10s) 的 `start_heartbeat_loop`
* 确保能从环境变量 (如 `API_GATEWAY_URL`) 获取 Gateway 地址。
* [ ] **Task 1.3 (Gateway Core)**: 在 `api-gateway` 中实现 `ServiceRegistry` 状态管理(使用 `Arc<RwLock<HashMap<...>>>`)。
* [ ] **Task 1.4 (Gateway API)**: 在 `api-gateway` 中添加 `/v1/registry/*` 路由并挂载 Handler。
### Phase 2: Provider 改造 (Provider Migration)
*由于所有 Provider 架构一致,以下步骤需在 `tushare`, `finnhub`, `alphavantage`, `yfinance` 四个服务中重复执行:*
* [ ] **Task 2.1 (Config)**: 更新 `AppConfig`,增加 `gateway_url` 配置项。
* [ ] **Task 2.2 (Main Loop)**: 修改 `main.rs`
* 初始化 `ServiceRegistrar`
* 在 HTTP Server 启动前(或同时)调用 `registrar.register().await`
* 使用 `tokio::spawn` 启动 `registrar.start_heartbeat_loop()`
* [ ] **Task 2.3 (Shutdown)**: 添加 Graceful Shutdown 钩子,确保在收到 Ctrl+C 时调用 `registrar.deregister()`
### Phase 3: 消费端适配 (Gateway Consumption)
* [ ] **Task 3.1**: 修改 `api-gateway``test_data_source_config`,不再查 Config改为查 Registry。
* [ ] **Task 3.2**: 修改 `api-gateway``trigger_data_fetch`,根据 `service_name` (如 "tushare") 从 Registry 查找可用的 `base_url`
* 如果找到多个同名服务,可以做简单的 Load Balance轮询
* [ ] **Task 3.3**: 修改 `api-gateway``get_task_progress`,遍历 Registry 中的所有服务来聚合状态。
### Phase 4: 清理 (Cleanup)
* [ ] **Task 4.1**: 移除 `api-gateway` 中关于 `provider_services` 的静态配置代码和环境变量。
## 4. 预期收益
* **即插即用 (Plug-and-Play)**: 启动一个新的 Provider 实例,它会自动出现在系统中。
* **自愈 (Self-Healing)**: 如果 Provider 崩溃它会从注册表中消失TTL 过期Gateway 不会再向其发送请求,避免了无意义的等待和超时。
* **零配置 (Zero-Config)**: 扩容或迁移 Provider 时无需修改 Gateway 环境变量。

View File

@ -1,45 +0,0 @@
# 前端架构重构计划:状态管理与工作流控制权移交
## 1. 背景与现状
当前的 `fundamental-analysis` 前端项目源自一个 POC (Proof of Concept) 原型。在快速迭代过程中,遗留了大量“为了跑通流程而写”的临时逻辑。核心问题在于**前端承担了过多的业务控制逻辑**,导致前后端状态不一致、错误处理困难、用户体验割裂。
### 核心痛点
1. **“自嗨式”状态流转**:前端自行判断何时从“数据获取”切换到“分析报告”阶段(基于轮询结果推断),而非响应后端的明确指令。
2. **脆弱的 Polling + SSE 混合模式**:前端先轮询 HTTP 接口查询进度,再断开连接 SSE 流。这两者之间存在状态断层,且严重依赖 HTTP 接口的实时性(而这个接口又是后端实时聚合下游得来的,极易超时)。
3. **缺乏统一的状态源 (Source of Truth)**:前端维护了一套复杂的 `ReportState`,后端也有一套状态,两者通过不稳定的网络请求同步,经常出现“前端显示完成,后端还在跑”或“后端报错,前端还在转圈”的情况。
## 2. 重构目标
**原则前端归前端UI展示后端归后端业务逻辑与流转控制。**
1. **控制权移交**所有涉及业务流程流转Phase Transition的逻辑必须由后端通过事件或状态字段明确驱动。前端只负责渲染当前状态。
2. **单一数据流 (Single Stream)**废除“HTTP Polling -> SSE”的混合模式建立统一的 WebSocket 或 SSE 通道。从发请求那一刻起,所有状态变更(包括数据获取进度、分析进度、报错)全由服务端推送。
3. **简化状态机**:前端 `useReportEngine` 钩子应简化为单纯的“状态订阅者”,不再包含复杂的判断逻辑(如 `if (tasks.every(t => t.success)) switchPhase()`)。
## 3. 实施方案 (Tasks)
### Phase 1: 后端基础设施准备 (Backend Readiness)
- [ ] **统一事件流接口**:在 `api-gateway` 实现一个统一的 SSE/WebSocket 端点(如 `/v2/workflow/events`)。
- 该端点应聚合:`DataFetchProgress` (NATS), `WorkflowStart` (NATS), `ModuleProgress` (ReportGenerator), `WorkflowComplete`
- [ ] **Gateway 状态缓存**`api-gateway` 需要维护一个轻量级的 Request 状态缓存Redis 或 内存),不再实时透传查询请求给下游 Provider而是直接返回缓存的最新状态。
- [ ] **定义统一状态协议**:制定前后端通用的状态枚举(`PENDING`, `DATA_FETCHING`, `ANALYZING`, `COMPLETED`, `FAILED`)。
### Phase 2: 前端逻辑剥离 (Frontend Refactoring)
- [ ] **废除 useReportEngine 里的推断逻辑**:删除所有 `useEffect` 里关于状态切换的 `if/else` 判断代码。
- [ ] **实现 Event-Driven Hook**:重写 `useReportEngine`,使其核心逻辑变为:连接流 -> 收到事件 -> 更新 State。
- 收到 `STATUS_CHANGED: DATA_FETCHING` -> 显示数据加载 UI。
- 收到 `STATUS_CHANGED: ANALYZING` -> 自动切换到分析 UI无需前端判断数据是否齐备
- 收到 `ERROR` -> 显示错误 UI。
- [ ] **清理旧代码**:移除对 `/api/tasks` 轮询的依赖代码。
### Phase 3: 验证与兜底
- [ ] **断线重连机制**:实现 SSE/WS 的自动重连,并能从后端获取“当前快照”来恢复状态,防止刷新页面丢失进度。
- [ ] **超时兜底**:仅保留最基本的网络超时提示(如“服务器连接中断”),不再处理业务逻辑超时。
## 4. 复杂度评估与建议
- **复杂度**:中等偏高 (Medium-High)。涉及前后端协议变更和核心 Hook 重写。
- **风险**:高。这是系统的心脏部位,重构期间可能会导致整个分析流程暂时不可用。
- **建议****单独开一个线程Branch/Session进行**。不要在当前修复 Bug 的线程中混合进行。这需要系统性的设计和逐步替换,无法通过简单的 Patch 完成。
---
*Created: 2025-11-20*

View File

@ -1,59 +0,0 @@
# 系统日志分析与调试报告 (2025-11-20)
## 1. 系统现状快照
基于 `scripts/inspect_logs.sh` 的执行结果,当前系统各服务状态如下:
| 服务名称 | 状态 | 关键日志/行为 |
| :--- | :--- | :--- |
| **API Gateway** | 🟢 Running | 成功接收数据获取请求 (`FetchCompanyDataCommand`);成功注册服务;**未观测到**发送 `GenerateReportCommand`。 |
| **Data Persistence** | 🟢 Running | 数据库连接正常;成功写入 `session_data` (Source: `yfinance`, `tushare`)。 |
| **Report Generator** | 🟢 Running | 已启动并连接 NATS**无**收到任务的日志;服务似乎在 13:43 重启过。 |
| **Alphavantage** | 🟢 Running | 任务执行成功 (Task Completed)。 |
| **YFinance** | 🟢 Running | 任务执行成功 (Cache HIT)。 |
| **Tushare** | 🟢 Running | 配置轮询正常;有数据写入记录。 |
| **Finnhub** | 🟡 Degraded | **配置错误**`No enabled Finnhub configuration found`,导致服务降级,无法执行任务。 |
| **NATS** | 🟢 Running | 正常运行。 |
## 2. 现象分析
### 2.1 核心问题:报告生成流程中断
用户反馈 "点击后无反应/报错",日志显示:
1. **数据获取阶段 (Data Fetch)**
* API Gateway 接收到了数据获取请求 (Req ID: `935e6999...`)。
* Alphavantage, YFinance, Tushare 成功响应并写入数据。
* **Finnhub 失败/超时**由于配置缺失Finnhub Provider 处于降级状态,无法处理请求。
* API Gateway 的 Aggregator 显示 `Received 2/4 responses`。它可能在等待所有 Provider 返回,导致整体任务状态卡在 "InProgress"。
2. **报告生成阶段 (Report Generation)**
* **完全未触发**。`api-gateway` 日志中没有 `Publishing analysis generation command`
* `report-generator-service` 日志中没有 `Received NATS command`
### 2.2 根因推断
前端 (Frontend) 或 API Gateway 的聚合逻辑可能存在**"全有或全无" (All-or-Nothing)** 的依赖:
* 前端通常轮询 `/tasks/{id}`
* 如果 Finnhub 任务从未完成(挂起或失败未上报),聚合状态可能永远不是 "Completed"。
* 前端因此卡在进度条,从未发送 `POST /analysis-requests/{symbol}` 来触发下一步的报告生成。
## 3. 潜在风险与待办
1. **Finnhub 配置缺失**:导致服务不可用,拖累整体流程。
2. **容错性不足**:单个 Provider (Finnhub) 的失败似乎阻塞了整个 Pipeline。我们需要确保 "部分成功" 也能继续后续流程。
3. **Report Generator 重启**:日志显示该服务在 13:43 重启。如果此前有请求,可能因 Crash 丢失。需要关注其稳定性。
## 4. 下一步调试与修复计划
### Phase 1: 修复阻塞点
- [ ] **修复 Finnhub 配置**:检查数据库中的 `data_sources_config`,确保 Finnhub 有效启用且 API Key 正确。
- [ ] **验证容错逻辑**:检查 API Gateway 的 `Aggregator` 和 Frontend 的 `useReportEngine`,确保设置超时机制。如果 3/4 成功1/4 超时,应允许用户继续生成报告。
### Phase 2: 验证报告生成器
- [ ] **手动触发**:使用 Postman 或 `curl` 直接调用 `POST http://localhost:4000/v1/analysis-requests/{symbol}`,绕过前端等待逻辑,验证 Report Generator 是否能正常工作。
- [ ] **观察日志**:确认 Report Generator 收到指令并开始流式输出。
### Phase 3: 增强可观测性
- [ ] **完善日志**Report Generator 的日志偏少,建议增加 "Start processing module X" 等详细步骤日志。
---
*Report generated by AI Assistant.*

View File

@ -1,90 +0,0 @@
# UI Improvement: Parallel Data Provider Status & Error Reporting
## 1. Problem Statement
Currently, the Fundamental Analysis page shows a generic "Fetching Data..." loading state. The detailed status and errors from individual data providers (Tushare, YFinance, AlphaVantage) are aggregated into a single status in the API Gateway.
This causes two issues:
1. **Ambiguity**: Users cannot see which provider is working, finished, or failed.
2. **Hidden Errors**: If one provider fails (e.g., database error) but the overall task is still "in progress" (or generic failed), the specific error details are lost or not displayed prominently.
## 2. Goal
Update the API and UI to reflect the parallel nature of data fetching. The UI should display a "control panel" style view where each Data Provider has its own status card, showing:
- Provider Name (e.g., "Tushare")
- Current Status (Queued, In Progress, Completed, Failed)
- Progress Details (e.g., "Fetching data...", "Persisting...", "Error: 500 Internal Server Error")
## 3. Proposed Changes
### 3.1 Backend (API Gateway)
**Endpoint**: `GET /v1/tasks/{request_id}`
**Current Behavior**: Returns a single `TaskProgress` object (the first one found).
**New Behavior**: Returns a list of all tasks associated with the `request_id`.
**Response Schema Change**:
```json
// BEFORE
{
"request_id": "uuid",
"task_name": "tushare:600519.SS",
"status": "in_progress",
...
}
// AFTER
[
{
"request_id": "uuid",
"task_name": "tushare:600519.SS",
"status": "failed",
"details": "Error: 500 ...",
...
},
{
"request_id": "uuid",
"task_name": "yfinance:600519.SS",
"status": "completed",
...
}
]
```
### 3.2 Frontend
#### Types
Update `TaskProgress` handling to support array responses.
#### Logic (`useReportEngine` & `useTaskProgress`)
- **Aggregation Logic**:
- The overall "Phase Status" (Fetching vs Complete) depends on *all* provider tasks.
- **Fetching**: If *any* task is `queued` or `in_progress`.
- **Complete**: When *all* tasks are `completed` or `failed`.
- **Error Handling**: Do not fail the whole report if one provider fails. Allow partial success.
#### UI (`RawDataViewer` & `FinancialTable`)
Replace the single loader with a grid layout:
```tsx
// Conceptual Layout
<div className="grid grid-cols-3 gap-4">
<ProviderStatusCard name="Tushare" task={tushareTask} />
<ProviderStatusCard name="YFinance" task={yfinanceTask} />
<ProviderStatusCard name="AlphaVantage" task={avTask} />
</div>
```
**Card States**:
- **Waiting**: Gray / Spinner
- **Success**: Green Checkmark + "Data retrieved"
- **Error**: Red X + Error Message (expanded or tooltip)
## 4. Implementation Steps
1. **Backend**: Modify `services/api-gateway/src/api.rs` to return `Vec<TaskProgress>`.
2. **Frontend**:
- Update `TaskProgress` type definition.
- Update `useTaskProgress` fetcher.
- Update `useReportEngine` polling logic to handle array.
- Create `ProviderStatusCard` component.
- Update `RawDataViewer` to render the grid.

View File

@ -1,99 +0,0 @@
# 系统生命周期与异常处理分析 (System Lifecycle Analysis)
## 1. 核心问题 (Core Issue)
目前系统的业务逻辑缺乏**确定性 (Determinism)** 和 **闭环 (Closed-loop Lifecycle)**
虽然各个微服务独立运行,但缺乏统一的状态协调机制。当“快乐路径” (Happy Path) 被打断如DB报错下游服务无法感知上游的失败导致系统处于“僵尸状态” (Zombie State)。
> **用户反馈**:“有始必有终...你接了这个任务你就要负责把它结束掉...我们既然是微服务,那这个有始有终,可以说是跟生命性一样重要的一个基本原则。”
## 2. 现状分析 (Current State Analysis)
### 2.1 当前的数据流与控制流
```mermaid
sequenceDiagram
User->>API Gateway: 1. POST /data-requests
API Gateway->>NATS: 2. Pub "data_fetch_commands"
par Provider Execution
NATS->>Provider: 3. Receive Command
Provider->>Provider: 4. Fetch External Data
Provider-->>DB: 5. Persist Data (Upsert)
end
rect rgb(20, 0, 0)
Note right of DB: [CRITICAL FAILURE POINT]
DB-->>Provider: 500 Error (Panic)
end
alt Happy Path
Provider->>NATS: 6. Pub "events.financials_persisted"
NATS->>Report Gen: 7. Trigger Analysis
else Failure Path (Current)
Provider->>Log: Log Error
Provider->>TaskStore: Update Task = Failed
Note right of Provider: 链条在此断裂 (Chain Breaks Here)
end
User->>API Gateway: 8. Poll Task Status
API Gateway-->>User: All Failed
User->>User: 9. Frontend Logic: "All Done" -> Switch to Analysis UI
User->>API Gateway: 10. Connect SSE (Analysis Stream)
Note right of User: Hangs forever (Waiting for Report Gen that never started)
```
### 2.2 存在的具体缺陷
1. **隐式依赖链 (Implicit Dependency Chain)**:
* Report Generator 被动等待 `FinancialsPersistedEvent`。如果 Provider 挂了事件永远不发Report Generator 就像一个不知道此时该上班的工人,一直在睡觉。
2. **缺乏全局协调者 (Lack of Orchestration)**:
* API Gateway 把命令发出去就不管了(除了被动提供查询)。
* 没有人负责说:“嘿,数据获取全部失败了,取消本次分析任务。”
3. **前端的状态误判**:
* 前端认为 `Failed` 也是一种 `Completed`(终止态),这是对的。但前端错误地假设“只要终止了就可以进行下一步”。
* **修正原则**:只有 `Success` 才能驱动下一步。`Failed` 应该导致整个工作流的**熔断 (Circuit Break)**。
## 3. 改进方案 (Improvement Plan)
我们需要引入**Rustic**的确定性原则:**如果不能保证成功,就明确地失败。**
### 3.1 方案一:引入显式的工作流状态 (Explicit Workflow State) - 推荐
我们不需要引入沉重的 Workflow Engine (如 Temporal),但在逻辑上必须闭环。
**后端改进:**
1. **修复数据库错误**:这是首要任务。`unexpected null` 必须被修复。
2. **事件驱动的失败传播 (Failure Propagation)**
* 如果 Provider 失败,发送 `events.data_fetch_failed`
* Report Generator 或者 API Gateway 监听这个失败事件?
* **更好方案**Report Generator 不需要监听失败。API Gateway 需要聚合状态。
**前端/交互改进:**
1. **熔断机制**
* 在 `useReportEngine` 中,如果所有 Task 都是 `Failed`**绝对不要**进入 Analysis 阶段。
* 直接在界面显示:“数据获取失败,无法生成最新报告。是否查看历史数据?”
### 3.2 具体的实施步骤 (Action Items)
#### Phase 1: 修复根本错误 (Fix the Root Cause)
* **Task**: 调试并修复 `data-persistence-service` 中的 `500 Internal Server Error`
* 原因推测:数据库 schema 中某列允许 NULL但 Rust 代码中定义为非 Option 类型;或者反之。
* 错误日志:`unexpected null; try decoding as an Option`。
#### Phase 2: 完善生命周期逻辑 (Lifecycle Logic)
* **Task (Frontend)**: 修改 `useReportEngine`
* 逻辑变更:`if (allTasksFailed) { stop(); show_error(); }`
* 逻辑变更:`if (partialSuccess) { proceed_with_warning(); }`
* **Task (Backend - ReportGen)**: 增加超时机制。
* 如果用户连接了 SSE 但长时间没有数据(因为没收到事件),应该发送一个 Timeout 消息给前端,结束连接,而不是无限挂起。
## 4. 结论
目前的“卡在 Analyzing”是因为**上游失败导致下游触发器丢失**,叠加**前端盲目推进流程**导致的。
我们必须:
1. 修好 DB 错误(让快乐路径通畅)。
2. 在前端增加“失败熔断”,不要在没有新数据的情况下假装去分析。
---
*Created: 2025-11-20*

View File

@ -1,110 +0,0 @@
# 系统日志分析与调试操作指南 (System Debugging Guide)
本文档旨在记录当前系统的运行状况、已知问题以及标准化的调试流程。它将指导开发人员如何利用现有工具(如 Docker、Tilt、自定义脚本快速定位问题。
## 1. 系统现状 (System Status Snapshot)
截至 2025-11-20Fundamental Analysis 系统由多个微服务组成,采用 Docker Compose 编排,并通过 Tilt 进行开发环境的热重载管理。
### 1.1 服务概览
| 服务名称 | 职责 | 当前状态 | 关键依赖 |
| :--- | :--- | :--- | :--- |
| **API Gateway** | 流量入口,任务分发,服务发现 | 🟢 正常 | NATS, Providers |
| **Report Generator** | 接收指令,调用 LLM 生成报告 | 🟢 正常 (但在等待任务) | NATS, Data Persistence, LLM API |
| **Data Persistence** | 数据库读写配置管理Session 数据隔离 | 🟢 正常 (已恢复 Seeding) | Postgres |
| **Alphavantage** | 美股数据 Provider | 🟢 正常 | NATS, External API |
| **YFinance** | 雅虎财经 Provider | 🟢 正常 | NATS, External API |
| **Tushare** | A股数据 Provider | 🟢 正常 | NATS, External API |
| **Finnhub** | 市场数据 Provider | 🟡 **降级 (Degraded)** | 缺少 API Key 配置 |
### 1.2 核心问题:报告生成流程阻塞
目前用户在前端点击 "生成报告" 后无反应。
* **现象**API Gateway 未收到生成报告的请求Report Generator 未收到 NATS 消息。
* **原因推断**Finnhub Provider 因配置缺失处于 "Degraded" 状态,导致前端轮询的任务列表 (`GET /tasks/{id}`) 中始终包含未完成/失败的任务。前端逻辑可能因等待所有 Provider 完成而阻塞了后续 "Generate Report" 请求的发送。
---
## 2. 运维与开发流程 (DevOps & Workflow)
我们使用 **Tilt** 管理 Docker Compose 环境。这意味着你不需要手动 `docker-compose up/down` 来应用代码变更。
### 2.1 启动与更新
1. **启动环境**
在项目根目录运行:
```bash
tilt up
```
这会启动所有服务,并打开 Tilt UI (通常在 `http://localhost:10350`)。
2. **代码更新**
* 直接在 IDE 中修改代码并保存。
* **Tilt 会自动检测变更**
* 如果是前端代码Tilt 会触发前端热更新。
* 如果是 Rust 服务代码Tilt 会在容器内或宿主机触发增量编译并重启服务。
* **操作建议**:修改代码后,只需**等待一会儿**,观察 Tilt UI 变绿即可。无需手动重启容器。
3. **配置变更**
* 如果修改了 `docker-compose.yml``.env`Tilt 通常也会检测到并重建相关资源。
### 2.2 快速重置数据库 (如有必要)
如果遇到严重的数据不一致或认证问题,可使用以下命令重置数据库(**警告:数据将丢失,但会自动 Seed 默认模板**
```bash
docker-compose down postgres-db
docker volume rm fundamental_analysis_pgdata
docker-compose up -d postgres-db
# 等待几秒后
# Tilt 会自动重启依赖 DB 的服务,触发 Seeding
```
---
## 3. 调试与分析工具 (Debugging Tools)
为了快速诊断跨服务的问题,我们提供了一个能够聚合查看所有容器最新日志的脚本。
### 3.1 `inspect_logs.sh` 使用指南
该脚本位于 `scripts/inspect_logs.sh`。它能一次性输出所有关键服务的最后 N 行日志,避免手动切换容器查看。
* **基本用法** (默认显示最后 10 行)
```bash
./scripts/inspect_logs.sh
```
* **指定行数** (例如查看最后 50 行)
```bash
./scripts/inspect_logs.sh 50
```
### 3.2 分析策略
当遇到 "点击无反应" 或 "流程卡住" 时,请按以下步骤操作:
1. **运行脚本**`./scripts/inspect_logs.sh 20`
2. **检查 API Gateway**
* 是否有 `Received data fetch request` -> 如果无,说明前端没发请求。
* 是否有 `Publishing analysis generation command` -> 如果无,说明 Gateway 没收到生成指令,或者内部逻辑(如等待 Provider卡住了。
3. **检查 Provider**
* 是否有 `Degraded``Error` 日志?(如当前的 Finnhub 问题)
4. **检查 Report Generator**
* 是否有 `Received NATS command` -> 如果无,说明消息没发过来。
---
## 4. 当前待办与修复建议 (Action Items)
为了打通流程,我们需要解决 Finnhub 导致的阻塞问题。
1. **修复配置**
* 在 `config/data_sources.yaml` (或数据库 `configs` 表) 中配置有效的 Finnhub API Key。
* 或者,暂时在配置中**禁用** Finnhub (`enabled: false`),让前端忽略该 Provider。
2. **前端容错**
* 检查前端 `useReportEngine.ts`
* 确保即使某个 Provider 失败/超时,用户依然可以强制触发 "Generate Report"。
3. **验证**
* 使用 `inspect_logs.sh` 确认 Finnhub 不再报错,或已被跳过。
* 确认 API Gateway 日志中出现 `Publishing analysis generation command`

View File

@ -1,144 +0,0 @@
# 测试策略设计文档:基于 Docker 环境的组件测试与 Orchestrator 逻辑验证
> **文档使用说明**:
> 本文档不仅作为测试设计方案,也是测试实施过程中的**Living Document (活文档)**。
> 请参阅第 4 节 "执行状态追踪 (Execution Status Tracking)" 了解当前进度、Milestones 和 Pending Tasks。
> 在每次完成重要步骤后,请更新此文档的状态部分。
## 1. 策略概述 (Strategy Overview)
响应“无 Mock、全真实环境”的要求结合“Rustic 强类型”设计原则,我们将采用 **混合测试策略 (Hybrid Strategy)**
1. **I/O 密集型服务 (Providers & ReportGen)**: 采用 **基于 Docker Compose 的组件集成测试**
* 直接连接真实的 Postgres, NATS 和第三方 API (Alphavantage/LLM)。
* 验证“端到端”的功能可用性Key 是否有效、数据格式是否兼容)。
2. **逻辑密集型服务 (Orchestrator)**: 采用 **基于 Trait 的内存测试 (In-Memory Testing)**
* 通过 Trait 抽象外部依赖,使用简单的内存实现 (Fake) 替代真实服务。
* 实现毫秒级反馈,覆盖复杂的状态机跳转和边界条件。
---
## 2. 实施阶段 (Implementation Phases)
### Phase 1: 测试基础设施 (Infrastructure)
* **Docker Environment**: `docker-compose.test.yml`
* `postgres-test`: 端口 `5433:5432`
* `nats-test`: 端口 `4223:4222`
* `persistence-test`: 端口 `3001:3000` (Data Persistence Service 本身也视作基础设施的一部分)
* **Abstraction (Refactoring)**:
* 在 `workflow-orchestrator-service` 中定义 `WorkflowRepository``CommandPublisher` traits用于解耦逻辑测试。
### Phase 2: 微服务组件测试 (IO-Heavy Services)
**执行方式**: 宿主机运行 `cargo test`,环境变量指向 Phase 1 启动的 Docker 端口。
#### 1. Data Providers (数据源)
验证从 API 获取数据并存入系统的能力。
* **Alphavantage Provider**: (Key: `alphaventage_key`)
* Input: `FetchCompanyDataCommand`
* Assert: DB 中存入 SessionData (Profile/Financials)NATS 发出 `FinancialsPersistedEvent`
* **Tushare Provider**: (Key: `tushare_key`)
* Input: `FetchCompanyDataCommand` (CN Market)
* Assert: 同上。
* **Finnhub Provider**: (Key: `finnhub_key`)
* Input: `FetchCompanyDataCommand`
* Assert: 同上。
* **YFinance Provider**: (No Key)
* Input: `FetchCompanyDataCommand`
* Assert: 同上。
#### 2. Report Generator (报告生成器)
验证从 Persistence 读取数据并调用 LLM 生成报告的能力。
* **Key**: `openrouter_key` (Model: `google/gemini-flash-1.5` 或其他低成本模型)
* **Pre-condition**: 需要先往 Persistence (localhost:3001) 插入一些伪造的 SessionData (Financials/Price),否则 LLM 上下文为空。
* **Input**: `GenerateReportCommand`
* **Logic**:
1. Service 从 Persistence 读取数据。
2. Service 组装 Prompt 调用 OpenRouter API。
3. Service 将生成的 Markdown 存回 Persistence。
* **Assert**:
* NATS 收到 `ReportGeneratedEvent`
* Persistence 中能查到 `analysis_report` 类型的 SessionData且内容非空。
### Phase 3: Orchestrator 逻辑测试 (Logic-Heavy)
**执行方式**: 纯内存单元测试,无需 Docker。
* **Refactoring**: 将 Orchestrator 的核心逻辑 `WorkflowEngine` 修改为接受 `Box<dyn WorkflowRepository>``Box<dyn CommandPublisher>`
* **Test Suite**:
* **DAG Construction**: 给定不同 Template ID验证生成的 DAG 结构(依赖关系)是否正确。
* **State Transition**:
* Scenario 1: Happy Path (所有 Task 成功 -> Workflow 完成)。
* Scenario 2: Dependency Failure (上游失败 -> 下游 Skipped)。
* Scenario 3: Resume (模拟服务重启,从 Repository 加载状态并继续)。
* **Policy Check**: 验证 "At least one provider" 策略是否生效。
### Phase 4: 全链路验收测试 (E2E)
**执行方式**: `scripts/run_e2e.sh` (Docker + Rust Test Runner)
* **配置策略**:
* 动态注入测试配置 (`setup_test_environment`):
* 注册 `simple_test_analysis` 模板。
* 配置 LLM Provider (`openrouter`/`new_api`) 使用 `google/gemini-2.5-flash-lite`
* **超时控制**:
* SSE 连接监听设置 60秒硬性超时防止长连接假死。
* **Scenarios**:
* **Scenario A (Happy Path)**: 使用 `simple_test_analysis` 模板完整运行。
* **Scenario B (Recovery)**: 模拟 Orchestrator 重启,验证状态恢复。 (SKIPPED: Requires DB Persistence)
* **Scenario C (Partial Failure)**: 模拟非关键 Provider (Tushare) 故障,验证工作流不受影响。
* **Scenario D (Invalid Input)**: 使用无效 Symbol验证错误传播和快速失败。
* **Scenario E (Module Failure)**: 模拟 Analysis 模块内部错误(如配置错误),验证工作流终止。
* **Status**: ✅ Completed (2025-11-21)
---
## 3. 执行计划 (Action Plan)
1. **Environment**: 创建 `docker-compose.test.yml` 和控制脚本。 ✅
2. **Providers Test**: 编写 4 个 Data Provider 的集成测试。 ✅
3. **ReportGen Test**: 编写 Report Generator 的集成测试(含数据预埋逻辑)。 ✅
4. **Orchestrator Refactor**: 引入 Traits 并编写内存测试。 ✅
5. **Final Verification**: 运行全套测试。 ✅
---
## 4. 执行状态追踪 (Execution Status Tracking)
### 当前状态 (Current Status)
* **日期**: 2025-11-21
* **阶段**: Phase 4 - E2E Testing Completed
* **最近活动**:
* 修复了测试模板配置错误导致 Scenario A 超时的问题。
* 修复了 Orchestrator 错误广播 Analysis 失败导致 Scenario C 误判的问题。
* 完整验证了 Scenario A, C, D, E。
* 暂时跳过 Scenario B (待持久化层就绪后启用)。
### 历史记录 (Milestones)
| 日期 | 阶段 | 事件/变更 | 状态 |
| :--- | :--- | :--- | :--- |
| 2025-11-20 | Planning | 完成测试策略文档编写,确定混合测试方案。 | ✅ Completed |
| 2025-11-20 | Phase 1 | 创建 `docker-compose.test.yml` 和基础设施。 | ✅ Completed |
| 2025-11-20 | Phase 2 | 完成 Data Providers 集成测试代码。 | ✅ Completed |
| 2025-11-20 | Phase 2 | 完成 Report Generator 集成测试代码。 | ✅ Completed |
| 2025-11-20 | Phase 3 | 完成 Orchestrator 重构与内存测试。 | ✅ Completed |
| 2025-11-21 | Phase 4 | 修复 SSE 超时问题,增加动态配置注入。 | ✅ Completed |
| 2025-11-21 | Phase 4 | 实现并验证异常场景 (Partial Failure, Invalid Input, Module Error)。 | ✅ Completed |
### 待处理项 (Next Steps)
- [ ] **Persistence**: 为 Orchestrator 引入 Postgres 存储,启用 Scenario B。
- [ ] **CI Integration**: 将 `run_e2e.sh` 集成到 CI 流水线。
## 5. 未来展望 (Future Outlook)
随着系统演进,建议增加以下测试场景:
1. **Network Resilience (网络分区)**:
* 使用 `toxiproxy` 或 Docker Network 操作模拟网络中断。
* 验证服务的重试机制 (Retry Policy) 和幂等性。
2. **Concurrency & Load (并发与负载)**:
* 同时启动 10+ 个工作流,验证 Orchestrator 调度和 Provider 吞吐量。
* 验证 Rate Limiting 是否生效(避免被上游 API 封禁)。
3. **Long-Running Workflows (长流程)**:
* 测试包含数十个步骤、运行时间超过 5 分钟的复杂模板。
* 验证 SSE 连接保活和超时处理。
4. **Data Integrity (数据一致性)**:
* 验证 Fetch -> Persistence -> Report Gen 链路中的数据精度(小数位、时区)。

View File

@ -1,68 +0,0 @@
# 后端 API 就绪性与接口验证报告
**日期**: 2025-11-21
**状态**: ✅ Backend Ready for Frontend Integration (全链路通过)
**作者**: AI Assistant
## 1. 概述
本报告总结了对 Fundamental Analysis System 后端进行的全面 API 级端到端测试结果。
我们通过 CURL 脚本完全模拟了前端的用户行为配置加载、工作流触发、SSE 事件监听、数据回读),验证了后端的契约实现和稳定性。
测试表明,后端核心功能已经就绪,前端可以开始进行对接和调试。所有关键数据源接口(包括此前不稳定的 Profile 获取)均已修复并验证通过。
## 2. 测试结果摘要
| 测试项 | 描述 | 结果 | 备注 |
| :--- | :--- | :--- | :--- |
| **System Health** | API Gateway 健康检查 | ✅ PASS | HTTP 200 |
| **Configuration** | LLM Providers & Templates 配置读取 | ✅ PASS | 成功加载配置 |
| **Workflow Core** | 启动工作流 -> 任务调度 -> 完成 | ✅ PASS | 无超时,无卡死 |
| **SSE Streaming** | 实时事件推送 (Started, TaskUpdate, Completed) | ✅ PASS | 前端进度条可正常驱动 |
| **LLM Integration** | 提示词组装 -> 调用 OpenRouter -> 生成报告 | ✅ PASS | **已修复 64K Context 限制问题** |
| **Data Persistence** | 分析报告 (AnalysisResult) 入库 | ✅ PASS | 最终结果可查 |
| **Data Fetching** | 财务数据 (Financials) 入库 | ✅ PASS | 成功拉取并解析数据 |
| **Company Profile** | 公司基本信息入库 | ✅ PASS | **已修复并发限流问题** |
## 3. 关键修复与改进
在验证过程中,我们发现并修复了以下阻碍性问题:
1. **Docker 网络与端口暴露**:
* 修改 `docker-compose.yml`,暴露 `api-gateway``4000` 端口。
* 修改 `frontend/next.config.mjs`,支持动态配置后端地址 (`API_GATEWAY_URL`)。
2. **LLM Context 溢出保护**:
* 发现 `report-generator-service` 在处理大量财务数据时可能生成超过 LLM 上下文限制的 Prompt。
* **修复**: 实施了 **64K 字符硬截断** 策略。如果 Prompt 过长,会自动截断并附加系统警告,确保 LLM 请求永远不会因为 Payload 过大而超时或被拒。
3. **AlphaVantage 数据源稳定性 (Profile 404 修复)**:
* **现象**: 免费版 Key 存在 5次/分钟 的 API 速率限制,并发请求导致 Profile 接口频繁失败。
* **修复**: 重构了 `alphavantage-provider-service` 的 Worker 逻辑,将并发请求改为 **串行执行**,并在每个请求间增加了 **2秒强制延迟**。同时引入了显式的错误检查机制("Early Fail"),确保不会静默吞掉 API 错误。验证证实现在可以稳定获取 `CompanyProfile`
4. **测试脚本竞态条件**:
* 优化了 E2E 测试脚本,解决了 SSE 连接建立与工作流启动之间的微小时序问题,确保能稳定捕获所有事件。
## 4. 工具与资源
### 4.1 调试工具 (Baseline Script)
我们交付了一个强大的 API 测试脚本,可用作未来的回归测试基准:
* 路径: `tests/api-e2e/run_api_test.sh`
* 用法: `./tests/api-e2e/run_api_test.sh http://localhost:4000/v1`
## 5. 下一步 (前端对接指南)
前端开发环境已准备就绪。您可以直接启动前端进行联调:
1. **确保后端运行**: `tilt up``docker-compose up -d`
2. **启动前端**:
```bash
cd frontend
# 指向本地暴露的 4000 端口
export API_GATEWAY_URL=http://localhost:4000
npm run dev
```
3. **验证**: 打开浏览器访问 `http://localhost:3000`,尝试输入 "AAPL" 或 "IBM" 进行分析。
---
**结论**: 后端 API 契约稳定,逻辑闭环,数据源集成问题已解决,已完全具备与前端集成的条件。

View File

@ -1,96 +0,0 @@
# Phase 4: End-to-End (E2E) 测试计划与执行方案
## 1. 测试目标
本次 E2E 测试旨在验证系统在“全链路真实环境”下的行为,涵盖**正常流程**、**异常恢复**及**组件动态插拔**场景。不涉及前端 UI而是通过模拟 HTTP/SSE 客户端直接与后端交互。
核心验证点:
1. **业务闭环**: 从 `POST /start` 到 SSE 接收 `WorkflowCompleted` 再到最终报告生成。
2. **状态一致性**: Orchestrator 重启后,能否通过 `SyncStateCommand` 恢复上下文并继续执行。
3. **容错机制**: 当部分 Data Provider 下线时,策略引擎是否按预期工作(如 "At least one provider")。
4. **并发稳定性**: 多个 Workflow 同时运行时互不干扰。
## 2. 测试环境架构
测试运行器 (`end-to-end` Rust Crate) 将作为外部观察者和控制器。
```mermaid
graph TD
TestRunner[Rust E2E Runner] -->|HTTP/SSE| Gateway[API Gateway]
TestRunner -->|Docker API| Docker[Docker Engine]
subgraph "Docker Compose Stack"
Gateway --> Orchestrator
Orchestrator --> NATS
NATS --> Providers
NATS --> ReportGen
Providers --> Postgres
end
Docker -.->|Stop/Start| Orchestrator
Docker -.->|Stop/Start| Providers
```
## 3. 详细测试场景 (Scenarios)
### Scenario A: The Happy Path (基准测试)
* **目标**: 验证标准流程无误。
* **步骤**:
1. 发送 `POST /api/v2/workflow/start` (Symbol: AAPL/000001.SZ)。
2. 建立 SSE 连接监听 `events.workflow.{id}`
3. 验证接收到的事件序列:
* `WorkflowStarted` (含完整 DAG)
* `TaskStateChanged` (Pending -> Running -> Completed)
* `TaskStreamUpdate` (Report 内容流式传输)
* `WorkflowCompleted`
4. **断言**: 最终报告内容非空,数据库中存在 Analysis 记录。
### Scenario B: Brain Transplant (Orchestrator 宕机恢复)
* **目标**: 验证 Orchestrator 的状态持久化与快照恢复能力。
* **步骤**:
1. 启动 Workflow。
2. 等待至少一个 Data Fetch Task 完成 (Receiving `TaskCompleted`)。
3. **Action**: `docker stop workflow-orchestrator-service`
4. 等待 5 秒,**Action**: `docker start workflow-orchestrator-service`
5. Test Runner 重新建立 SSE 连接 (自动触发 `SyncStateCommand`)。
6. **断言**:
* 收到 `WorkflowStateSnapshot` 事件。
* 快照中已完成的任务状态保持 `Completed`
* 流程继续向下执行,直到最终完成。
### Scenario C: Partial Failure (组件拔插)
* **目标**: 验证 "At least one provider" 容错策略。
* **步骤**:
1. **Action**: `docker stop tushare-provider-service` (模拟 Tushare 挂掉)。
2. 启动 Workflow (Symbol: 000001.SZ需涉及 Tushare)。
3. **断言**:
* Tushare 对应的 Task 状态变为 `Failed``Skipped`
* 由于还有其他 Provider (或模拟数据)Orchestrator 判定满足 "At least one" 策略。
* 下游 Analysis Task **正常启动** (而不是被 Block)。
* 流程最终显示 `WorkflowCompleted` (可能带有 Warning)。
4. **Cleanup**: `docker start tushare-provider-service`
### Scenario D: Network Jitter (网络中断模拟)
* **目标**: 验证 Gateway 到 Orchestrator 通讯中断后的恢复。
* **步骤**:
1. 启动 Workflow。
2. Test Runner 主动断开 SSE 连接。
3. 等待 10 秒。
4. Test Runner 重连 SSE。
5. **断言**: 立即收到 `WorkflowStateSnapshot`,且补齐了断连期间产生的状态变更。
## 4. 工程实现 (Rustic Implementation)
新建独立 Rust Crate `tests/end-to-end`,不依赖现有 workspace 的构建配置,独立编译运行。
**依赖栈**:
* `reqwest`: HTTP Client
* `eventsource-stream` + `futures`: SSE Handling
* `bollard`: Docker Control API
* `tokio`: Async Runtime
* `anyhow`: Error Handling
* `serde`: JSON Parsing
**执行方式**:
```bash
# 在 tests/end-to-end 目录下
cargo run -- --target-env test
```

View File

@ -1,132 +0,0 @@
# NATS Subject 强类型重构设计文档
## 1. 背景与现状 (Background & Status Quo)
目前,项目中微服务之间的 NATS 消息通信主要依赖于硬编码的字符串String Literals来指定 Subject主题。例如
- `services/report-generator-service` 使用 `"events.analysis.report_generated"` 发布消息。
- `services/workflow-orchestrator-service` 使用 `"events.analysis.>"` 订阅消息,并使用字符串匹配 `if subject == "events.analysis.report_generated"` 来区分消息类型。
这种方式存在以下问题:
1. **弱类型约束**字符串拼接容易出现拼写错误Typos且无法在编译期捕获只能在运行时发现违反了 "Fail Early" 原则。
2. **维护困难**Subject 散落在各个服务的代码中缺乏统一视图Single Source of Truth修改一个 Subject 需要全局搜索并小心替换。
3. **缺乏契约**Subject 与 Payload消息体之间的对应关系仅通过注释或隐式约定存在缺乏代码层面的强制约束。
## 2. 目的 (Objectives)
本设计旨在贯彻 Rustic 的工程原则(强类型约束、单一来源、早失败、无回退),通过以下方式重构 NATS Subject 的管理:
1. **强类型枚举 (Enum-driven Subjects)**:在 `common-contracts` 中定义全局唯一的枚举类型,涵盖系统中所有合法的 NATS Subject。
2. **消除魔法字符串**:禁止在业务逻辑中直接使用字符串字面量进行 publish 或 subscribe 操作。
3. **编译期安全**:利用 Rust 的类型系统,确保 Subject 的构造和匹配是合法的。
## 3. 设计方案 (Design Proposal)
### 3.1 核心数据结构 (`common-contracts`)
`services/common-contracts/src/subjects.rs` 中定义 `NatsSubject` 枚举。该枚举涵盖系统中所有合法的 NATS Subject。
```rust
use uuid::Uuid;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NatsSubject {
// --- Commands ---
WorkflowCommandStart, // "workflow.commands.start"
WorkflowCommandSyncState, // "workflow.commands.sync_state"
DataFetchCommands, // "data_fetch_commands"
AnalysisCommandGenerateReport, // "analysis.commands.generate_report"
// --- Events ---
// Analysis Events
AnalysisReportGenerated, // "events.analysis.report_generated"
AnalysisReportFailed, // "events.analysis.report_failed"
// Data Events
DataFinancialsPersisted, // "events.data.financials_persisted"
DataFetchFailed, // "events.data.fetch_failed"
// Workflow Events (Dynamic)
WorkflowProgress(Uuid), // "events.workflow.{uuid}"
// --- Wildcards (For Subscription) ---
AnalysisEventsWildcard, // "events.analysis.>"
WorkflowCommandsWildcard, // "workflow.commands.>"
DataEventsWildcard, // "events.data.>"
}
// ... impl Display and FromStr ...
```
### 3.2 使用方式
#### 发布消息 (Publish)
```rust
// Old
state.nats.publish("events.analysis.report_generated", payload).await?;
// New
use common_contracts::subjects::NatsSubject;
state.nats.publish(NatsSubject::AnalysisReportGenerated.to_string(), payload).await?;
```
#### 订阅与匹配 (Subscribe & Match)
```rust
// Old
let sub = nats.subscribe("events.analysis.>").await?;
while let Some(msg) = sub.next().await {
if msg.subject == "events.analysis.report_generated" { ... }
}
// New
let sub = nats.subscribe(NatsSubject::AnalysisEventsWildcard.to_string()).await?;
while let Some(msg) = sub.next().await {
// 将接收到的 subject 字符串尝试转换为枚举
match NatsSubject::try_from(msg.subject.as_str()) {
Ok(NatsSubject::AnalysisReportGenerated) => {
// Handle report generated
},
Ok(NatsSubject::AnalysisReportFailed) => {
// Handle report failed
},
_ => {
// Log warning or ignore
}
}
}
```
## 4. 实施状态 (Implementation Status)
### 4.1 `common-contracts`
- [x] 定义 `NatsSubject` 枚举及相关 Trait (`Display`, `FromStr`) 在 `src/subjects.rs`
- [x] 添加单元测试确保 Round-trip 正确性。
### 4.2 `report-generator-service`
- [x] `src/worker.rs`: 替换 Publish Subject。
### 4.3 `workflow-orchestrator-service`
- [x] `src/message_consumer.rs`: 替换 Subscribe Subject 和 Match 逻辑。
### 4.4 `api-gateway`
- [x] `src/api.rs`: 替换 Publish Subject。
### 4.5 Provider Services
- [x] `finnhub-provider-service`: 替换 Subscribe Subject移除魔法字符串常量。
- [x] `alphavantage-provider-service`: 替换 Subscribe Subject移除魔法字符串常量。
- [x] `tushare-provider-service`: 替换 Subscribe Subject移除魔法字符串常量。
- [x] `yfinance-provider-service`: 替换 Subscribe Subject移除魔法字符串常量。
## 5. 进阶优化 (Future Work)
- [x] **关联 Payload 类型**: 利用 Rust 的 trait 系统,将 Subject 枚举与对应的 Payload 结构体关联起来,使得 `publish` 函数能够根据 Subject 自动推断 Payload 类型,从而防止 Subject 与 Payload 不匹配的问题。
```rust
trait SubjectMessage {
// type Payload: Serialize + DeserializeOwned; // Simplified: trait is implemented on Payload struct itself
fn subject(&self) -> NatsSubject;
}
```
已在 `services/common-contracts/src/subjects.rs` 中实现 `SubjectMessage` trait并在 `messages.rs` 中为各个 Command/Event 实现了该 trait。各服务已更新为使用 `msg.subject().to_string()` 进行发布。

View File

@ -1,130 +0,0 @@
# 重构任务:动态数据提供商配置架构 (Dynamic Data Provider Configuration)
## 1. 背景与目标 (Background & Objective)
目前系统的前端页面 (`DataSourceTab`) 硬编码了支持的数据源列表Tushare, Finnhub 等)及其配置表单。这导致每增加一个新的数据源,都需要修改前端代码,违反了“单一来源”和“开闭原则”。
本次重构的目标是实现 **“前端无知 (Frontend Agnostic)”** 的架构:
1. **后端驱动**:各 Provider 服务在启动注册时,声明自己的元数据(名称、描述)和配置规范(需要哪些字段)。
2. **动态渲染**:前端通过 Gateway 获取所有已注册服务的元数据,动态生成配置表单。
3. **通用交互**:测试连接、保存配置等操作通过统一的接口进行,不再针对特定 Provider 编写逻辑。
## 2. 核心数据结构设计 (Core Data Structures)
我们在 `common-contracts` 中扩展了服务注册的定义。
### 2.1 配置字段定义 (Config Field Definition)
不使用复杂的通用 JSON Schema而是定义一套符合我们 UI 需求的强类型 Schema。
```rust
/// 字段类型枚举
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub enum FieldType {
Text, // 普通文本
Password, // 密码/Token (前端掩码显示)
Url, // URL 地址
Boolean, // 开关
Select, // 下拉选框 (需要 options)
}
/// 单个配置字段的定义
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ConfigFieldSchema {
pub key: String, // 字段键名 (如 "api_key")
pub label: String, // 显示名称 (如 "API Token")
pub field_type: FieldType,// 字段类型
pub required: bool, // 是否必填
pub placeholder: Option<String>, // 占位符
pub default_value: Option<String>, // 默认值
pub description: Option<String>, // 字段说明
}
```
### 2.2 服务元数据扩展 (Service Metadata Extension)
修改 `ServiceRegistration` 并增加了 `ProviderMetadata` 结构。
```rust
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProviderMetadata {
pub id: String, // 服务ID (如 "tushare")
pub name_en: String, // 英文名 (如 "Tushare Pro")
pub name_cn: String, // 中文名 (如 "Tushare Pro (中国股市)")
pub description: String, // 描述
pub icon_url: Option<String>, // 图标 (可选)
/// 该服务需要的配置字段列表
pub config_schema: Vec<ConfigFieldSchema>,
/// 是否支持“测试连接”功能
pub supports_test_connection: bool,
}
```
## 3. 架构交互流程 (Architecture Flow)
### 3.1 服务注册 (Registration Phase)
1. **Provider 启动** (如 `tushare-provider-service`):
* 构建 `ProviderMetadata`,声明自己需要 `api_key` (必填, Password) 和 `api_url` (选填, Url, 默认值...)。
* 调用 API Gateway 的 `/register` 接口,将 Metadata 发送给 Gateway。
2. **API Gateway**:
* 在内存 Registry 中存储这些 Metadata。
### 3.2 前端渲染 (Rendering Phase)
1. **前端请求**: `GET /v1/registry/providers` (网关聚合接口)。
2. **网关响应**: 返回所有活跃 Provider 的 `ProviderMetadata` 列表。
3. **前端请求**: `GET /v1/configs/data_sources` (获取用户已保存的配置值)。
4. **UI 生成**:
* 前端遍历 Metadata 列表。
* 对每个 Provider根据 `config_schema` 动态生成 Input/Switch 组件。
* 将已保存的配置值填充到表单中。
### 3.3 动作执行 (Action Phase)
1. **保存 (Save)**:
* 前端收集表单数据,发送标准 JSON 到 `POST /v1/configs/data_sources`
* 后端持久化服务保存数据。
2. **测试连接 (Test Connection)**:
* 前端发送 `POST /v1/configs/test` (注意Gateway已有通用接口)。
* Payload: `{ type: "tushare", data: { "api_key": "..." } }`
* 网关将请求路由到对应的 Provider 微服务进行自我检查。
## 4. 任务拆解 (Task Breakdown)
### 4.1 后端开发 (Backend Development) - [已完成/Completed]
1. **Common Contracts**: 更新 `registry.rs`,添加 `ProviderMetadata``ConfigFieldSchema` 定义。 (Done)
2. **API Gateway**:
* 更新注册逻辑,接收并存储 Metadata。 (Done)
* 新增接口 `GET /v1/registry/providers` 供前端获取元数据。 (Done)
* **移除旧版接口**: 已删除 Legacy 的静态 schema 接口,强制转向动态机制。 (Done)
* **单元测试**: 实现了 `ServiceRegistry` 的单元测试 (`registry_unit_test.rs`),覆盖注册、心跳、发现、注销等核心逻辑。 (Done)
3. **Provider Services** (Tushare, Finnhub, etc.):
* 实现 `ConfigSchema` 的构建逻辑。 (Done)
* 更新注册调用,发送 Schema。 (Done for Tushare, Finnhub, AlphaVantage, YFinance)
### 4.2 后端验证测试 (Backend Verification) - [已完成/Completed]
在开始前端重构前,已进行一次集成测试,确保后端服务启动后能正确交互。
1. **启动服务**: 使用 `tests/api-e2e/run_registry_test.sh` 启动最小化服务集。
2. **API 验证**:
* 调用 `GET /v1/registry/providers`,验证返回的 JSON 是否包含所有 Provider 的 Metadata 和 Schema。
* 使用 `tests/api-e2e/registry_verifier.py` 脚本验证了 Tushare 的 Schema 字段正确性。
3. **结果确认**:
* ✅ Tushare 服务成功注册。
* ✅ Schema 正确包含 `api_token` (Password, Required)。
* ✅ E2E 测试集 (`tests/end-to-end`) 也已更新并验证通过。
### 4.3 前端重构 (Frontend Refactor) - [待办/Pending]
1. **API Client**: 更新 Client SDK 以支持新的元数据接口。
* **自动化更新脚本**: `scripts/update_api_spec.sh`
* **执行逻辑**:
1. 调用 `cargo test ... generate_openapi_json` 生成 `openapi.json`
2. 调用 `npm run gen:api` 重新生成前端 TypeScript 类型定义。
* **输出位置**: `frontend/src/api/schema.gen.ts`
2. **State Management**: 修改 `useConfig` hook增加获取 Provider Metadata 的逻辑。
3. **UI Refactor**:
* 废弃 `supportedProviders` 硬编码。
* 创建 `DynamicConfigForm` 组件,根据 `ConfigFieldSchema` 渲染界面。
* 对接新的测试连接和保存逻辑。

View File

@ -1,89 +0,0 @@
# 重构任务:统一 Data Provider 工作流抽象
## 1. 背景 (Background)
目前的 Data Provider 服务Tushare, YFinance, AlphaVantage 等)在架构上存在严重的重复代码和实现不一致问题。每个服务都独立实现了完整的工作流,包括:
- NATS 消息接收与反序列化
- 缓存检查与写入 (Cache-Aside Pattern)
- 任务状态管理 (Observability/Task Progress)
- Session Data 持久化
- NATS 事件发布 (Success/Failure events)
这种"散弹式"架构导致了以下问题:
1. **Bug 易发且难以统一修复**:例如 Tushare 服务因未执行 NATS Flush 导致事件丢失,而 YFinance 却因为实现方式不同而没有此问题。修复一个 Bug 需要在每个服务中重复操作。
2. **逻辑不一致**:不同 Provider 对缓存策略、错误处理、重试机制的实现可能存在细微差异,违背了系统的统一性。
3. **维护成本高**:新增一个 Provider 需要复制粘贴大量基础设施代码Boilerplate容易出错。
## 2. 目标 (Objectives)
贯彻 "Rustic" 的设计理念强类型、单一来源、早失败通过控制反转IoC和模板方法模式将**业务逻辑**与**基础设施逻辑**彻底分离。
- **单一来源 (Single Source of Truth)**:工作流的核心逻辑(缓存、持久化、通知)只在一个地方定义和维护。
- **降低耦合**:具体 Provider 只需关注 "如何从 API 获取数据",而无需关心 "如何与系统交互"。
- **提升稳定性**:统一修复基础设施层面的问题(如 NATS Flush所有 Provider 自动受益。
## 3. 技术方案 (Technical Design)
### 3.1 核心抽象 (The Trait)
`common-contracts` 中定义纯粹的业务逻辑接口:
```rust
#[async_trait]
pub trait DataProviderLogic: Send + Sync {
/// Provider 的唯一标识符 (e.g., "tushare", "yfinance")
fn provider_id(&self) -> &str;
/// 检查是否支持该市场 (前置检查)
fn supports_market(&self, market: &str) -> bool {
true
}
/// 核心业务:从外部源获取原始数据并转换为标准 DTO
/// 不涉及任何 DB 或 NATS 操作
async fn fetch_data(&self, symbol: &str) -> Result<(CompanyProfileDto, Vec<TimeSeriesFinancialDto>), anyhow::Error>;
}
```
### 3.2 通用工作流引擎 (The Engine)
实现一个泛型结构体或函数 `StandardFetchWorkflow`,封装所有基础设施逻辑:
1. **接收指令**:解析 `FetchCompanyDataCommand`
2. **前置检查**:调用 `supports_market`
3. **状态更新**:向 `AppState` 写入 "InProgress"。
4. **缓存层**
* 检查 `persistence_client` 缓存。
* HIT -> 直接返回。
* MISS -> 调用 `fetch_data`,然后写入缓存。
5. **持久化层**:将结果写入 `SessionData`
6. **事件通知**
* 构建 `FinancialsPersistedEvent`
* 发布 NATS 消息。
* **关键:执行 `flush().await`。**
7. **错误处理**:统一捕获错误,发布 `DataFetchFailedEvent`,更新 Task 状态为 Failed。
## 4. 执行步骤 (Execution Plan)
1. **基础设施准备**
* 在 `services/common-contracts` 中添加 `DataProviderLogic` trait。
* 在 `services/common-contracts` (或新建 `service-kit` 模块) 中实现 `StandardFetchWorkflow`
2. **重构 Tushare Service**
* 创建 `TushareFetcher` 实现 `DataProviderLogic`
* 删除 `worker.rs` 中的冗余代码,替换为对 `StandardFetchWorkflow` 的调用。
* 验证 NATS Flush 问题是否自然解决。
3. **重构 YFinance Service**
* 同样方式重构,验证通用性。
4. **验证**
* 运行 E2E 测试,确保数据获取流程依然通畅。
## 5. 验收标准 (Acceptance Criteria)
- `common-contracts` 中包含清晰的 Trait 定义。
- Tushare 和 YFinance 的 `worker.rs` 代码量显著减少(预计减少 60%+)。
- 所有 Provider 的行为(日志格式、状态更新频率、缓存行为)完全一致。
- 即使不手动写 `flush`,重构后的 Provider 也能可靠发送 NATS 消息。

View File

@ -1,97 +0,0 @@
# 设计方案 0: 系统总览与开发指南 (Overview & Setup)
## 1. 项目背景 (Context)
本项目是一个 **金融基本面分析系统 (Fundamental Analysis System)**
目标是通过自动化的工作流从多个数据源Tushare, YFinance抓取数据经过处理最终生成一份包含估值、风险分析的综合报告。
本次重构旨在解决原系统调度逻辑僵化、上下文管理混乱、大文件支持差的问题。我们将构建一个基于 **Git 内核** 的分布式上下文管理系统。
## 2. 系统架构 (Architecture)
系统由四个核心模块组成:
1. **VGCS (Virtual Git Context System)**: 底层存储。基于 Git + Blob Store 的版本化文件系统。
2. **DocOS (Document Object System)**: 逻辑层。提供“文档树”的裂变、聚合操作,屏蔽底层文件细节。
3. **Worker Runtime**: 适配层。提供 SDK 给业务 WorkerPython/Rust使其能读写上下文。
4. **Orchestrator**: 调度层。负责任务分发、依赖管理和并行结果合并。
```mermaid
graph TD
subgraph "Storage Layer (Shared Volume)"
Repo[Git Bare Repo]
Blob[Blob Store]
end
subgraph "Library Layer (Rust Crate)"
VGCS[VGCS Lib] --> Repo & Blob
DocOS[DocOS Lib] --> VGCS
end
subgraph "Execution Layer"
Orch[Orchestrator Service] --> DocOS
Worker[Python/Rust Worker] --> Runtime[Worker Runtime SDK]
Runtime --> DocOS
end
Orch -- NATS (RPC) --> Worker
```
## 3. 技术栈规范 (Tech Stack)
### 3.1 Rust (VGCS, DocOS, Orchestrator)
* **Version**: Stable (1.75+)
* **Key Crates**:
* `git2` (0.18): libgit2 bindings. **注意**: 默认开启 `vendored-openssl` feature 以确保静态链接。
* `serde`, `serde_json`: 序列化。
* `thiserror`, `anyhow`: 错误处理。
* `async-nats` (0.33): 消息队列。
* `tokio` (1.0+): 异步运行时。
* `sha2`, `hex`: 哈希计算。
### 3.2 Python (Worker Runtime)
* **Version**: 3.10+
* **Package Manager**: Poetry
* **Key Libs**:
* `nats-py`: NATS Client.
* `pydantic`: 数据验证。
* (可选) `libgit2-python`: 如果需要高性能 Git 操作,否则通过 FFI 调用 Rust Lib 或 Shell Out。
## 4. 开发环境搭建 (Dev Setup)
### 4.1 目录准备
在本地开发机上,我们需要模拟共享存储。
```bash
mkdir -p /tmp/workflow_dev/repos
mkdir -p /tmp/workflow_dev/blobs
export WORKFLOW_DATA_PATH=/tmp/workflow_dev
```
### 4.2 Docker 环境
为了确保一致性,所有服务应在 Docker Compose 中运行,挂载同一个 Volume。
```yaml
volumes:
workflow_data:
services:
orchestrator:
image: rust-builder
volumes:
- workflow_data:/mnt/workflow_data
environment:
- WORKFLOW_DATA_PATH=/mnt/workflow_data
```
## 5. 模块集成方式
* **VGCS & DocOS**: 将实现为一个独立的 Rust Workspace Member (`crates/workflow-context`)。
* **Orchestrator**: 引用该 Crate。
* **Worker (Rust)**: 引用该 Crate。
* **Worker (Python)**: 通过 `PyO3` 绑定该 Crate或者MVP阶段重写部分逻辑/Shell调用。**建议 MVP 阶段 Python SDK 仅封装 git binary 调用以简化构建**。
## 6. 新人上手路径
1. 阅读 `design_1_vgcs.md`,理解如何在不 clone 的情况下读写 git object。
2. 阅读 `design_2_doc_os.md`,理解“文件变目录”的逻辑。
3. 实现 Rust Crate `workflow-context` (包含 VGCS + DocOS)。
4. 编写单元测试,验证 File -> Blob 的分流逻辑。
5. 集成到 Orchestrator。

View File

@ -1,150 +0,0 @@
# 设计方案 1: VGCS (Virtual Git Context System) [详细设计版]
## 1. 定位与目标
VGCS 是底层存储引擎,提供版本化、高性能、支持大文件的分布式文件系统接口。
它基于 **git2-rs (libgit2)** 实现,直接操作 Git Object DB。
## 2. 物理存储规范 (Specification)
所有服务共享挂载卷 `/mnt/workflow_data`
### 2.1 目录结构
```text
/mnt/workflow_data/
├── repos/
│ └── {request_id}.git/ <-- Bare Git Repo
│ ├── HEAD
│ ├── config
│ ├── objects/ <-- Standard Git Objects
│ └── refs/
└── blobs/
└── {request_id}/ <-- Blob Store Root
├── ab/
│ └── ab1234... <-- Raw File (SHA-256 of content)
└── cd/
└── cd5678...
```
### 2.2 Blob 引用文件格式 (.ref)
当文件 > 1MB 时Git Blob 存储如下 JSON 内容:
```json
{
"$vgcs_ref": "v1",
"sha256": "ab1234567890...",
"size": 10485760,
"mime_type": "application/json",
"original_name": "data.json"
}
```
## 3. 核心接口定义 (Rust Trait)
### 3.1 ContextStore Trait
```rust
use anyhow::Result;
use std::io::Read;
pub trait ContextStore {
/// 初始化仓库
fn init_repo(&self, req_id: &str) -> Result<()>;
/// 读取文件内容
/// 自动逻辑:读取 Git Blob -> 解析 JSON -> 如果是 Ref读 Blob Store否则直接返回
fn read_file(&self, req_id: &str, commit_hash: &str, path: &str) -> Result<Box<dyn Read>>;
/// 读取目录
fn list_dir(&self, req_id: &str, commit_hash: &str, path: &str) -> Result<Vec<DirEntry>>;
/// 获取变更
fn diff(&self, req_id: &str, from_commit: &str, to_commit: &str) -> Result<Vec<FileChange>>;
/// 三路合并 (In-Memory)
/// 返回新生成的 Tree OID不生成 Commit
fn merge_trees(&self, req_id: &str, base: &str, ours: &str, theirs: &str) -> Result<String>;
/// 创建写事务
fn begin_transaction(&self, req_id: &str, base_commit: &str) -> Result<Box<dyn Transaction>>;
}
#[derive(Debug)]
pub struct DirEntry {
pub name: String,
pub kind: EntryKind, // File | Dir
pub object_id: String,
}
#[derive(Debug)]
pub enum FileChange {
Added(String),
Modified(String),
Deleted(String),
}
```
### 3.2 Transaction Trait (写操作)
```rust
pub trait Transaction {
/// 写入文件
/// 内部逻辑:
/// 1. 计算 content SHA-256
/// 2. if size > 1MB: 写 Blob Store构造 Ref JSON
/// 3. 写 Git Blob (Raw 或 Ref)
/// 4. 更新内存中的 Index/TreeBuilder
fn write(&mut self, path: &str, content: &[u8]) -> Result<()>;
/// 删除文件
fn remove(&mut self, path: &str) -> Result<()>;
/// 提交更改
/// 1. Write Tree Object
/// 2. Create Commit Object (Parent = base_commit)
/// 3. Return new Commit Hash
fn commit(self, message: &str, author: &str) -> Result<String>;
}
```
## 4. 实现细节规范
### 4.1 读操作流程 (read_file)
1. Open Repo: `/mnt/workflow_data/repos/{req_id}.git`
2. Locate Tree: Parse `commit_hash` -> `tree_id`.
3. Find Entry: 在 Tree 中查找 `path`.
4. Read Blob: 读取 Git Blob 内容。
5. **Check Ref**: 尝试解析为 `BlobRef` 结构。
* **Success**: 构建 Blob Store 路径 `/mnt/workflow_data/blobs/{req_id}/{sha256[0..2]}/{sha256}`,打开文件流。
* **Fail** (说明是普通小文件): 返回 `Cursor::new(blob_content)`.
### 4.2 写操作流程 (write)
1. Check Size: `content.len()`.
2. **Large File Path**:
* Calc SHA-256.
* Write to Blob Store (Ensure parent dir exists).
* Content = JSON String of `BlobRef`.
3. **Git Write**:
* `odb.write(Blob, content)`.
* Update in-memory `git2::TreeBuilder`.
### 4.3 合并流程 (merge_trees)
1. Load Trees: `base_tree`, `our_tree`, `their_tree`.
2. `repo.merge_trees(base, our, their, opts)`.
3. Check Conflicts: `index.has_conflicts()`.
* If conflict: Return Error (Complex resolution left to Orchestrator Agent).
4. Write Result: `index.write_tree_to(repo)`.
5. Return Tree Hash.
## 5. Web UI & Fuse
* **Web**: 基于 Axum路由 `GET /api/v1/repo/:req_id/tree/:commit/*path`。复用 `ContextStore` 接口。
* **Fuse**: 基于 `fuser`
* Mount Point: `/mnt/vgcs_view/{req_id}/{commit}`.
* `read()` -> `store.read_file()`.
* `readdir()` -> `store.list_dir()`.
* 只读挂载,用于 Debug。
## 6. 依赖库
* `git2` (0.18): 核心操作。
* `sha2` (0.10): 哈希计算。
* `serde_json`: Ref 序列化。
* `anyhow`: 错误处理。

View File

@ -1,100 +0,0 @@
# 设计方案 2: DocOS (Document Object System) [详细设计版]
## 1. 定位与目标
DocOS 是构建在 VGCS 之上的逻辑层,负责处理文档结构的演进(裂变、聚合)。
它不操作 Git Hash而是操作逻辑路径。它通过 `ContextStore` 接口与底层交互。
## 2. 数据结构定义
### 2.1 逻辑节点 (DocNode)
这屏蔽了底层是 `File` 还是 `Dir` 的差异。
```rust
#[derive(Debug, Serialize, Deserialize)]
pub enum DocNodeKind {
Leaf, // 纯内容节点 (对应文件)
Composite, // 复合节点 (对应目录,含 index.md)
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DocNode {
pub name: String,
pub path: String, // 逻辑路径 e.g., "Analysis/Revenue"
pub kind: DocNodeKind,
pub children: Vec<DocNode>, // 仅 Composite 有值
}
```
## 3. 核心接口定义 (DocManager Trait)
```rust
pub trait DocManager {
/// 基于最新的 Commit 重新加载状态
fn reload(&mut self, commit_hash: &str) -> Result<()>;
/// 获取当前文档树大纲
fn get_outline(&self) -> Result<DocNode>;
/// 读取节点内容
/// 逻辑:
/// - Leaf: 读 `path`
/// - Composite: 读 `path/index.md`
fn read_content(&self, path: &str) -> Result<String>;
// --- 写入操作 (Buffer Action) ---
/// 写入内容 (Upsert)
/// 逻辑:
/// - 路径存在且是 Leaf -> 覆盖
/// - 路径存在且是 Composite -> 覆盖 index.md
/// - 路径不存在 -> 创建 Leaf
fn write_content(&mut self, path: &str, content: &str);
/// 插入子章节 (Implies Promotion)
/// 逻辑:
/// - 如果 parent 是 Leaf -> 执行 Promote (Rename Leaf->Dir/index.md) -> 创建 Child
/// - 如果 parent 是 Composite -> 直接创建 Child
fn insert_subsection(&mut self, parent_path: &str, name: &str, content: &str);
/// 提交变更
fn save(&mut self, message: &str) -> Result<String>;
}
```
## 4. 关键演进逻辑 (Implementation Specs)
### 4.1 裂变 (Promote Leaf to Composite)
假设 `path = "A"` 是 Leaf (对应文件 `A`).
Action `insert_subsection("A", "B")`:
1. Read content of `A`.
2. Delete file `A` (in transaction).
3. Write content to `A/index.md`.
4. Write new content to `A/B`.
*注意*: Git 会将其视为 Delete + Add或 Rename。VGCS 底层会处理。
### 4.2 聚合 (Demote Composite to Leaf) - *Optional*
假设 `path = "A"` 是 Composite (目录 `A/`).
Action `demote("A")`:
1. Read `A/index.md`.
2. Concatenate children content (Optional policy).
3. Delete dir `A/`.
4. Write content to file `A`.
### 4.3 路径规范
* **Root**: `/` (对应 Repo Root).
* **Meta**: `_meta.json` (用于存储手动排序信息,如果需要).
* **Content File**:
* Leaf: `Name` (No extension assumption, or `.md` default).
* Composite: `Name/index.md`.
## 5. 实现依赖
DocOS 需要持有一个 `Box<dyn Transaction>` 实例。
所有的 `write_*` 操作都只是在调用 Transaction 的 `write`
只有调用 `save()`Transaction 才会 `commit`
## 6. 错误处理
* `PathNotFound`: 读不存在的路径。
* `PathCollision`: 尝试创建已存在的文件。
* `InvalidOperation`: 尝试在 Leaf 节点下创建子节点(需显式调用 Promote 或 insert_subsection

View File

@ -1,181 +0,0 @@
# 设计方案 3: Worker Runtime (Context Shell & Utilities)
## 1. 定位与目标
Worker Runtime 是连接底层文档系统 (DocOS) 与上层业务逻辑 (LLM Worker) 的桥梁。
如果说 VGCS 是硬盘DocOS 是文件系统,那么 Worker Runtime 就是 **Shell (命令行工具集)**
它的核心任务是:**高效地检索、过滤、组装上下文,为大模型准备输入 (Prompt Context)。**
## 2. 核心设计Context Shell
我们借鉴 Linux Coreutils 及现代 Rust CLI 工具(如 `fd`, `ripgrep`, `exa`)的理念,提供一组高效、强类型、支持结构化输出的原语。
### 2.1 接口定义 (Rust Trait)
```rust
pub enum OutputFormat {
Text, // 人类/LLM 可读的文本 (如 ASCII Tree)
Json, // 程序可读的结构化数据 (类似 jq 输入)
}
pub trait ContextShell {
/// [tree]: 生成目录树视图
/// depth: 递归深度
/// format: Text (ASCII Tree) | Json (Nested Objects)
fn tree(&self, path: &str, depth: Option<usize>, format: OutputFormat) -> Result<String>;
/// [find]: 基于元数据的快速查找 (不读取内容)
/// name_pattern: Glob (如 "*.rs")
/// min_size/max_size: 大小过滤
fn find(&self, name_pattern: &str, options: FindOptions) -> Result<Vec<NodeMetadata>>;
/// [grep]: 全文检索 (读取内容)
/// pattern: Regex
/// paths: 限制搜索的文件列表 (通常由 find 的结果输入)
fn grep(&self, pattern: &str, paths: Option<Vec<String>>) -> Result<Vec<GrepMatch>>;
/// [cat]: 读取并组装内容
/// 自动处理 Blob 下载,拼接多个文件,添加 header
fn cat(&self, paths: &[&str]) -> Result<String>;
/// [wc]: 统计元数据 (行数, 字节数)
fn wc(&self, paths: &[&str]) -> Result<Vec<FileStats>>;
/// [patch]: 局部修补 (新增)
/// 基于精确文本匹配的替换,避免全量读写。
/// original: 必须在文件中唯一存在的原文片段
/// replacement: 替换后的新文本
fn patch(&self, path: &str, original: &str, replacement: &str) -> Result<()>;
}
```
### 2.2 数据结构 (Serializable)
```rust
#[derive(Serialize, Deserialize)]
pub struct NodeMetadata {
pub path: String,
pub kind: NodeKind, // File | Dir
pub size: u64,
// pub modified: bool, // TODO: Implement diff check against base
}
#[derive(Serialize, Deserialize)]
pub struct FindOptions {
pub recursive: bool,
pub max_depth: Option<usize>,
pub type_filter: Option<String>, // "File" or "Dir"
pub min_size: Option<u64>,
pub max_size: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GrepMatch {
pub path: String,
pub line_number: usize,
pub content: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FileStats {
pub path: String,
pub lines: usize,
pub bytes: usize,
}
```
## 3. 详细功能设计
### 3.1 tree (Structure Awareness)
* **场景**: LLM 需要先看一眼 "Map" 才能知道去哪找宝藏。
* **Text Mode**: 生成经典的 ASCII Tree直接作为 Prompt 的一部分。
* **Json Mode**: 嵌套的 JSON 对象,供代码逻辑分析结构。
### 3.2 find (Fast Filter)
* **场景**: 效率优化。不要一开始就 `grep` 全文。
* **原理**: 只遍历 Git Tree 对象(元数据),**不解压 Blob**。速度极快。
### 3.3 grep (Content Search)
* **场景**: RAG (Retrieval) 环节。
* **优化**: 接受 `find` 的输出作为 `paths` 参数,避免全盘扫描。
* **并行**: 利用 Rayon 并行读取 Blob 并匹配。
### 3.4 cat (Assemble)
* **场景**: 组装 Prompt Context。
* **格式**: 使用 XML tags 包裹,明确文件边界。
### 3.5 patch (Atomic Update)
* **场景**: 修正笔误或更新局部数据。
* **逻辑**:
1. 读取文件内容。
2. 查找 `original` 字符串。
3. **Fail Fast**: 如果找不到,或者找到多处(歧义),直接报错,防止改错位置。
4. 执行替换并在内存中生成新 Blob更新 Index。
* **优势**: 相比 `write_file`,它不需要 Worker 回传整个文件内容,节省网络传输和 Token。
## 4. Tool Definition Schema (AI Configuration)
为了让 LLM 能够直接使用这些工具Runtime 将提供一个方法导出标准的 JSON Schema (OpenAI Compatible)。
```rust
impl WorkerContext {
pub fn get_tool_definitions() -> serde_json::Value {
serde_json::json!([
{
"type": "function",
"function": {
"name": "tree",
"description": "List directory structure. Use this first to understand the file layout.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Root path to list (default: root)" },
"depth": { "type": "integer", "description": "Recursion depth" }
}
}
}
},
{
"type": "function",
"function": {
"name": "patch",
"description": "Replace a specific text block in a file. Use this for small corrections.",
"parameters": {
"type": "object",
"required": ["path", "original", "replacement"],
"properties": {
"path": { "type": "string" },
"original": { "type": "string", "description": "Exact text to look for. Must be unique in file." },
"replacement": { "type": "string", "description": "New text to insert." }
}
}
}
}
// ... defined for grep, cat, find, etc.
])
}
}
```
## 5. 高效工作流示例 (The Funnel Pattern)
采用了类似 "漏斗" 的筛选机制,层层递进,效率最高。
```rust
// 1. 全局感知 (Tree)
let structure = ctx.tree("/", Some(2), OutputFormat::Text)?;
// 2. 快速定位 (Find)
let json_files = ctx.find("**/*.json", FindOptions::default())?;
// 3. 精确检索 (Grep)
let matches = ctx.grep("NetProfit", Some(json_files.map(|f| f.path)))?;
// 4. 局部修正 (Patch)
// 假设我们在 matches 中发现了一个拼写错误 "NetProft"
ctx.patch("data/financials.json", "\"NetProft\":", "\"NetProfit\":")?;
// 5. 组装阅读 (Cat)
let context = ctx.cat(&distinct_files)?;
```

View File

@ -1,117 +0,0 @@
# 设计方案 4: Workflow Orchestrator (调度器) [详细设计版]
## 1. 定位与目标
Orchestrator 负责 DAG 调度、RPC 分发和冲突合并。它使用 Rust 实现。
**核心原则:**
* **Git as Source of Truth**: 所有的任务产出(数据、报告)均存储在 VGCS (Git) 中。
* **数据库轻量化**: 数据库仅用于存储系统配置、TimeSeries 缓存以及 Request ID 与 Git Commit Hash 的映射。Workflow 执行过程中不依赖数据库进行状态流转。
* **Context 隔离**: 每次 Workflow 启动均为全新的 Context或基于特定 Snapshot无全局共享 Context。
## 2. 调度逻辑规范
### 2.1 RPC Subject 命名
基于 NATS。
* **Command Topic**: `workflow.cmd.{routing_key}`
* e.g., `workflow.cmd.provider.tushare`
* e.g., `workflow.cmd.analysis.report`
* **Event Topic**: `workflow.evt.task_completed` (统一监听)
### 2.2 Command Payload Schema
```json
{
"request_id": "uuid-v4",
"task_id": "fetch_tushare",
"routing_key": "provider.tushare",
"config": {
"market": "CN",
"years": 5
// Provider Specific Config
},
"context": {
"base_commit": "a1b2c3d4...", // Empty for initial task
"mount_path": "/Raw Data/Tushare"
// 指示 Worker 建议把结果挂在哪里
},
"storage": {
"root_path": "/mnt/workflow_data"
}
}
```
### 2.3 Event Payload Schema
```json
{
"request_id": "uuid-v4",
"task_id": "fetch_tushare",
"status": "Completed",
"result": {
"new_commit": "e5f6g7h8...",
"error": null
}
}
```
## 3. 合并策略 (Merge Strategy) 实现
### 3.1 串行合并 (Fast-Forward)
DAG: A -> B
1. A returns `C1`.
2. Orchestrator dispatch B with `base_commit = C1`.
### 3.2 并行合并 (Three-Way Merge)
DAG: A -> B, A -> C. (B, C parallel)
1. Dispatch B with `base_commit = C1`.
2. Dispatch C with `base_commit = C1`.
3. B returns `C2`. C returns `C3`.
4. **Merge Action**:
* Wait for BOTH B and C to complete.
* Call `VGCS.merge_trees(base=C1, ours=C2, theirs=C3)`.
* Result: `TreeHash T4`.
* Create Commit `C4` (Parents: C2, C3).
5. Dispatch D (dependent on B, C) with `base_commit = C4`.
### 3.3 冲突处理
如果 `VGCS.merge_trees` 返回 Error (Conflict):
1. Orchestrator 捕获错误。
2. 标记 Workflow 状态为 `Conflict`.
3. (Future) 触发 `ConflictResolver` Agent传入 C1, C2, C3。Agent 生成 C4。
## 4. 状态机重构
废弃旧的 `WorkflowStateMachine` 中关于 TaskType 的判断。
引入 `CommitTracker`:
```rust
struct CommitTracker {
// 记录每个任务产出的 Commit
task_commits: HashMap<TaskId, String>,
// 记录当前主分支的 Commit (Latest Merged)
head_commit: String,
}
```
## 5. 执行计划
1. **Contract**: 定义 Rust Structs for RPC Payloads.
2. **NATS**: 实现 Publisher/Subscriber。
3. **Engine**: 实现 Merge Loop。
## 6. 数据持久化与缓存策略 (Persistence & Caching)
### 6.1 数据库角色 (Database Role)
数据库不再作为业务数据的“主存储”,其角色转变为:
1. **Configuration**: 存储系统运行所需的配置信息。
2. **Cache (Hot Data)**: 缓存 Data Provider 抓取的原始数据 (Time-series),避免重复调用外部 API。
3. **Index**: 存储 `request_id` -> `final_commit_hash` 的映射,作为系统快照的索引。
### 6.2 Provider 行为模式
Provider 在接收到 Workflow Command 时:
1. **Check Cache**: 检查本地 DB/Cache 是否有有效数据。
2. **Fetch (If miss)**: 如果缓存未命中,调用外部 API 获取数据并更新缓存。
3. **Inject to Context**: 将数据写入当前的 VGCS Context (via `WorkerRuntime`),生成新的 Commit。
* *注意*: Provider 不直接将此次 Workflow 的结果“存”回数据库的业务表,数据库仅作 Cache 用。
### 6.3 Orchestrator 行为
Orchestrator 仅负责追踪 Commit Hash 的演变。
Workflow 结束时Orchestrator 将最终的 `Head Commit Hash` 关联到 `Request ID` 并持久化即“Snapshot 落盘”)。

View File

@ -1,39 +0,0 @@
# [Pending] 为工作流任务添加人类可读名称 (Display Name)
## 背景
目前,前端在显示任务名称时使用的是任务 ID例如 `analysis:news_analysis`,或者是经过简单格式化后的 `news analysis`)。然而,真正的人类可读名称(例如 “新闻分析”)是定义在 `AnalysisTemplate` 配置中的,但这些名称并没有通过工作流事件传播到 `WorkflowOrchestrator` 或前端。
## 目标
确保前端可以在工作流可视化图表Visualizer和标签页Tab Headers中显示模板中定义的本地化/人类可读的任务名称。
## 需要的变更
### 1. Common Contracts (`services/common-contracts`)
- **文件**: `src/workflow_types.rs``src/messages.rs`
- **行动**: 更新 `TaskNode` 结构体(用于 `WorkflowStateSnapshot`),增加一个 `display_name` (`Option<String>`) 字段。
- **行动**: (可选) 如果我们需要实时更新也携带名称,可以更新 `WorkflowTaskEvent`虽然对于静态拓扑来说快照Snapshot通常就足够了。
### 2. Workflow Orchestrator Service (`services/workflow-orchestrator-service`)
- **文件**: `src/dag_scheduler.rs`
- **行动**: 在通过 `add_node` 添加节点时,接受一个 `display_name` 参数。
- **文件**: `src/workflow.rs`
- **行动**: 在 `build_dag` 函数中,遍历 `template.modules` 时:
- 提取 `module_config.name`(例如 “新闻分析”)。
- 在创建 DAG 节点时传递这个名称。
### 3. Frontend (`frontend`)
- **文件**: `src/types/workflow.ts`
- **行动**: 更新 `TaskNode` 接口以匹配新的后端 DTO。
- **文件**: `src/components/workflow/WorkflowVisualizer.tsx` & `src/pages/ReportPage.tsx`
- **行动**: 如果 `node.display_name` 存在,则优先使用它;否则回退到使用 `formatNodeName(node.id)`
## 替代方案 / 临时方案 (纯前端)
由于前端已经(通过 `useAnalysisTemplates` hook获取了 `AnalysisTemplate`,我们可以:
1. 从 URL 参数中获取当前的 `templateId`
2. 查找对应的模板定义。
3. 创建一个映射表:`module_id -> module_name`。
4. 在 `ReportPage``WorkflowVisualizer` 中使用此映射表来动态解析名称。
## 优先级
中等 - 能够显著改善用户体验 (UX),但现有功能不受影响。

View File

@ -1,160 +0,0 @@
# 任务:重构分析模块上下文机制 (两阶段选择与统一 I/O 绑定的融合)
**状态**: 设计中 (Finalizing)
**日期**: 2025-11-27
**优先级**: 高
**负责人**: @User / @Assistant
## 1. 核心理念:意图与实现的解耦
我们经历了三个思维阶段,现在需要将其融合成一个完整的体系:
1. **Context Projection**: 模块需要从全局上下文中“投影”出自己需要的数据。
2. **Two-Stage Selection**: 这种投影过程分为“选择(我需要什么?)”和“分析(怎么处理它?)”两个阶段,且都需要 Prompt/Model 驱动。
3. **Unified I/O Binding**: 模块本身不应处理物理路径,应由 Orchestrator 负责 I/O 绑定。
**融合方案**:
* **Module 定义意图 (Intent)**: 模块通过 Configuration (Prompt/Rules) 描述“我需要什么样的输入”(例如:“我需要去年的财务数据” 或 “按此 Glob 规则匹配”)。
* **Orchestrator 负责解析 (Resolution)**: Orchestrator借助 IO Binder根据模块的意图和当前的全局上下文状态计算出具体的**物理路径**绑定。
* **Module 执行实现 (Execution)**: 模块接收 Orchestrator 传来的物理路径,执行读取、分析和写入。
## 2. 架构设计
### 2.1. 模块配置:描述“我需要什么”
`AnalysisModuleConfig` 依然保持两阶段结构但这里的“Input/Context Selector”描述的是**逻辑需求**。
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalysisModuleConfig {
pub id: String,
// Phase 1: Input Intent (我需要什么数据?)
pub context_selector: ContextSelectorConfig,
// Manual: 明确的规则 (e.g., "financials/*.json")
// Auto: 模糊的需求,交给 Orchestrator/Agent 自动推断
// Hybrid: 具体的 Prompt (e.g., "Find all news about 'Environment' from last year")
// Phase 2: Analysis Intent (怎么处理这些数据?)
pub analysis_prompt: String,
pub llm_config: Option<LlmConfig>,
// Output Intent (结果是什么?)
// 模块只定义它产生什么类型的结果,物理路径由 Orchestrator 分配
pub output_type: String, // e.g., "markdown_report", "json_summary"
}
```
### 2.2. Orchestrator 运行时:解析“在哪里”
Orchestrator 在调度任务前,会执行一个 **Resolution Step**
* **对于 Manual Selector**:
* Orchestrator 根据规则Glob在当前 VGCS Head Commit 中查找匹配的文件。
* 生成具体的 `InputBindings` (Map<FileName, PhysicalPath>)。
* **对于 Auto/Hybrid Selector**:
* **这里是关键融合点**Orchestrator (或专门的 Resolution Agent) 会运行一个轻量级的 LLM 任务。
* Input: 当前 VGCS 目录树 + 模块定义的 Selection Prompt (或 Auto 策略)。
* Output: 具体的 VGCS 文件路径列表。
* Orchestrator 将这些路径打包成 `InputBindings`
### 2.3. 模块执行:执行“转换”
当模块真正启动时Worker 接收到 Command它看到的是**已经被解析过**的确定的世界。
```rust
// 最终发给 Worker 的指令
pub struct GenerateReportCommand {
pub request_id: Uuid,
pub commit_hash: String, // 锁定的世界状态
// 具体的 I/O 绑定 (由 Orchestrator 解析完毕)
pub input_bindings: Vec<String>, // e.g., ["raw/tushare/AAPL/financials.json", ...]
pub output_path: String, // e.g., "analysis/financial_v1/report.md"
// 分析逻辑 (透传给 Worker)
pub analysis_prompt: String,
pub llm_config: Option<LlmConfig>,
}
```
**变化点**:
* **复杂的 Selection 逻辑上移**:原本打算放在 Worker 里的 `Select_Smart` 逻辑,现在看来更适合作为 Orchestrator 的预处理步骤(或者一个独立的微任务)。
* **Worker 变轻**Worker 变得非常“傻”,只负责 `Read(paths) -> Expand -> Prompt -> Write(output_path)`。这就实现了真正的“模块只关注核心任务”。
* **灵活性保留**:如果是 Auto/Hybrid 模式Orchestrator 会动态决定 Input Bindings如果是 Manual 模式,则是静态规则解析。对 Worker 来说,它收到的永远是确定的文件列表。
## 3. 实施路线图 (Revised)
### Phase 1: 协议与配置 (Contracts)
1. 定义 `AnalysisModuleConfig` (包含 Selector, Prompt, LlmConfig)。
2. 定义 `GenerateReportCommand` (包含 `input_bindings` 物理路径列表, `output_path`, `commit_hash`)。
### Phase 2: Orchestrator Resolution Logic
1. 实现 `ContextResolver` 组件:
* 支持 Glob 解析 (Manual)。
* (后续) 支持 LLM 目录树推理 (Auto/Hybrid)。
2. 在调度循环中,在生成 Command 之前调用 `ContextResolver`
### Phase 3: 模块改造 (Module Refactor)
1. **Provider**: 接收 `output_path` (由 Orchestrator 按约定生成,如 `raw/{provider}/{symbol}`) 并写入。
2. **Generator**:
* 移除所有选择逻辑。
* 直接读取 `cmd.input_bindings` 中的文件。
* 执行 Expander (JSON->Table 等)。
* 执行 Prompt。
* 写入 `cmd.output_path`
## 4. 总结
这个方案完美融合了我们的讨论:
* **Input/Output Symmetry**: 都在 Command 中明确绑定。
* **Two-Stage**:
* Stage 1 (Selection) 发生在 **Orchestration Time** (解析 Binding)。
* Stage 2 (Analysis) 发生在 **Execution Time** (Worker 运行)。
* **Module Focus**: 模块不需要知道“去哪找”,只知道“给我这些文件,我给你那个结果”。
## 5. 实施步骤清单 (Checklist)
### Phase 1: 协议与配置定义 (Contracts & Configs)
- [x] **Common Contracts**: 在 `services/common-contracts/src` 创建或更新 `configs.rs`
- [x] 定义 `SelectionMode` (Manual, Auto, Hybrid)。
- [x] 定义 `LlmConfig` (model_id, parameters)。
- [x] 定义 `ContextSelectorConfig` (mode, rules, prompt, llm_config)。
- [x] 定义 `AnalysisModuleConfig` (id, selector, analysis_prompt, llm_config, output_type)。
- [x] **Messages**: 更新 `services/common-contracts/src/messages.rs`
- [x] `GenerateReportCommand`: 添加 `commit_hash`, `input_bindings: Vec<String>`, `output_path: String`, `llm_config`.
- [x] `FetchCompanyDataCommand`: 添加 `output_path: Option<String>`.
- [x] **VGCS Types**: 确保 `workflow-context` crate 中的类型足以支持路径操作。(Confirmed: Vgcs struct has methods)
### Phase 2: Orchestrator 改造 (Resolution Logic)
- [x] **Context Resolver**: 在 `workflow-orchestrator-service` 中创建 `context_resolver.rs`
- [x] 实现 `resolve_input(selector, vgcs_client, commit_hash) -> Result<Vec<String>>`
- [x] 针对 `Manual` 模式:实现 Glob 匹配逻辑 (调用 VGCS `list_dir` 递归查找)。
- [x] 针对 `Auto/Hybrid` 模式:(暂留接口) 返回 Empty 或 NotImplemented后续接入 LLM。
- [x] **IO Binder**: 实现 `io_binder.rs`
- [x] 实现 `allocate_output_path(task_type, task_id) -> String` 约定生成逻辑。
- [x] **Scheduler**: 更新 `dag_scheduler.rs`
- [x] 在 dispatch 任务前,调用 `ContextResolver``IOBinder`
- [x] 将解析结果填入 Command。
### Phase 3: 写入端改造 (Provider Adaptation)
- [x] **Tushare Provider**: 更新 `services/tushare-provider-service/src/generic_worker.rs`
- [x] 读取 Command 中的 `output_path` (如果存在)。
- [x] 使用 `WorkerContext` 写入数据到指定路径 (不再硬编码 `raw/tushare/...`,而是信任 Command)。
- [x] 提交并返回 New Commit Hash。
### Phase 4: 读取端改造 (Report Generator Adaptation)
- [x] **Worker Refactor**: 重写 `services/report-generator-service/src/worker.rs`
- [x] **Remove**: 删除 `fetch_data_and_configs` (旧的 DB 读取逻辑)。
- [x] **Checkout**: 使用 `vgcs.checkout(cmd.commit_hash)`
- [x] **Read Input**: 遍历 `cmd.input_bindings`,使用 `vgcs.read_file` 读取内容。
- [x] **Expand**: 实现简单 `Expander` (JSON -> Markdown Table)。
- [x] **Prompt**: 渲染 `cmd.analysis_prompt`
- [x] **LLM Call**: 使用 `cmd.llm_config` 初始化 Client 并调用。
- [x] **Write Output**: 将结果写入 `cmd.output_path`
- [x] **Commit**: 提交更改并广播 Event。
### Phase 5: 集成与验证 (Integration)
- [x] **Config Migration**: 更新 `config/analysis-config.json` (或 DB 中的配置),适配新的 `AnalysisModuleConfig` 结构。
- [ ] **End-to-End Test**: 运行完整流程,验证:
1. Provider 写文件到 Git。
2. Orchestrator 解析路径。
3. Generator 读文件并生成报告。

View File

@ -1,70 +0,0 @@
# 历史报告功能实施前 Dry Run 报告
## 概览
本报告对“历史报告”功能的实施方案进行了全面的“Dry Run”检查。我们检查了后端服务 (`data-persistence-service`, `api-gateway`, `report-generator-service`) 和前端代码 (`schema.gen.ts`, UI 组件),确认了所有必要的变更点和潜在的遗漏。
## 检查清单与发现 (Dry Run Findings)
### 1. 后端: Report Generator Service (Fix Missing Persistence)
* **现状**: `run_vgcs_based_generation` 函数在生成报告后,仅提交到了 VGCS (Git),没有调用 `persistence_client` 写入数据库。
* **影响**: 数据库中没有记录,导致历史查询接口返回空列表。
* **修正动作**:
* 在 `worker.rs``run_vgcs_based_generation` 函数末尾(生成 commit 后),构建 `NewAnalysisResult` 结构体。
* 调用 `persistence_client.create_analysis_result`
* 注意:`NewAnalysisResult` 需要 `module_id``template_id`,目前这些信息在 `command` 中是可选的 (`Option`)。在 `message_consumer.rs` 中解析 `WorkflowTaskCommand` 时,我们已经提取了这些信息并放入了 `GenerateReportCommand`。需要确保传递链路完整。
### 2. 后端: Data Persistence Service (Query Update)
* **现状**:
* `AnalysisQuery` 结构体强制要求 `symbol: String`
* DB 查询 `get_analysis_results` 强制 `WHERE symbol = $1`
* **修正动作**:
* 修改 `AnalysisQuery`,将 `symbol` 改为 `Option<String>`
* 修改 `get_analysis_results` SQL使其根据 symbol 是否存在动态构建 `WHERE` 子句(或者使用 `COALESCE` 技巧,但动态构建更清晰)。
* 在 SQL 中强制加上 `LIMIT 10`或者通过参数控制为了简化本次只做最近10条建议硬编码默认值或加 `limit` 参数)。
### 3. 后端: API Gateway (Endpoint Update)
* **现状**:
* `AnalysisResultQuery` 结构体定义了 `symbol: String`
* `get_analysis_results_by_symbol` 处理器绑定了该 Query。
* 没有 `GET /api/v1/analysis-results/:id` 接口。
* **修正动作**:
* 修改 `AnalysisResultQuery``symbol: Option<String>`
* 更新 `get_analysis_results` 处理器以适应可选参数。
* 新增 `get_analysis_result_by_id` 处理器,代理请求到 persistence service。
* **关键遗漏检查**: 确保 `utoipa` 的宏定义 (`#[utoipa::path(...)]`) 也同步更新,否则生成的 OpenAPI 文档不对,前端 schema 就不会更新。
### 4. 前端: Schema与API客户端
* **现状**: `schema.gen.ts` 是自动生成的。
* **动作**:
* 后端修改完并启动后,运行脚本 `scripts/update_api_spec.sh` (或类似机制) 重新生成 `openapi.json`
* 前端运行 `npm run openapi-ts` 更新 `schema.gen.ts`
* **确认**: `get_analysis_results` 的参数应变为可选,且新增 `get_analysis_result_by_id` 方法。
### 5. 前端: UI 组件
* **Dashboard / Header**:
* 需要新增 `RecentReportsDropdown` 组件。
* 逻辑Mount 时调用 `api.get_analysis_results()` (无参),获取列表。
* 渲染:下拉列表,点击项使用 `Link` 跳转。
* **HistoricalReportPage**:
* 新增路由 `/history/:id``App.tsx`
* 组件逻辑:获取 `id` 参数 -> 调用 `api.get_analysis_result_by_id({ id })` -> 渲染 Markdown。
* **复用**: 可以复用 `TaskDetailView` 中的 Markdown 渲染逻辑(样式一致性)。
## 风险评估与应对
* **数据不一致**: 如果 Worker 写入 Git 成功但写入 DB 失败怎么办?
* *应对*: 记录 Error 日志。本次暂不引入复杂的分布式事务。DB 缺失仅导致历史列表少一条,不影响核心业务(报告已生成且在 Git 中)。
* **Schema 类型不匹配**: 手动修改后端 struct 后,前端生成的 TS 类型可能报错。
* *应对*: 严格按照 `common-contracts` 定义 DTO确保 `utoipa` 宏准确描述 `Option` 类型。
## 执行结论
计划可行。已识别关键遗漏Worker 持久化缺失)。
**执行顺序**:
1. **Fix Worker Persistence** (最关键,确保新数据能进去)
2. **Update Persistence Service** (支持无 symbol 查询)
3. **Update API Gateway** (暴露接口)
4. **Update OpenAPI & Frontend Client**
5. **Implement Frontend UI**
准备开始执行。

View File

@ -1,71 +0,0 @@
# 任务:重构 Report Worker 为通用执行器 (Generic Execution)
**状态**: 规划中 -> 实施准备中
**优先级**: 高
**相关组件**: `report-generator-service`, `common-contracts`
## 1. 问题背景
当前的 `report-generator-service/src/worker.rs` 存在严重的设计缺陷:**业务逻辑泄露**。
Worker 代码中硬编码了对 `financials.json` 的特殊处理逻辑(反序列化 `TimeSeriesFinancialDto` 并转换为 Markdown Table。这导致 Worker 不再是一个通用的分析执行器,而是与特定的财务分析业务强耦合。这违背了系统设计的初衷,即 Worker 应该只负责通用的 `IO -> Context Assembly -> LLM` 流程。
## 2. 目标
将 Worker 彻底重构为 **Generic Analysis Worker**。它不应该知道什么是 "Financials",什么是 "Profile"。它只知道:
1. 我有输入文件JSON, Text, etc.)。
2. 我需要把它们转换成 Prompt Context优先对人类可读如 YAML
3. 我调用 LLM。
4. 我写入结果。
## 3. 核心变更点
### 3.1 移除硬编码的 DTO 解析
* **彻底删除** `worker.rs` 中所有关于 `TimeSeriesFinancialDto` 的引用。Worker 不应该知道任何业务特定的数据结构。
* 删除 `formatter.rs` 中专门针对 Financials 的表格生成逻辑。
### 3.2 通用格式化策略 (Generic Formatter) -> YAML First
我们需要一种通用的方式来将结构化数据展示给 LLM同时兼顾人类调试时的可读性。
**方案: YAML Pretty Print (首选)**
* **理由**YAML 相比 JSON 更干净没有大量的括号和引号人类阅读体验更好。LLM 对 YAML 的理解能力也很好。既然我们目前处于开发调试阶段,**人类可读性 (Human Readability)** 优于极致的 Token 效率。
* **策略**
* 尝试将输入文件内容解析为 JSON Value。
* 如果成功,将其转换为 **YAML** 字符串。
* 如果解析失败(非结构化文本),则保持原样 (Raw Text)。
* **Context 结构**:避免使用 XML Tags采用更直观的分隔符。
```yaml
---
# Data Source: financials.json (Original Size: 1.2MB)
data:
- date: 2023-12-31
revenue: 10000
...
```
### 3.3 增强的 Execution Trace 与截断策略
* **Sidecar Log**: 必须记录详细的执行过程。
* **截断策略 (Truncation)**:
* 保留字符级截断作为最后的安全防线 (Safety Net)。
* **Critical Logging**: 一旦发生截断,必须在 Log 中留下醒目的警告。
* **详细信息**: 必须记录“截断前大小” vs “截断后大小”(例如:`Original: 1MB, Truncated to: 64KB`),让开发者清楚意识到数据丢失的程度。
## 4. 实施步骤
1. **Cleanup**: 移除 `worker.rs``formatter.rs` 中所有特定业务 DTO 的代码。
2. **Generic Implementation**:
* 引入 `serde_yaml` 依赖。
* 实现通用的 `context_builder`
* Input -> `serde_json::Value` -> `serde_yaml::to_string`.
* Fallback: Raw Text.
* 组装 Context String。
3. **Safety & Logging**:
* 实现截断逻辑,计算 `original_size``truncated_size`
* 在 `execution_trace.md` 中记录详细的文件处理情况。
4. **Verify**: 运行测试,查看生成的 Context 是否清晰易读。
## 5. 预期效果
* **解耦**: 彻底切断 Worker 与 Financial Domain 的耦合。
* **直观**: Context 变得像配置文件一样易读,方便人工 Review LLM 的输入。
* **透明**: 明确知道哪些数据喂给了 LLM哪些被截断了。

View File

@ -1,137 +0,0 @@
# 全栈一致性与历史回放增强设计 (Unified Architecture Design)
## 1. 背景与目标
当前系统存在三个主要割裂点:
1. **节点行为不一致**: Analysis 节点输出报告,而部分 Data Provider (YFinance/Mock) 仅抓取数据无可视化输出(虽已部分修复,但缺乏强制规范)。
2. **实时与历史割裂**: 实时页面依赖 SSE 推送,历史页面缺乏状态恢复机制,导致无法查看 DAG 结构和执行日志。
3. **日志展示分散**: 实时日志是全局流,难以对应到具体任务;历史日志未与 UI 深度集成。
**本设计旨在将上述问题合并为一个系统性工程,实现以下目标:**
* **后端标准化**: 所有节点必须通过统一 Trait 实现,强制产出 Markdown 报告和流式更新。
* **前端统一化**: 使用同一套 `ReportPage` 逻辑处理“实时监控”和“历史回放”。
* **上下文完整性**: 无论是实时还是历史,都能查看 DAG 状态、节点报告、以及执行日志 (`_execution.md`)。
---
## 2. 后端架构升级 (Backend Architecture)
### 2.1. 统一节点运行时 (`WorkflowNode` Trait)
引入强制性接口,确保所有 Worker 行为一致。
* **Trait 定义**:
```rust
#[async_trait]
pub trait WorkflowNode {
async fn execute(&self, ctx: &NodeContext, config: &Value) -> Result<NodeExecutionResult>;
fn render_report(&self, result: &NodeExecutionResult) -> Result<String>;
}
```
* **`WorkflowNodeRunner` (Harness)**:
* **职责**: 负责 NATS 订阅、VGCS 读写、Git Commit、错误处理。
* **增强**:
* 自动将 `execute` 产生的日志写入 `_execution.md`
* 自动推送 `TaskStreamUpdate` (包含 Report Markdown)。
* 自动推送 `TaskLog` (带 `task_id` 的结构化日志,用于前端分流)。
### 2.2. 工作流快照持久化 (Snapshot Persistence)
为了支持历史回放Orchestrator 必须在工作流结束时保存“案发现场”。
* **触发时机**: `handle_task_completed` 检测到 Workflow 结束 (Completed/Failed)。
* **保存内容**: `WorkflowStateSnapshot` (DAG 结构、每个 Task 的最终 Status、Output Commit Hash)。
* **存储位置**: `data-persistence-service` -> `session_data` 表。
* `data_type`: "workflow_snapshot"
* `request_id`: workflow_id
* **API**: `GET /api/v1/workflow/snapshot/{request_id}` (API Gateway 转发)。
---
## 3. 前端架构升级 (Frontend Architecture)
### 3.1. 统一状态管理 (`useWorkflowStore`)
改造 Store 以支持“双模式”加载。
* **State**: 增加 `mode: 'realtime' | 'historical'`
* **Action `initialize(id)`**:
1. 重置 Store。
2. **尝试 SSE 连接** (`/api/v1/workflow/events/{id}`)。
* 如果连接成功且收到 `WorkflowStarted` / `TaskStateChanged`,进入 **Realtime Mode**。
3. **并行/Fallback 调用 Snapshot API** (`/api/v1/workflow/snapshot/{id}`)。
* 如果 SSE 连接失败404/Closed说明任务已结束或者 Snapshot 返回了数据:
* 调用 `loadFromSnapshot(snapshot)` 填充 DAG 和 Task 状态。
* 进入 **Historical Mode**。
### 3.2. 统一页面逻辑 (`ReportPage.tsx`)
不再区分 `HistoricalReportPage`,统一使用 `ReportPage`
* **DAG 可视化**: 复用 `WorkflowVisualizer`,数据源由 Store 提供(无论是 SSE 来的还是 Snapshot 来的)。
* **状态指示**:
* 实时模式:显示 Spinner 和实时进度。
* 历史模式:显示最终结果 Badge并提示“Viewing History”。
### 3.3. 沉浸式报告与调试面板 (Immersive Report & Debugging Panel)
为了提升用户体验,我们将摒弃原本“三等分标签页”的设计,采用 **"主视图 + 侧边栏"** 的布局策略,实现“隐形调试”。
* **主视图 (Main View - The Report)**:
* **定位**: 面向最终用户,强调阅读体验。
* **布局**: 占据屏幕中央核心区域,无干扰展示 Markdown 渲染结果。
* **状态栏**: 顶部仅保留最关键信息(任务状态 Badge、耗时
* **调试面板 (Debug Panel - The Inspector)**:
* **定位**: 面向开发者和排查问题,默认隐藏。
* **入口**: 顶部导航栏右侧的 "Terminal/Code" 图标按钮。
* **交互**: 点击从屏幕右侧滑出 (Sheet/Drawer),支持拖拽调整宽度。
* **内容结构**: 面板内采用 Tabs 组织调试信息:
1. **Logs**: 聚合实时流日志与 `_execution.md` 回放。
2. **Context**: 文件系统快照 (Context Explorer),展示 Input/Output 文件及 Diff。
3. **Raw**: 任务原始配置 (Config) 与元数据 (Metadata)。
这样的设计实现了“一步到位”:普通用户完全感知不到调试页面的存在,而开发者只需一次点击即可获得所有深层上下文。
### 3.4. 历史记录入口 (`RecentReports`)
* **组件**: `RecentReportsDropdown` (Header 区域)。
* **逻辑**: 调用 `GET /api/v1/analysis-results?limit=10`
* **跳转**: 点击跳转到 `/report/{id}` (复用统一页面)。
---
## 4. 实施计划 (Implementation Plan)
### Phase 1: 后端 - 统一运行时 (Backend Standardization)
1. 在 `common-contracts` 实现 `WorkflowNode` Trait 和 `WorkflowNodeRunner`
2. 重构 `yfinance`, `tushare`, `mock` Provider 使用新架构。
* 确保它们都生成 `report.md``_execution.md`
3. 验证实时流推送是否包含正确的 `task_id` 日志。
### Phase 2: 后端 - 快照持久化 (Snapshot Persistence)
1. `workflow-orchestrator`: 结束时保存 `WorkflowStateSnapshot` 到 Session Data。
2. `api-gateway`: 暴露 Snapshot 查询接口。
3. `data-persistence`: 优化 `get_analysis_results` 支持全局最近 10 条查询。
### Phase 3: 前端 - 统一页面与日志 (Frontend Unification)
1. 改造 `useWorkflowStore` 支持 Snapshot Hydration。
2. 改造 `ReportPage` 实现 SSE + Snapshot 双重加载策略。
3. 改造 `TaskDetailView` 为“沉浸式”布局:
* 默认仅展示 Markdown Viewer。
* 添加 Right Sheet 组件承载 Logs/Context。
4. 实现 `RecentReportsDropdown`
### Phase 4: 清理与验证
1. 删除旧的 `HistoricalReportPage` 路由和组件。
2. 验证全流程:
* **实时**: 启动新任务 -> 看到 DAG 生长 -> 看到节点实时 Log -> 看到 Report 生成。
* **历史**: 点击下拉框 -> 加载旧任务 -> 看到完整 DAG -> 点击节点看到 Report -> 打开 Inspector 看到 Logs。
---
## 5. 废弃文档
本设计取代以下文档:
- `docs/3_project_management/tasks/pending/20251128_backend_unified_worker_trait.md`
- `tasks/pending/20251128_historical_playback_design.md`
- `docs/3_project_management/tasks/pending/20251128_historical_reports_design.md`

Some files were not shown because too many files have changed in this diff Show More