505 lines
21 KiB
Python
505 lines
21 KiB
Python
import json
|
||
import re
|
||
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.legal_article_service import LegalArticleService
|
||
from app.services.legal_domain_registry import BUSINESS_AUXILIARY_PROMPT_KEYS
|
||
from app.services.prompt_runtime_service import PromptRuntimeService
|
||
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_chat_legal_article_service(db: Session = Depends(get_db_session)) -> LegalArticleService:
|
||
return LegalArticleService(db)
|
||
|
||
|
||
def get_chat_prompt_runtime_service(db: Session = Depends(get_db_session)) -> PromptRuntimeService:
|
||
return PromptRuntimeService(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),
|
||
legal_article_service: Optional[LegalArticleService] = Depends(get_chat_legal_article_service),
|
||
prompt_runtime_service: Optional[PromptRuntimeService] = Depends(get_chat_prompt_runtime_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)
|
||
prompt_runtime = _resolve_chat_prompt_runtime(current_user, review_context, prompt_runtime_service, payload.message)
|
||
runtime_legal_sources = _search_runtime_legal_article_sources(
|
||
current_user,
|
||
payload.message,
|
||
legal_article_service,
|
||
prompt_runtime=prompt_runtime,
|
||
)
|
||
review_context = _enrich_review_context_with_runtime_legal_sources(review_context, runtime_legal_sources)
|
||
review_context = _enrich_review_context_with_prompt_runtime(review_context, prompt_runtime, prompt_runtime_service)
|
||
template_key = prompt_runtime.get("template_key") if prompt_runtime and prompt_runtime.get("available") else None
|
||
if prompt_runtime_service is not None:
|
||
prompt_runtime_service.record_run_link(
|
||
current_user,
|
||
prompt_runtime,
|
||
run_type=str((prompt_runtime or {}).get("prompt_type") or "legal_chat"),
|
||
run_id=payload.conversation_id or "new_conversation",
|
||
resource_type="conversation",
|
||
resource_id=payload.conversation_id,
|
||
trace={
|
||
"message_length": len(payload.message or ""),
|
||
"has_review_context": bool(review_context),
|
||
"review_id": (review_context or {}).get("reviewId") or (review_context or {}).get("review_id"),
|
||
"template_key": template_key,
|
||
},
|
||
)
|
||
local_legal_sources = _dedupe_sources([*_build_legal_article_sources(review_context), *runtime_legal_sources])
|
||
return StreamingResponse(
|
||
_merge_local_sources_into_stream(
|
||
shared_ai_platform_client.stream_answer(
|
||
current_user,
|
||
payload.conversation_id,
|
||
payload.message,
|
||
review_context,
|
||
template_key=template_key,
|
||
),
|
||
local_legal_sources,
|
||
payload.message,
|
||
),
|
||
media_type="text/event-stream",
|
||
)
|
||
|
||
|
||
def _resolve_chat_prompt_runtime(
|
||
current_user: Dict[str, Any],
|
||
review_context: Optional[Dict[str, Any]],
|
||
prompt_runtime_service: Optional[PromptRuntimeService],
|
||
message: str = "",
|
||
) -> Dict[str, Any]:
|
||
if prompt_runtime_service is None:
|
||
return {"available": False, "prompt_type": "legal_chat", "prompt_version": "legal_chat.shared_ai_stream.v1", "source": "local_default"}
|
||
has_review_context = bool(review_context and ((review_context.get("reviewId") or review_context.get("review_id"))))
|
||
business_auxiliary_key = _detect_business_auxiliary_prompt_key(message, review_context)
|
||
if business_auxiliary_key:
|
||
return prompt_runtime_service.resolve_published_prompt(
|
||
current_user,
|
||
"business_auxiliary",
|
||
preferred_keys=[
|
||
business_auxiliary_key,
|
||
],
|
||
fallback_prompt_version="business_auxiliary.shared_ai_stream.v1",
|
||
)
|
||
return prompt_runtime_service.resolve_published_prompt(
|
||
current_user,
|
||
"legal_chat",
|
||
preferred_keys=[
|
||
"legal_chat_review" if has_review_context else None,
|
||
"legal_chat_default",
|
||
],
|
||
fallback_prompt_version="legal_chat.shared_ai_stream.v1",
|
||
)
|
||
|
||
|
||
def _detect_business_auxiliary_prompt_key(
|
||
message: str,
|
||
review_context: Optional[Dict[str, Any]],
|
||
) -> Optional[str]:
|
||
context_text = _build_review_context_action_text(review_context)
|
||
if context_text:
|
||
routed_key = _detect_business_auxiliary_prompt_key_from_text(context_text)
|
||
if routed_key:
|
||
return routed_key
|
||
|
||
return _detect_business_auxiliary_prompt_key_from_text(_build_prompt_intent_text(message, review_context))
|
||
|
||
|
||
def _detect_business_auxiliary_prompt_key_from_text(text: str) -> Optional[str]:
|
||
if _contains_any(text, ["法律意见书强边界", "法律意见书兜底", "法律意见书边界", "使用限制", "前提假设", "不能出具", "正式法律意见", "legal opinion boundary", "legal opinion fallback"]):
|
||
return BUSINESS_AUXILIARY_PROMPT_KEYS["legal_opinion_boundary_fallback"]
|
||
if _contains_any(text, ["履约义务", "履行义务", "义务提取", "履约清单", "义务清单", "交付义务", "配合义务", "双方要做什么", "对方需要做什么", "obligation extraction", "performance obligation", "performance checklist"]):
|
||
return BUSINESS_AUXILIARY_PROMPT_KEYS["obligation_extraction"]
|
||
if _contains_any(text, ["工商", "备案材料", "变更登记", "登记材料", "材料清单", "提交材料", "申报材料", "登记机关", "filing materials", "registration materials", "registration checklist", "filing checklist"]):
|
||
return BUSINESS_AUXILIARY_PROMPT_KEYS["filing_materials_checklist"]
|
||
if _contains_any(text, ["谈判策略", "谈判建议", "怎么谈", "如何谈", "议价", "让步方案", "让步空间", "底线", "沟通话术", "对方不接受", "替代方案", "negotiate", "negotiation strategy", "negotiation plan", "fallback position"]):
|
||
return BUSINESS_AUXILIARY_PROMPT_KEYS["negotiation_strategy"]
|
||
if _contains_any(text, ["法律意见书", "legal opinion"]) and _contains_any(
|
||
text,
|
||
["边界", "兜底", "免责声明", "不能替代", "人工复核", "使用限制", "前提假设", "boundary", "fallback", "disclaimer", "human review", "assumption", "limitation"],
|
||
):
|
||
return BUSINESS_AUXILIARY_PROMPT_KEYS["legal_opinion_boundary_fallback"]
|
||
return None
|
||
|
||
|
||
def _build_review_context_action_text(review_context: Optional[Dict[str, Any]]) -> str:
|
||
if not isinstance(review_context, dict):
|
||
return ""
|
||
metadata = review_context.get("metadata") if isinstance(review_context.get("metadata"), Mapping) else {}
|
||
parts = [
|
||
metadata.get("actionLabel"),
|
||
metadata.get("focusLabel"),
|
||
metadata.get("reviewOpinion"),
|
||
review_context.get("actionLabel"),
|
||
review_context.get("focusLabel"),
|
||
review_context.get("prompt"),
|
||
]
|
||
return "\n".join(str(item) for item in parts if item).lower()
|
||
|
||
|
||
def _build_prompt_intent_text(message: str, review_context: Optional[Dict[str, Any]]) -> str:
|
||
parts = [message or ""]
|
||
if isinstance(review_context, dict):
|
||
parts.append(json.dumps(review_context, ensure_ascii=False, default=str))
|
||
return "\n".join(parts).lower()
|
||
|
||
|
||
def _contains_any(value: str, candidates: Iterable[str]) -> bool:
|
||
return any(candidate.lower() in value for candidate in candidates)
|
||
|
||
|
||
def _enrich_review_context_with_prompt_runtime(
|
||
review_context: Optional[Dict[str, Any]],
|
||
prompt_runtime: Optional[Dict[str, Any]],
|
||
prompt_runtime_service: Optional[PromptRuntimeService],
|
||
) -> Optional[Dict[str, Any]]:
|
||
if prompt_runtime_service is None or not prompt_runtime:
|
||
return review_context
|
||
enriched = dict(review_context or {})
|
||
prompt_trace = prompt_runtime_service.to_trace(prompt_runtime)
|
||
enriched["promptRuntime"] = prompt_trace
|
||
enriched["prompt_runtime"] = prompt_trace
|
||
metadata = dict(enriched.get("metadata") or {})
|
||
metadata["promptRuntime"] = prompt_trace
|
||
metadata["promptVersion"] = prompt_trace.get("prompt_version")
|
||
metadata["promptKey"] = prompt_trace.get("prompt_key")
|
||
enriched["metadata"] = metadata
|
||
return enriched
|
||
|
||
|
||
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 _search_runtime_legal_article_sources(
|
||
current_user: Dict[str, Any],
|
||
message: str,
|
||
legal_article_service: Optional[LegalArticleService],
|
||
prompt_runtime: Optional[Dict[str, Any]] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
if legal_article_service is None:
|
||
return []
|
||
normalized_message = str(message or "").strip()
|
||
if not normalized_message:
|
||
return []
|
||
if str((prompt_runtime or {}).get("prompt_type") or "").strip().lower() == "business_auxiliary":
|
||
return []
|
||
if not _should_search_runtime_legal_sources(normalized_message):
|
||
return []
|
||
try:
|
||
return legal_article_service.search_runtime_sources(current_user, normalized_message, limit=5)
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def _should_search_runtime_legal_sources(message: str) -> bool:
|
||
if re.search(r"[\u4e00-\u9fff]", message):
|
||
return True
|
||
lowered = message.lower()
|
||
english_legal_markers = (
|
||
"article",
|
||
"law",
|
||
"regulation",
|
||
"civil code",
|
||
"company law",
|
||
"labor contract law",
|
||
"employment law",
|
||
"privacy law",
|
||
"pipl",
|
||
"data security law",
|
||
"cybersecurity law",
|
||
"e-signature law",
|
||
)
|
||
return any(marker in lowered for marker in english_legal_markers)
|
||
|
||
|
||
def _enrich_review_context_with_runtime_legal_sources(
|
||
review_context: Optional[Dict[str, Any]],
|
||
runtime_sources: List[Dict[str, Any]],
|
||
) -> Optional[Dict[str, Any]]:
|
||
if not runtime_sources:
|
||
return review_context
|
||
enriched = dict(review_context or {})
|
||
enriched["legalArticleSearchSources"] = runtime_sources
|
||
enriched["legal_article_search_sources"] = runtime_sources
|
||
metadata = dict(enriched.get("metadata") or {})
|
||
metadata["legalArticleSearchSourceCount"] = len(runtime_sources)
|
||
metadata["legalArticleSearchSources"] = [
|
||
{
|
||
"id": source.get("source_id"),
|
||
"title": source.get("title"),
|
||
"detail_path": source.get("detail_path"),
|
||
}
|
||
for source in runtime_sources
|
||
]
|
||
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]], message: str = "") -> Iterable[str]:
|
||
if not local_sources:
|
||
yield from upstream
|
||
return
|
||
|
||
emitted_sources = False
|
||
emitted_basis_intro = False
|
||
basis_intro = _build_legal_basis_intro(local_sources)
|
||
for chunk in upstream:
|
||
event = _parse_sse_event(chunk)
|
||
if event and event.get("type") == "text":
|
||
content = str(event.get("content") or "")
|
||
polished_content = _polish_chat_answer(content, message, local_sources)
|
||
if basis_intro and not emitted_basis_intro:
|
||
emitted_basis_intro = True
|
||
polished_content = _prepend_legal_basis_intro(polished_content, basis_intro)
|
||
yield _format_sse_event({"type": "text", "content": polished_content})
|
||
continue
|
||
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 _prepend_legal_basis_intro(content: str, basis_intro: str) -> str:
|
||
normalized_content = str(content or "").lstrip()
|
||
if not normalized_content:
|
||
return basis_intro
|
||
return f"{basis_intro}\n\n{normalized_content}"
|
||
|
||
|
||
def _build_legal_basis_intro(sources: List[Dict[str, Any]]) -> str:
|
||
legal_sources = [source for source in sources if _is_legal_article_source(source)]
|
||
if not legal_sources:
|
||
return ""
|
||
lines = [
|
||
"本次回答已先检索本地法规库,优先依据如下法条:",
|
||
]
|
||
for index, source in enumerate(legal_sources[:3], start=1):
|
||
citation = _format_legal_article_citation(source)
|
||
excerpt = _format_legal_article_excerpt(source.get("content"))
|
||
if excerpt:
|
||
lines.append(f"{index}. {citation}:{excerpt}")
|
||
else:
|
||
lines.append(f"{index}. {citation}")
|
||
lines.append("以下为结合上述依据形成的分析意见:")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _is_legal_article_source(source: Mapping[str, Any]) -> bool:
|
||
detail_path = str(source.get("detail_path") or "")
|
||
return source.get("source_type") == "legal_article" or detail_path.startswith("/legal-articles")
|
||
|
||
|
||
def _format_legal_article_citation(source: Mapping[str, Any]) -> str:
|
||
metadata = source.get("metadata") if isinstance(source.get("metadata"), Mapping) else {}
|
||
article_no = str(source.get("article_no") or "").strip()
|
||
source_title = str(metadata.get("source_title") or "").strip()
|
||
title = str(source.get("title") or "").strip()
|
||
if not source_title and article_no and title.endswith(article_no):
|
||
source_title = title[: -len(article_no)].strip()
|
||
if not source_title:
|
||
source_title = title
|
||
if source_title and article_no:
|
||
return f"《{source_title}》{article_no}"
|
||
return title or "本地法规库"
|
||
|
||
|
||
def _format_legal_article_excerpt(value: Any, max_length: int = 120) -> str:
|
||
text = " ".join(str(value or "").split())
|
||
if not text:
|
||
return ""
|
||
if len(text) <= max_length:
|
||
return text
|
||
return f"{text[:max_length].rstrip()}..."
|
||
|
||
|
||
def _polish_chat_answer(content: str, message: str, sources: List[Dict[str, Any]]) -> str:
|
||
if not _looks_like_raw_retrieval_answer(content):
|
||
return content
|
||
source_titles = [str(source.get("title") or "依据来源").strip() for source in sources[:3] if source.get("title")]
|
||
source_line = "、".join(source_titles) if source_titles else "下方引用来源"
|
||
return (
|
||
"我已检索到相关依据,但当前平台返回的是依据片段而不是完整法律分析。\n\n"
|
||
f"针对你的问题“{message.strip()}”,建议先按以下方向人工核查:\n"
|
||
"1. 明确付款节点、付款前置条件、发票要求和验收标准是否写清。\n"
|
||
"2. 检查逾期付款、拒付、扣款和违约金规则是否具体、对等、可执行。\n"
|
||
"3. 核对付款条款是否和交付、验收、解除、争议解决条款互相冲突。\n\n"
|
||
f"可优先复核这些依据:{source_line}。"
|
||
)
|
||
|
||
|
||
def _looks_like_raw_retrieval_answer(content: str) -> bool:
|
||
normalized = content.strip().lower()
|
||
if not normalized:
|
||
return False
|
||
return (
|
||
normalized.startswith("i found relevant knowledge-base content")
|
||
or "please treat this as ai-assisted guidance" in normalized
|
||
or "chapter_title:" in normalized
|
||
or "official_url:" in normalized
|
||
)
|
||
|
||
|
||
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
|