200 lines
7.1 KiB
Python
200 lines
7.1 KiB
Python
import json
|
|
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.reviews import require_current_user
|
|
from app.clients.shared_ai_platform import SharedAIPlatformClient, get_shared_ai_platform_client
|
|
from app.db import get_db_session
|
|
from app.schemas.chat import ChatStreamRequest
|
|
from app.services.review_service import ReviewService
|
|
from app.services.tenant_service import TenantService
|
|
|
|
router = APIRouter(prefix="/chat", tags=["chat"])
|
|
ROUTE_PERMISSION_SCOPE = "chat"
|
|
ROUTE_PERMISSION_ACTIONS = {
|
|
"stream": "chat:stream",
|
|
}
|
|
|
|
|
|
def get_chat_review_service(db: Session = Depends(get_db_session)) -> ReviewService:
|
|
return ReviewService(db)
|
|
|
|
|
|
def get_tenant_service(db: Session = Depends(get_db_session)) -> TenantService:
|
|
return TenantService(db)
|
|
|
|
|
|
@router.post("/stream")
|
|
def stream_chat(
|
|
payload: ChatStreamRequest,
|
|
shared_ai_platform_client: SharedAIPlatformClient = Depends(get_shared_ai_platform_client),
|
|
tenant_service: TenantService = Depends(get_tenant_service),
|
|
current_user: dict = Depends(require_current_user),
|
|
review_service: Optional[ReviewService] = Depends(get_chat_review_service),
|
|
):
|
|
tenant_service.assert_billing_allows(current_user, "chat")
|
|
review_context = (
|
|
payload.review_context.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
if payload.review_context
|
|
else None
|
|
)
|
|
review_context = _enrich_review_context_with_legal_articles(current_user, review_context, review_service)
|
|
local_legal_sources = _build_legal_article_sources(review_context)
|
|
return StreamingResponse(
|
|
_merge_local_sources_into_stream(
|
|
shared_ai_platform_client.stream_answer(current_user, payload.conversation_id, payload.message, review_context),
|
|
local_legal_sources,
|
|
),
|
|
media_type="text/event-stream",
|
|
)
|
|
|
|
|
|
def _enrich_review_context_with_legal_articles(
|
|
current_user: Dict[str, Any],
|
|
review_context: Optional[Dict[str, Any]],
|
|
review_service: Optional[ReviewService],
|
|
) -> Optional[Dict[str, Any]]:
|
|
if not review_context or review_service is None:
|
|
return review_context
|
|
|
|
review_id = review_context.get("reviewId") or review_context.get("review_id")
|
|
if not review_id:
|
|
return review_context
|
|
|
|
try:
|
|
result = review_service.get_review_result(current_user, str(review_id))
|
|
except Exception:
|
|
return review_context
|
|
if not result:
|
|
return review_context
|
|
|
|
refs = _collect_legal_article_refs(result.get("risk_items") or [])
|
|
if not refs:
|
|
return review_context
|
|
|
|
enriched = dict(review_context)
|
|
enriched["legalArticleRefs"] = refs
|
|
enriched["legal_article_refs"] = refs
|
|
metadata = dict(enriched.get("metadata") or {})
|
|
metadata["legalArticleRefCount"] = len(refs)
|
|
metadata["legalArticleSources"] = [
|
|
{
|
|
"id": ref.get("id"),
|
|
"title": _build_legal_article_source_title(ref),
|
|
"detail_path": ref.get("detail_path"),
|
|
}
|
|
for ref in refs
|
|
]
|
|
enriched["metadata"] = metadata
|
|
return enriched
|
|
|
|
|
|
def _collect_legal_article_refs(risk_items: Iterable[Mapping[str, Any]]) -> List[Dict[str, Any]]:
|
|
refs: List[Dict[str, Any]] = []
|
|
seen: set[str] = set()
|
|
for item in risk_items:
|
|
for ref in item.get("legal_article_refs") or []:
|
|
if not isinstance(ref, Mapping):
|
|
continue
|
|
ref_id = str(ref.get("id") or "")
|
|
key = ref_id or f"{ref.get('source_title')}::{ref.get('article_no')}::{ref.get('detail_path')}"
|
|
if not key or key in seen:
|
|
continue
|
|
seen.add(key)
|
|
refs.append(dict(ref))
|
|
return refs
|
|
|
|
|
|
def _build_legal_article_sources(review_context: Optional[Mapping[str, Any]]) -> List[Dict[str, Any]]:
|
|
if not review_context:
|
|
return []
|
|
refs = review_context.get("legalArticleRefs") or review_context.get("legal_article_refs") or []
|
|
if not isinstance(refs, list):
|
|
return []
|
|
sources: List[Dict[str, Any]] = []
|
|
for ref in refs:
|
|
if not isinstance(ref, Mapping):
|
|
continue
|
|
source_id = ref.get("id") or ref.get("source_id")
|
|
title = _build_legal_article_source_title(ref)
|
|
content = ref.get("article_excerpt") or ref.get("match_reason") or ref.get("detail_path") or ""
|
|
sources.append(
|
|
{
|
|
"title": title,
|
|
"content": content,
|
|
"score": 1.0,
|
|
"source_type": "legal_article",
|
|
"source_id": source_id,
|
|
"detail_path": ref.get("detail_path"),
|
|
"article_no": ref.get("article_no"),
|
|
"authority": ref.get("authority"),
|
|
"knowledge_base_id": ref.get("knowledge_base_id"),
|
|
"knowledge_document_id": ref.get("knowledge_document_id"),
|
|
"metadata": {
|
|
"source_id": ref.get("source_id"),
|
|
"source_title": ref.get("source_title"),
|
|
"match_reason": ref.get("match_reason"),
|
|
"tags": ref.get("tags") or [],
|
|
},
|
|
}
|
|
)
|
|
return _dedupe_sources(sources)
|
|
|
|
|
|
def _build_legal_article_source_title(ref: Mapping[str, Any]) -> str:
|
|
source_title = str(ref.get("source_title") or "法条全文")
|
|
article_no = str(ref.get("article_no") or "")
|
|
return f"{source_title} {article_no}".strip()
|
|
|
|
|
|
def _merge_local_sources_into_stream(upstream: Iterable[str], local_sources: List[Dict[str, Any]]) -> Iterable[str]:
|
|
if not local_sources:
|
|
yield from upstream
|
|
return
|
|
|
|
emitted_sources = False
|
|
for chunk in upstream:
|
|
event = _parse_sse_event(chunk)
|
|
if event and event.get("type") == "sources":
|
|
merged = _dedupe_sources([*(event.get("sources") or []), *local_sources])
|
|
emitted_sources = True
|
|
yield _format_sse_event({"type": "sources", "sources": merged})
|
|
continue
|
|
if event and event.get("type") == "end" and not emitted_sources:
|
|
emitted_sources = True
|
|
yield _format_sse_event({"type": "sources", "sources": local_sources})
|
|
yield chunk
|
|
|
|
|
|
def _parse_sse_event(chunk: str) -> Optional[Dict[str, Any]]:
|
|
for line in chunk.splitlines():
|
|
if not line.startswith("data: "):
|
|
continue
|
|
try:
|
|
payload = json.loads(line[6:])
|
|
except json.JSONDecodeError:
|
|
return None
|
|
return payload if isinstance(payload, dict) else None
|
|
return None
|
|
|
|
|
|
def _format_sse_event(payload: Dict[str, Any]) -> str:
|
|
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
|
|
|
|
|
def _dedupe_sources(sources: Iterable[Mapping[str, Any]]) -> List[Dict[str, Any]]:
|
|
deduped: List[Dict[str, Any]] = []
|
|
seen: set[str] = set()
|
|
for source in sources:
|
|
if not isinstance(source, Mapping):
|
|
continue
|
|
key = str(source.get("source_id") or source.get("detail_path") or f"{source.get('title')}::{source.get('content')}")
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
deduped.append(dict(source))
|
|
return deduped
|