"""Document optimization & evaluation — SSE streaming API.""" import json import sys import time from pathlib import Path from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import StreamingResponse # Ensure app dir and root are on path APP_DIR = Path(__file__).resolve().parent.parent ROOT_DIR = APP_DIR.parent for p in (str(APP_DIR), str(ROOT_DIR)): if p not in sys.path: sys.path.insert(0, p) from db import get_distractor_set, save_record from config_ui import get_missing_key_message router = APIRouter(prefix="/api/optimize", tags=["optimize"]) def _pct(before: float, after: float) -> str: if before == 0: return "N/A" change = (after - before) / before * 100 return f"{change:+.1f}%" def _optimize_stream( query: str, document: str, set_id: int, engine_llm: str, max_rounds: int, ): """Synchronous generator yielding SSE events for the full optimization flow.""" from engine import run_evaluate, run_rewrite_iterative # Validate key msg = get_missing_key_message(engine_llm) if msg: yield f"data: {json.dumps({'type': 'error', 'message': msg})}\n\n" return # Load distractor set ds = get_distractor_set(set_id) if not ds: yield f"data: {json.dumps({'type': 'error', 'message': '干扰文档集合不存在'})}\n\n" return distractors = json.loads(ds["distractors"]) text_list = [document.strip()] + [d for d in distractors if d.strip()] target_id = 0 # ---- Step 1: Evaluate original (baseline) ---- yield f"data: {json.dumps({'type': 'progress', 'stage': 'baseline', 'text': '评估原始文档...'})}\n\n" try: vanilla_scores = run_evaluate( query=query.strip(), text_list=text_list, target_id=target_id, engine_llm=engine_llm, ) yield f"data: {json.dumps({'type': 'baseline_complete', 'scores': vanilla_scores})}\n\n" except ValueError as e: yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n" return except Exception as e: yield f"data: {json.dumps({'type': 'error', 'message': f'原始文档评估失败: {e}'})}\n\n" return # ---- Step 2: Iterative rewrite + evaluate ---- rounds_data = [] def progress_callback(round_num: int, status: str): """Called by run_rewrite_iterative for each stage.""" stage_text = "改写中" if status == "rewriting" else ("优化中" if status == "refining" else "评估中") # This callback is synchronous and called from inside the engine. # We can't yield from here directly in a generator pattern, # so we store the state and yield from the wrapper. progress_callback.last = {"round": round_num, "status": status, "text": stage_text} progress_callback.last = None try: # We need to run the iterative process and capture results. # The progress_callback can't yield SSE events directly, # so we wrap each round manually to emit SSE. from engine import run_rewrite, refine_document total_rounds = max_rounds rewritten = None # Round 1: Standard rewrite yield f"data: {json.dumps({'type': 'progress', 'round': 1, 'total_rounds': total_rounds, 'stage': 'rewriting', 'text': f'第 1/{total_rounds} 轮:改写中...'})}\n\n" rewritten = run_rewrite(document=document, engine_llm=engine_llm) yield f"data: {json.dumps({'type': 'progress', 'round': 1, 'total_rounds': total_rounds, 'stage': 'evaluating', 'text': f'第 1/{total_rounds} 轮:评估中...'})}\n\n" text_list_eval = [rewritten] + text_list[1:] scores = run_evaluate( query=query.strip(), text_list=text_list_eval, target_id=target_id, engine_llm=engine_llm, ) rounds_data.append({"round": 1, "text": rewritten, "scores": scores}) yield f"data: {json.dumps({'type': 'round_complete', 'round': 1, 'scores': scores, 'text': rewritten})}\n\n" # Rounds 2+: Refine with feedback for rnd in range(2, total_rounds + 1): prev = rounds_data[-1] # Check if already excellent if all(v >= 0.9 for v in prev["scores"].values()): break yield f"data: {json.dumps({'type': 'progress', 'round': rnd, 'total_rounds': total_rounds, 'stage': 'refining', 'text': f'第 {rnd}/{total_rounds} 轮:优化中...'})}\n\n" refined = refine_document( original_doc=document, previous_rewrite=prev["text"], scores=prev["scores"], query=query, engine_llm=engine_llm, ) yield f"data: {json.dumps({'type': 'progress', 'round': rnd, 'total_rounds': total_rounds, 'stage': 'evaluating', 'text': f'第 {rnd}/{total_rounds} 轮:评估中...'})}\n\n" text_list_eval = [refined] + text_list[1:] scores = run_evaluate( query=query.strip(), text_list=text_list_eval, target_id=target_id, engine_llm=engine_llm, ) rounds_data.append({"round": rnd, "text": refined, "scores": scores}) yield f"data: {json.dumps({'type': 'round_complete', 'round': rnd, 'scores': scores, 'text': refined})}\n\n" # Early stop if rnd >= 3: prev_prev = rounds_data[-2] prev_cur = rounds_data[-1] if prev_cur["scores"]["wordpos"] <= prev_prev["scores"]["wordpos"] * 1.02: break # ---- Step 3: Best result ---- best = max(rounds_data, key=lambda x: x["scores"]["wordpos"]) rewritten_text = best["text"] optimized_scores = best["scores"] # Save to history save_record( model=engine_llm, query=query.strip(), original_doc=document.strip(), rewritten_doc=rewritten_text, vanilla_scores=vanilla_scores, optimized_scores=optimized_scores, distractors=distractors, ) yield f"data: {json.dumps({ 'type': 'complete', 'vanilla_scores': vanilla_scores, 'optimized_scores': optimized_scores, 'rewritten_text': rewritten_text, 'rounds': rounds_data, 'improvements': { 'pos': _pct(vanilla_scores['pos'], optimized_scores['pos']), 'word': _pct(vanilla_scores['word'], optimized_scores['word']), 'wordpos': _pct(vanilla_scores['wordpos'], optimized_scores['wordpos']), }, })}\n\n" except ValueError as e: yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n" except Exception as e: yield f"data: {json.dumps({'type': 'error', 'message': f'优化失败: {e}'})}\n\n" @router.get("/stream") async def optimize_stream( query: str = Query(..., description="用户查询"), document: str = Query(..., description="原始文档"), set_id: int = Query(..., description="干扰文档集合 ID"), engine_llm: str = Query("doubao", description="引擎"), max_rounds: int = Query(2, ge=1, le=5, description="最大迭代轮数"), ): """SSE endpoint — stream optimization + evaluation progress.""" # Validate if not query.strip(): raise HTTPException(status_code=400, detail="查询不能为空") if not document.strip(): raise HTTPException(status_code=400, detail="文档不能为空") if max_rounds < 1 or max_rounds > 5: raise HTTPException(status_code=400, detail="迭代轮数需要 1-5") return StreamingResponse( _optimize_stream(query.strip(), document.strip(), set_id, engine_llm, max_rounds), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, )