"""FinSight FastAPI 接口层。 接口: GET /health 健康检查 + 知识库规模 POST /analyze 文档分析(走 LangGraph:router → 4Agent并行 → synthesis) POST /chat 问答(自动 RAG 混合检索;返回答案 + 来源) POST /chat/stream 问答流式输出(text/plain 逐块) POST /upload 上传文本入知识库 所有请求统一经 LangGraph 状态机执行(router 决定路径)。同步图调用由 FastAPI 自动放到线程池,天然支持并发。换微调/推理服务只动 agents/llm.py,本层不变。 启动:.venv/bin/uvicorn api.main:app --port 8000 """ from fastapi import FastAPI, UploadFile from fastapi.responses import StreamingResponse from pydantic import BaseModel from agents.analysts import QA from agents.llm import chat_stream from graph import analyze from graph.workflow import get_retriever, refresh_retriever from rag import KnowledgeBase, read_pdf app = FastAPI(title="FinSight", description="金融研报多Agent分析系统", version="0.1") class AnalyzeReq(BaseModel): text: str class ChatReq(BaseModel): question: str context: str | None = None # 显式给则跳过 RAG 检索 class UploadReq(BaseModel): doc_id: str text: str @app.get("/health") def health(): return {"status": "ok", "kb_chunks": KnowledgeBase().count(), "models": {"analysts": "finsight-qwen", "qa/router": "qwen2.5:7b-instruct"}} @app.post("/analyze") def analyze_doc(req: AnalyzeReq): """文档分析:返回 intent + 结构化报告(若路由判为问答则返回 answer)。""" r = analyze(req.text) out = {"intent": r["intent"], "router": r.get("router")} if r["intent"] == "doc_analysis": out["report"] = r["report"] else: out["answer"] = r["answer"].get("result") out["sources"] = r.get("sources", []) return out @app.post("/chat") def chat_qa(req: ChatReq): """问答:自动 RAG 检索注入,返回答案 + 来源。""" r = analyze(req.question, context=req.context) if r["intent"] == "doc_analysis": # 用户把文档发进了问答口,如实回路由结果 return {"intent": "doc_analysis", "report": r["report"]} return {"intent": "qa", "answer": r["answer"].get("result"), "sources": r.get("sources", [])} @app.post("/chat/stream") def chat_stream_qa(req: ChatReq): """流式问答:先检索注入上下文,再逐块流式输出纯文本答案。""" ctx = req.context if ctx is None: try: ctx = get_retriever().context(req.question, k=4) or None except Exception: ctx = None system = ("你是专业的金融问答助手。基于问题(如提供【参考资料】则优先依据资料)" "给出准确、客观的回答,不编造数据,涉及投资建议时提示风险。") user = req.question if ctx is None else f"【参考资料】\n{ctx}\n\n【问题】\n{req.question}" return StreamingResponse( chat_stream(system, user, model=QA.model), media_type="text/plain; charset=utf-8", ) @app.post("/upload") def upload(req: UploadReq): """把一段文本切块入库,并刷新检索器。""" kb = KnowledgeBase() n = kb.add(req.doc_id, req.text, meta={"source": "upload"}) refresh_retriever() return {"doc_id": req.doc_id, "chunks_added": n, "kb_total": kb.count()} @app.post("/upload/pdf") async def upload_pdf(file: UploadFile): """上传研报 PDF 入库:解析文本层 → 切块 → 入库 → 刷新检索器。doc_id 取文件名。""" doc_id = (file.filename or "uploaded").rsplit(".", 1)[0] text = read_pdf(file.file) # 直接读上传流,不落盘 if not text.strip(): return {"doc_id": doc_id, "chunks_added": 0, "error": "无文本层(可能是扫描件),未入库"} kb = KnowledgeBase() n = kb.add(doc_id, text, meta={"source": "pdf", "file": file.filename}) refresh_retriever() return {"doc_id": doc_id, "chunks_added": n, "kb_total": kb.count()}