Add LLM token
This commit is contained in:
@@ -20,7 +20,11 @@ from app.api.models import (
|
||||
)
|
||||
from app.config.settings import settings
|
||||
from app.shared.async_utils import iter_in_thread
|
||||
from app.shared.bootstrap import get_agent_conversation_service, get_agent_session_service
|
||||
from app.shared.bootstrap import (
|
||||
get_agent_conversation_service,
|
||||
get_agent_session_service,
|
||||
get_agentic_conversation_service,
|
||||
)
|
||||
# Keep route handlers close to their transport-layer wiring for easier auditing.
|
||||
|
||||
|
||||
@@ -182,3 +186,58 @@ async def submit_feedback(request: FeedbackRequest):
|
||||
return {"message": "反馈已提交", "session_id": result.session_id, "message_index": result.message_index}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
|
||||
|
||||
# ── P0-1: Agentic RAG endpoint ────────────────────────────────────────────────
|
||||
|
||||
@router.post("/agentic/stream")
|
||||
async def agentic_stream(request: ChatRequest):
|
||||
"""Stream an Agentic RAG response with live multi-step reasoning trace.
|
||||
|
||||
Unlike the standard ``/chat/stream`` endpoint this route runs a full pipeline:
|
||||
intent analysis → query planning → iterative retrieval → grounding check →
|
||||
answer generation.
|
||||
|
||||
Extra SSE event types beyond the standard ones:
|
||||
|
||||
* ``thinking`` — reasoning sub-step progress; data is a JSON object with
|
||||
``step`` (intent_analysis / query_planning / retrieving / grounding_check),
|
||||
``status`` (running / done), and step-specific fields.
|
||||
|
||||
The ``sources``, ``content``, and ``done`` events are identical to the standard
|
||||
chat-stream contract so the existing frontend parser can handle them without
|
||||
changes.
|
||||
"""
|
||||
async def generate_sse() -> AsyncGenerator[str, None]:
|
||||
"""Handle SSE generation for the agentic chat endpoint."""
|
||||
try:
|
||||
session_id_, event_stream = get_agentic_conversation_service().stream_agentic_chat(
|
||||
query=request.query,
|
||||
session_id=request.session_id,
|
||||
filters=request.filters,
|
||||
provider=request.provider or settings.llm_provider,
|
||||
model=request.model or settings.llm_model,
|
||||
top_k=request.top_k or settings.rag_top_k,
|
||||
context_text=request.context_text,
|
||||
context_filename=request.context_filename,
|
||||
)
|
||||
yield f"event: session\ndata: {json.dumps({'session_id': session_id_})}\n\n"
|
||||
async for event_data in iter_in_thread(event_stream):
|
||||
event_type = event_data.get("event", "content")
|
||||
data = event_data.get("data", "")
|
||||
if isinstance(data, (dict, list)):
|
||||
yield f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
else:
|
||||
yield f"event: {event_type}\ndata: {data}\n\n"
|
||||
except Exception as exc:
|
||||
yield f"event: error\ndata: {str(exc)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate_sse(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -85,9 +85,10 @@ async def analyze_stream(
|
||||
Events: stage | source | finding | done | error
|
||||
"""
|
||||
from app.application.compliance.pipeline import (
|
||||
detect_cross_clause_conflicts,
|
||||
extract_text_from_doc_id,
|
||||
extract_text_from_file,
|
||||
run_clauses_parallel,
|
||||
run_clauses_streaming,
|
||||
split_into_clauses,
|
||||
synthesize_conclusion,
|
||||
)
|
||||
@@ -135,23 +136,27 @@ async def analyze_stream(
|
||||
await asyncio.sleep(0)
|
||||
clauses: list[str] = await asyncio.to_thread(split_into_clauses, para_text, client)
|
||||
|
||||
# ── Stage 3: retrieve + gap check (parallel across all clauses) ────────────
|
||||
# ── Stage 3: progressive per-clause retrieve + gap check ──────
|
||||
findings: list[dict] = []
|
||||
total_clauses = len(clauses)
|
||||
|
||||
yield _sse({
|
||||
"type": "stage",
|
||||
"stage": "analyzing",
|
||||
"label": f"Analyzing {len(clauses)} clauses in parallel…",
|
||||
"label": f"Analyzing {total_clauses} clauses…",
|
||||
})
|
||||
# Emit initial progress so the frontend can show the total count
|
||||
yield _sse({"type": "progress", "done": 0, "total": total_clauses})
|
||||
await asyncio.sleep(0)
|
||||
|
||||
clause_results = await run_clauses_parallel(
|
||||
done_count = 0
|
||||
# Stream results as each clause completes (not after all finish)
|
||||
async for res in run_clauses_streaming(
|
||||
clauses, retrieval_service, client,
|
||||
top_k=5,
|
||||
domains=domains or None,
|
||||
)
|
||||
|
||||
for res in clause_results:
|
||||
):
|
||||
done_count += 1
|
||||
i = res["index"]
|
||||
chunks = res["chunks"]
|
||||
finding = res["finding"]
|
||||
@@ -165,14 +170,25 @@ async def analyze_stream(
|
||||
"score": round(float(getattr(chunk, "score", 0)), 3),
|
||||
"status": "retrieved",
|
||||
"full_content": (getattr(chunk, "text", "") or "")[:300],
|
||||
"clause_index": i,
|
||||
})
|
||||
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
yield _sse({"type": "finding", **finding})
|
||||
|
||||
# Real progress update after each clause completes
|
||||
yield _sse({"type": "progress", "done": done_count, "total": total_clauses})
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# ── Stage 3b: cross-clause conflict detection ─────────────────
|
||||
if findings:
|
||||
conflicts = await asyncio.to_thread(
|
||||
detect_cross_clause_conflicts, findings, client
|
||||
)
|
||||
if conflicts:
|
||||
yield _sse({"type": "conflicts", "items": conflicts})
|
||||
|
||||
# ── Stage 4: synthesize conclusion ────────────────────────────
|
||||
yield _sse({"type": "stage", "stage": "concluding", "label": "Generating conclusion…"})
|
||||
await asyncio.sleep(0)
|
||||
|
||||
@@ -241,6 +241,9 @@ async def get_document_management_list():
|
||||
"updated_at": item.updated_at.isoformat(),
|
||||
"regulation_type": item.regulation_type,
|
||||
"version": item.version,
|
||||
# True only when the original binary file is stored in MinIO.
|
||||
# Milvus-only synthetic docs have no binary file — download is disabled.
|
||||
"has_file": bool(item.object_name),
|
||||
}
|
||||
for item in documents
|
||||
],
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import AsyncGenerator
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, File, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from loguru import logger
|
||||
|
||||
from app.api.dependencies.auth import get_current_user
|
||||
from app.config.settings import settings
|
||||
@@ -15,6 +19,8 @@ from app.schemas.rag import RagChatRequest, QuickQuestionsResponse, QuickQuestio
|
||||
from app.shared.async_utils import iter_in_thread
|
||||
from app.shared.bootstrap import get_agent_conversation_service
|
||||
|
||||
# Maximum characters of document text injected as LLM context (≈ 6 000 tokens).
|
||||
_MAX_CONTEXT_CHARS = 8_000
|
||||
|
||||
router = APIRouter(prefix="/rag", tags=["RAG问答"])
|
||||
|
||||
@@ -28,17 +34,90 @@ _DEFAULT_QUICK_QUESTIONS = [
|
||||
]
|
||||
|
||||
|
||||
def _extract_text_from_bytes(content: bytes, filename: str) -> str:
|
||||
"""Extract plain text from an uploaded file using the document parser.
|
||||
|
||||
Tries the configured parser first; falls back to raw UTF-8 decode for
|
||||
plain-text formats (.txt, .md). Returns at most _MAX_CONTEXT_CHARS characters
|
||||
so the text fits comfortably inside the LLM context window.
|
||||
"""
|
||||
suffix = os.path.splitext(filename or "doc.pdf")[1] or ".pdf"
|
||||
# Fast path: plain-text files don't need a parser
|
||||
if suffix.lower() in {".txt", ".md", ".csv"}:
|
||||
try:
|
||||
return content.decode("utf-8", errors="replace")[:_MAX_CONTEXT_CHARS]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tmp_path = ""
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
from app.shared.bootstrap import get_document_command_service
|
||||
svc = get_document_command_service()
|
||||
parsed = svc.parser.parse(file_path=tmp_path, doc_id="ctx_extract", doc_name=filename)
|
||||
if parsed.raw_text:
|
||||
return parsed.raw_text[:_MAX_CONTEXT_CHARS]
|
||||
# Fallback: join semantic blocks
|
||||
return "\n".join(
|
||||
b.get("text", "") for b in parsed.semantic_blocks if b.get("text")
|
||||
)[:_MAX_CONTEXT_CHARS]
|
||||
except Exception as exc:
|
||||
logger.warning("Context text extraction failed for {}: {}", filename, exc)
|
||||
return ""
|
||||
finally:
|
||||
if tmp_path:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@router.post("/upload-context")
|
||||
async def upload_context(
|
||||
file: UploadFile = File(...),
|
||||
current_user: UserClaims = Depends(get_current_user),
|
||||
):
|
||||
"""Extract text from an uploaded document and return it as conversation context.
|
||||
|
||||
The client stores the returned text and includes it in subsequent /rag/chat
|
||||
requests via the context_text field — the LLM receives the document content
|
||||
directly without requiring vector-store indexing.
|
||||
"""
|
||||
content = await file.read()
|
||||
filename = file.filename or "document"
|
||||
text = await __import__("asyncio").to_thread(_extract_text_from_bytes, content, filename)
|
||||
if not text.strip():
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=422, detail="Could not extract text from the uploaded file.")
|
||||
return {
|
||||
"filename": filename,
|
||||
"text": text,
|
||||
"char_count": len(text),
|
||||
"truncated": len(text) >= _MAX_CONTEXT_CHARS,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/chat")
|
||||
async def rag_chat(
|
||||
request: RagChatRequest,
|
||||
current_user: UserClaims = Depends(get_current_user),
|
||||
):
|
||||
"""Stream RAG Q&A using the real agent service."""
|
||||
"""Stream RAG Q&A using the real agent service.
|
||||
|
||||
When request.context_text is provided the document text is passed directly
|
||||
to the answer generator as a dedicated document context section — RAG
|
||||
retrieval still runs on the user's original question (not the document text)
|
||||
so embedding quality is preserved for regulation chunk matching.
|
||||
"""
|
||||
session_id, event_stream = get_agent_conversation_service().stream_chat(
|
||||
query=request.query,
|
||||
session_id=request.session_id,
|
||||
filters=request.filters,
|
||||
top_k=request.top_k or settings.rag_top_k,
|
||||
context_text=request.context_text,
|
||||
context_filename=request.context_filename,
|
||||
)
|
||||
|
||||
async def generate() -> AsyncGenerator[str, None]:
|
||||
|
||||
Reference in New Issue
Block a user