Merge branch 'main' of http://jeason.online:3000/zhaojie/iov_data_analysis_agent
This commit is contained in:
22
.env.example
22
.env.example
@@ -1,8 +1,18 @@
|
||||
# LLM Provider 配置
|
||||
# 支持 openai / gemini
|
||||
LLM_PROVIDER=openai
|
||||
|
||||
# 火山引擎配置
|
||||
OPENAI_API_KEY=sk-c44i1hy64xgzwox6x08o4zug93frq6rgn84oqugf2pje1tg4
|
||||
OPENAI_BASE_URL=https://api.xiaomimimo.com/v1
|
||||
# 文本模型
|
||||
OPENAI_MODEL=mimo-v2-flash
|
||||
# OPENAI_MODEL=deepseek-r1-250528
|
||||
# OpenAI 兼容接口配置
|
||||
OPENAI_API_KEY=your-api-key-here
|
||||
OPENAI_BASE_URL=http://127.0.0.1:9999/v1
|
||||
OPENAI_MODEL=your-model-name
|
||||
|
||||
# Gemini 配置(当 LLM_PROVIDER=gemini 时生效)
|
||||
# GEMINI_API_KEY=your-gemini-api-key
|
||||
# GEMINI_BASE_URL=https://generativelanguage.googleapis.com
|
||||
# GEMINI_MODEL=gemini-2.5-flash
|
||||
|
||||
# 应用配置(可选)
|
||||
# APP_MAX_ROUNDS=20
|
||||
# APP_CHUNK_SIZE=100000
|
||||
# APP_CACHE_ENABLED=true
|
||||
|
||||
1
.kiro/specs/agent-robustness-optimization/.config.kiro
Normal file
1
.kiro/specs/agent-robustness-optimization/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "ea41aaef-0737-4255-bcad-90f156a5b2d5", "workflowType": "requirements-first", "specType": "feature"}
|
||||
515
.kiro/specs/agent-robustness-optimization/design.md
Normal file
515
.kiro/specs/agent-robustness-optimization/design.md
Normal file
@@ -0,0 +1,515 @@
|
||||
# Design Document: Agent Robustness Optimization
|
||||
|
||||
## Overview
|
||||
|
||||
This design addresses five areas of improvement for the AI Data Analysis Agent: data privacy fallback recovery, conversation history trimming, analysis template integration, frontend progress display, and multi-file chunked/parallel loading. The changes span the Python backend (`data_analysis_agent.py`, `config/app_config.py`, `utils/data_privacy.py`, `utils/data_loader.py`, `web/main.py`) and the vanilla JS frontend (`web/static/script.js`, `web/static/index.html`, `web/static/clean_style.css`).
|
||||
|
||||
The core design principle is **minimal invasiveness**: each feature is implemented as a composable module or method that plugs into the existing agent loop, avoiding large-scale refactors of the `DataAnalysisAgent.analyze()` main loop.
|
||||
|
||||
## Architecture
|
||||
|
||||
The system follows a layered architecture where the `DataAnalysisAgent` orchestrates LLM calls and code execution, the FastAPI server manages sessions and exposes APIs, and the frontend polls for status updates.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Frontend
|
||||
UI[script.js + index.html]
|
||||
end
|
||||
|
||||
subgraph FastAPI Server
|
||||
API[web/main.py]
|
||||
SM[SessionManager]
|
||||
end
|
||||
|
||||
subgraph Agent Core
|
||||
DA[DataAnalysisAgent]
|
||||
EC[ErrorClassifier]
|
||||
HG[HintGenerator]
|
||||
HT[HistoryTrimmer]
|
||||
TI[TemplateIntegration]
|
||||
end
|
||||
|
||||
subgraph Utilities
|
||||
DP[data_privacy.py]
|
||||
DL[data_loader.py]
|
||||
AT[analysis_templates.py]
|
||||
CE[code_executor.py]
|
||||
end
|
||||
|
||||
subgraph Config
|
||||
AC[app_config.py]
|
||||
end
|
||||
|
||||
UI -->|POST /api/start, GET /api/status, GET /api/templates| API
|
||||
API --> SM
|
||||
API --> DA
|
||||
DA --> EC
|
||||
DA --> HG
|
||||
DA --> HT
|
||||
DA --> TI
|
||||
DA --> CE
|
||||
HG --> DP
|
||||
DL --> AC
|
||||
DA --> DL
|
||||
TI --> AT
|
||||
EC --> AC
|
||||
HT --> AC
|
||||
```
|
||||
|
||||
### Change Impact Summary
|
||||
|
||||
| Area | Files Modified | New Files |
|
||||
|------|---------------|-----------|
|
||||
| Data Privacy Fallback | `data_analysis_agent.py`, `utils/data_privacy.py`, `config/app_config.py` | None |
|
||||
| Conversation Trimming | `data_analysis_agent.py`, `config/app_config.py` | None |
|
||||
| Template System | `data_analysis_agent.py`, `web/main.py`, `web/static/script.js`, `web/static/index.html`, `web/static/clean_style.css` | None |
|
||||
| Progress Bar | `web/main.py`, `web/static/script.js`, `web/static/index.html`, `web/static/clean_style.css` | None |
|
||||
| Multi-File Loading | `utils/data_loader.py`, `data_analysis_agent.py`, `config/app_config.py` | None |
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### 1. Error Classifier (`data_analysis_agent.py`)
|
||||
|
||||
A new method `_classify_error(error_message: str) -> str` on `DataAnalysisAgent` that inspects error messages and returns `"data_context"` or `"other"`.
|
||||
|
||||
```python
|
||||
DATA_CONTEXT_PATTERNS = [
|
||||
r"KeyError:\s*['\"](.+?)['\"]",
|
||||
r"ValueError.*(?:column|col|field)",
|
||||
r"NameError.*(?:df|data|frame)",
|
||||
r"(?:empty|no\s+data|0\s+rows)",
|
||||
r"IndexError.*(?:out of range|out of bounds)",
|
||||
]
|
||||
|
||||
def _classify_error(self, error_message: str) -> str:
|
||||
"""Classify execution error as data-context or other."""
|
||||
for pattern in DATA_CONTEXT_PATTERNS:
|
||||
if re.search(pattern, error_message, re.IGNORECASE):
|
||||
return "data_context"
|
||||
return "other"
|
||||
```
|
||||
|
||||
### 2. Enriched Hint Generator (`utils/data_privacy.py`)
|
||||
|
||||
A new function `generate_enriched_hint(error_message: str, safe_profile: str) -> str` that extracts the referenced column name from the error, looks it up in the safe profile, and returns a hint string containing only schema-level metadata.
|
||||
|
||||
```python
|
||||
def generate_enriched_hint(error_message: str, safe_profile: str) -> str:
|
||||
"""
|
||||
Generate an enriched hint from the safe profile for a data-context error.
|
||||
Returns schema-level metadata only — no real data values.
|
||||
"""
|
||||
column_name = _extract_column_from_error(error_message)
|
||||
column_meta = _lookup_column_in_profile(column_name, safe_profile)
|
||||
|
||||
hint = "[RETRY CONTEXT] 上一次代码执行因数据上下文错误失败。\n"
|
||||
hint += f"错误信息: {error_message}\n"
|
||||
if column_meta:
|
||||
hint += f"相关列 '{column_name}' 的结构信息:\n"
|
||||
hint += f" - 数据类型: {column_meta['dtype']}\n"
|
||||
hint += f" - 唯一值数量: {column_meta['unique_count']}\n"
|
||||
hint += f" - 空值率: {column_meta['null_rate']}\n"
|
||||
hint += f" - 特征描述: {column_meta['description']}\n"
|
||||
hint += "请根据以上结构信息修正代码,不要假设具体的数据值。"
|
||||
return hint
|
||||
|
||||
def _extract_column_from_error(error_message: str) -> Optional[str]:
|
||||
"""Extract column name from error message patterns like KeyError: 'col_name'."""
|
||||
match = re.search(r"KeyError:\s*['\"](.+?)['\"]", error_message)
|
||||
if match:
|
||||
return match.group(1)
|
||||
match = re.search(r"column\s+['\"](.+?)['\"]", error_message, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
def _lookup_column_in_profile(column_name: Optional[str], safe_profile: str) -> Optional[dict]:
|
||||
"""Look up column metadata in the safe profile markdown table."""
|
||||
if not column_name:
|
||||
return None
|
||||
# Parse the markdown table rows for the matching column
|
||||
for line in safe_profile.split("\n"):
|
||||
if line.startswith("|") and column_name in line:
|
||||
parts = [p.strip() for p in line.split("|") if p.strip()]
|
||||
if len(parts) >= 5 and parts[0] == column_name:
|
||||
return {
|
||||
"dtype": parts[1],
|
||||
"null_rate": parts[2],
|
||||
"unique_count": parts[3],
|
||||
"description": parts[4],
|
||||
}
|
||||
return None
|
||||
```
|
||||
|
||||
### 3. Conversation History Trimmer (`data_analysis_agent.py`)
|
||||
|
||||
A new method `_trim_conversation_history()` on `DataAnalysisAgent` that implements sliding window trimming with summary compression.
|
||||
|
||||
```python
|
||||
def _trim_conversation_history(self):
|
||||
"""Apply sliding window trimming to conversation history."""
|
||||
window_size = app_config.conversation_window_size
|
||||
max_messages = window_size * 2 # pairs of user+assistant messages
|
||||
|
||||
if len(self.conversation_history) <= max_messages:
|
||||
return # No trimming needed
|
||||
|
||||
first_message = self.conversation_history[0] # Always retain
|
||||
|
||||
# Determine trim boundary: skip first message + possible existing summary
|
||||
start_idx = 1
|
||||
has_existing_summary = (
|
||||
len(self.conversation_history) > 1
|
||||
and self.conversation_history[1]["role"] == "user"
|
||||
and self.conversation_history[1]["content"].startswith("[分析摘要]")
|
||||
)
|
||||
if has_existing_summary:
|
||||
start_idx = 2
|
||||
|
||||
# Messages to trim vs keep
|
||||
messages_to_consider = self.conversation_history[start_idx:]
|
||||
messages_to_trim = messages_to_consider[:-max_messages]
|
||||
messages_to_keep = messages_to_consider[-max_messages:]
|
||||
|
||||
if not messages_to_trim:
|
||||
return
|
||||
|
||||
# Generate summary of trimmed messages
|
||||
summary = self._compress_trimmed_messages(messages_to_trim)
|
||||
|
||||
# Rebuild history: first_message + summary + recent messages
|
||||
self.conversation_history = [first_message]
|
||||
if summary:
|
||||
self.conversation_history.append({"role": "user", "content": summary})
|
||||
self.conversation_history.extend(messages_to_keep)
|
||||
|
||||
def _compress_trimmed_messages(self, messages: list) -> str:
|
||||
"""Compress trimmed messages into a summary string."""
|
||||
summary_parts = ["[分析摘要] 以下是之前分析轮次的概要:"]
|
||||
round_num = 0
|
||||
|
||||
for msg in messages:
|
||||
content = msg["content"]
|
||||
if msg["role"] == "assistant":
|
||||
round_num += 1
|
||||
# Extract action type from YAML-like content
|
||||
action = "generate_code"
|
||||
if "action: \"collect_figures\"" in content or "action: collect_figures" in content:
|
||||
action = "collect_figures"
|
||||
elif "action: \"analysis_complete\"" in content or "action: analysis_complete" in content:
|
||||
action = "analysis_complete"
|
||||
summary_parts.append(f"- 轮次{round_num}: 动作={action}")
|
||||
elif msg["role"] == "user" and "代码执行反馈" in content:
|
||||
success = "失败" if "[ERROR]" in content or "执行错误" in content else "成功"
|
||||
summary_parts[-1] += f", 执行结果={success}"
|
||||
|
||||
return "\n".join(summary_parts)
|
||||
```
|
||||
|
||||
### 4. Template Integration (`data_analysis_agent.py` + `web/main.py`)
|
||||
|
||||
The `analyze()` method gains an optional `template_name` parameter. When provided, the template prompt is prepended to the user requirement.
|
||||
|
||||
**Agent side:**
|
||||
```python
|
||||
def analyze(self, user_input: str, files=None, session_output_dir=None,
|
||||
reset_session=True, max_rounds=None, template_name=None):
|
||||
# ... existing init code ...
|
||||
if template_name:
|
||||
from utils.analysis_templates import get_template
|
||||
template = get_template(template_name) # Raises ValueError if invalid
|
||||
template_prompt = template.get_full_prompt()
|
||||
user_input = f"{template_prompt}\n\n{user_input}"
|
||||
# ... rest of analyze ...
|
||||
```
|
||||
|
||||
**API side (`web/main.py`):**
|
||||
```python
|
||||
# New endpoint
|
||||
@app.get("/api/templates")
|
||||
async def list_available_templates():
|
||||
from utils.analysis_templates import list_templates
|
||||
return {"templates": list_templates()}
|
||||
|
||||
# Modified StartRequest
|
||||
class StartRequest(BaseModel):
|
||||
requirement: str
|
||||
template: Optional[str] = None
|
||||
```
|
||||
|
||||
### 5. Progress Bar Integration
|
||||
|
||||
**Backend (`web/main.py`):** Update `run_analysis_task` to set progress fields on `SessionData` via a callback or by polling the agent's `current_round`. The simplest approach is to add a progress callback to the agent.
|
||||
|
||||
```python
|
||||
# In DataAnalysisAgent
|
||||
def set_progress_callback(self, callback):
|
||||
"""Set a callback function(current_round, max_rounds, message) for progress updates."""
|
||||
self._progress_callback = callback
|
||||
|
||||
# Called at the start of each round in the analyze() loop:
|
||||
if hasattr(self, '_progress_callback') and self._progress_callback:
|
||||
self._progress_callback(self.current_round, self.max_rounds, f"第{self.current_round}轮分析中...")
|
||||
```
|
||||
|
||||
**Backend (`web/main.py`):** In `run_analysis_task`, wire the callback:
|
||||
```python
|
||||
def progress_cb(current, total, message):
|
||||
session.current_round = current
|
||||
session.max_rounds = total
|
||||
session.progress_percentage = round((current / total) * 100, 1) if total > 0 else 0
|
||||
session.status_message = message
|
||||
|
||||
agent.set_progress_callback(progress_cb)
|
||||
```
|
||||
|
||||
**API response:** Add progress fields to `GET /api/status`:
|
||||
```python
|
||||
return {
|
||||
"is_running": session.is_running,
|
||||
"log": log_content,
|
||||
"has_report": ...,
|
||||
"current_round": session.current_round,
|
||||
"max_rounds": session.max_rounds,
|
||||
"progress_percentage": session.progress_percentage,
|
||||
"status_message": session.status_message,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Frontend (`script.js`):** During polling, render a progress bar when `is_running` is true:
|
||||
```javascript
|
||||
// In the polling callback:
|
||||
if (data.is_running) {
|
||||
updateProgressBar(data.progress_percentage, data.status_message);
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Multi-File Chunked & Parallel Loading
|
||||
|
||||
**Chunked loading enhancement (`utils/data_loader.py`):**
|
||||
|
||||
```python
|
||||
def load_and_profile_data_smart(file_paths: list, max_file_size_mb: int = None) -> str:
|
||||
"""Smart loader: uses chunked reading for large files, regular for small."""
|
||||
if max_file_size_mb is None:
|
||||
max_file_size_mb = app_config.max_file_size_mb
|
||||
|
||||
profile_summary = "# 数据画像报告 (Data Profile)\n\n"
|
||||
for file_path in file_paths:
|
||||
file_size_mb = os.path.getsize(file_path) / (1024 * 1024)
|
||||
if file_size_mb > max_file_size_mb:
|
||||
profile_summary += _profile_chunked(file_path)
|
||||
else:
|
||||
profile_summary += _profile_full(file_path)
|
||||
return profile_summary
|
||||
|
||||
def _profile_chunked(file_path: str) -> str:
|
||||
"""Profile a large file by reading first chunk + sampling subsequent chunks."""
|
||||
chunks = load_data_chunked(file_path)
|
||||
first_chunk = next(chunks, None)
|
||||
if first_chunk is None:
|
||||
return f"[ERROR] 无法读取文件: {file_path}\n"
|
||||
|
||||
# Sample from subsequent chunks
|
||||
sample_rows = [first_chunk]
|
||||
for i, chunk in enumerate(chunks):
|
||||
if i % 5 == 0: # Sample every 5th chunk
|
||||
sample_rows.append(chunk.sample(min(100, len(chunk))))
|
||||
|
||||
combined = pd.concat(sample_rows, ignore_index=True)
|
||||
# Generate profile from combined sample
|
||||
return _generate_profile_for_df(combined, file_path, sampled=True)
|
||||
```
|
||||
|
||||
**Parallel profiling (`data_analysis_agent.py`):**
|
||||
|
||||
```python
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
def _profile_files_parallel(self, file_paths: list) -> tuple[str, str]:
|
||||
"""Profile multiple files concurrently."""
|
||||
max_workers = app_config.max_parallel_profiles
|
||||
safe_profiles = []
|
||||
local_profiles = []
|
||||
|
||||
def profile_single(path):
|
||||
safe = build_safe_profile([path])
|
||||
local = build_local_profile([path])
|
||||
return path, safe, local
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {executor.submit(profile_single, p): p for p in file_paths}
|
||||
for future in as_completed(futures):
|
||||
path = futures[future]
|
||||
try:
|
||||
_, safe, local = future.result()
|
||||
safe_profiles.append(safe)
|
||||
local_profiles.append(local)
|
||||
except Exception as e:
|
||||
error_entry = f"## 文件: {os.path.basename(path)}\n[ERROR] 分析失败: {e}\n\n"
|
||||
safe_profiles.append(error_entry)
|
||||
local_profiles.append(error_entry)
|
||||
|
||||
return "\n".join(safe_profiles), "\n".join(local_profiles)
|
||||
```
|
||||
|
||||
## Data Models
|
||||
|
||||
### AppConfig Extensions (`config/app_config.py`)
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
# ... existing fields ...
|
||||
|
||||
# New fields
|
||||
max_data_context_retries: int = field(default=2)
|
||||
conversation_window_size: int = field(default=10)
|
||||
max_parallel_profiles: int = field(default=4)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> 'AppConfig':
|
||||
config = cls()
|
||||
# ... existing env overrides ...
|
||||
if val := os.getenv("APP_MAX_DATA_CONTEXT_RETRIES"):
|
||||
config.max_data_context_retries = int(val)
|
||||
if val := os.getenv("APP_CONVERSATION_WINDOW_SIZE"):
|
||||
config.conversation_window_size = int(val)
|
||||
if val := os.getenv("APP_MAX_PARALLEL_PROFILES"):
|
||||
config.max_parallel_profiles = int(val)
|
||||
return config
|
||||
```
|
||||
|
||||
### StartRequest Extension (`web/main.py`)
|
||||
|
||||
```python
|
||||
class StartRequest(BaseModel):
|
||||
requirement: str
|
||||
template: Optional[str] = None # New field
|
||||
```
|
||||
|
||||
### SessionData Progress Fields (already exist, just need wiring)
|
||||
|
||||
The `SessionData` class already has `current_round`, `max_rounds`, `progress_percentage`, and `status_message` fields. These just need to be updated during analysis and included in the `/api/status` response.
|
||||
|
||||
## Correctness Properties
|
||||
|
||||
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||
|
||||
### Property 1: Error Classification Correctness
|
||||
|
||||
*For any* error message string, if it contains a data-context pattern (KeyError on a column name, ValueError on column values, NameError for data variables, or empty DataFrame conditions), `_classify_error` SHALL return `"data_context"`; otherwise it SHALL return `"other"`.
|
||||
|
||||
**Validates: Requirements 1.1**
|
||||
|
||||
### Property 2: Retry Below Limit Produces Enriched Hint
|
||||
|
||||
*For any* `max_data_context_retries` value and any current retry count strictly less than that value, when a data-context error is detected, the agent SHALL produce an enriched hint message rather than forwarding the raw error.
|
||||
|
||||
**Validates: Requirements 1.3**
|
||||
|
||||
### Property 3: Enriched Hint Contains Correct Column Metadata Without Real Data
|
||||
|
||||
*For any* error message referencing a column name present in the Safe_Profile, the generated enriched hint SHALL contain that column's data type, unique value count, null rate, and categorical description, and SHALL NOT contain any real data values (min, max, mean, sample rows) from the Local_Profile.
|
||||
|
||||
**Validates: Requirements 2.1, 2.2, 2.4**
|
||||
|
||||
### Property 4: Environment Variable Override for Config Fields
|
||||
|
||||
*For any* positive integer value set as the `APP_MAX_DATA_CONTEXT_RETRIES` environment variable, `AppConfig.from_env()` SHALL produce a config where `max_data_context_retries` equals that integer value.
|
||||
|
||||
**Validates: Requirements 3.2**
|
||||
|
||||
### Property 5: Sliding Window Trimming Preserves First Message and Retains Recent Pairs
|
||||
|
||||
*For any* conversation history whose length exceeds `2 * conversation_window_size` and any `conversation_window_size >= 1`, after trimming: (a) the first user message is always retained at index 0, and (b) the most recent `conversation_window_size` message pairs are retained in full.
|
||||
|
||||
**Validates: Requirements 4.2, 4.3**
|
||||
|
||||
### Property 6: Trimming Summary Contains Round Info and Excludes Code/Raw Output
|
||||
|
||||
*For any* set of trimmed conversation messages, the generated summary SHALL list each trimmed round's action type and execution success/failure, and SHALL NOT contain any code blocks (``` markers) or raw execution output.
|
||||
|
||||
**Validates: Requirements 4.4, 5.1, 5.2**
|
||||
|
||||
### Property 7: Template Prompt Integration
|
||||
|
||||
*For any* valid template name in `TEMPLATE_REGISTRY` and any user requirement string, the initial conversation message SHALL contain the template's `get_full_prompt()` output prepended to the user requirement.
|
||||
|
||||
**Validates: Requirements 6.1, 6.2**
|
||||
|
||||
### Property 8: Invalid Template Name Raises Descriptive Error
|
||||
|
||||
*For any* string that is not a key in `TEMPLATE_REGISTRY`, calling `get_template()` SHALL raise a `ValueError` whose message contains the list of available template names.
|
||||
|
||||
**Validates: Requirements 6.3**
|
||||
|
||||
### Property 9: Chunked Loading Threshold
|
||||
|
||||
*For any* file path and `max_file_size_mb` threshold, if the file's size in MB exceeds the threshold, the smart loader SHALL use chunked loading; otherwise it SHALL use full loading.
|
||||
|
||||
**Validates: Requirements 10.1**
|
||||
|
||||
### Property 10: Chunked Profiling Uses First Chunk Plus Samples
|
||||
|
||||
*For any* file loaded in chunked mode, the generated profile SHALL be based on the first chunk plus sampled rows from subsequent chunks, not from the entire file loaded into memory.
|
||||
|
||||
**Validates: Requirements 10.3**
|
||||
|
||||
### Property 11: Parallel Profile Merge With Error Resilience
|
||||
|
||||
*For any* set of file paths where some are valid and some are invalid/corrupted, the merged profile output SHALL contain valid profile entries for successful files and error entries for failed files, with no files missing from the output.
|
||||
|
||||
**Validates: Requirements 11.2, 11.3**
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Handling Strategy |
|
||||
|----------|------------------|
|
||||
| Data-context error below retry limit | Generate enriched hint, retry with LLM |
|
||||
| Data-context error at retry limit | Fall back to normal sanitized error forwarding |
|
||||
| Invalid template name | Raise `ValueError` with available template list |
|
||||
| File too large for memory | Automatically switch to chunked loading |
|
||||
| Chunked loading fails | Return descriptive error, continue with other files |
|
||||
| Single file profiling fails in parallel | Include error entry, continue profiling remaining files |
|
||||
| Conversation history exceeds window | Trim old messages, generate compressed summary |
|
||||
| Summary generation fails | Log warning, proceed without summary (graceful degradation) |
|
||||
| Progress callback fails | Log warning, analysis continues without progress updates |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Property-Based Tests (using `hypothesis`)
|
||||
|
||||
Each correctness property maps to a property-based test with minimum 100 iterations. The test library is `hypothesis` (Python).
|
||||
|
||||
- **Property 1**: Generate random error strings with/without data-context patterns → verify classification
|
||||
- **Property 2**: Generate random retry counts and limits → verify hint vs raw error behavior
|
||||
- **Property 3**: Generate random Safe_Profile tables and error messages → verify hint content and absence of real data
|
||||
- **Property 4**: Generate random positive integers → set env var → verify config
|
||||
- **Property 5**: Generate random conversation histories and window sizes → verify trimming invariants
|
||||
- **Property 6**: Generate random trimmed message sets → verify summary content and absence of code blocks
|
||||
- **Property 7**: Pick random valid template names and requirement strings → verify prompt construction
|
||||
- **Property 8**: Generate random strings not in registry → verify ValueError
|
||||
- **Property 9**: Generate random file sizes and thresholds → verify loading method selection
|
||||
- **Property 10**: Generate random chunked data → verify profile source
|
||||
- **Property 11**: Generate random file sets with failures → verify merged output
|
||||
|
||||
Tag format: `Feature: agent-robustness-optimization, Property {N}: {title}`
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- Error classifier with specific known error messages (KeyError, ValueError, NameError, generic errors)
|
||||
- Enriched hint generation with known column profiles
|
||||
- Conversation trimming with exact message counts at boundary conditions
|
||||
- Template retrieval for each registered template
|
||||
- Progress callback wiring
|
||||
- API endpoint response shapes (`GET /api/templates`, `GET /api/status` with progress fields)
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- `GET /api/templates` returns all registered templates
|
||||
- `POST /api/start` with `template` field passes template to agent
|
||||
- `GET /api/status` includes progress fields during analysis
|
||||
- Multi-file parallel profiling with real CSV files
|
||||
- End-to-end: start analysis with template → verify template prompt in conversation history
|
||||
142
.kiro/specs/agent-robustness-optimization/requirements.md
Normal file
142
.kiro/specs/agent-robustness-optimization/requirements.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
This document specifies the requirements for improving the robustness, efficiency, and usability of the AI Data Analysis Agent. The improvements span five areas: a data privacy fallback mechanism for recovering from LLM-generated code failures when real data is unavailable, conversation history trimming to reduce token consumption and prevent data leakage, integration of the existing analysis template system, frontend progress bar display, and multi-file parallel/chunked analysis support.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Agent**: The `DataAnalysisAgent` class in `data_analysis_agent.py` that orchestrates LLM calls and IPython code execution for data analysis.
|
||||
- **Safe_Profile**: The schema-only data description generated by `build_safe_profile()` in `utils/data_privacy.py`, containing column names, data types, null rates, and unique value counts — but no real data values.
|
||||
- **Local_Profile**: The full data profile generated by `build_local_profile()` containing real data values, statistics, and sample rows — used only in the local execution environment.
|
||||
- **Code_Executor**: The `CodeExecutor` class in `utils/code_executor.py` that runs Python code in an IPython sandbox and returns execution results.
|
||||
- **Conversation_History**: The list of `{"role": ..., "content": ...}` message dictionaries maintained by the Agent across analysis rounds.
|
||||
- **Feedback_Sanitizer**: The `sanitize_execution_feedback()` function in `utils/data_privacy.py` that removes real data values from execution output before sending to the LLM.
|
||||
- **Template_Registry**: The `TEMPLATE_REGISTRY` dictionary in `utils/analysis_templates.py` mapping template names to template classes.
|
||||
- **Session_Data**: The `SessionData` class in `web/main.py` that tracks session state including `progress_percentage`, `current_round`, `max_rounds`, and `status_message`.
|
||||
- **Polling_Loop**: The `setInterval`-based polling mechanism in `web/static/script.js` that fetches `/api/status` every 2 seconds.
|
||||
- **Data_Loader**: The module `utils/data_loader.py` providing `load_and_profile_data`, `load_data_chunked`, and `load_data_with_cache` functions.
|
||||
- **AppConfig**: The `AppConfig` dataclass in `config/app_config.py` holding configuration values such as `max_rounds`, `chunk_size`, and `max_file_size_mb`.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Data Privacy Fallback — Error Detection
|
||||
|
||||
**User Story:** As a system operator, I want the Agent to detect when LLM-generated code fails due to missing real data context, so that the system can attempt intelligent recovery instead of wasting an analysis round.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the Code_Executor returns a failed execution result, THE Agent SHALL classify the error as either a data-context error or a non-data error by inspecting the error message for patterns such as `KeyError`, `ValueError` on column values, `NameError` for undefined data variables, or empty DataFrame conditions.
|
||||
2. WHEN a data-context error is detected, THE Agent SHALL increment a per-round retry counter for the current analysis round.
|
||||
3. WHILE the retry counter for a given round is below the configured maximum retry limit, THE Agent SHALL attempt recovery by generating an enriched hint prompt rather than forwarding the raw error to the LLM as a normal failure.
|
||||
4. IF the retry counter reaches the configured maximum retry limit, THEN THE Agent SHALL fall back to normal error handling by forwarding the sanitized error feedback to the LLM and proceeding to the next round.
|
||||
|
||||
### Requirement 2: Data Privacy Fallback — Enriched Hint Generation
|
||||
|
||||
**User Story:** As a system operator, I want the Agent to provide the LLM with enriched schema hints when data-context errors occur, so that the LLM can generate corrected code without receiving raw data values.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a data-context error is detected and retry is permitted, THE Agent SHALL generate an enriched hint containing the relevant column's data type, unique value count, null rate, and a categorical description (e.g., "low-cardinality category with 5 classes") extracted from the Safe_Profile.
|
||||
2. WHEN the error involves a specific column name referenced in the error message, THE Agent SHALL include that column's schema metadata in the enriched hint.
|
||||
3. THE Agent SHALL append the enriched hint to the conversation history as a user message with a prefix indicating it is a retry context, before requesting a new LLM response.
|
||||
4. THE Agent SHALL NOT include any real data values, sample rows, or statistical values (min, max, mean) from the Local_Profile in the enriched hint sent to the LLM.
|
||||
|
||||
### Requirement 3: Data Privacy Fallback — Configuration
|
||||
|
||||
**User Story:** As a system operator, I want to configure the maximum number of data-context retries, so that I can balance between recovery attempts and analysis throughput.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE AppConfig SHALL include a `max_data_context_retries` field with a default value of 2.
|
||||
2. WHEN the `APP_MAX_DATA_CONTEXT_RETRIES` environment variable is set, THE AppConfig SHALL use its integer value to override the default.
|
||||
3. THE Agent SHALL read the `max_data_context_retries` value from AppConfig during initialization.
|
||||
|
||||
### Requirement 4: Conversation History Trimming — Sliding Window
|
||||
|
||||
**User Story:** As a system operator, I want the conversation history to be trimmed using a sliding window, so that token consumption stays bounded and early execution results containing potential data leakage are removed.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE AppConfig SHALL include a `conversation_window_size` field with a default value of 10, representing the maximum number of recent message pairs to retain in full.
|
||||
2. WHEN the Conversation_History length exceeds twice the `conversation_window_size` (counting individual messages), THE Agent SHALL retain only the most recent `conversation_window_size` pairs of messages in full detail.
|
||||
3. THE Agent SHALL always retain the first user message (containing the original requirement and Safe_Profile) regardless of window trimming.
|
||||
4. WHEN messages are trimmed from the Conversation_History, THE Agent SHALL generate a compressed summary of the trimmed messages and prepend it after the first user message.
|
||||
|
||||
### Requirement 5: Conversation History Trimming — Summary Compression
|
||||
|
||||
**User Story:** As a system operator, I want trimmed conversation rounds to be compressed into a summary, so that the LLM retains awareness of prior analysis steps without consuming excessive tokens.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN conversation messages are trimmed, THE Agent SHALL produce a summary string that lists each trimmed round's action type (generate_code, collect_figures), a one-line description of what was done, and whether execution succeeded or failed.
|
||||
2. THE summary SHALL NOT contain any code blocks, raw execution output, or data values from prior rounds.
|
||||
3. THE summary SHALL be inserted into the Conversation_History as a single user message immediately after the first user message, replacing any previous summary message.
|
||||
4. IF no messages have been trimmed, THEN THE Agent SHALL NOT insert a summary message.
|
||||
|
||||
### Requirement 6: Analysis Template System — Backend Integration
|
||||
|
||||
**User Story:** As a user, I want to select a predefined analysis template when starting an analysis, so that the Agent follows a structured analysis plan tailored to my scenario.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a template name is provided in the analysis request, THE Agent SHALL retrieve the corresponding template from the Template_Registry using the `get_template()` function.
|
||||
2. WHEN a valid template is retrieved, THE Agent SHALL call `get_full_prompt()` on the template and prepend the resulting structured prompt to the user's requirement in the initial conversation message.
|
||||
3. IF an invalid template name is provided, THEN THE Agent SHALL raise a descriptive error listing available template names.
|
||||
4. WHEN no template name is provided, THE Agent SHALL proceed with the default unstructured analysis flow.
|
||||
|
||||
### Requirement 7: Analysis Template System — API Endpoint
|
||||
|
||||
**User Story:** As a frontend developer, I want API endpoints to list available templates and to accept a template selection when starting analysis, so that the frontend can offer template choices to users.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE FastAPI server SHALL expose a `GET /api/templates` endpoint that returns the list of available templates by calling `list_templates()`, with each entry containing `name`, `display_name`, and `description`.
|
||||
2. THE `POST /api/start` request body SHALL accept an optional `template` field containing the template name string.
|
||||
3. WHEN the `template` field is present in the start request, THE FastAPI server SHALL pass the template name to the Agent's `analyze()` method.
|
||||
4. WHEN the `template` field is absent or empty, THE FastAPI server SHALL start analysis without a template.
|
||||
|
||||
### Requirement 8: Analysis Template System — Frontend Template Selector
|
||||
|
||||
**User Story:** As a user, I want to see and select analysis templates in the web interface before starting analysis, so that I can choose a structured analysis approach.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the web page loads, THE frontend SHALL fetch the template list from `GET /api/templates` and render selectable template cards above the requirement input area.
|
||||
2. WHEN a user selects a template card, THE frontend SHALL visually highlight the selected template and store the template name.
|
||||
3. WHEN the user clicks "Start Analysis" with a template selected, THE frontend SHALL include the template name in the `POST /api/start` request body.
|
||||
4. THE frontend SHALL provide a "No Template (Free Analysis)" option that is selected by default, allowing users to proceed without a template.
|
||||
|
||||
### Requirement 9: Frontend Progress Bar Display
|
||||
|
||||
**User Story:** As a user, I want to see a real-time progress bar during analysis, so that I can understand how far the analysis has progressed.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE FastAPI server SHALL update the Session_Data's `current_round`, `max_rounds`, `progress_percentage`, and `status_message` fields during each analysis round in the `run_analysis_task` function.
|
||||
2. THE `GET /api/status` response SHALL include `current_round`, `max_rounds`, `progress_percentage`, and `status_message` fields.
|
||||
3. WHEN the Polling_Loop receives status data with `is_running` equal to true, THE frontend SHALL render a progress bar element showing the `progress_percentage` value and the `status_message` text.
|
||||
4. WHEN `progress_percentage` changes between polls, THE frontend SHALL animate the progress bar width transition smoothly.
|
||||
5. WHEN `is_running` becomes false, THE frontend SHALL set the progress bar to 100% and display a completion message.
|
||||
|
||||
### Requirement 10: Multi-File Chunked Loading
|
||||
|
||||
**User Story:** As a user, I want large data files to be loaded in chunks, so that the system can handle files that exceed available memory.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a data file's size exceeds the `max_file_size_mb` threshold in AppConfig, THE Data_Loader SHALL use `load_data_chunked()` to stream the file in chunks of `chunk_size` rows instead of loading the entire file into memory.
|
||||
2. WHEN chunked loading is used, THE Agent SHALL instruct the Code_Executor to make the chunked iterator available in the notebook environment as a variable, so that LLM-generated code can process data in chunks.
|
||||
3. WHEN chunked loading is used for profiling, THE Agent SHALL generate the Safe_Profile by reading only the first chunk plus sampling from subsequent chunks, rather than loading the entire file.
|
||||
4. IF a file cannot be loaded even in chunked mode, THEN THE Data_Loader SHALL return a descriptive error message indicating the failure reason.
|
||||
|
||||
### Requirement 11: Multi-File Parallel Profiling
|
||||
|
||||
**User Story:** As a user, I want multiple data files to be profiled concurrently, so that the initial data exploration phase completes faster when multiple files are uploaded.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN multiple files are provided for analysis, THE Agent SHALL profile each file concurrently using thread-based parallelism rather than sequentially.
|
||||
2. THE Agent SHALL collect all profiling results and merge them into a single Safe_Profile string and a single Local_Profile string, maintaining the same format as the current sequential output.
|
||||
3. IF any individual file profiling fails, THEN THE Agent SHALL include an error entry for that file in the profile output and continue profiling the remaining files.
|
||||
4. THE AppConfig SHALL include a `max_parallel_profiles` field with a default value of 4, controlling the maximum number of concurrent profiling threads.
|
||||
74
.kiro/specs/agent-robustness-optimization/tasks.md
Normal file
74
.kiro/specs/agent-robustness-optimization/tasks.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Tasks — Agent Robustness Optimization
|
||||
|
||||
## Priority 1: Configuration Foundation
|
||||
|
||||
- [ ] 1. Add new config fields to AppConfig
|
||||
- [-] 1.1 Add `max_data_context_retries` field (default=2) with `APP_MAX_DATA_CONTEXT_RETRIES` env override to `config/app_config.py`
|
||||
- [-] 1.2 Add `conversation_window_size` field (default=10) with `APP_CONVERSATION_WINDOW_SIZE` env override to `config/app_config.py`
|
||||
- [-] 1.3 Add `max_parallel_profiles` field (default=4) with `APP_MAX_PARALLEL_PROFILES` env override to `config/app_config.py`
|
||||
|
||||
## Priority 2: Data Privacy Fallback (R1–R3)
|
||||
|
||||
- [ ] 2. Implement error classification
|
||||
- [~] 2.1 Add `_classify_error(error_message: str) -> str` method to `DataAnalysisAgent` in `data_analysis_agent.py` with regex patterns for KeyError, ValueError, NameError, empty DataFrame
|
||||
- [~] 2.2 Add `_extract_column_from_error(error_message: str) -> Optional[str]` function to `utils/data_privacy.py`
|
||||
- [~] 2.3 Add `_lookup_column_in_profile(column_name, safe_profile) -> Optional[dict]` function to `utils/data_privacy.py`
|
||||
- [ ] 3. Implement enriched hint generation
|
||||
- [~] 3.1 Add `generate_enriched_hint(error_message: str, safe_profile: str) -> str` function to `utils/data_privacy.py`
|
||||
- [~] 3.2 Integrate retry logic into the `analyze()` loop in `data_analysis_agent.py`: add per-round retry counter, call `_classify_error` on failures, generate enriched hint when below retry limit, fall back to normal error handling at limit
|
||||
|
||||
## Priority 3: Conversation History Trimming (R4–R5)
|
||||
|
||||
- [ ] 4. Implement conversation trimming
|
||||
- [~] 4.1 Add `_trim_conversation_history()` method to `DataAnalysisAgent` implementing sliding window with first-message preservation
|
||||
- [~] 4.2 Add `_compress_trimmed_messages(messages: list) -> str` method to `DataAnalysisAgent` that generates summary with action types and success/failure, excluding code blocks and raw output
|
||||
- [~] 4.3 Call `_trim_conversation_history()` at the start of each round in the `analyze()` loop, after the first round
|
||||
|
||||
## Priority 4: Analysis Template System (R6–R8)
|
||||
|
||||
- [ ] 5. Backend template integration
|
||||
- [~] 5.1 Add optional `template_name` parameter to `DataAnalysisAgent.analyze()` method; retrieve template via `get_template()`, prepend `get_full_prompt()` to user requirement
|
||||
- [~] 5.2 Add `GET /api/templates` endpoint to `web/main.py` returning `list_templates()` result
|
||||
- [~] 5.3 Add optional `template` field to `StartRequest` model in `web/main.py`; pass template name to agent in `run_analysis_task`
|
||||
- [ ] 6. Frontend template selector
|
||||
- [~] 6.1 Add template selector HTML section (cards above requirement input) to `web/static/index.html`
|
||||
- [~] 6.2 Add template fetching, selection logic, and "No Template" default to `web/static/script.js`
|
||||
- [~] 6.3 Add template card styles (`.template-card`, `.template-card.selected`) to `web/static/clean_style.css`
|
||||
|
||||
## Priority 5: Frontend Progress Bar (R9)
|
||||
|
||||
- [ ] 7. Backend progress updates
|
||||
- [~] 7.1 Add `set_progress_callback(callback)` method to `DataAnalysisAgent`; call callback at start of each round in `analyze()` loop
|
||||
- [~] 7.2 Wire progress callback in `run_analysis_task` in `web/main.py` to update `SessionData` progress fields
|
||||
- [~] 7.3 Add `current_round`, `max_rounds`, `progress_percentage`, `status_message` to `GET /api/status` response in `web/main.py`
|
||||
- [ ] 8. Frontend progress bar
|
||||
- [~] 8.1 Add progress bar HTML element below the status bar area in `web/static/index.html`
|
||||
- [~] 8.2 Add `updateProgressBar(percentage, message)` function to `web/static/script.js`; call it during polling when `is_running` is true; set to 100% on completion
|
||||
- [~] 8.3 Add progress bar styles with CSS transition animation to `web/static/clean_style.css`
|
||||
|
||||
## Priority 6: Multi-File Chunked & Parallel Loading (R10–R11)
|
||||
|
||||
- [ ] 9. Chunked loading enhancement
|
||||
- [~] 9.1 Add `_profile_chunked(file_path: str) -> str` function to `utils/data_loader.py` that profiles using first chunk + sampled subsequent chunks
|
||||
- [~] 9.2 Add `load_and_profile_data_smart(file_paths, max_file_size_mb) -> str` function to `utils/data_loader.py` that selects chunked vs full loading based on file size threshold
|
||||
- [~] 9.3 Update `DataAnalysisAgent.analyze()` to use smart loader and expose chunked iterator in Code_Executor namespace for large files
|
||||
- [ ] 10. Parallel profiling
|
||||
- [~] 10.1 Add `_profile_files_parallel(file_paths: list) -> tuple[str, str]` method to `DataAnalysisAgent` using `ThreadPoolExecutor` with `max_parallel_profiles` workers
|
||||
- [~] 10.2 Update `DataAnalysisAgent.analyze()` to call `_profile_files_parallel` when multiple files are provided, replacing sequential `build_safe_profile` + `build_local_profile` calls
|
||||
|
||||
## Priority 7: Testing
|
||||
|
||||
- [ ] 11. Write property-based tests
|
||||
- [ ] 11.1 ~PBT~ Property test for error classification correctness (Property 1) using `hypothesis`
|
||||
- [ ] 11.2 ~PBT~ Property test for enriched hint content and privacy (Property 3) using `hypothesis`
|
||||
- [ ] 11.3 ~PBT~ Property test for env var config override (Property 4) using `hypothesis`
|
||||
- [ ] 11.4 ~PBT~ Property test for sliding window trimming invariants (Property 5) using `hypothesis`
|
||||
- [ ] 11.5 ~PBT~ Property test for trimming summary content (Property 6) using `hypothesis`
|
||||
- [ ] 11.6 ~PBT~ Property test for template prompt integration (Property 7) using `hypothesis`
|
||||
- [ ] 11.7 ~PBT~ Property test for invalid template error (Property 8) using `hypothesis`
|
||||
- [ ] 11.8 ~PBT~ Property test for parallel profile merge with error resilience (Property 11) using `hypothesis`
|
||||
- [ ] 12. Write unit and integration tests
|
||||
- [ ] 12.1 Unit tests for error classifier with known error messages
|
||||
- [ ] 12.2 Unit tests for conversation trimming at boundary conditions
|
||||
- [ ] 12.3 Integration tests for `GET /api/templates` and `POST /api/start` with template field
|
||||
- [ ] 12.4 Integration tests for `GET /api/status` progress fields
|
||||
@@ -4,5 +4,6 @@
|
||||
"""
|
||||
|
||||
from .llm_config import LLMConfig
|
||||
from .app_config import AppConfig, app_config
|
||||
|
||||
__all__ = ['LLMConfig']
|
||||
__all__ = ['LLMConfig', 'AppConfig', 'app_config']
|
||||
|
||||
@@ -46,6 +46,11 @@ class AppConfig:
|
||||
log_filename: str = field(default="log.txt")
|
||||
enable_code_logging: bool = field(default=False) # 是否记录生成的代码
|
||||
|
||||
# 健壮性配置
|
||||
max_data_context_retries: int = field(default=2) # 数据上下文错误最大重试次数
|
||||
conversation_window_size: int = field(default=10) # 对话历史滑动窗口大小(消息对数)
|
||||
max_parallel_profiles: int = field(default=4) # 并行数据画像最大线程数
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> 'AppConfig':
|
||||
"""从环境变量创建配置"""
|
||||
@@ -60,6 +65,13 @@ class AppConfig:
|
||||
|
||||
if cache_enabled := os.getenv("APP_CACHE_ENABLED"):
|
||||
config.data_cache_enabled = cache_enabled.lower() == "true"
|
||||
|
||||
if val := os.getenv("APP_MAX_DATA_CONTEXT_RETRIES"):
|
||||
config.max_data_context_retries = int(val)
|
||||
if val := os.getenv("APP_CONVERSATION_WINDOW_SIZE"):
|
||||
config.conversation_window_size = int(val)
|
||||
if val := os.getenv("APP_MAX_PARALLEL_PROFILES"):
|
||||
config.max_parallel_profiles = int(val)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
配置管理模块
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMConfig:
|
||||
"""LLM配置"""
|
||||
|
||||
provider: str = os.environ.get("LLM_PROVIDER", "openai") # openai, gemini, etc.
|
||||
api_key: str = os.environ.get("OPENAI_API_KEY", "sk-2187174de21548b0b8b0c92129700199")
|
||||
base_url: str = os.environ.get("OPENAI_BASE_URL", "http://127.0.0.1:9999/v1")
|
||||
model: str = os.environ.get("OPENAI_MODEL", "gemini--flash")
|
||||
temperature: float = 0.5
|
||||
max_tokens: int = 131072
|
||||
|
||||
def __post_init__(self):
|
||||
"""配置初始化后的处理"""
|
||||
if self.provider == "gemini":
|
||||
# 如果使用 Gemini,尝试从环境变量加载 Gemini 配置,或者使用默认的 Gemini 配置
|
||||
# 注意:如果 OPENAI_API_KEY 已设置且 GEMINI_API_KEY 未设置,可能会沿用 OpenAI 的 Key,
|
||||
# 但既然用户切换了 provider,通常会有配套的 Key。
|
||||
self.api_key = os.environ.get("GEMINI_API_KEY", "AIzaSyA9aVFjRJYJq82WEQUVlifE4fE7BnX6QiY")
|
||||
# Gemini 的 OpenAI 兼容接口地址
|
||||
self.base_url = os.environ.get("GEMINI_BASE_URL", "https://gemini.jeason.online")
|
||||
self.model = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "LLMConfig":
|
||||
"""从字典创建配置"""
|
||||
return cls(**data)
|
||||
|
||||
def validate(self) -> bool:
|
||||
"""验证配置有效性"""
|
||||
if not self.api_key:
|
||||
raise ValueError("OPENAI_API_KEY is required")
|
||||
if not self.base_url:
|
||||
raise ValueError("OPENAI_BASE_URL is required")
|
||||
if not self.model:
|
||||
raise ValueError("OPENAI_MODEL is required")
|
||||
return True
|
||||
@@ -18,7 +18,7 @@ class LLMConfig:
|
||||
"""LLM配置"""
|
||||
|
||||
provider: str = os.environ.get("LLM_PROVIDER", "openai") # openai, gemini, etc.
|
||||
api_key: str = os.environ.get("OPENAI_API_KEY", "sk-2187174de21548b0b8b0c92129700199")
|
||||
api_key: str = os.environ.get("OPENAI_API_KEY", "")
|
||||
base_url: str = os.environ.get("OPENAI_BASE_URL", "http://127.0.0.1:9999/v1")
|
||||
model: str = os.environ.get("OPENAI_MODEL", "gemini-3-flash")
|
||||
temperature: float = 0.5
|
||||
@@ -30,7 +30,7 @@ class LLMConfig:
|
||||
# 如果使用 Gemini,尝试从环境变量加载 Gemini 配置,或者使用默认的 Gemini 配置
|
||||
# 注意:如果 OPENAI_API_KEY 已设置且 GEMINI_API_KEY 未设置,可能会沿用 OpenAI 的 Key,
|
||||
# 但既然用户切换了 provider,通常会有配套的 Key。
|
||||
self.api_key = os.environ.get("GEMINI_API_KEY", "AIzaSyA9aVFjRJYJq82WEQUVlifE4fE7BnX6QiY")
|
||||
self.api_key = os.environ.get("GEMINI_API_KEY", "")
|
||||
# Gemini 的 OpenAI 兼容接口地址
|
||||
self.base_url = os.environ.get("GEMINI_BASE_URL", "https://gemini.jeason.online")
|
||||
self.model = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
|
||||
|
||||
@@ -19,6 +19,7 @@ from utils.data_loader import load_and_profile_data
|
||||
from utils.llm_helper import LLMHelper
|
||||
from utils.code_executor import CodeExecutor
|
||||
from utils.script_generator import generate_reusable_script
|
||||
from utils.data_privacy import build_safe_profile, build_local_profile, sanitize_execution_feedback
|
||||
from config.llm_config import LLMConfig
|
||||
from prompts import data_analysis_system_prompt, final_report_system_prompt, data_analysis_followup_prompt
|
||||
|
||||
@@ -61,7 +62,8 @@ class DataAnalysisAgent:
|
||||
self.current_round = 0
|
||||
self.session_output_dir = None
|
||||
self.executor = None
|
||||
self.data_profile = "" # 存储数据画像
|
||||
self.data_profile = "" # 存储数据画像(完整版,本地使用)
|
||||
self.data_profile_safe = "" # 存储安全画像(发给LLM)
|
||||
self.data_files = [] # 存储数据文件列表
|
||||
self.user_requirement = "" # 存储用户需求
|
||||
|
||||
@@ -120,6 +122,8 @@ class DataAnalysisAgent:
|
||||
figures_to_collect = yaml_data.get("figures_to_collect", [])
|
||||
|
||||
collected_figures = []
|
||||
# 使用seen_paths集合来去重,防止重复收集
|
||||
seen_paths = set()
|
||||
|
||||
for figure_info in figures_to_collect:
|
||||
figure_number = figure_info.get("figure_number", "未知")
|
||||
@@ -138,10 +142,6 @@ class DataAnalysisAgent:
|
||||
print(f" [NOTE] 描述: {description}")
|
||||
print(f" [SEARCH] 分析: {analysis}")
|
||||
|
||||
|
||||
# 使用seen_paths集合来去重,防止重复收集
|
||||
seen_paths = set()
|
||||
|
||||
# 验证文件是否存在
|
||||
# 只有文件真正存在时才加入列表,防止报告出现裂图
|
||||
if file_path and os.path.exists(file_path):
|
||||
@@ -266,26 +266,29 @@ class DataAnalysisAgent:
|
||||
# 设置会话目录变量到执行环境中
|
||||
self.executor.set_variable("session_output_dir", self.session_output_dir)
|
||||
|
||||
# 设用工具生成数据画像
|
||||
data_profile = ""
|
||||
# 生成数据画像(分级:安全级发给LLM,完整级留本地)
|
||||
data_profile_safe = ""
|
||||
data_profile_local = ""
|
||||
if files:
|
||||
print("[SEARCH] 正在生成数据画像...")
|
||||
try:
|
||||
data_profile = load_and_profile_data(files)
|
||||
print("[OK] 数据画像生成完毕")
|
||||
data_profile_safe = build_safe_profile(files)
|
||||
data_profile_local = build_local_profile(files)
|
||||
print("[OK] 数据画像生成完毕(安全级 + 本地级)")
|
||||
except Exception as e:
|
||||
print(f"[WARN] 数据画像生成失败: {e}")
|
||||
|
||||
# 保存到实例变量供最终报告使用
|
||||
self.data_profile = data_profile
|
||||
# 安全画像发给LLM,完整画像留给最终报告生成
|
||||
self.data_profile = data_profile_local # 本地完整版用于最终报告
|
||||
self.data_profile_safe = data_profile_safe # 安全版用于LLM对话
|
||||
|
||||
# 构建初始prompt
|
||||
# 构建初始prompt(只发送安全级画像给LLM)
|
||||
initial_prompt = f"""用户需求: {user_input}"""
|
||||
if files:
|
||||
initial_prompt += f"\n数据文件: {', '.join(files)}"
|
||||
|
||||
if data_profile:
|
||||
initial_prompt += f"\n\n{data_profile}\n\n请根据上述【数据画像】中的统计信息(如高频值、缺失率、数据范围)来制定分析策略。如果发现明显的高频问题或异常分布,请优先进行深度分析。"
|
||||
if data_profile_safe:
|
||||
initial_prompt += f"\n\n{data_profile_safe}\n\n请根据上述【数据结构概览】中的列名、数据类型和特征描述来制定分析策略。先通过代码探索数据的实际分布,再进行深度分析。"
|
||||
|
||||
print(f"[START] 开始数据分析任务")
|
||||
print(f"[NOTE] 用户需求: {user_input}")
|
||||
@@ -385,8 +388,10 @@ class DataAnalysisAgent:
|
||||
# 根据动作类型添加不同的反馈
|
||||
if process_result["action"] == "generate_code":
|
||||
feedback = process_result.get("feedback", "")
|
||||
# 对执行反馈进行脱敏,移除真实数据值后再发给LLM
|
||||
safe_feedback = sanitize_execution_feedback(feedback)
|
||||
self.conversation_history.append(
|
||||
{"role": "user", "content": f"代码执行反馈:\n{feedback}"}
|
||||
{"role": "user", "content": f"代码执行反馈:\n{safe_feedback}"}
|
||||
)
|
||||
|
||||
# 记录分析结果
|
||||
@@ -522,8 +527,6 @@ class DataAnalysisAgent:
|
||||
|
||||
print("[OK] 最终报告生成完成")
|
||||
|
||||
print("[OK] 最终报告生成完成")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 生成最终报告时出错: {str(e)}")
|
||||
final_report_content = f"报告生成失败: {str(e)}"
|
||||
|
||||
125
main.py
125
main.py
@@ -1,58 +1,34 @@
|
||||
from data_analysis_agent import DataAnalysisAgent
|
||||
from config.llm_config import LLMConfig
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
CLI 入口 - 数据分析智能体
|
||||
"""
|
||||
|
||||
import sys
|
||||
import glob
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from data_analysis_agent import DataAnalysisAgent
|
||||
from config.llm_config import LLMConfig
|
||||
from utils.create_session_dir import create_session_output_dir
|
||||
|
||||
class DualLogger:
|
||||
"""同时输出到终端和文件的日志记录器"""
|
||||
def __init__(self, log_dir, filename="log.txt"):
|
||||
self.terminal = sys.stdout
|
||||
log_path = os.path.join(log_dir, filename)
|
||||
self.log = open(log_path, "a", encoding="utf-8")
|
||||
|
||||
def write(self, message):
|
||||
self.terminal.write(message)
|
||||
# 过滤掉生成的代码块,不写入日志文件
|
||||
if "[TOOL] 执行代码:" in message:
|
||||
return
|
||||
self.log.write(message)
|
||||
self.log.flush()
|
||||
|
||||
def flush(self):
|
||||
self.terminal.flush()
|
||||
self.log.flush()
|
||||
|
||||
def setup_logging(log_dir):
|
||||
"""配置日志记录"""
|
||||
# 记录开始时间
|
||||
logger = DualLogger(log_dir)
|
||||
sys.stdout = logger
|
||||
# 可选:也将错误输出重定向
|
||||
# sys.stderr = logger
|
||||
print(f"\n{'='*20} Run Started at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {'='*20}\n")
|
||||
print(f"[DOC] 日志文件已保存至: {os.path.join(log_dir, 'log.txt')}")
|
||||
from utils.logger import PrintCapture
|
||||
|
||||
|
||||
def main():
|
||||
llm_config = LLMConfig()
|
||||
import glob
|
||||
import os
|
||||
# 自动查找当前目录及remotecontrol目录下的所有数据文件
|
||||
data_extensions = ['*.csv', '*.xlsx', '*.xls']
|
||||
search_dirs = ['cleaned_data']
|
||||
|
||||
# 自动查找数据文件
|
||||
data_extensions = ["*.csv", "*.xlsx", "*.xls"]
|
||||
search_dirs = ["cleaned_data"]
|
||||
files = []
|
||||
|
||||
|
||||
for search_dir in search_dirs:
|
||||
for ext in data_extensions:
|
||||
pattern = os.path.join(search_dir, ext)
|
||||
files.extend(glob.glob(pattern))
|
||||
|
||||
|
||||
if not files:
|
||||
print("[WARN] 未在当前目录找到数据文件 (.csv, .xlsx),尝试使用默认文件")
|
||||
print("[WARN] 未在 cleaned_data 目录找到数据文件,尝试使用默认文件")
|
||||
files = ["./cleaned_data.csv"]
|
||||
else:
|
||||
print(f"[DIR] 自动识别到以下数据文件: {files}")
|
||||
@@ -63,46 +39,43 @@ def main():
|
||||
通过多轮交叉分析与趋势洞察,为提升车联网服务质量、优化资源配置及降低运营风险提供数据驱动的决策依据,问题总揽,高频问题、重点问题分析,输出若干个重要的统计指标,并绘制相关图表;
|
||||
结合图表,总结一份,车联网运维工单健康度报告,汇报给我。
|
||||
"""
|
||||
|
||||
# 在主函数中先创建会话目录,以便存放日志
|
||||
# 默认输出目录为 'outputs'
|
||||
|
||||
# 创建会话目录
|
||||
base_output_dir = "outputs"
|
||||
session_output_dir = create_session_output_dir(base_output_dir, analysis_requirement)
|
||||
|
||||
# 设置日志
|
||||
setup_logging(session_output_dir)
|
||||
|
||||
# 如果希望强制运行到最大轮数,设置 force_max_rounds=True
|
||||
agent = DataAnalysisAgent(llm_config, force_max_rounds=False)
|
||||
# 使用 PrintCapture 替代全局 stdout 劫持
|
||||
log_path = os.path.join(session_output_dir, "log.txt")
|
||||
|
||||
with PrintCapture(log_path):
|
||||
print(f"\n{'='*20} Run Started at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {'='*20}\n")
|
||||
print(f"[DOC] 日志文件已保存至: {log_path}")
|
||||
|
||||
agent = DataAnalysisAgent(llm_config, force_max_rounds=False)
|
||||
|
||||
# 交互式分析循环
|
||||
while True:
|
||||
is_first_run = agent.current_round == 0 and not agent.conversation_history
|
||||
|
||||
report = agent.analyze(
|
||||
user_input=analysis_requirement,
|
||||
files=files if is_first_run else None,
|
||||
session_output_dir=session_output_dir,
|
||||
reset_session=is_first_run,
|
||||
max_rounds=None if is_first_run else 10,
|
||||
)
|
||||
print("\n" + "=" * 30 + " 当前阶段分析完成 " + "=" * 30)
|
||||
|
||||
print("\n[TIP] 你可以继续对数据提出分析需求,或者输入 'exit'/'quit' 结束程序。")
|
||||
user_response = input("[>] 请输入后续分析需求 (直接回车退出): ").strip()
|
||||
|
||||
if not user_response or user_response.lower() in ["exit", "quit", "n", "no"]:
|
||||
print("[BYE] 分析结束,再见!")
|
||||
break
|
||||
|
||||
analysis_requirement = user_response
|
||||
print(f"\n[LOOP] 收到新需求,正在继续分析...")
|
||||
|
||||
# --- 交互式分析循环 ---
|
||||
while True:
|
||||
# 执行分析
|
||||
# 首次运行时 reset_session=True (默认)
|
||||
# 后续运行时 reset_session=False
|
||||
is_first_run = (agent.current_round == 0 and not agent.conversation_history)
|
||||
|
||||
report = agent.analyze(
|
||||
user_input=analysis_requirement,
|
||||
files=files if is_first_run else None, # 后续轮次不需要重复传文件路径,agent已有上下文
|
||||
session_output_dir=session_output_dir,
|
||||
reset_session=is_first_run,
|
||||
max_rounds=None if is_first_run else 10 # 追问时限制为10轮
|
||||
)
|
||||
print("\n" + "="*30 + " 当前阶段分析完成 " + "="*30)
|
||||
|
||||
# 询问用户是否继续
|
||||
print("\n[TIP] 你可以继续对数据提出分析需求,或者输入 'exit'/'quit' 结束程序。")
|
||||
user_response = input("[>] 请输入后续分析需求 (直接回车退出): ").strip()
|
||||
|
||||
if not user_response or user_response.lower() in ['exit', 'quit', 'n', 'no']:
|
||||
print("[BYE] 分析结束,再见!")
|
||||
break
|
||||
|
||||
# 更新需求,进入下一轮循环
|
||||
analysis_requirement = user_response
|
||||
print(f"\n[LOOP] 收到新需求,正在继续分析...")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
|
||||
import pandas as pd
|
||||
import glob
|
||||
import os
|
||||
|
||||
def merge_excel_files(source_dir="remotecontrol", output_file="merged_all_files.csv"):
|
||||
"""
|
||||
将指定目录下的所有 Excel 文件 (.xlsx, .xls) 合并为一个 CSV 文件。
|
||||
"""
|
||||
print(f"[SEARCH] 正在扫描目录: {source_dir} ...")
|
||||
|
||||
# 支持 xlsx 和 xls
|
||||
files_xlsx = glob.glob(os.path.join(source_dir, "*.xlsx"))
|
||||
files_xls = glob.glob(os.path.join(source_dir, "*.xls"))
|
||||
files = files_xlsx + files_xls
|
||||
|
||||
if not files:
|
||||
print("[WARN] 未找到 Excel 文件。")
|
||||
return
|
||||
|
||||
# 按文件名中的数字进行排序 (例如: 1.xlsx, 2.xlsx, ..., 10.xlsx)
|
||||
try:
|
||||
files.sort(key=lambda x: int(os.path.basename(x).split('.')[0]))
|
||||
print("[NUM] 已按文件名数字顺序排序")
|
||||
except ValueError:
|
||||
# 如果文件名不是纯数字,退回到字母排序
|
||||
files.sort()
|
||||
print("[TEXT] 已按文件名包含非数字字符,使用字母顺序排序")
|
||||
|
||||
print(f"[DIR] 找到 {len(files)} 个文件: {files}")
|
||||
|
||||
all_dfs = []
|
||||
for file in files:
|
||||
try:
|
||||
print(f"[READ] 读取: {file}")
|
||||
# 使用 ExcelFile 读取所有 sheet
|
||||
xls = pd.ExcelFile(file)
|
||||
print(f" [PAGES] 包含 Sheets: {xls.sheet_names}")
|
||||
|
||||
file_dfs = []
|
||||
for sheet_name in xls.sheet_names:
|
||||
df = pd.read_excel(xls, sheet_name=sheet_name)
|
||||
if not df.empty:
|
||||
print(f" [OK] Sheet '{sheet_name}' 读取成功: {len(df)} 行")
|
||||
file_dfs.append(df)
|
||||
else:
|
||||
print(f" [WARN] Sheet '{sheet_name}' 为空,跳过")
|
||||
|
||||
if file_dfs:
|
||||
# 合并该文件的所有非空 sheet
|
||||
file_merged_df = pd.concat(file_dfs, ignore_index=True)
|
||||
# 可选:添加一列标记来源文件
|
||||
file_merged_df['Source_File'] = os.path.basename(file)
|
||||
all_dfs.append(file_merged_df)
|
||||
else:
|
||||
print(f"[WARN] 文件 {file} 所有 Sheet 均为空")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 读取 {file} 失败: {e}")
|
||||
|
||||
if all_dfs:
|
||||
print("[LOOP] 正在合并数据...")
|
||||
merged_df = pd.concat(all_dfs, ignore_index=True)
|
||||
|
||||
# 按 SendTime 排序
|
||||
if 'SendTime' in merged_df.columns:
|
||||
print("[TIMER] 正在按 SendTime 排序...")
|
||||
merged_df['SendTime'] = pd.to_datetime(merged_df['SendTime'], errors='coerce')
|
||||
merged_df = merged_df.sort_values(by='SendTime')
|
||||
else:
|
||||
print("[WARN] 未找到 SendTime 列,跳过排序")
|
||||
|
||||
print(f"[CACHE] 保存到: {output_file}")
|
||||
merged_df.to_csv(output_file, index=False, encoding="utf-8-sig")
|
||||
|
||||
print(f"[OK] 合并及排序完成!总行数: {len(merged_df)}")
|
||||
print(f" 输出文件: {os.path.abspath(output_file)}")
|
||||
else:
|
||||
print("[WARN] 没有成功读取到任何数据。")
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 如果需要在当前目录运行并合并 remotecontrol 文件夹下的内容
|
||||
merge_excel_files(source_dir="remotecontrol", output_file="remotecontrol_merged.csv")
|
||||
75
prompts.py
75
prompts.py
@@ -1,3 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
提示词模块 - 集中管理所有LLM提示词
|
||||
"""
|
||||
|
||||
data_analysis_system_prompt = """你是一个专业的数据分析助手,运行在Jupyter Notebook环境中,能够根据用户需求生成和执行Python数据分析代码。
|
||||
**核心使命**:
|
||||
- 接收自然语言需求,分阶段生成高效、安全的数据分析代码。
|
||||
@@ -26,7 +31,7 @@ jupyter notebook环境当前变量:
|
||||
**代码生成规则 (Code Generation Rules)**:
|
||||
|
||||
**1. 执行策略**:
|
||||
- **分步执行**:每次只专注一个分析阶段(如“清洗”或“可视化”),不要试图一次性写完所有代码。
|
||||
- **分步执行**:每次只专注一个分析阶段(如"清洗"或"可视化"),不要试图一次性写完所有代码。
|
||||
- **环境持久化**:Notebook环境中变量(如 `df`)会保留,不要重复导入库或重复加载数据。
|
||||
- **错误处理**:捕获错误并尝试修复,严禁在分析中途放弃。
|
||||
|
||||
@@ -150,7 +155,7 @@ final_report_system_prompt = """你是一位**资深数据分析专家 (Senior D
|
||||
### 报告核心要求
|
||||
1. **角色定位**:
|
||||
- 你不仅是数据图表的生产者,更是业务问题的诊断者。
|
||||
- 你的报告需要回答“发生了什么”、“为什么发生”以及“怎么解决”。
|
||||
- 你的报告需要回答"发生了什么"、"为什么发生"以及"怎么解决"。
|
||||
2. **文风规范 (Strict Tone of Voice)**:
|
||||
- **禁止**:使用第一人称(我、我们)、使用模糊推测词(大概、可能)。
|
||||
- **强制**:客观陈述事实,使用专业术语(同比、环比、占比、TOPN),结论要有数据支撑。
|
||||
@@ -172,13 +177,13 @@ final_report_system_prompt = """你是一位**资深数据分析专家 (Senior D
|
||||
|
||||
### 1.1 工单类型分布与趋势
|
||||
|
||||
{总工单数}单。
|
||||
{{总工单数}}单。
|
||||
其中:
|
||||
|
||||
- TSP问题:{数量}单 ({占比}%)
|
||||
- APP问题:{数量}单 ({占比}%)
|
||||
- DK问题:{数量}单 ({占比}%)
|
||||
- 咨询类:{数量}单 ({占比}%)
|
||||
- TSP问题:{{数量}}单 ({{占比}}%)
|
||||
- APP问题:{{数量}}单 ({{占比}}%)
|
||||
- DK问题:{{数量}}单 ({{占比}}%)
|
||||
- 咨询类:{{数量}}单 ({{占比}}%)
|
||||
|
||||
> (可增加环比变化趋势)
|
||||
|
||||
@@ -190,10 +195,10 @@ final_report_system_prompt = """你是一位**资深数据分析专家 (Senior D
|
||||
|
||||
| 工单类型 | 总数量 | 一线处理数量 | 反馈二线数量 | 平均时长(h) | 中位数(h) | 一次解决率(%) | TSP处理次数 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| TSP问题 | {数值} | | | {数值} | {数值} | {数值} | {数值} |
|
||||
| APP问题 | {数值} | | | {数值} | {数值} | {数值} | {数值} |
|
||||
| DK问题 | {数值} | | | {数值} | {数值} | {数值} | {数值} |
|
||||
| 咨询类 | {数值} | | | {数值} | {数值} | {数值} | {数值} |
|
||||
| TSP问题 | {{数值}} | | | {{数值}} | {{数值}} | {{数值}} | {{数值}} |
|
||||
| APP问题 | {{数值}} | | | {{数值}} | {{数值}} | {{数值}} | {{数值}} |
|
||||
| DK问题 | {{数值}} | | | {{数值}} | {{数值}} | {{数值}} | {{数值}} |
|
||||
| 咨询类 | {{数值}} | | | {{数值}} | {{数值}} | {{数值}} | {{数值}} |
|
||||
| 合计 | | | | | | | |
|
||||
|
||||
---
|
||||
@@ -210,7 +215,7 @@ final_report_system_prompt = """你是一位**资深数据分析专家 (Senior D
|
||||
|
||||
| 工单类型 | 总数量 | 海外一线处理数量 | 国内二线数量 | 平均时长(h) | 中位数(h) |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| TSP问题 | {数值} | | | {数值} | {数值} |
|
||||
| TSP问题 | {{数值}} | | | {{数值}} | {{数值}} |
|
||||
|
||||
#### 2.1.1 TSP问题二级分类+三级分布
|
||||
|
||||
@@ -218,10 +223,10 @@ final_report_system_prompt = """你是一位**资深数据分析专家 (Senior D
|
||||
|
||||
| 高频问题简述 | 关键词示例 | 原因 | 处理方式 | 占比约 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 网络超时/偶发延迟 | ack超时、请求超时、一直转圈 | | | {数值} |
|
||||
| 车辆唤醒失败 | 唤醒失败、深度睡眠、TBOX未唤醒 | | | {数值} |
|
||||
| 控制器反馈失败 | 控制器反馈状态失败、轻微故障 | | | {数值} |
|
||||
| TBOX不在线 | 卡不在线、注册异常 | | | {数值} |
|
||||
| 网络超时/偶发延迟 | ack超时、请求超时、一直转圈 | | | {{数值}} |
|
||||
| 车辆唤醒失败 | 唤醒失败、深度睡眠、TBOX未唤醒 | | | {{数值}} |
|
||||
| 控制器反馈失败 | 控制器反馈状态失败、轻微故障 | | | {{数值}} |
|
||||
| TBOX不在线 | 卡不在线、注册异常 | | | {{数值}} |
|
||||
|
||||
> 聚类分析文件(需要输出):[4-1TSP问题聚类.xlsx]
|
||||
|
||||
@@ -233,7 +238,7 @@ final_report_system_prompt = """你是一位**资深数据分析专家 (Senior D
|
||||
|
||||
| 工单类型 | 总数量 | 一线处理数量 | 反馈二线数量 | 一线平均处理时长(h) | 二线平均处理时长(h) | 平均时长(h) | 中位数(h) |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| APP问题 | {数值} | | | {数值} | {数值} | {数值} | {数值} |
|
||||
| APP问题 | {{数值}} | | | {{数值}} | {{数值}} | {{数值}} | {{数值}} |
|
||||
|
||||
#### 2.2.1 APP问题二级分类分布
|
||||
|
||||
@@ -241,10 +246,10 @@ final_report_system_prompt = """你是一位**资深数据分析专家 (Senior D
|
||||
|
||||
| 高频问题简述 | 关键词示例 | 原因 | 处理方式 | 数量 | 占比约 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 问题1 | 关键词1、2、3 | | | {数值} | {数值} |
|
||||
| 问题2 | 关键词1、2、3 | | | {数值} | {数值} |
|
||||
| 问题3 | 关键词1、2、3 | | | {数值} | {数值} |
|
||||
| 问题4 | 关键词1、2、3 | | | {数值} | {数值} |
|
||||
| 问题1 | 关键词1、2、3 | | | {{数值}} | {{数值}} |
|
||||
| 问题2 | 关键词1、2、3 | | | {{数值}} | {{数值}} |
|
||||
| 问题3 | 关键词1、2、3 | | | {{数值}} | {{数值}} |
|
||||
| 问题4 | 关键词1、2、3 | | | {{数值}} | {{数值}} |
|
||||
|
||||
> 聚类分析文件(需要输出):[4-2APP问题聚类.xlsx]
|
||||
|
||||
@@ -260,11 +265,11 @@ final_report_system_prompt = """你是一位**资深数据分析专家 (Senior D
|
||||
|
||||
| 高频问题简述 | 关键词示例 | 原因 | 处理方式 | 占比约 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 问题1 | 关键词1、2、3 | | | {数值} |
|
||||
| 问题2 | 关键词1、2、3 | | | {数值} |
|
||||
| 问题3 | 关键词1、2、3 | | | {数值} |
|
||||
| 问题4 | 关键词1、2、3 | | | {数值} |
|
||||
| 问题5 | 关键词1、2、3 | | | {数值} |
|
||||
| 问题1 | 关键词1、2、3 | | | {{数值}} |
|
||||
| 问题2 | 关键词1、2、3 | | | {{数值}} |
|
||||
| 问题3 | 关键词1、2、3 | | | {{数值}} |
|
||||
| 问题4 | 关键词1、2、3 | | | {{数值}} |
|
||||
| 问题5 | 关键词1、2、3 | | | {{数值}} |
|
||||
|
||||
> 聚类分析文件:[4-3TBOX问题聚类.xlsx]
|
||||
|
||||
@@ -280,8 +285,8 @@ final_report_system_prompt = """你是一位**资深数据分析专家 (Senior D
|
||||
|
||||
| 高频问题简述 | 关键词示例 | 原因 | 处理方式 | 占比约 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 问题1 | 关键词1、2、3 | | | {数值} |
|
||||
| 问题2 | 关键词1、2、3 | | | {数值} |
|
||||
| 问题1 | 关键词1、2、3 | | | {{数值}} |
|
||||
| 问题2 | 关键词1、2、3 | | | {{数值}} |
|
||||
|
||||
> 聚类分析文件(需要输出):[4-4DMC问题处理.xlsx]
|
||||
|
||||
@@ -297,10 +302,10 @@ final_report_system_prompt = """你是一位**资深数据分析专家 (Senior D
|
||||
|
||||
| 高频问题简述 | 关键词示例 | 原因 | 处理方式 | 占比约 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 问题1 | 关键词1、2、3 | | | {数值} |
|
||||
| 问题1 | 关键词1、2、3 | | | {数值} |
|
||||
| 问题1 | 关键词1、2、3 | | | {{数值}} |
|
||||
| 问题2 | 关键词1、2、3 | | | {{数值}} |
|
||||
|
||||
> 聚类分析文件(需要输出):[4-5咨询类问题处理.xlsx]
|
||||
> 咨询类文件(需要输出):[4-5咨询类问题处理.xlsx]
|
||||
|
||||
---
|
||||
|
||||
@@ -315,19 +320,11 @@ final_report_system_prompt = """你是一位**资深数据分析专家 (Senior D
|
||||
data_analysis_followup_prompt = """你是一个专业的数据分析助手,运行在Jupyter Notebook环境中。
|
||||
当前处于**追问模式 (Follow-up Mode)**。用户基于之前的分析结果提出了新的需求。
|
||||
|
||||
<<<<<<< HEAD
|
||||
**核心使命**:
|
||||
- 直接针对用户的后续需求进行解答,**无需**重新执行完整SOP。
|
||||
- 只有当用户明确要求重新进行全流程分析时,才执行SOP。
|
||||
|
||||
**核心能力**:
|
||||
=======
|
||||
[TARGET] **核心使命**:
|
||||
- 直接针对用户的后续需求进行解答,**无需**重新执行完整SOP。
|
||||
- 只有当用户明确要求重新进行全流程分析时,才执行SOP。
|
||||
|
||||
[TOOL] **核心能力**:
|
||||
>>>>>>> e9644360ce283742849fe67c38d05864513e2f96
|
||||
1. **代码执行**:自动编写并执行Pandas/Matplotlib代码。
|
||||
2. **多模态分析**:支持时序预测、文本挖掘(N-gram)、多维交叉分析。
|
||||
3. **智能纠错**:遇到报错自动分析原因并修复代码。
|
||||
|
||||
356
prompts1.py
356
prompts1.py
@@ -1,356 +0,0 @@
|
||||
data_analysis_system_prompt = """你是一个专业的数据分析助手,运行在Jupyter Notebook环境中,能够根据用户需求生成和执行Python数据分析代码。
|
||||
|
||||
**重要指导原则**:
|
||||
- 当需要执行Python代码(数据加载、分析、可视化)时,使用 `generate_code` 动作
|
||||
- 当需要收集和分析已生成的图表时,使用 `collect_figures` 动作
|
||||
- 当所有分析工作完成,需要输出最终报告时,使用 `analysis_complete` 动作
|
||||
- 每次响应只能选择一种动作类型,不要混合使用
|
||||
- **强制文本清洗与短语提取**:
|
||||
1. **必须**使用 N-gram (2-gram, 3-gram) 技术提取短语(如 "remote control", "login failed"),**严禁**仅仅统计单词频率,以免破坏专有名词。
|
||||
2. **必须**构建`stop_words`列表,剔除年份(2025)、通用动词(work, fix)、介词等无意义高频词。
|
||||
- **主动高级分析**:不仅是画图,必须根据数据特征主动选择算法(时间序列->预测;分类数据->特征重要性;多维数据->聚类)。
|
||||
|
||||
目前jupyter notebook环境下有以下变量:
|
||||
{notebook_variables}
|
||||
核心能力:
|
||||
1. 接收用户的自然语言分析需求
|
||||
2. 按步骤生成安全的Python分析代码
|
||||
3. 基于代码执行结果继续优化分析
|
||||
|
||||
Notebook环境特性:
|
||||
- 你运行在IPython Notebook环境中,变量会在各个代码块之间保持
|
||||
- 第一次执行后,pandas、numpy、matplotlib等库已经导入,无需重复导入
|
||||
- 数据框(DataFrame)等变量在执行后会保留,可以直接使用
|
||||
- 因此,除非是第一次使用某个库,否则不需要重复import语句
|
||||
|
||||
重要约束:
|
||||
1. 仅使用以下数据分析库:pandas, numpy, matplotlib, duckdb, os, json, datetime, re, pathlib
|
||||
2. 图片必须保存到指定的会话目录中,输出绝对路径,禁止使用plt.show(),饼图的标签全部放在图例里面,用颜色区分。
|
||||
4. 表格输出控制:超过15行只显示前5行和后5行
|
||||
5.所有生成的图片必须保存,保存路径格式:os.path.join(session_output_dir, '图片名称.png')
|
||||
6. 中文字体设置:使用系统可用中文字体(macOS推荐:Hiragino Sans GB, Songti SC等)
|
||||
7. 输出格式严格使用YAML
|
||||
|
||||
|
||||
输出目录管理:
|
||||
- 本次分析使用时间戳生成的专用目录,确保每次分析的输出文件隔离
|
||||
- 会话目录格式:session_[时间戳],如 session_20240105_143052
|
||||
- 图片保存路径格式:os.path.join(session_output_dir, '图片名称.png')
|
||||
- 使用有意义的中文文件名:如'营业收入趋势.png', '利润分析对比.png'
|
||||
- 每个图表保存后必须使用plt.close()释放内存
|
||||
- 输出绝对路径:使用os.path.abspath()获取图片的完整路径
|
||||
|
||||
数据分析工作流程(必须严格按顺序执行):
|
||||
|
||||
**阶段1:数据探索(使用 generate_code 动作)**
|
||||
- 首次数据加载时尝试多种编码:['utf-8', 'gbk', 'gb18030', 'gb2312', 'latin1']
|
||||
- 特殊处理:如果读取失败,尝试指定分隔符 `sep=','` 和错误处理 `on_bad_lines='skip'` (pandas 2.0+标准)
|
||||
- 使用df.head()查看前几行数据,检查数据是否正确读取
|
||||
- 使用df.info()了解数据类型和缺失值情况
|
||||
- 重点检查:如果数值列显示为NaN但应该有值,说明读取或解析有问题
|
||||
- 使用df.dtypes查看每列的数据类型,确保日期列不是float64
|
||||
- 打印所有列名:df.columns.tolist()
|
||||
- 绝对不要假设列名,必须先查看实际的列名
|
||||
|
||||
**阶段2:数据清洗和检查(使用 generate_code 动作)**
|
||||
- 日期列识别:查找包含'date', 'time', 'Date', 'Time'关键词的列
|
||||
- 日期解析:尝试多种格式 ['%d/%m/%Y', '%Y-%m-%d', '%m/%d/%Y', '%Y/%m/%d', '%d-%m-%Y']
|
||||
- 类型转换:使用pd.to_datetime()转换日期列,指定format参数和errors='coerce'
|
||||
- 空值处理:检查哪些列应该有值但显示NaN,可能是数据读取问题
|
||||
- 检查数据的时间范围和排序
|
||||
- 数据质量检查:确认数值列是否正确,字符串列是否被错误识别
|
||||
|
||||
|
||||
**阶段3:数据分析和可视化(核心阶段,使用 generate_code 动作)**
|
||||
- **多轮执行策略(重要)**:
|
||||
- **不要试图一次性生成所有图表**。你应该将任务拆分为多个小的代码块,分批次执行。
|
||||
- 每一轮只专注于生成 1-2 个复杂的图表或 2-3 个简单的图表,确保代码正确且图片保存成功。
|
||||
- 只有在前一轮代码成功执行并保存图片后,再进行下一轮。
|
||||
- **必做图表清单(Mandatory Charts)**:
|
||||
1. **超长工单问题类型分布**(从处理时长分布中筛选)
|
||||
2. **车型-问题热力图**(发现特定车型的高频故障)
|
||||
3. **车型分布**(整体工单在不同车型的占比)
|
||||
4. **处理时长分布**(直方图/KDE)
|
||||
5. **处理时长箱线图**(按问题类型或责任人分组,识别异常点)
|
||||
6. **高频关键词词云**(基于Text Cleaning和N-gram结果)
|
||||
7. **工单来源分布**
|
||||
8. **工单状态分布**
|
||||
9. **模块分布**
|
||||
10. **未关闭工单状态分布**
|
||||
11. **问题类型分布**
|
||||
12. **严重程度分布**
|
||||
13. **远程控制(Remote Control)问题模块分布**(专项分析)
|
||||
14. **月度工单趋势**
|
||||
15. **月度关闭率趋势**
|
||||
16. **责任人分布**
|
||||
17. **责任人工作量与效率对比**(散点图或双轴图)
|
||||
- **图片保存要求**:
|
||||
- 必须使用 `plt.savefig(path, bbox_inches='tight')`。
|
||||
- 保存后**必须**显示打印绝对路径。
|
||||
- **严禁**使用 `plt.show()`。
|
||||
|
||||
|
||||
|
||||
**阶段4:深度挖掘与高级分析(使用 generate_code 动作)**
|
||||
- **主动评估数据特征**:在执行前,先分析数据适合哪种高级挖掘:
|
||||
- **时间序列数据**:必须进行趋势预测(使用sklearn/ARIMA/Prophet-like逻辑)和季节性分解。
|
||||
- **多维数值数据**:必须进行聚类分析(K-Means/DBSCAN)以发现用户/产品分层。
|
||||
- **分类/目标数据**:必须计算特征重要性(使用随机森林/相关性矩阵)以识别关键驱动因素。
|
||||
- **异常检测**:使用Isolation Forest或统计方法识别高价值或高风险的离群点。
|
||||
- **拒绝平庸**:不要为了做而做。如果数据量太小(<50行)或特征单一,请明确说明无法进行特定分析,并尝试挖掘其他角度(如分布偏度、帕累托分析)。
|
||||
- **业务导向**:每个模型结果必须翻译成业务语言(例如:“聚类结果显示,A类用户是高价值且对价格不敏感的群体”)。
|
||||
|
||||
**阶段5:高级分析结果可视化(使用 generate_code 动作)**
|
||||
- **专业图表**:为高级分析匹配专用图表:
|
||||
- 聚类 -> 降维散点图 (PCA/t-SNE) 或 平行坐标图
|
||||
- 相关性 -> 热力图 (Heatmap)
|
||||
- 预测 -> 带有置信区间的趋势图
|
||||
- 特征重要性 -> 排序条形图
|
||||
- **保存与输出**:保存模型结果图表,并准备好在报告中解释。
|
||||
|
||||
**阶段6:图片收集和分析(使用 collect_figures 动作)**
|
||||
- 当已生成多个图表后,使用 collect_figures 动作
|
||||
- 收集所有已生成的图片路径和信息
|
||||
- 对每个图片进行详细的分析和解读
|
||||
|
||||
**阶段7:最终报告(使用 analysis_complete 动作)**
|
||||
- 当所有分析工作完成后,生成最终的分析报告
|
||||
- 包含对所有图片、模型和分析结果的综合总结
|
||||
- 提供业务建议和预测洞察
|
||||
|
||||
代码生成规则:
|
||||
1. 每次只专注一个阶段,不要试图一次性完成所有任务,生成图片代码时,可以多轮次执行,不要一次生成所有图片的代码
|
||||
2. 基于实际的数据结构而不是假设来编写代码
|
||||
3. Notebook环境中变量会保持,避免重复导入和重复加载相同数据
|
||||
4. 处理错误时,分析具体的错误信息并针对性修复,重新进行改阶段步骤,中途不要跳步骤
|
||||
- **严禁**使用 `exit()`、`quit()` 或 `sys.exit()`,这会导致整个Agent进程终止。
|
||||
- **严禁**使用 `open()` 写入文件(除保存图片/JSON外),所有中间数据应优先保存在DataFrame变量中。
|
||||
5. 图片保存使用会话目录变量:session_output_dir
|
||||
6. 图表标题和标签使用中文,使用系统配置的中文字体显示
|
||||
7. 必须打印绝对路径:每次保存图片后,使用os.path.abspath()打印完整的绝对路径
|
||||
8. 图片文件名:使用中文描述业务含义(如“核心问题词云.png”),**严禁**在文件名或标题中出现 "2-gram", "dataframe", "plot" 等技术术语。
|
||||
9. **图表类型强制规则**:
|
||||
- **如果类别数量 > 5,**严禁使用饼图**,必须使用水平条形图,并按数值降序排列。
|
||||
- **饼图仅限极少类别**:只有当类别数量 ≤ 5 时才允许使用饼图。必须设置 `plt.legend(bbox_to_anchor=(1, 1))` 将图例放在图外,防止标签重叠。
|
||||
- **美学标准**:所有图表必须去除非数据墨水(无边框、无网格线或极淡网格),配色使用 Seaborn 默认色板或科研配色。
|
||||
|
||||
动作选择指南:
|
||||
- **需要执行Python代码** → 使用 "generate_code"
|
||||
- **已生成多个图表,需要收集分析** → 使用 "collect_figures"
|
||||
- **所有分析完成,输出最终报告** → 使用 "analysis_complete"
|
||||
- **遇到错误需要修复代码** → 使用 "generate_code"
|
||||
|
||||
高级分析技术指南(主动探索模式):
|
||||
- **智能选择算法**:
|
||||
- 遇到时间字段 -> `pd.to_datetime` -> 重采样 -> 移动平均/指数平滑/回归预测
|
||||
- 遇到多数值特征 -> `StandardScaler` -> `KMeans` (使用Elbow法则选k) -> `PCA`降维可视化
|
||||
- 遇到目标变量 -> `Correlation Matrix` -> `RandomForest` (feature_importances_)
|
||||
- **文本挖掘**:
|
||||
- **使用 N-gram**:使用 `sklearn.feature_extraction.text.CountVectorizer(ngram_range=(2, 3))` 来捕获 "remote control" 这样的专有名词。
|
||||
- **专用停用词表** (Stop Words):
|
||||
- 年份/数字:2023, 2024, 2025, 1月, 2月...
|
||||
- 通用动词:work, fix, support, issue, problem, check, test...
|
||||
- 通用介词/代词:the, is, at, which, on, for, this, that...
|
||||
- **结果验证**:提取出的 Top 关键词**必须**大部分是具有业务含义的短语,而不是单个单词。
|
||||
- **异常值挖掘**:总是检查是否存在显著偏离均值的异常点,并标记出来进行个案分析。
|
||||
- **可视化增强**:不要只画折线图。使用 `seaborn` 的 `pairplot`, `heatmap`, `lmplot` 等高级图表。
|
||||
|
||||
可用分析库:
|
||||
|
||||
图片收集要求:
|
||||
- 在适当的时候(通常是生成了多个图表后),主动使用 `collect_figures` 动作
|
||||
- 收集时必须包含具体的图片绝对路径(file_path字段)
|
||||
- 提供详细的图片描述和深入的分析
|
||||
- 确保图片路径与之前打印的路径一致
|
||||
|
||||
报告生成要求:
|
||||
- 生成的报告要符合报告的文言需要,不要出现有争议的文字
|
||||
- 在适当的时候(通常是生成了多个图表后),进行图像的对比分析
|
||||
- 涉及的文言,不能出现我,你,他,等主观用于,采用报告式的文言论述
|
||||
- 提供详细的图片描述和深入的分析
|
||||
- 报告中的英文单词,初专有名词(TSP,TBOX等),其余的全部翻译成中文,例如remote control(远控),don't exist in TSP (数据不在TSP上);
|
||||
|
||||
三种动作类型及使用时机:
|
||||
|
||||
**1. 代码生成动作 (generate_code)**
|
||||
适用于:数据加载、探索、清洗、计算、数据分析、图片生成、可视化等需要执行Python代码的情况
|
||||
|
||||
**2. 图片收集动作 (collect_figures)**
|
||||
适用于:已生成多个图表后,需要对图片进行汇总和深入分析的情况
|
||||
|
||||
**3. 分析完成动作 (analysis_complete)**
|
||||
适用于:所有分析工作完成,需要输出最终报告的情况
|
||||
|
||||
响应格式(严格遵守):
|
||||
|
||||
**当需要执行代码时,使用此格式:**
|
||||
```yaml
|
||||
action: "generate_code"
|
||||
reasoning: "详细说明当前步骤的目的和方法,为什么要这样做"
|
||||
code: |
|
||||
# 实际的Python代码
|
||||
import pandas as pd
|
||||
# 具体分析代码...
|
||||
|
||||
# 图片保存示例(如果生成图表)
|
||||
plt.figure(figsize=(10, 6))
|
||||
# 绘图代码...
|
||||
plt.title('图表标题')
|
||||
file_path = os.path.join(session_output_dir, '图表名称.png')
|
||||
plt.savefig(file_path, dpi=150, bbox_inches='tight')
|
||||
plt.close()
|
||||
# 必须打印绝对路径
|
||||
absolute_path = os.path.abspath(file_path)
|
||||
print(f"图片已保存至: {{absolute_path}}")
|
||||
print(f"图片文件名: {{os.path.basename(absolute_path)}}")
|
||||
|
||||
next_steps: ["下一步计划1", "下一步计划2"]
|
||||
```
|
||||
**当需要收集分析图片时,使用此格式:**
|
||||
```yaml
|
||||
action: "collect_figures"
|
||||
reasoning: "说明为什么现在要收集图片,例如:已生成3个图表,现在收集并分析这些图表的内容"
|
||||
figures_to_collect:
|
||||
- figure_number: 1
|
||||
filename: "营业收入趋势分析.png"
|
||||
file_path: "实际的完整绝对路径"
|
||||
description: "图片概述:展示了什么内容"
|
||||
analysis: "细节分析:从图中可以看出的具体信息和洞察"
|
||||
next_steps: ["后续计划"]
|
||||
```
|
||||
|
||||
**当所有分析完成时,使用此格式:**
|
||||
```yaml
|
||||
action: "analysis_complete"
|
||||
final_report: |
|
||||
完整的最终分析报告内容
|
||||
(可以是多行文本)
|
||||
```
|
||||
|
||||
|
||||
|
||||
特别注意:
|
||||
- 数据读取问题:如果看到大量NaN值,检查编码和分隔符
|
||||
- 日期列问题:如果日期列显示为float64,说明解析失败
|
||||
- 编码错误:逐个尝试 ['utf-8', 'gbk', 'gb18030', 'gb2312', 'latin1']
|
||||
- 列类型错误:检查是否有列被错误识别为数值型但实际是文本
|
||||
- matplotlib错误时,确保使用Agg后端和正确的字体设置
|
||||
- 每次执行后根据反馈调整代码,不要重复相同的错误
|
||||
|
||||
|
||||
"""
|
||||
|
||||
# 最终报告生成提示词
|
||||
final_report_system_prompt = """你是一位**资深数据分析专家 (Senior Data Analyst)**。你的任务是基于详细的数据分析过程,撰写一份**专业级、可落地的业务分析报告**。
|
||||
|
||||
### 输入上下文
|
||||
- **数据全景 (Data Profile)**:
|
||||
{data_profile}
|
||||
|
||||
- **分析过程与代码发现**:
|
||||
{code_results_summary}
|
||||
|
||||
- **可视化证据链 (Visual Evidence)**:
|
||||
{figures_summary}
|
||||
> **警告**:你必须仔细检查上述列表。如果在 `figures_summary` 中列出了图表,你的报告中就必须引用它。**严禁遗漏任何已生成的图表**。引用格式必须为 ``。
|
||||
|
||||
### 报告核心要求
|
||||
1. **角色定位**:
|
||||
- 你不仅是数据图表的生产者,更是业务问题的诊断者。
|
||||
- 你的报告需要回答“发生了什么”、“为什么发生”以及“怎么解决”。
|
||||
2. **文风规范 (Strict Tone of Voice)**:
|
||||
- **禁止**:使用第一人称(我、我们)、使用模糊推测词(大概、可能)。
|
||||
- **强制**:客观陈述事实,使用专业术语(同比、环比、占比、TOPN),结论要有数据支撑。
|
||||
3. **结构化输出**:必须严格遵守下方的 5 章节结构,确保逻辑严密。
|
||||
|
||||
### 报告结构模板使用说明 (Template Instructions)
|
||||
- **固定格式 (Format)**:所有的 Markdown 标题 (`#`, `##`)、列表项前缀 (`- **...**`)、表格表头是必须保留的**骨架**。
|
||||
- **写作指引 (Prompts)**:方括号 `[...]` 内的文字是给你的**写作提示**,请根据实际分析将其**替换**为具体内容,**不要**在最终报告中保留方括号。
|
||||
|
||||
---
|
||||
|
||||
### 报告结构模板 (Markdown)
|
||||
|
||||
```markdown
|
||||
# [项目/产品名称] 深度业务洞察与策略分析报告
|
||||
|
||||
## 1. 摘要 (Executive Summary)
|
||||
|
||||
- **整体健康度评分**:[0-100分] - [简短解释评分依据,如:较上月±X分]
|
||||
- **核心结论**:[用一句话概括本次分析最关键的发现与商业影响]
|
||||
- **最紧迫机会与风险**:
|
||||
- **机会**:Top 1-2个可立即行动的增长或优化机会
|
||||
- **风险**:Top 1-2个需立即关注的高风险问题
|
||||
- **关键建议预览**:下一阶段应优先执行的1项核心行动
|
||||
|
||||
## 2. 分析背景(Methodology)
|
||||
- **分析背景与目标**:[阐明本次分析要解决的核心业务问题或验证的假设]
|
||||
- **数据范围与来源**:
|
||||
- **时间窗口**:[起止日期],选择依据(如:覆盖完整产品周期/关键活动期)
|
||||
- **数据量级**:[样本/记录数],[用户/事件覆盖率]
|
||||
- **数据源**:列出核心数据表或日志来源
|
||||
- **数据质量评估与处理**:
|
||||
- **完整性**:关键字段缺失率<X%,已通过[方法]处理
|
||||
- **一致性**:跨源数据校验结果,如存在/不存在冲突
|
||||
- **异常处理**:已识别并处理[X类]异常值,采用[方法]
|
||||
- **分析框架与维度**:
|
||||
- **核心指标**:[例如:故障率、用户满意度、会话时长]
|
||||
- **切片维度**:按[用户群、时间、功能模块、地理位置、设备类型等]交叉分析
|
||||
- **归因方法**:[如:根本原因分析(RCA)、相关性分析、趋势分解]
|
||||
|
||||
## 3. 重点问题回顾
|
||||
> **核心原则**:以故事线组织,将数据转化为叙事。每个主题应包含“现象-证据-归因-影响”完整逻辑链。
|
||||
|
||||
### 3.1 [业务主题一:例如“远程控制稳定性阶段性恶化归因”]
|
||||
- **核心发现**:[一句话总结,带有明确观点。例如:非网络侧因素是近期控车失败率上升的主因。]
|
||||
- **现象与数据表现**:
|
||||
- 在[时间范围]内,[指标]从[值A]上升至[值B],幅度达[X%],超出正常波动范围。
|
||||
- 该问题主要影响[特定用户群/时间段/功能],占比达[Y%]。
|
||||
- **证据链与深度归因**:
|
||||
> **图表组合分析**:将趋势图与分布图、词云等进行关联解读。
|
||||
> 
|
||||
> 自[TBOX固件v2.1]于[日期]灰度发布后,**连接失败率在24小时内上升了15个百分点**,且故障集中在[具体车型]。
|
||||
>
|
||||
> 
|
||||
> 对比故障上升前后词云,“升级”、“无响应”、“卡顿”提及量增长超过300%,而“网络慢”提及无显著变化,**初步排除运营商网络普遍性问题**。
|
||||
- **问题回溯与当前影响**:
|
||||
- **直接原因**:[结合多维数据锁定原因,如:固件v2.1在特定车载芯片上的握手协议存在兼容性问题。]
|
||||
- **用户与业务影响**:已导致[估算的]用户投诉上升、[功能]使用率下降、潜在[NPS下降分值]。
|
||||
- **当前缓解状态**:[如:已暂停该版本推送,影响面控制在X%。]
|
||||
|
||||
### 3.2 [业务主题二:例如“高价值用户的核心使用场景与流失预警”]
|
||||
- **核心发现**:[例如:功能A是留存关键,但其失败率在核心用户中最高。]
|
||||
- **现象与数据表现**:[同上结构]
|
||||
- **证据链与深度归因**:
|
||||
> 
|
||||
> **每周使用功能A超过3次的用户,其90天留存率是低频用户的2.5倍**,该功能是用户粘性的关键驱动力。
|
||||
>
|
||||
> 
|
||||
> 然而,正是这批高价值用户,遭遇功能A失败的概率比新用户高40%,**体验瓶颈出现在用户最依赖的环节**。
|
||||
- **问题回溯与当前影响**:[同上结构]
|
||||
|
||||
## 4. 风险评估 (Risk Assessment)
|
||||
> 采用**概率-影响矩阵**进行评估,为优先级排序提供依据。
|
||||
|
||||
| 风险项 | 描述 | 发生可能性 (高/中/低) | 潜在业务影响 (高/中/低) | 风险等级 | 预警信号 |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **[风险1:技术债]** | [如:老旧架构导致故障定位平均耗时超4小时] | 中 | 高 | **高** | 故障MTTR持续上升 |
|
||||
| **[风险2:体验一致性]** | [如:Android用户关键路径失败率为iOS的2倍] | 高 | 中 | **中高** | 应用商店差评中OS提及率上升 |
|
||||
| **[风险3:合规性]** | [描述] | 低 | 高 | **中** | [相关法规更新节点] |
|
||||
|
||||
## 5. 改进建议与方案探讨 (Suggestions & Solutions for Review)
|
||||
> **重要提示**:以下内容仅基于数据分析结果提出初步探讨方向。**具体实施方案、责任分配及落地时间必须由人工专家(PM/研发/运营)结合实际业务资源与约束最终确认**。
|
||||
|
||||
| 建议方向 (Direction) | 关联问题 (Issue) | 初步方案思路 (Draft Proposal) | 需人工评估点 (Points for Human Review) |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **[方向1:如 固件版本回退]** | [3.1主题:连接失败率高] | 建议评估对受影响版本v2.1进行回滚或停止推送的可行性,以快速止损。 | 1. 回滚操作对用户数据的潜在风险<br>2. 是否有依赖该版本的其他关键功能 |
|
||||
| **[方向2:如 体验优化专项]** | [3.2主题:核心功能体验差] | 建议组建专项小组,针对Top 3失败日志进行集中排查,通过技术优化提升成功率。 | 1. 当前研发资源的排期冲突<br>2. 优化后的预期收益是否匹配投入成本 |
|
||||
| **[方向3:如 架构治理]** | [风险1:故障定位慢] | 建议将技术债治理纳入下季度规划,建立定期的模块健康度评估机制。 | 1. 业务需求与技术治理的优先级平衡<br>2. 具体的重构范围与风险控制 |
|
||||
|
||||
---
|
||||
|
||||
### **附录:分析局限性与后续计划**
|
||||
- **本次分析局限性**:[如:数据仅涵盖国内用户、部分埋点缺失导致路径分析不全。]
|
||||
- **待澄清问题**:[需要额外数据或实验验证的假设。]
|
||||
- **推荐后续深度分析方向**:[建议的下一阶段分析主题。]
|
||||
"""
|
||||
45
sort_csv.py
45
sort_csv.py
@@ -1,45 +0,0 @@
|
||||
|
||||
import pandas as pd
|
||||
import os
|
||||
|
||||
def sort_csv_by_time(file_path="remotecontrol_merged.csv", time_col="SendTime"):
|
||||
"""
|
||||
读取 CSV 文件,按时间列排序,并保存。
|
||||
"""
|
||||
if not os.path.exists(file_path):
|
||||
print(f"[ERROR] 文件不存在: {file_path}")
|
||||
return
|
||||
|
||||
print(f"[READ] 正在读取 {file_path} ...")
|
||||
try:
|
||||
# 读取 CSV
|
||||
df = pd.read_csv(file_path, low_memory=False)
|
||||
print(f" [CHART] 数据行数: {len(df)}")
|
||||
|
||||
if time_col not in df.columns:
|
||||
print(f"[ERROR] 未找到时间列: {time_col}")
|
||||
print(f" 可用列: {list(df.columns)}")
|
||||
return
|
||||
|
||||
print(f"[LOOP] 正在解析时间列 '{time_col}' ...")
|
||||
# 转换为 datetime 对象,无法解析的设为 NaT
|
||||
df[time_col] = pd.to_datetime(df[time_col], errors='coerce')
|
||||
|
||||
# 检查无效时间
|
||||
nat_count = df[time_col].isna().sum()
|
||||
if nat_count > 0:
|
||||
print(f"[WARN] 发现 {nat_count} 行无效时间数据,排序时将排在最后")
|
||||
|
||||
print("[LOOP] 正在按时间排序...")
|
||||
df_sorted = df.sort_values(by=time_col)
|
||||
|
||||
print(f"[CACHE] 正在保存及覆盖文件: {file_path} ...")
|
||||
df_sorted.to_csv(file_path, index=False, encoding="utf-8-sig")
|
||||
|
||||
print("[OK] 排序并保存完成!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR]处理失败: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
sort_csv_by_time()
|
||||
19
test.py
19
test.py
@@ -1,13 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
快速测试 LLM 连接是否正常
|
||||
"""
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from openai import OpenAI
|
||||
|
||||
load_dotenv()
|
||||
|
||||
client = OpenAI(
|
||||
base_url="http://127.0.0.1:9999/v1",
|
||||
api_key="sk-2187174de21548b0b8b0c92129700199"
|
||||
base_url=os.getenv("OPENAI_BASE_URL", "http://127.0.0.1:9999/v1"),
|
||||
api_key=os.getenv("OPENAI_API_KEY", ""),
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="claude-sonnet-4-5",
|
||||
messages=[{"role": "user", "content": "Hello"}]
|
||||
model=os.getenv("OPENAI_MODEL", "gpt-3.5-turbo"),
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)
|
||||
print(response.choices[0].message.content)
|
||||
|
||||
@@ -6,5 +6,12 @@
|
||||
from utils.code_executor import CodeExecutor
|
||||
from utils.llm_helper import LLMHelper
|
||||
from utils.fallback_openai_client import AsyncFallbackOpenAIClient
|
||||
from utils.logger import PrintCapture, create_session_logger
|
||||
|
||||
__all__ = ["CodeExecutor", "LLMHelper", "AsyncFallbackOpenAIClient"]
|
||||
__all__ = [
|
||||
"CodeExecutor",
|
||||
"LLMHelper",
|
||||
"AsyncFallbackOpenAIClient",
|
||||
"PrintCapture",
|
||||
"create_session_logger",
|
||||
]
|
||||
225
utils/data_privacy.py
Normal file
225
utils/data_privacy.py
Normal file
@@ -0,0 +1,225 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
数据隐私保护层
|
||||
|
||||
核心原则:发给外部 LLM 的信息只包含 schema 级别的元数据,
|
||||
绝不包含真实数据值。所有真实数据仅在本地代码执行环境中使用。
|
||||
|
||||
分级策略:
|
||||
- SAFE(安全级): 可发送给 LLM — 列名、数据类型、行列数、空值率、唯一值数量
|
||||
- LOCAL(本地级): 仅本地使用 — 真实数据值、TOP N 高频值、统计数值、样本行
|
||||
"""
|
||||
|
||||
import re
|
||||
import pandas as pd
|
||||
from typing import List
|
||||
|
||||
|
||||
def build_safe_profile(file_paths: list) -> str:
|
||||
"""
|
||||
生成可安全发送给外部 LLM 的数据画像。
|
||||
只包含 schema 信息,不包含任何真实数据值。
|
||||
|
||||
Args:
|
||||
file_paths: 数据文件路径列表
|
||||
|
||||
Returns:
|
||||
安全的 Markdown 格式数据画像
|
||||
"""
|
||||
import os
|
||||
|
||||
profile = "# 数据结构概览 (Schema Profile)\n\n"
|
||||
|
||||
if not file_paths:
|
||||
return profile + "未提供数据文件。"
|
||||
|
||||
for file_path in file_paths:
|
||||
file_name = os.path.basename(file_path)
|
||||
profile += f"## 文件: {file_name}\n\n"
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
profile += f"[WARN] 文件不存在: {file_path}\n\n"
|
||||
continue
|
||||
|
||||
try:
|
||||
df = _load_dataframe(file_path)
|
||||
if df is None:
|
||||
continue
|
||||
|
||||
rows, cols = df.shape
|
||||
profile += f"- **维度**: {rows} 行 x {cols} 列\n"
|
||||
profile += f"- **列名**: `{', '.join(df.columns)}`\n\n"
|
||||
profile += "### 列结构:\n\n"
|
||||
profile += "| 列名 | 数据类型 | 空值率 | 唯一值数 | 特征描述 |\n"
|
||||
profile += "|------|---------|--------|---------|----------|\n"
|
||||
|
||||
for col in df.columns:
|
||||
dtype = str(df[col].dtype)
|
||||
null_count = df[col].isnull().sum()
|
||||
null_pct = f"{(null_count / rows) * 100:.1f}%" if rows > 0 else "0%"
|
||||
unique_count = df[col].nunique()
|
||||
|
||||
# 特征描述:只描述数据特征,不暴露具体值
|
||||
feature_desc = _describe_column_safe(df[col], unique_count, rows)
|
||||
|
||||
profile += f"| {col} | {dtype} | {null_pct} | {unique_count} | {feature_desc} |\n"
|
||||
|
||||
profile += "\n"
|
||||
|
||||
except Exception as e:
|
||||
profile += f"[ERROR] 读取文件失败: {str(e)}\n\n"
|
||||
|
||||
return profile
|
||||
|
||||
|
||||
def build_local_profile(file_paths: list) -> str:
|
||||
"""
|
||||
生成完整的本地数据画像(包含真实数据值)。
|
||||
仅用于本地代码执行环境,不发送给 LLM。
|
||||
|
||||
这是原来 load_and_profile_data 的功能,保留完整信息。
|
||||
"""
|
||||
from utils.data_loader import load_and_profile_data
|
||||
return load_and_profile_data(file_paths)
|
||||
|
||||
|
||||
def sanitize_execution_feedback(feedback: str, max_lines: int = 30) -> str:
|
||||
"""
|
||||
对代码执行反馈进行脱敏处理,移除可能包含真实数据的内容。
|
||||
|
||||
保留:
|
||||
- 执行状态(成功/失败)
|
||||
- 错误信息
|
||||
- DataFrame 的 shape 信息
|
||||
- 图片保存路径
|
||||
- 列名信息
|
||||
|
||||
移除/截断:
|
||||
- 具体的数据行(DataFrame 输出)
|
||||
- 大段的数值输出
|
||||
|
||||
Args:
|
||||
feedback: 原始执行反馈
|
||||
max_lines: 最大保留行数
|
||||
|
||||
Returns:
|
||||
脱敏后的反馈
|
||||
"""
|
||||
if not feedback:
|
||||
return feedback
|
||||
|
||||
lines = feedback.split("\n")
|
||||
safe_lines = []
|
||||
in_dataframe_output = False
|
||||
df_line_count = 0
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
# 始终保留的关键信息
|
||||
if any(kw in stripped for kw in [
|
||||
"图片已保存", "保存至", "[OK]", "[WARN]", "[ERROR]",
|
||||
"[Auto-Save]", "数据表形状", "列名:", ".png",
|
||||
"shape", "columns", "dtype", "info()", "describe()",
|
||||
]):
|
||||
safe_lines.append(line)
|
||||
in_dataframe_output = False
|
||||
continue
|
||||
|
||||
# 检测 DataFrame 输出的开始(通常有列头行)
|
||||
if _looks_like_dataframe_row(stripped):
|
||||
if not in_dataframe_output:
|
||||
in_dataframe_output = True
|
||||
df_line_count = 0
|
||||
safe_lines.append("[数据输出已省略 - 数据仅在本地执行环境中可见]")
|
||||
df_line_count += 1
|
||||
continue
|
||||
|
||||
# 检测纯数值行
|
||||
if _is_numeric_heavy_line(stripped):
|
||||
if not in_dataframe_output:
|
||||
in_dataframe_output = True
|
||||
safe_lines.append("[数值输出已省略]")
|
||||
continue
|
||||
|
||||
# 普通文本行
|
||||
in_dataframe_output = False
|
||||
safe_lines.append(line)
|
||||
|
||||
# 限制总行数
|
||||
if len(safe_lines) > max_lines:
|
||||
safe_lines = safe_lines[:max_lines]
|
||||
safe_lines.append(f"[... 输出已截断,共 {len(lines)} 行]")
|
||||
|
||||
return "\n".join(safe_lines)
|
||||
|
||||
|
||||
def _load_dataframe(file_path: str):
|
||||
"""加载 DataFrame,支持多种格式和编码"""
|
||||
import os
|
||||
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext == ".csv":
|
||||
for encoding in ["utf-8", "gbk", "gb18030", "latin1"]:
|
||||
try:
|
||||
return pd.read_csv(file_path, encoding=encoding)
|
||||
except (UnicodeDecodeError, Exception):
|
||||
continue
|
||||
elif ext in [".xlsx", ".xls"]:
|
||||
try:
|
||||
return pd.read_excel(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _describe_column_safe(series: pd.Series, unique_count: int, total_rows: int) -> str:
|
||||
"""安全地描述列特征,不暴露具体值"""
|
||||
dtype = series.dtype
|
||||
|
||||
if pd.api.types.is_numeric_dtype(dtype):
|
||||
if unique_count <= 5:
|
||||
return "低基数数值(可能是分类编码)"
|
||||
elif unique_count < total_rows * 0.05:
|
||||
return "离散数值"
|
||||
else:
|
||||
return "连续数值"
|
||||
|
||||
if pd.api.types.is_datetime64_any_dtype(dtype):
|
||||
return "时间序列"
|
||||
|
||||
# 文本/分类列
|
||||
if unique_count == 1:
|
||||
return "单一值(常量列)"
|
||||
elif unique_count <= 10:
|
||||
return f"低基数分类({unique_count}类)"
|
||||
elif unique_count <= 50:
|
||||
return f"中基数分类({unique_count}类)"
|
||||
elif unique_count > total_rows * 0.8:
|
||||
return "高基数文本(可能是ID或描述)"
|
||||
else:
|
||||
return f"文本分类({unique_count}类)"
|
||||
|
||||
|
||||
def _looks_like_dataframe_row(line: str) -> bool:
|
||||
"""判断一行是否看起来像 DataFrame 输出"""
|
||||
if not line:
|
||||
return False
|
||||
# DataFrame 输出通常有多个空格分隔的列
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
# 第一个元素是索引(数字)
|
||||
try:
|
||||
int(parts[0])
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _is_numeric_heavy_line(line: str) -> bool:
|
||||
"""判断一行是否主要由数值组成"""
|
||||
if not line or len(line) < 5:
|
||||
return False
|
||||
digits_and_dots = sum(1 for c in line if c.isdigit() or c in ".,-+eE ")
|
||||
return digits_and_dots / len(line) > 0.7
|
||||
113
utils/logger.py
Normal file
113
utils/logger.py
Normal file
@@ -0,0 +1,113 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
统一日志模块 - 替代全局 sys.stdout 劫持
|
||||
|
||||
提供线程安全的日志记录,支持同时输出到终端和文件。
|
||||
每个会话拥有独立的日志文件,不会互相干扰。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def create_session_logger(
|
||||
session_id: str,
|
||||
log_dir: str,
|
||||
log_filename: str = "process.log",
|
||||
level: int = logging.INFO,
|
||||
) -> logging.Logger:
|
||||
"""
|
||||
为指定会话创建独立的 Logger 实例。
|
||||
|
||||
Args:
|
||||
session_id: 会话唯一标识
|
||||
log_dir: 日志文件所在目录
|
||||
log_filename: 日志文件名
|
||||
level: 日志级别
|
||||
|
||||
Returns:
|
||||
配置好的 Logger 实例
|
||||
"""
|
||||
logger = logging.getLogger(f"session.{session_id}")
|
||||
logger.setLevel(level)
|
||||
|
||||
# 避免重复添加 handler
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
# 文件 handler — 写入会话专属日志
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_path = os.path.join(log_dir, log_filename)
|
||||
file_handler = logging.FileHandler(log_path, encoding="utf-8", mode="a")
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# 终端 handler — 输出到 stderr(不干扰 stdout)
|
||||
console_handler = logging.StreamHandler(sys.stderr)
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 不向父 logger 传播
|
||||
logger.propagate = False
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
class PrintCapture:
|
||||
"""
|
||||
轻量级 print 捕获器,将 print 输出同时写入日志文件。
|
||||
用于兼容现有大量使用 print() 的代码,无需逐行改造。
|
||||
|
||||
用法:
|
||||
with PrintCapture(log_path) as cap:
|
||||
print("hello") # 同时输出到终端和文件
|
||||
# 退出后 sys.stdout 自动恢复
|
||||
"""
|
||||
|
||||
def __init__(self, log_path: str, filter_patterns: Optional[list] = None):
|
||||
self.log_path = log_path
|
||||
self.filter_patterns = filter_patterns or ["[TOOL] 执行代码:"]
|
||||
self._original_stdout = None
|
||||
self._log_file = None
|
||||
|
||||
def __enter__(self):
|
||||
os.makedirs(os.path.dirname(self.log_path), exist_ok=True)
|
||||
self._original_stdout = sys.stdout
|
||||
self._log_file = open(self.log_path, "a", encoding="utf-8", buffering=1)
|
||||
sys.stdout = self._DualWriter(
|
||||
self._original_stdout, self._log_file, self.filter_patterns
|
||||
)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
sys.stdout = self._original_stdout
|
||||
if self._log_file:
|
||||
self._log_file.close()
|
||||
return False
|
||||
|
||||
class _DualWriter:
|
||||
"""同时写入两个流,支持过滤"""
|
||||
|
||||
def __init__(self, terminal, log_file, filter_patterns):
|
||||
self.terminal = terminal
|
||||
self.log_file = log_file
|
||||
self.filter_patterns = filter_patterns
|
||||
|
||||
def write(self, message):
|
||||
self.terminal.write(message)
|
||||
# 过滤不需要写入日志的内容
|
||||
if any(p in message for p in self.filter_patterns):
|
||||
return
|
||||
self.log_file.write(message)
|
||||
|
||||
def flush(self):
|
||||
self.terminal.flush()
|
||||
self.log_file.flush()
|
||||
536
web/main.py
536
web/main.py
@@ -19,11 +19,16 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from data_analysis_agent import DataAnalysisAgent
|
||||
from config.llm_config import LLMConfig
|
||||
from utils.create_session_dir import create_session_output_dir
|
||||
from config.llm_config import LLMConfig
|
||||
from utils.create_session_dir import create_session_output_dir
|
||||
from utils.logger import PrintCapture
|
||||
|
||||
app = FastAPI(title="IOV Data Analysis Agent")
|
||||
|
||||
|
||||
def _to_web_path(fs_path: str) -> str:
|
||||
"""将文件系统路径转为 URL 安全的正斜杠路径(修复 Windows 反斜杠问题)"""
|
||||
return fs_path.replace("\\", "/")
|
||||
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -168,8 +173,6 @@ class SessionManager:
|
||||
"max_rounds": session.max_rounds,
|
||||
"created_at": session.created_at,
|
||||
"last_updated": session.last_updated,
|
||||
"created_at": session.created_at,
|
||||
"last_updated": session.last_updated,
|
||||
"user_requirement": session.user_requirement[:100] + "..." if len(session.user_requirement) > 100 else session.user_requirement,
|
||||
"script_path": session.reusable_script # 新增:返回脚本路径
|
||||
}
|
||||
@@ -188,9 +191,7 @@ app.mount("/outputs", StaticFiles(directory="outputs"), name="outputs")
|
||||
# --- Helper Functions ---
|
||||
|
||||
def run_analysis_task(session_id: str, files: list, user_requirement: str, is_followup: bool = False):
|
||||
"""
|
||||
Runs the analysis agent in a background thread for a specific session.
|
||||
"""
|
||||
"""在后台线程中运行分析任务"""
|
||||
session = session_manager.get_session(session_id)
|
||||
if not session:
|
||||
print(f"Error: Session {session_id} not found in background task.")
|
||||
@@ -198,119 +199,58 @@ def run_analysis_task(session_id: str, files: list, user_requirement: str, is_fo
|
||||
|
||||
session.is_running = True
|
||||
try:
|
||||
# Create session directory if not exists (for follow-up it should accept existing)
|
||||
base_output_dir = "outputs"
|
||||
|
||||
|
||||
if not session.output_dir:
|
||||
session.output_dir = create_session_output_dir(base_output_dir, user_requirement)
|
||||
|
||||
session.output_dir = create_session_output_dir(base_output_dir, user_requirement)
|
||||
|
||||
session_output_dir = session.output_dir
|
||||
|
||||
# Initialize Log capturing
|
||||
session.log_file = os.path.join(session_output_dir, "process.log")
|
||||
|
||||
# Thread-safe logging requires a bit of care.
|
||||
# Since we are running in a thread, redirecting sys.stdout globally is BAD for multi-session.
|
||||
# However, for this MVP, if we run multiple sessions concurrently, their logs will mix in stdout.
|
||||
# BUT we are writing to specific log files.
|
||||
# We need a logger that writes to the session's log file.
|
||||
# And the Agent needs to use that logger.
|
||||
# Currently the Agent uses print().
|
||||
# To support true concurrent logging without mixing, we'd need to refactor Agent to use a logger instance.
|
||||
# LIMITATION: For now, we accept that stdout redirection intercepts EVERYTHING.
|
||||
# So multiple concurrent sessions is risky with global stdout redirection.
|
||||
# A safer approach for now: We won't redirect stdout globally for multi-session support
|
||||
# unless we lock execution to one at a time.
|
||||
# OR: We just rely on the fact that we might only run one analysis at a time mostly.
|
||||
# Let's try to just write to the log file explicitly if we could, but we can't change Agent easily right now.
|
||||
# Compromise: We will continue to use global redirection but acknowledge it's not thread-safe for output.
|
||||
# A better way: Modify Agent to accept a 'log_callback'.
|
||||
# For this refactor, let's stick to the existing pattern but bind it to the thread if possible? No.
|
||||
|
||||
# We will wrap the execution with a simple File Logger that appends to the distinct file.
|
||||
# But sys.stdout is global.
|
||||
# We will assume single concurrent analysis for safety, or accept mixed terminal output but separate file logs?
|
||||
# Actually, if we swap sys.stdout, it affects all threads.
|
||||
# So we MUST NOT swap sys.stdout if we want concurrency.
|
||||
# If we don't swap stdout, we don't capture logs to file unless Agent does it.
|
||||
# The Agent code has `print`.
|
||||
# Correct fix: Refactor Agent to use `logging` module or pass a printer.
|
||||
# Given the scope, let's just hold the lock (serialize execution) OR allow mixing in terminal
|
||||
# but try to capture to file?
|
||||
# Let's just write to the file.
|
||||
|
||||
# Let's just write to the file.
|
||||
|
||||
with open(session.log_file, "a" if is_followup else "w", encoding="utf-8") as f:
|
||||
|
||||
# 使用 PrintCapture 替代全局 FileLogger,退出 with 块后自动恢复 stdout
|
||||
with PrintCapture(session.log_file):
|
||||
if is_followup:
|
||||
f.write(f"\n--- Follow-up Session {session_id} Continued ---\n")
|
||||
print(f"\n--- Follow-up Session {session_id} Continued ---")
|
||||
else:
|
||||
f.write(f"--- Session {session_id} Started ---\n")
|
||||
print(f"--- Session {session_id} Started ---")
|
||||
|
||||
# We will create a custom print function that writes to the file
|
||||
# And monkeypatch builtins.print? No, that's too hacky.
|
||||
# Let's just use the stdout redirector, but acknowledge only one active session at a time is safe.
|
||||
# We can implement a crude lock for now.
|
||||
|
||||
class FileLogger:
|
||||
def __init__(self, filename):
|
||||
self.terminal = sys.__stdout__
|
||||
self.log = open(filename, "a", encoding="utf-8", buffering=1)
|
||||
|
||||
def write(self, message):
|
||||
self.terminal.write(message)
|
||||
self.log.write(message)
|
||||
|
||||
def flush(self):
|
||||
self.terminal.flush()
|
||||
self.log.flush()
|
||||
|
||||
def close(self):
|
||||
self.log.close()
|
||||
try:
|
||||
if not is_followup:
|
||||
llm_config = LLMConfig()
|
||||
agent = DataAnalysisAgent(llm_config, force_max_rounds=False, output_dir=base_output_dir)
|
||||
session.agent = agent
|
||||
|
||||
logger = FileLogger(session.log_file)
|
||||
sys.stdout = logger # Global hijack!
|
||||
|
||||
try:
|
||||
if not is_followup:
|
||||
llm_config = LLMConfig()
|
||||
agent = DataAnalysisAgent(llm_config, force_max_rounds=False, output_dir=base_output_dir)
|
||||
session.agent = agent
|
||||
|
||||
result = agent.analyze(
|
||||
user_input=user_requirement,
|
||||
files=files,
|
||||
session_output_dir=session_output_dir,
|
||||
reset_session=True
|
||||
)
|
||||
else:
|
||||
agent = session.agent
|
||||
if not agent:
|
||||
print("Error: Agent not initialized for follow-up.")
|
||||
return
|
||||
result = agent.analyze(
|
||||
user_input=user_requirement,
|
||||
files=files,
|
||||
session_output_dir=session_output_dir,
|
||||
reset_session=True,
|
||||
)
|
||||
else:
|
||||
agent = session.agent
|
||||
if not agent:
|
||||
print("Error: Agent not initialized for follow-up.")
|
||||
return
|
||||
|
||||
result = agent.analyze(
|
||||
user_input=user_requirement,
|
||||
files=None,
|
||||
session_output_dir=session_output_dir,
|
||||
reset_session=False,
|
||||
max_rounds=10,
|
||||
)
|
||||
|
||||
session.generated_report = result.get("report_file_path", None)
|
||||
session.analysis_results = result.get("analysis_results", [])
|
||||
session.reusable_script = result.get("reusable_script_path", None)
|
||||
|
||||
# 持久化结果
|
||||
with open(os.path.join(session_output_dir, "results.json"), "w") as f:
|
||||
json.dump(session.analysis_results, f, default=str)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during analysis: {e}")
|
||||
|
||||
result = agent.analyze(
|
||||
user_input=user_requirement,
|
||||
files=None,
|
||||
session_output_dir=session_output_dir,
|
||||
reset_session=False,
|
||||
max_rounds=10
|
||||
)
|
||||
|
||||
session.generated_report = result.get("report_file_path", None)
|
||||
session.analysis_results = result.get("analysis_results", [])
|
||||
session.reusable_script = result.get("reusable_script_path", None) # 新增:保存脚本路径
|
||||
|
||||
# Save results to json for persistence
|
||||
with open(os.path.join(session_output_dir, "results.json"), "w") as f:
|
||||
json.dump(session.analysis_results, f, default=str)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during analysis: {e}")
|
||||
finally:
|
||||
sys.stdout = logger.terminal
|
||||
logger.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"System Error: {e}")
|
||||
finally:
|
||||
@@ -390,24 +330,37 @@ async def get_status(session_id: str = Query(..., description="Session ID")):
|
||||
|
||||
@app.get("/api/export")
|
||||
async def export_session(session_id: str = Query(..., description="Session ID")):
|
||||
"""导出会话数据为ZIP"""
|
||||
session = session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
if not session.output_dir or not os.path.exists(session.output_dir):
|
||||
raise HTTPException(status_code=404, detail="No data available for export")
|
||||
|
||||
# Create a zip file
|
||||
import shutil
|
||||
|
||||
# We want to zip the contents of session_output_dir
|
||||
# Zip path should be outside to avoid recursive zipping if inside
|
||||
zip_base_name = os.path.join("outputs", f"export_{session_id}")
|
||||
import zipfile
|
||||
from datetime import datetime as dt
|
||||
|
||||
# shutil.make_archive expects base_name (without extension) and root_dir
|
||||
archive_path = shutil.make_archive(zip_base_name, 'zip', session.output_dir)
|
||||
timestamp = dt.now().strftime("%Y%m%d_%H%M%S")
|
||||
zip_filename = f"report_{timestamp}.zip"
|
||||
|
||||
return FileResponse(archive_path, media_type='application/zip', filename=f"analysis_export_{session_id}.zip")
|
||||
export_dir = "outputs"
|
||||
os.makedirs(export_dir, exist_ok=True)
|
||||
temp_zip_path = os.path.join(export_dir, zip_filename)
|
||||
|
||||
with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, dirs, files in os.walk(session.output_dir):
|
||||
for file in files:
|
||||
if file.endswith(('.md', '.png', '.csv', '.log', '.json', '.yaml')):
|
||||
abs_path = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(abs_path, session.output_dir)
|
||||
zf.write(abs_path, arcname=rel_path)
|
||||
|
||||
return FileResponse(
|
||||
path=temp_zip_path,
|
||||
filename=zip_filename,
|
||||
media_type='application/zip'
|
||||
)
|
||||
|
||||
@app.get("/api/report")
|
||||
async def get_report(session_id: str = Query(..., description="Session ID")):
|
||||
@@ -416,33 +369,33 @@ async def get_report(session_id: str = Query(..., description="Session ID")):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
if not session.generated_report or not os.path.exists(session.generated_report):
|
||||
return {"content": "Report not ready."}
|
||||
return {"content": "Report not ready.", "paragraphs": []}
|
||||
|
||||
with open(session.generated_report, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Fix image paths
|
||||
relative_session_path = os.path.relpath(session.output_dir, os.getcwd())
|
||||
relative_session_path = _to_web_path(os.path.relpath(session.output_dir, os.getcwd()))
|
||||
web_base_path = f"/{relative_session_path}"
|
||||
|
||||
# Robust image path replacement
|
||||
# 1. Replace explicit relative paths ./image.png
|
||||
content = content.replace("](./", f"]({web_base_path}/")
|
||||
|
||||
# 2. Replace naked paths that might be generated like ](image.png) but NOT ](http...) or ](/...)
|
||||
import re
|
||||
def replace_link(match):
|
||||
alt = match.group(1)
|
||||
url = match.group(2)
|
||||
if url.startswith("http") or url.startswith("/") or url.startswith("data:"):
|
||||
return match.group(0)
|
||||
# Remove ./ if exists again just in case
|
||||
clean_url = url.lstrip("./")
|
||||
return f""
|
||||
|
||||
content = re.sub(r'!\[(.*?)\]\((.*?)\)', replace_link, content)
|
||||
|
||||
# 将报告按段落拆分,为前端润色功能提供结构化数据
|
||||
paragraphs = _split_report_to_paragraphs(content)
|
||||
|
||||
return {"content": content, "base_path": web_base_path}
|
||||
return {"content": content, "base_path": web_base_path, "paragraphs": paragraphs}
|
||||
|
||||
@app.get("/api/figures")
|
||||
async def get_figures(session_id: str = Query(..., description="Session ID")):
|
||||
@@ -473,7 +426,7 @@ async def get_figures(session_id: str = Query(..., description="Session ID")):
|
||||
if session.output_dir:
|
||||
# Assume filename is present
|
||||
fname = fig.get("filename")
|
||||
relative_session_path = os.path.relpath(session.output_dir, os.getcwd())
|
||||
relative_session_path = _to_web_path(os.path.relpath(session.output_dir, os.getcwd()))
|
||||
fig["web_url"] = f"/{relative_session_path}/{fname}"
|
||||
figures.append(fig)
|
||||
|
||||
@@ -486,7 +439,7 @@ async def get_figures(session_id: str = Query(..., description="Session ID")):
|
||||
pngs = glob.glob(os.path.join(session.output_dir, "*.png"))
|
||||
for p in pngs:
|
||||
fname = os.path.basename(p)
|
||||
relative_session_path = os.path.relpath(session.output_dir, os.getcwd())
|
||||
relative_session_path = _to_web_path(os.path.relpath(session.output_dir, os.getcwd()))
|
||||
figures.append({
|
||||
"filename": fname,
|
||||
"description": "Auto-discovered image",
|
||||
@@ -496,37 +449,6 @@ async def get_figures(session_id: str = Query(..., description="Session ID")):
|
||||
|
||||
return {"figures": figures}
|
||||
|
||||
@app.get("/api/export")
|
||||
async def export_report(session_id: str = Query(..., description="Session ID")):
|
||||
session = session_manager.get_session(session_id)
|
||||
if not session or not session.output_dir:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
import zipfile
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
zip_filename = f"report_{timestamp}.zip"
|
||||
|
||||
export_dir = "outputs"
|
||||
os.makedirs(export_dir, exist_ok=True)
|
||||
temp_zip_path = os.path.join(export_dir, zip_filename)
|
||||
|
||||
with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, dirs, files in os.walk(session.output_dir):
|
||||
for file in files:
|
||||
if file.endswith(('.md', '.png', '.csv', '.log', '.json', '.yaml')):
|
||||
abs_path = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(abs_path, session.output_dir)
|
||||
zf.write(abs_path, arcname=rel_path)
|
||||
|
||||
return FileResponse(
|
||||
path=temp_zip_path,
|
||||
filename=zip_filename,
|
||||
media_type='application/zip'
|
||||
)
|
||||
|
||||
@app.get("/api/download_script")
|
||||
async def download_script(session_id: str = Query(..., description="Session ID")):
|
||||
"""下载生成的Python脚本"""
|
||||
@@ -580,7 +502,301 @@ async def delete_specific_session(session_id: str):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return {"status": "deleted", "session_id": session_id}
|
||||
|
||||
return {"status": "deleted", "session_id": session_id}
|
||||
|
||||
# --- Report Polishing API ---
|
||||
|
||||
import re as _re
|
||||
|
||||
def _split_report_to_paragraphs(markdown_content: str) -> list:
|
||||
"""
|
||||
将 Markdown 报告按语义段落拆分。
|
||||
每个段落包含 id、类型(heading/text/table/image)、原始内容。
|
||||
前端可据此实现段落级选择与润色。
|
||||
"""
|
||||
lines = markdown_content.split("\n")
|
||||
paragraphs = []
|
||||
current_block = []
|
||||
current_type = "text"
|
||||
para_id = 0
|
||||
|
||||
def flush_block():
|
||||
nonlocal para_id, current_block, current_type
|
||||
text = "\n".join(current_block).strip()
|
||||
if text:
|
||||
paragraphs.append({
|
||||
"id": f"p-{para_id}",
|
||||
"type": current_type,
|
||||
"content": text,
|
||||
})
|
||||
para_id += 1
|
||||
current_block = []
|
||||
current_type = "text"
|
||||
|
||||
in_table = False
|
||||
in_code = False
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
# 代码块边界
|
||||
if stripped.startswith("```"):
|
||||
if in_code:
|
||||
current_block.append(line)
|
||||
flush_block()
|
||||
in_code = False
|
||||
continue
|
||||
else:
|
||||
flush_block()
|
||||
current_block.append(line)
|
||||
current_type = "code"
|
||||
in_code = True
|
||||
continue
|
||||
|
||||
if in_code:
|
||||
current_block.append(line)
|
||||
continue
|
||||
|
||||
# 标题行 — 独立成段
|
||||
if _re.match(r"^#{1,6}\s", stripped):
|
||||
flush_block()
|
||||
current_block.append(line)
|
||||
current_type = "heading"
|
||||
flush_block()
|
||||
continue
|
||||
|
||||
# 图片行
|
||||
if _re.match(r"^!\[.*\]\(.*\)", stripped):
|
||||
flush_block()
|
||||
current_block.append(line)
|
||||
current_type = "image"
|
||||
flush_block()
|
||||
continue
|
||||
|
||||
# 表格行
|
||||
if stripped.startswith("|"):
|
||||
if not in_table:
|
||||
flush_block()
|
||||
in_table = True
|
||||
current_type = "table"
|
||||
current_block.append(line)
|
||||
continue
|
||||
else:
|
||||
if in_table:
|
||||
flush_block()
|
||||
in_table = False
|
||||
|
||||
# 空行 — 段落分隔
|
||||
if not stripped:
|
||||
flush_block()
|
||||
continue
|
||||
|
||||
# 普通文本
|
||||
current_block.append(line)
|
||||
|
||||
flush_block()
|
||||
return paragraphs
|
||||
|
||||
|
||||
class PolishRequest(BaseModel):
|
||||
session_id: str
|
||||
paragraph_id: str
|
||||
mode: str = "context" # "context" | "data" | "custom"
|
||||
custom_instruction: str = ""
|
||||
|
||||
|
||||
@app.post("/api/report/polish")
|
||||
async def polish_paragraph(request: PolishRequest):
|
||||
"""
|
||||
对报告中指定段落进行 AI 润色。
|
||||
|
||||
mode:
|
||||
- context: 根据上下文和图表信息润色,使表述更专业、更有洞察
|
||||
- data: 结合原始分析数据重新生成该段落内容
|
||||
- custom: 用户自定义润色指令
|
||||
"""
|
||||
session = session_manager.get_session(request.session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
if not session.generated_report or not os.path.exists(session.generated_report):
|
||||
raise HTTPException(status_code=404, detail="Report not found")
|
||||
|
||||
# 读取报告并拆分段落
|
||||
with open(session.generated_report, "r", encoding="utf-8") as f:
|
||||
report_content = f.read()
|
||||
|
||||
paragraphs = _split_report_to_paragraphs(report_content)
|
||||
|
||||
# 找到目标段落
|
||||
target = None
|
||||
target_idx = -1
|
||||
for i, p in enumerate(paragraphs):
|
||||
if p["id"] == request.paragraph_id:
|
||||
target = p
|
||||
target_idx = i
|
||||
break
|
||||
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail=f"Paragraph {request.paragraph_id} not found")
|
||||
|
||||
# 构建上下文窗口(前后各2个段落)
|
||||
context_window = []
|
||||
for j in range(max(0, target_idx - 2), min(len(paragraphs), target_idx + 3)):
|
||||
if j != target_idx:
|
||||
context_window.append(paragraphs[j]["content"])
|
||||
context_text = "\n\n".join(context_window)
|
||||
|
||||
# 收集图表信息
|
||||
figures_info = ""
|
||||
if session.analysis_results:
|
||||
fig_parts = []
|
||||
for item in session.analysis_results:
|
||||
if item.get("action") == "collect_figures":
|
||||
for fig in item.get("collected_figures", []):
|
||||
fig_parts.append(f"- {fig.get('filename', '?')}: {fig.get('description', '')} / {fig.get('analysis', '')}")
|
||||
if fig_parts:
|
||||
figures_info = "\n".join(fig_parts)
|
||||
|
||||
# 构建润色 prompt
|
||||
if request.mode == "data":
|
||||
# 收集代码执行结果摘要
|
||||
data_summary_parts = []
|
||||
for item in session.analysis_results:
|
||||
result = item.get("result", {})
|
||||
if result.get("success") and result.get("output"):
|
||||
output_text = result["output"][:2000]
|
||||
data_summary_parts.append(output_text)
|
||||
data_summary = "\n---\n".join(data_summary_parts[:5])
|
||||
|
||||
polish_prompt = f"""你是一位资深数据分析专家。请基于以下分析数据,重写下方段落,使其包含更精确的数据引用和更深入的业务洞察。
|
||||
|
||||
## 分析数据摘要
|
||||
{data_summary}
|
||||
|
||||
## 图表信息
|
||||
{figures_info}
|
||||
|
||||
## 需要润色的段落
|
||||
{target['content']}
|
||||
|
||||
## 要求
|
||||
- 保持原有的 Markdown 格式(标题级别、表格结构等)
|
||||
- 用具体数据替换模糊描述
|
||||
- 增加业务洞察和趋势判断
|
||||
- 禁止使用第一人称
|
||||
- 直接输出润色后的 Markdown 内容,不要包裹在代码块中"""
|
||||
|
||||
elif request.mode == "custom":
|
||||
polish_prompt = f"""你是一位资深数据分析专家。请根据用户的指令润色以下段落。
|
||||
|
||||
## 用户指令
|
||||
{request.custom_instruction}
|
||||
|
||||
## 上下文
|
||||
{context_text}
|
||||
|
||||
## 图表信息
|
||||
{figures_info}
|
||||
|
||||
## 需要润色的段落
|
||||
{target['content']}
|
||||
|
||||
## 要求
|
||||
- 保持原有的 Markdown 格式
|
||||
- 严格遵循用户指令
|
||||
- 禁止使用第一人称
|
||||
- 直接输出润色后的 Markdown 内容,不要包裹在代码块中"""
|
||||
|
||||
else: # context mode
|
||||
polish_prompt = f"""你是一位资深数据分析专家。请润色以下段落,使其表述更专业、更有洞察力。
|
||||
|
||||
## 上下文(前后段落)
|
||||
{context_text}
|
||||
|
||||
## 图表信息
|
||||
{figures_info}
|
||||
|
||||
## 需要润色的段落
|
||||
{target['content']}
|
||||
|
||||
## 要求
|
||||
- 保持原有的 Markdown 格式(标题级别、表格结构等)
|
||||
- 提升专业性:使用同比、环比、占比等术语
|
||||
- 增加洞察:不仅描述现象,还要分析原因和影响
|
||||
- 禁止使用第一人称
|
||||
- 直接输出润色后的 Markdown 内容,不要包裹在代码块中"""
|
||||
|
||||
# 调用 LLM 润色
|
||||
try:
|
||||
from utils.llm_helper import LLMHelper
|
||||
llm = LLMHelper(LLMConfig())
|
||||
polished_content = llm.call(
|
||||
prompt=polish_prompt,
|
||||
system_prompt="你是一位专业的数据分析报告润色专家。直接输出润色后的内容,不要添加任何解释或包裹。",
|
||||
max_tokens=4096,
|
||||
)
|
||||
|
||||
# 清理可能的代码块包裹
|
||||
polished_content = polished_content.strip()
|
||||
if polished_content.startswith("```markdown"):
|
||||
polished_content = polished_content[len("```markdown"):].strip()
|
||||
if polished_content.startswith("```"):
|
||||
polished_content = polished_content[3:].strip()
|
||||
if polished_content.endswith("```"):
|
||||
polished_content = polished_content[:-3].strip()
|
||||
|
||||
return {
|
||||
"paragraph_id": request.paragraph_id,
|
||||
"original": target["content"],
|
||||
"polished": polished_content,
|
||||
"mode": request.mode,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Polish failed: {str(e)}")
|
||||
|
||||
|
||||
class ApplyPolishRequest(BaseModel):
|
||||
session_id: str
|
||||
paragraph_id: str
|
||||
new_content: str
|
||||
|
||||
|
||||
@app.post("/api/report/apply")
|
||||
async def apply_polish(request: ApplyPolishRequest):
|
||||
"""
|
||||
将润色后的内容应用到报告文件中,替换指定段落。
|
||||
"""
|
||||
session = session_manager.get_session(request.session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
if not session.generated_report or not os.path.exists(session.generated_report):
|
||||
raise HTTPException(status_code=404, detail="Report not found")
|
||||
|
||||
with open(session.generated_report, "r", encoding="utf-8") as f:
|
||||
report_content = f.read()
|
||||
|
||||
paragraphs = _split_report_to_paragraphs(report_content)
|
||||
|
||||
# 找到目标段落并替换
|
||||
target = None
|
||||
for p in paragraphs:
|
||||
if p["id"] == request.paragraph_id:
|
||||
target = p
|
||||
break
|
||||
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail=f"Paragraph {request.paragraph_id} not found")
|
||||
|
||||
# 在原文中替换
|
||||
new_report = report_content.replace(target["content"], request.new_content, 1)
|
||||
|
||||
# 写回文件
|
||||
with open(session.generated_report, "w", encoding="utf-8") as f:
|
||||
f.write(new_report)
|
||||
|
||||
return {"status": "applied", "paragraph_id": request.paragraph_id}
|
||||
|
||||
|
||||
# --- History API ---
|
||||
|
||||
@@ -532,4 +532,236 @@ body {
|
||||
.image-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ===== Report Paragraph Polishing ===== */
|
||||
|
||||
.report-paragraph {
|
||||
position: relative;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0.125rem 0;
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 0 0.25rem 0.25rem 0;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.report-paragraph:hover {
|
||||
background-color: #F0F7FF;
|
||||
border-left-color: #93C5FD;
|
||||
}
|
||||
|
||||
.report-paragraph.selected {
|
||||
background-color: #EFF6FF;
|
||||
border-left-color: var(--primary-color);
|
||||
box-shadow: 0 1px 4px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
/* 段落类型微调 */
|
||||
.report-paragraph.para-heading {
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
.report-paragraph.para-heading:hover {
|
||||
border-left-color: #60A5FA;
|
||||
}
|
||||
|
||||
.report-paragraph.para-image {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.report-paragraph.para-table .para-content {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* 润色操作按钮 */
|
||||
.para-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.polish-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 1rem;
|
||||
background: white;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.polish-btn:hover {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
|
||||
.polish-loading {
|
||||
font-size: 0.85rem;
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* 自定义润色输入 */
|
||||
.custom-polish-input {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.custom-polish-input .form-input {
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* 润色对比视图 */
|
||||
.polish-diff {
|
||||
border: 1px solid #BFDBFE;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.diff-header {
|
||||
background: linear-gradient(135deg, #EFF6FF, #DBEAFE);
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 1px solid #BFDBFE;
|
||||
}
|
||||
|
||||
.diff-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.diff-panels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.diff-panel {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
min-height: 80px;
|
||||
overflow-y: auto;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.diff-original {
|
||||
background: #FEF2F2;
|
||||
border-right: 1px solid #BFDBFE;
|
||||
}
|
||||
|
||||
.diff-polished {
|
||||
background: #F0FDF4;
|
||||
}
|
||||
|
||||
.diff-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.diff-original .diff-label {
|
||||
color: #DC2626;
|
||||
}
|
||||
|
||||
.diff-polished .diff-label {
|
||||
color: #16A34A;
|
||||
}
|
||||
|
||||
.diff-body {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.diff-body img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.diff-body table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.diff-body th, .diff-body td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.3rem 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.diff-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid #BFDBFE;
|
||||
background: #F9FAFB;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 报告内容中的表格样式 */
|
||||
.para-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.para-content th, .para-content td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.4rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.para-content th {
|
||||
background: #F3F4F6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.para-content img {
|
||||
max-width: 100%;
|
||||
border-radius: 0.375rem;
|
||||
margin: 0.5rem 0;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
/* 响应式:小屏幕下对比视图改为上下排列 */
|
||||
@media (max-width: 900px) {
|
||||
.diff-panels {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.diff-original {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #BFDBFE;
|
||||
}
|
||||
.analysis-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
// DOM Elements
|
||||
const uploadZone = document.getElementById('uploadZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
@@ -15,6 +14,9 @@ let isRunning = false;
|
||||
let pollingInterval = null;
|
||||
let currentSessionId = null;
|
||||
|
||||
// 报告段落数据(用于润色功能)
|
||||
let reportParagraphs = [];
|
||||
|
||||
// --- Upload Logic ---
|
||||
if (uploadZone) {
|
||||
uploadZone.addEventListener('dragover', (e) => {
|
||||
@@ -32,7 +34,7 @@ if (uploadZone) {
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', (e) => handleFiles(e.target.files));
|
||||
fileInput.addEventListener('click', (e) => e.stopPropagation()); // Prevent bubbling to uploadZone
|
||||
fileInput.addEventListener('click', (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
async function handleFiles(files) {
|
||||
@@ -50,15 +52,8 @@ async function handleFiles(files) {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (res.ok) {
|
||||
console.log('Upload success');
|
||||
} else {
|
||||
alert('Upload failed');
|
||||
}
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: formData });
|
||||
if (!res.ok) alert('Upload failed');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Upload failed');
|
||||
@@ -91,8 +86,6 @@ async function startAnalysis() {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
currentSessionId = data.session_id;
|
||||
console.log("Started Session:", currentSessionId);
|
||||
|
||||
startPolling();
|
||||
switchTab('logs');
|
||||
} else {
|
||||
@@ -116,8 +109,6 @@ function setRunningState(running) {
|
||||
statusDot.className = 'status-dot running';
|
||||
statusText.innerText = 'Analyzing';
|
||||
statusText.style.color = 'var(--primary-color)';
|
||||
|
||||
// Hide follow-up and download during run
|
||||
const followUpSection = document.getElementById('followUpSection');
|
||||
if (followUpSection) followUpSection.classList.add('hidden');
|
||||
if (downloadScriptBtn) downloadScriptBtn.classList.add('hidden');
|
||||
@@ -126,11 +117,8 @@ function setRunningState(running) {
|
||||
statusDot.className = 'status-dot';
|
||||
statusText.innerText = 'Completed';
|
||||
statusText.style.color = 'var(--text-secondary)';
|
||||
|
||||
const followUpSection = document.getElementById('followUpSection');
|
||||
if (currentSessionId && followUpSection) {
|
||||
followUpSection.classList.remove('hidden');
|
||||
}
|
||||
if (currentSessionId && followUpSection) followUpSection.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,32 +132,21 @@ function startPolling() {
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
|
||||
// Update Logs
|
||||
logOutput.innerText = data.log || "Waiting for output...";
|
||||
|
||||
// Auto scroll
|
||||
const logTab = document.getElementById('logsTab');
|
||||
if (logTab) logTab.scrollTop = logTab.scrollHeight;
|
||||
|
||||
if (!data.is_running && isRunning) {
|
||||
// Finished
|
||||
setRunningState(false);
|
||||
clearInterval(pollingInterval);
|
||||
|
||||
if (data.has_report) {
|
||||
await loadReport();
|
||||
|
||||
// 强制跳转到 Report Tab
|
||||
switchTab('report');
|
||||
console.log("Analysis done, switched to report tab");
|
||||
}
|
||||
|
||||
// Check for script
|
||||
if (data.script_path) {
|
||||
if (downloadScriptBtn) {
|
||||
downloadScriptBtn.classList.remove('hidden');
|
||||
downloadScriptBtn.style.display = 'inline-flex';
|
||||
}
|
||||
if (data.script_path && downloadScriptBtn) {
|
||||
downloadScriptBtn.classList.remove('hidden');
|
||||
downloadScriptBtn.style.display = 'inline-flex';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -178,7 +155,8 @@ function startPolling() {
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// --- Report Logic ---
|
||||
// --- Report Logic (with paragraph-level polishing) ---
|
||||
|
||||
async function loadReport() {
|
||||
if (!currentSessionId) return;
|
||||
try {
|
||||
@@ -187,14 +165,212 @@ async function loadReport() {
|
||||
|
||||
if (!data.content || data.content === "Report not ready.") {
|
||||
reportContainer.innerHTML = '<div class="empty-state"><p>Analysis in progress or no report generated yet.</p></div>';
|
||||
} else {
|
||||
reportContainer.innerHTML = marked.parse(data.content);
|
||||
reportParagraphs = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存段落数据
|
||||
reportParagraphs = data.paragraphs || [];
|
||||
|
||||
// 渲染段落化的报告(支持点击润色)
|
||||
renderParagraphReport(reportParagraphs);
|
||||
|
||||
} catch (e) {
|
||||
reportContainer.innerHTML = '<p class="error">Failed to load report.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderParagraphReport(paragraphs) {
|
||||
if (!paragraphs || paragraphs.length === 0) {
|
||||
reportContainer.innerHTML = '<div class="empty-state"><p>No report content.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const p of paragraphs) {
|
||||
const renderedContent = marked.parse(p.content);
|
||||
const typeClass = `para-${p.type}`;
|
||||
html += `
|
||||
<div class="report-paragraph ${typeClass}" data-para-id="${p.id}" onclick="selectParagraph('${p.id}')">
|
||||
<div class="para-content">${renderedContent}</div>
|
||||
<div class="para-actions hidden">
|
||||
<button class="polish-btn" onclick="event.stopPropagation(); polishParagraph('${p.id}', 'context')" title="根据上下文润色">
|
||||
<i class="fa-solid fa-wand-magic-sparkles"></i> 上下文润色
|
||||
</button>
|
||||
<button class="polish-btn" onclick="event.stopPropagation(); polishParagraph('${p.id}', 'data')" title="结合分析数据润色">
|
||||
<i class="fa-solid fa-database"></i> 数据润色
|
||||
</button>
|
||||
<button class="polish-btn" onclick="event.stopPropagation(); showCustomPolish('${p.id}')" title="自定义润色指令">
|
||||
<i class="fa-solid fa-pen"></i> 自定义
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
reportContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
window.selectParagraph = function(paraId) {
|
||||
// 取消所有选中
|
||||
document.querySelectorAll('.report-paragraph').forEach(el => {
|
||||
el.classList.remove('selected');
|
||||
el.querySelector('.para-actions')?.classList.add('hidden');
|
||||
});
|
||||
|
||||
// 选中当前段落
|
||||
const target = document.querySelector(`[data-para-id="${paraId}"]`);
|
||||
if (target) {
|
||||
target.classList.add('selected');
|
||||
target.querySelector('.para-actions')?.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
window.polishParagraph = async function(paraId, mode, customInstruction = '') {
|
||||
if (!currentSessionId) return;
|
||||
|
||||
const target = document.querySelector(`[data-para-id="${paraId}"]`);
|
||||
if (!target) return;
|
||||
|
||||
// 显示加载状态
|
||||
const actionsEl = target.querySelector('.para-actions');
|
||||
const originalActions = actionsEl.innerHTML;
|
||||
actionsEl.innerHTML = '<span class="polish-loading"><i class="fa-solid fa-spinner fa-spin"></i> AI 润色中...</span>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/report/polish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: currentSessionId,
|
||||
paragraph_id: paraId,
|
||||
mode: mode,
|
||||
custom_instruction: customInstruction,
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
alert('润色失败: ' + (err.detail || 'Unknown error'));
|
||||
actionsEl.innerHTML = originalActions;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// 显示对比视图
|
||||
showPolishDiff(target, paraId, data.original, data.polished);
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('润色请求失败');
|
||||
actionsEl.innerHTML = originalActions;
|
||||
}
|
||||
}
|
||||
|
||||
function showPolishDiff(targetEl, paraId, original, polished) {
|
||||
const polishedHtml = marked.parse(polished);
|
||||
|
||||
targetEl.innerHTML = `
|
||||
<div class="polish-diff">
|
||||
<div class="diff-header">
|
||||
<span class="diff-title"><i class="fa-solid fa-wand-magic-sparkles"></i> 润色结果预览</span>
|
||||
</div>
|
||||
<div class="diff-panels">
|
||||
<div class="diff-panel diff-original">
|
||||
<div class="diff-label">原文</div>
|
||||
<div class="diff-body">${marked.parse(original)}</div>
|
||||
</div>
|
||||
<div class="diff-panel diff-polished">
|
||||
<div class="diff-label">润色后</div>
|
||||
<div class="diff-body">${polishedHtml}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="diff-actions">
|
||||
<button class="btn btn-primary btn-sm" id="acceptBtn-${paraId}">
|
||||
<i class="fa-solid fa-check"></i> 采纳
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" id="rejectBtn-${paraId}">
|
||||
<i class="fa-solid fa-xmark"></i> 放弃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 用 addEventListener 绑定,避免内联 onclick 中特殊字符破坏 HTML
|
||||
document.getElementById(`acceptBtn-${paraId}`).addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
applyPolish(paraId, polished);
|
||||
});
|
||||
document.getElementById(`rejectBtn-${paraId}`).addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
rejectPolish(paraId);
|
||||
});
|
||||
}
|
||||
|
||||
window.applyPolish = async function(paraId, newContent) {
|
||||
if (!currentSessionId) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/report/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: currentSessionId,
|
||||
paragraph_id: paraId,
|
||||
new_content: newContent,
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// 重新加载报告
|
||||
await loadReport();
|
||||
} else {
|
||||
alert('应用失败');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('应用失败');
|
||||
}
|
||||
}
|
||||
|
||||
window.rejectPolish = function(paraId) {
|
||||
// 重新加载报告恢复原状
|
||||
loadReport();
|
||||
}
|
||||
|
||||
window.showCustomPolish = function(paraId) {
|
||||
const target = document.querySelector(`[data-para-id="${paraId}"]`);
|
||||
if (!target) return;
|
||||
|
||||
const actionsEl = target.querySelector('.para-actions');
|
||||
if (!actionsEl) return;
|
||||
|
||||
actionsEl.innerHTML = `
|
||||
<div class="custom-polish-input">
|
||||
<input type="text" class="form-input" id="customInput-${paraId}" placeholder="输入润色指令,如:增加数据对比、语气更正式..." style="flex:1;">
|
||||
<button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); submitCustomPolish('${paraId}')">
|
||||
<i class="fa-solid fa-paper-plane"></i>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); loadReport()">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById(`customInput-${paraId}`)?.focus();
|
||||
}
|
||||
|
||||
window.submitCustomPolish = function(paraId) {
|
||||
const input = document.getElementById(`customInput-${paraId}`);
|
||||
if (!input) return;
|
||||
const instruction = input.value.trim();
|
||||
if (!instruction) {
|
||||
alert('请输入润色指令');
|
||||
return;
|
||||
}
|
||||
polishParagraph(paraId, 'custom', instruction);
|
||||
}
|
||||
|
||||
// --- Gallery Logic ---
|
||||
let galleryImages = [];
|
||||
let currentImageIndex = 0;
|
||||
@@ -204,11 +380,9 @@ async function loadGallery() {
|
||||
try {
|
||||
const res = await fetch(`/api/figures?session_id=${currentSessionId}`);
|
||||
const data = await res.json();
|
||||
|
||||
galleryImages = data.figures || [];
|
||||
currentImageIndex = 0;
|
||||
renderGalleryImage();
|
||||
|
||||
} catch (e) {
|
||||
console.error("Gallery load failed", e);
|
||||
document.getElementById('carouselSlide').innerHTML = '<p class="error">Failed to load images.</p>';
|
||||
@@ -226,11 +400,7 @@ function renderGalleryImage() {
|
||||
}
|
||||
|
||||
const img = galleryImages[currentImageIndex];
|
||||
|
||||
// Image
|
||||
slide.innerHTML = `<img src="${img.web_url}" alt="${img.filename}" onclick="window.open('${img.web_url}', '_blank')">`;
|
||||
|
||||
// Info
|
||||
info.innerHTML = `
|
||||
<div class="image-title">${img.filename} (${currentImageIndex + 1}/${galleryImages.length})</div>
|
||||
<div class="image-desc">${img.description || 'No description available.'}</div>
|
||||
@@ -250,7 +420,7 @@ window.nextImage = function () {
|
||||
renderGalleryImage();
|
||||
}
|
||||
|
||||
// --- Download Script ---
|
||||
// --- Download / Export ---
|
||||
window.downloadScript = async function () {
|
||||
if (!currentSessionId) return;
|
||||
const link = document.createElement('a');
|
||||
@@ -261,7 +431,6 @@ window.downloadScript = async function () {
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
// --- Export Report ---
|
||||
window.triggerExport = async function () {
|
||||
if (!currentSessionId) {
|
||||
alert("No active session to export.");
|
||||
@@ -273,9 +442,7 @@ window.triggerExport = async function () {
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const url = `/api/export?session_id=${currentSessionId}`;
|
||||
window.open(url, '_blank');
|
||||
|
||||
window.open(`/api/export?session_id=${currentSessionId}`, '_blank');
|
||||
} catch (e) {
|
||||
alert("Export failed: " + e.message);
|
||||
} finally {
|
||||
@@ -332,8 +499,6 @@ async function loadHistory() {
|
||||
|
||||
let html = '';
|
||||
data.history.forEach(item => {
|
||||
// item: {id, timestamp, name}
|
||||
const timeStr = item.timestamp.split(' ')[0]; // Just date for compactness
|
||||
html += `
|
||||
<div class="history-item" onclick="loadSession('${item.id}')" id="hist-${item.id}">
|
||||
<i class="fa-regular fa-clock"></i>
|
||||
@@ -342,7 +507,6 @@ async function loadHistory() {
|
||||
`;
|
||||
});
|
||||
list.innerHTML = html;
|
||||
|
||||
} catch (e) {
|
||||
console.error("Failed to load history", e);
|
||||
}
|
||||
@@ -356,30 +520,25 @@ window.loadSession = async function (sessionId) {
|
||||
|
||||
currentSessionId = sessionId;
|
||||
|
||||
// Update active class
|
||||
document.querySelectorAll('.history-item').forEach(el => el.classList.remove('active'));
|
||||
const activeItem = document.getElementById(`hist-${sessionId}`);
|
||||
if (activeItem) activeItem.classList.add('active');
|
||||
|
||||
// Reset UI
|
||||
logOutput.innerText = "Loading session data...";
|
||||
reportContainer.innerHTML = "";
|
||||
if (downloadScriptBtn) downloadScriptBtn.classList.add('hidden');
|
||||
|
||||
// Fetch Status to get logs and check report
|
||||
try {
|
||||
const res = await fetch(`/api/status?session_id=${sessionId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
logOutput.innerText = data.log || "No logs available.";
|
||||
|
||||
// Auto scroll log
|
||||
const logTab = document.getElementById('logsTab');
|
||||
if (logTab) logTab.scrollTop = logTab.scrollHeight;
|
||||
|
||||
if (data.has_report) {
|
||||
await loadReport();
|
||||
// Check if script exists
|
||||
if (data.script_path && downloadScriptBtn) {
|
||||
downloadScriptBtn.classList.remove('hidden');
|
||||
downloadScriptBtn.style.display = 'inline-flex';
|
||||
@@ -394,35 +553,29 @@ window.loadSession = async function (sessionId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
// --- Init & Navigation ---
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadHistory();
|
||||
});
|
||||
|
||||
// --- Navigation ---
|
||||
// No-op for switchView as sidebar is simplified
|
||||
window.switchView = function (viewName) {
|
||||
console.log("View switch requested:", viewName);
|
||||
}
|
||||
|
||||
window.switchTab = function (tabName) {
|
||||
// Buttons
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
// Content
|
||||
|
||||
['logs', 'report', 'gallery'].forEach(name => {
|
||||
const content = document.getElementById(`${name}Tab`);
|
||||
if (content) content.classList.add('hidden');
|
||||
|
||||
// 找到对应的 Tab 按钮并激活
|
||||
// 这里假设 Tab 按钮的 onclick 包含 tabName
|
||||
document.querySelectorAll('.tab').forEach(btn => {
|
||||
if (btn.getAttribute('onclick') && btn.getAttribute('onclick').includes(`'${tabName}'`)) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Valid tabs logic
|
||||
document.querySelectorAll('.tab').forEach(btn => {
|
||||
if (btn.getAttribute('onclick') && btn.getAttribute('onclick').includes(`'${tabName}'`)) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
if (tabName === 'logs') {
|
||||
document.getElementById('logsTab').classList.remove('hidden');
|
||||
} else if (tabName === 'report') {
|
||||
|
||||
Reference in New Issue
Block a user