Add LLM token

This commit is contained in:
wangwei
2026-07-02 22:03:39 +08:00
parent e3afb8a07a
commit 52e67b0e7b
36 changed files with 2392 additions and 394 deletions
+5
View File
@@ -42,6 +42,11 @@ class ChatRequest(BaseModel):
provider: Optional[str] = None
model: Optional[str] = None
top_k: Optional[int] = Field(default=None, ge=1, le=20)
# Optional document text uploaded by the user as conversation context.
# The text is injected directly into the LLM prompt so the model can
# answer questions about it without vector-store indexing.
context_text: Optional[str] = Field(default=None, max_length=12000)
context_filename: Optional[str] = Field(default=None, max_length=256)
class ChatResponse(BaseModel):
+60 -1
View File
@@ -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",
},
)
+23 -7
View File
@@ -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)
+3
View File
@@ -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
],
+82 -3
View File
@@ -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]: