349 lines
14 KiB
Python
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 analysis.services.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})
|