Files
tianxuan/analysis/views/auto.py
T

349 lines
14 KiB
Python

"""LLM auto analysis views."""
import json
import asyncio
import threading
import traceback
import logging
from django.shortcuts import render, get_object_or_404
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from config import get_config
from analysis.models import AnalysisRun
from .pipeline import _run_pipeline_worker
from .clustering import _run_clustering_pipeline
from .helpers import _PLANS_DIR, _add_to_auto_index
logger = logging.getLogger(__name__)
def auto_page(request):
"""LLM-driven auto analysis page."""
from analysis.session_store import SessionStore
import json as _json
cfg = get_config()
# Get datasets from SessionStore + ALL runs from DB
store = SessionStore()
session_datasets = store.list_datasets() if hasattr(store, 'list_datasets') else []
session_ds_map = {ds['dataset_id']: ds for ds in session_datasets}
# Build dataset entries from ALL AnalysisRun records
all_runs = AnalysisRun.objects.all().order_by('-created_at')[:100]
datasets_with_status = []
for run in all_runs:
if run.run_type == 'upload':
ds_id = f'upload_{run.display_id}'
else:
ds_id = f'run_{run.display_id}'
session_entry = session_ds_map.get(ds_id)
if session_entry:
datasets_with_status.append({
**session_entry,
'dataset_id': ds_id,
'run_status': run.status,
'total_flows': run.total_flows,
'display_id': run.display_id,
'needs_reload': False,
'csv_glob': run.csv_glob or '',
'sqlite_table': run.sqlite_table or '',
})
elif run.csv_glob or run.sqlite_table:
datasets_with_status.append({
'dataset_id': ds_id,
'run_status': run.status,
'display_id': run.display_id,
'total_flows': run.total_flows,
'row_count': run.total_flows,
'file_count': None,
'columns': [],
'column_count': 0,
'svd_components': 0,
'needs_reload': True,
'csv_glob': run.csv_glob or '',
'sqlite_table': run.sqlite_table or '',
})
for ds_info in datasets_with_status:
s = ds_info['run_status']
if s in ('completed',):
ds_info['status_label'] = '已分析'
ds_info['badge_class'] = 'completed'
elif s in ('failed',):
ds_info['status_label'] = '错误'
ds_info['badge_class'] = 'failed'
elif s in ('pending', 'loading', 'profiling', 'aggregating', 'clustering', 'extracting'):
ds_info['status_label'] = '分析中'
ds_info['badge_class'] = 'analyzing'
else:
ds_info['status_label'] = '待分析'
ds_info['badge_class'] = 'ready'
return render(request, 'tianxuan/auto.html', {
'llm_configured': bool(cfg.llm.base_url and cfg.llm.api_key),
'datasets_with_status_json': _json.dumps(datasets_with_status, ensure_ascii=False),
})
@csrf_exempt
def run_llm_analysis_view(request):
"""AJAX POST: run LLM-driven analysis pipeline."""
if request.method != 'POST':
return JsonResponse({'error': 'POST required'}, status=405)
body = json.loads(request.body)
display_id = body.get('run_id')
run = get_object_or_404(AnalysisRun, display_id=display_id)
pk = run.id
run.run_type = 'auto'
run.save(update_fields=['run_type'])
head = body.get('head')
from analysis.session_store import SessionStore
store = SessionStore()
# Determine dataset key format (consistent with manual_page)
upload_ds_id = f'upload_{display_id}'
run_ds_id = f'run_{display_id}'
entry = store.get_dataset(upload_ds_id)
if entry is None:
entry = store.get_dataset(run_ds_id)
if entry is None:
# Try primary key fallback
upload_pk_id = f'upload_{pk}'
entity_pk_id = f'entity_{pk}'
entry = store.get_dataset(upload_pk_id)
if entry is None:
entry = store.get_dataset(entity_pk_id)
if entry is None:
# Auto-reload from sqlite_table or csv_glob
from analysis.data_loader import load_csv_directory, load_from_db
lf = None
schema = {}
if run.sqlite_table:
try:
lf = load_from_db(run.sqlite_table)
except Exception:
logger.error("unknown failed: {}".format(traceback.format_exc()))
pass
if lf is None and run.csv_glob:
try:
lf, schema, _, _, _ = load_csv_directory(run.csv_glob)
except Exception:
logger.error("unknown failed: {}".format(traceback.format_exc()))
pass
if lf is None:
return JsonResponse({'error': '数据集不存在,请先上传并等待预处理完成'})
entry_key = upload_ds_id if run.run_type == 'upload' else run_ds_id
store.store_dataset(entry_key, lf, schema=schema, metadata={
'row_count': run.total_flows,
'csv_glob': run.csv_glob or '',
'sqlite_table': run.sqlite_table or '',
})
# Re-fetch entry
entry = store.get_dataset(entry_key)
# Determine the active dataset_id
for candidate in [upload_ds_id, run_ds_id, f'upload_{pk}', f'entity_{pk}']:
if store.get_dataset(candidate):
dataset_id = candidate
break
else:
return JsonResponse({'error': '数据集不存在,请先上传并等待预处理完成'})
# ── Optional: apply filter before LLM pipeline ──
filters = body.get('filters', [])
if filters and len(filters) > 0:
logic = body.get('logic', 'and')
try:
from analysis.tool_registry import _handle_filter_data
filter_result = asyncio.run(_handle_filter_data(
dataset_id=dataset_id,
filters=filters,
logic=logic,
))
if 'error' not in filter_result:
filtered_id = filter_result.get('filtered_dataset_id', '')
if filtered_id and store.get_dataset(filtered_id):
dataset_id = filtered_id
except Exception:
logger.warning(f'[LLM auto] Filter failed (non-fatal): {traceback.format_exc()}')
def _analysis_fn(run, ctx):
from tianxuan.llm_orchestrator import run_llm_pipeline, LLMConfig
dataset_id = ctx['dataset_id']
pk = ctx['pk']
def progress_callback(step, tool_name, result=None, meta=None):
nonlocal run
try:
run = AnalysisRun.objects.get(id=pk)
pct = min(10 + step * 6, 95)
run.progress_pct = pct
run.progress_msg = f'步骤 {step}: {tool_name}'
save_fields = ['progress_pct', 'progress_msg']
if tool_name == 'thinking':
# Accumulate LLM thinking text (appended to llm_thinking)
thought = (result or '').strip()
if not thought:
thought = f'步骤 {step} 分析中...'
run.llm_thinking = (run.llm_thinking or '') + f'\n[{step}] {thought}'
save_fields = ['progress_pct', 'progress_msg', 'llm_thinking']
elif tool_name == 'complete':
pass # No extra tracking needed
else:
# Single tool call — always has result
if isinstance(result, dict):
summary = json.dumps(result, default=str, ensure_ascii=False)[:500]
else:
summary = str(result)[:500]
run.run_log += f'[{step}] {tool_name}: {summary}...\n'
# One entry per tool call
if meta and isinstance(meta, dict) and 'args' in meta:
tool_calls = list(run.tool_calls_json or [])
tool_calls.append({
'step': step,
'name': tool_name,
'input': meta['args'],
'output': result,
})
run.tool_calls_json = tool_calls
save_fields = ['progress_pct', 'progress_msg', 'run_log', 'tool_calls_json']
run.save(update_fields=save_fields)
except Exception as e:
logger.warning('progress_callback error: %s', e)
try:
from config import get_config
cfg = get_config()
llm_cfg = LLMConfig(cfg.llm.base_url, cfg.llm.api_key, cfg.llm.model)
run.run_log = '启动 LLM 分析...\n'
run.status = 'loading'
run.progress_pct = 5
run.progress_msg = 'LLM 初始化中...'
run.save(update_fields=['status', 'progress_pct', 'progress_msg', 'run_log'])
result = run_llm_pipeline(dataset_id, config=llm_cfg, callback=progress_callback)
if 'error' in result:
run.status = 'failed'
run.progress_msg = f'失败: {result["error"]}'
run.run_log += f'\n[ERROR] {result["error"]}'
run.save(update_fields=['status', 'progress_pct', 'progress_msg', 'run_log'])
else:
run.progress_pct = 55
run.progress_msg = f'LLM分析完成: {result.get("result", "")[:200]}'
run.run_log += f'\n[完成] {result.get("result", "")}\n[INFO] 开始自动聚类流程...'
run.save(update_fields=['progress_pct', 'progress_msg', 'run_log'])
# ── Auto-run clustering pipeline after LLM analysis ──
entity_ds_id = None
tool_calls = run.tool_calls_json or []
for tc in tool_calls:
tool_name = tc.get('name', '')
tool_input = tc.get('input', {})
if tool_name in ('build_entity_profiles', 'run_clustering'):
entity_ds_id = tool_input.get('dataset_id', '')
if entity_ds_id and store.get_dataset(entity_ds_id):
break
entity_ds_id = None
# Fallback: try common dataset ID patterns
if not entity_ds_id:
candidates = [
ctx.get('dataset_id', ''),
f'entity_{pk}',
f'entity_{run.display_id}',
f'upload_{run.display_id}',
]
for cid in candidates:
if cid and store.get_dataset(cid):
entity_ds_id = cid
break
if entity_ds_id:
_run_clustering_pipeline(
run=run,
store=store,
ds_id=entity_ds_id,
feature_columns=None,
algorithm='agglomerative',
min_cluster_size=5,
run_umap=True,
head=ctx.get('head'),
)
else:
logger.warning(
f'[LLM auto] No entity dataset found for clustering. '
f'Tool calls: {[tc.get("name") for tc in tool_calls]}. '
f'Marking run as completed without clustering.'
)
run.status = 'completed'
run.progress_pct = 100
run.progress_msg = 'LLM分析完成(无实体数据,跳过聚类)'
run.save(update_fields=['status', 'progress_pct', 'progress_msg'])
# ── Auto-save workflow as loadable plan ──
try:
tool_calls_data = run.tool_calls_json or []
steps = []
for tc in tool_calls_data:
tc_name = tc.get('name', '')
if tc_name == 'thinking':
continue
steps.append({
'tool_name': tc_name,
'params': tc.get('input', {}),
})
if steps:
plan_data = {
'name': f'_auto_{run.display_id}',
'steps': steps,
'auto': True,
'display_id': run.display_id,
'created_at': str(run.created_at) if run.created_at else '',
}
plan_path = _PLANS_DIR / f'_auto_{run.display_id}.json'
plan_path.write_text(
json.dumps(plan_data, ensure_ascii=False, indent=2),
encoding='utf-8',
)
_add_to_auto_index(f'_auto_{run.display_id}.json')
logger.info(
'[LLM auto] Saved auto plan %s (%d steps)',
plan_path.name, len(steps),
)
except Exception as save_err:
logger.warning('[LLM auto] Failed to save auto plan: %s', save_err)
except Exception:
tb = traceback.format_exc()
logger.error(tb)
try:
run = AnalysisRun.objects.get(id=pk)
run.status = 'failed'
run.progress_msg = f'失败: {tb}'
run.run_log += f'\n[ERROR] {tb}'
run.error_message = tb
run.save(update_fields=['status', 'progress_msg', 'run_log', 'error_message'])
except Exception as e:
logger.warning('save failed after pipeline error: %s', e)
t = threading.Thread(
target=_run_pipeline_worker,
args=(pk, _analysis_fn),
kwargs=dict(dataset_id=dataset_id, head=head, pk=pk),
daemon=True,
)
t.start()
return JsonResponse({'status': 'started', 'run_id': run.display_id})